i18n(clients): replace English fallbacks with proper translation keys

Pulls every hard-coded English label/title in the Clients page and its
four modals through the i18n layer so localized panels stop leaking
English. New keys live under pages.clients (auth, hysteriaAuth, uuid,
flow, flowNone, reverseTag, reverseTagPlaceholder, telegramId,
telegramIdPlaceholder, created, updated, ipLimit) plus refresh at the
root and toasts.bulkDeletedMixed / bulkCreatedMixed for partial-failure
toasts. Also switches the add-client modal's primary button from "Add"
to "Create" for consistency with other create flows.

The bulk-add Random/Random+Prefix/... email-method options stay
hard-coded by request - they're identifier-shaped strings.
This commit is contained in:
MHSanaei 2026-05-18 10:17:15 +02:00
parent 106adca414
commit b57cafed94
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
6 changed files with 111 additions and 104 deletions

View file

@ -129,7 +129,7 @@ function buildEmails() {
async function submit() {
if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
message.error(t('pages.clients.selectInbound') || 'Select one or more inbounds.');
message.error(t('pages.clients.selectInbound'));
return;
}
const emails = buildEmails();
@ -159,9 +159,9 @@ async function submit() {
else failed++;
}
if (failed === 0) {
message.success(t('pages.clients.toasts.bulkCreated', { count: ok }) || `${ok} clients created`);
message.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
} else {
message.warning(`${ok} created, ${failed} failed`);
message.warning(t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
}
emit('saved');
close();
@ -172,10 +172,10 @@ async function submit() {
</script>
<template>
<a-modal :open="open" :title="t('pages.clients.bulk') || 'Add 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" :width="640" @ok="submit" @cancel="close">
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
<a-form-item :label="t('pages.clients.attachedInbounds') || 'Attached inbounds'" required>
<a-form-item :label="t('pages.clients.attachedInbounds')" required>
<a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions"
:placeholder="t('pages.clients.selectInbound')" :show-search="true"
:filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
@ -219,14 +219,14 @@ async function submit() {
<a-input v-model:value="form.comment" />
</a-form-item>
<a-form-item v-if="showFlow" label="Flow">
<a-form-item v-if="showFlow" :label="t('pages.clients.flow')">
<a-select v-model:value="form.flow" :style="{ width: '220px' }">
<a-select-option value="">none</a-select-option>
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="ipLimitEnable" :label="t('pages.clients.limitIp') || 'IP Limit'">
<a-form-item v-if="ipLimitEnable" :label="t('pages.clients.limitIp')">
<a-input-number v-model:value="form.limitIp" :min="0" />
</a-form-item>

View file

@ -188,7 +188,7 @@ function regenerateEmail() {
async function onSubmit() {
if (!form.email || form.email.trim() === '') {
message.error(t('pages.clients.email') + ' *');
message.error(`${t('pages.clients.email')} *`);
return;
}
if (!isEdit.value && (!form.inboundIds || form.inboundIds.length === 0)) {
@ -243,7 +243,7 @@ async function onSubmit() {
<template>
<a-modal :open="open" :title="isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')"
:destroy-on-close="true" :ok-text="isEdit ? t('save') : t('add')" :cancel-text="t('cancel')"
:destroy-on-close="true" :ok-text="isEdit ? t('save') : t('create')" :cancel-text="t('cancel')"
:ok-button-props="{ loading: submitting }" :width="720" @ok="onSubmit" @cancel="close">
<a-form layout="vertical" :model="form">
<a-row :gutter="16">
@ -256,7 +256,7 @@ async function onSubmit() {
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.clients.subId') || 'subId'">
<a-form-item :label="t('pages.clients.subId')">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.subId" style="flex: 1" />
<a-button @click="regenerateSubId"></a-button>
@ -267,7 +267,7 @@ async function onSubmit() {
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="Auth (Hysteria)">
<a-form-item :label="t('pages.clients.hysteriaAuth')">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.auth" style="flex: 1" />
<a-button @click="regenerateAuth"></a-button>
@ -275,7 +275,7 @@ async function onSubmit() {
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.clients.password') || 'Password'">
<a-form-item :label="t('pages.clients.password')">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.password" style="flex: 1" />
<a-button @click="regeneratePassword"></a-button>
@ -286,7 +286,7 @@ async function onSubmit() {
<a-row :gutter="16">
<a-col :span="ipLimitEnable ? 12 : 24">
<a-form-item label="UUID">
<a-form-item :label="t('pages.clients.uuid')">
<a-input-group compact style="display: flex">
<a-input v-model:value="form.uuid" style="flex: 1" />
<a-button @click="regenerateUUID"></a-button>
@ -294,7 +294,7 @@ async function onSubmit() {
</a-form-item>
</a-col>
<a-col v-if="ipLimitEnable" :span="12">
<a-form-item :label="t('pages.clients.limitIp') || 'IP limit'">
<a-form-item :label="t('pages.clients.limitIp')">
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
@ -302,12 +302,12 @@ async function onSubmit() {
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('pages.clients.totalGB') || 'Total (GB, 0 = unlimited)'">
<a-form-item :label="t('pages.clients.totalGB')">
<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.clients.expiryTime') || 'Expiry'">
<a-form-item :label="t('pages.clients.expiryTime')">
<a-date-picker v-model:value="form.expiryTime" show-time style="width: 100%" />
</a-form-item>
</a-col>
@ -315,38 +315,38 @@ async function onSubmit() {
<a-row v-if="showFlow || showReverseTag" :gutter="16">
<a-col v-if="showFlow" :span="12">
<a-form-item label="Flow">
<a-form-item :label="t('pages.clients.flow')">
<a-select v-model:value="form.flow">
<a-select-option value="">none</a-select-option>
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col v-if="showReverseTag" :span="12">
<a-form-item label="Reverse tag">
<a-input v-model:value="form.reverseTag" placeholder="Optional reverse tag" />
<a-form-item :label="t('pages.clients.reverseTag')">
<a-input v-model:value="form.reverseTag" :placeholder="t('pages.clients.reverseTagPlaceholder')" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="'Telegram user ID'">
<a-form-item :label="t('pages.clients.telegramId')">
<a-input-number v-model:value="form.tgId" :min="0" :controls="false"
:placeholder="t('pages.clients.telegramIdPlaceholder') || 'Numeric Telegram user ID (0 = none)'"
:placeholder="t('pages.clients.telegramIdPlaceholder')"
style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.clients.comment') || 'Comment'">
<a-form-item :label="t('pages.clients.comment')">
<a-input v-model:value="form.comment" />
</a-form-item>
</a-col>
</a-row>
<a-form-item :label="t('pages.clients.attachedInbounds') || 'Attached inbounds'" :required="!isEdit">
<a-form-item :label="t('pages.clients.attachedInbounds')" :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'"
:placeholder="t('pages.clients.selectInbound')"
:filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
</a-form-item>
@ -355,17 +355,17 @@ async function onSubmit() {
<span style="margin-left: 8px">{{ t('enable') }}</span>
</a-form-item>
<a-form-item v-if="isEdit && ipLimitEnable" :label="t('pages.clients.ipLog') || 'IP Log'">
<a-form-item v-if="isEdit && ipLimitEnable" :label="t('pages.clients.ipLog')">
<a-space style="margin-bottom: 8px">
<a-button size="small" :loading="ipsLoading" @click="loadIps">{{ t('refresh') }}</a-button>
<a-button size="small" danger :loading="ipsClearing" :disabled="clientIps.length === 0" @click="clearIps">
{{ t('clearAll') || 'Clear' }}
{{ t('pages.clients.clearAll') }}
</a-button>
</a-space>
<div v-if="clientIps.length > 0">
<a-tag v-for="(ip, idx) in clientIps" :key="idx" color="blue" style="margin-bottom: 4px">{{ ip }}</a-tag>
</div>
<a-tag v-else>{{ t('tgbot.noIpRecord') || 'No IP record' }}</a-tag>
<a-tag v-else>{{ t('tgbot.noIpRecord') }}</a-tag>
</a-form-item>
</a-form>
</a-modal>

View file

@ -105,10 +105,10 @@ function close() {
<table class="info-table block">
<tbody>
<tr>
<td>{{ t('pages.clients.online') || 'Online' }}</td>
<td>{{ t('pages.clients.online') }}</td>
<td>
<a-tag v-if="client.enable && isOnline" color="green">{{ t('pages.clients.online') || 'Online' }}</a-tag>
<a-tag v-else>{{ t('pages.clients.offline') || 'Offline' }}</a-tag>
<a-tag v-if="client.enable && isOnline" color="green">{{ t('pages.clients.online') }}</a-tag>
<a-tag v-else>{{ t('pages.clients.offline') }}</a-tag>
<span class="hint">{{ t('lastOnline') }}: {{ lastOnlineLabel(traffic?.lastOnline) }}</span>
</td>
</tr>
@ -123,7 +123,7 @@ function close() {
</tr>
<tr>
<td>{{ t('pages.clients.email') || 'Email' }}</td>
<td>{{ t('pages.clients.email') }}</td>
<td>
<a-tag v-if="client.email" color="green">{{ client.email }}</a-tag>
<a-tag v-else color="red">{{ t('none') }}</a-tag>
@ -131,7 +131,7 @@ function close() {
</tr>
<tr>
<td>subId</td>
<td>{{ t('pages.clients.subId') }}</td>
<td>
<a-tag class="info-large-tag">{{ client.subId || '-' }}</a-tag>
<a-button v-if="client.subId" size="small" type="text" @click="copyValue(client.subId)">
@ -141,7 +141,7 @@ function close() {
</tr>
<tr v-if="client.uuid">
<td>UUID</td>
<td>{{ t('pages.clients.uuid') }}</td>
<td>
<a-tag class="info-large-tag">{{ client.uuid }}</a-tag>
<a-button size="small" type="text" @click="copyValue(client.uuid)">
@ -161,7 +161,7 @@ function close() {
</tr>
<tr v-if="client.auth">
<td>Auth</td>
<td>{{ t('pages.clients.auth') }}</td>
<td>
<a-tag class="info-large-tag">{{ client.auth }}</a-tag>
<a-button size="small" type="text" @click="copyValue(client.auth)">
@ -171,7 +171,7 @@ function close() {
</tr>
<tr>
<td>Flow</td>
<td>{{ t('pages.clients.flow') }}</td>
<td>
<a-tag v-if="client.flow">{{ client.flow }}</a-tag>
<a-tag v-else color="orange">{{ t('none') }}</a-tag>
@ -194,7 +194,7 @@ function close() {
</tr>
<tr>
<td>{{ t('remained') || 'Remaining' }}</td>
<td>{{ t('remained') }}</td>
<td>
<a-tag v-if="remaining < 0" color="purple"></a-tag>
<a-tag v-else :color="remaining > 0 ? '' : 'red'">
@ -204,7 +204,7 @@ function close() {
</tr>
<tr>
<td>{{ t('pages.inbounds.expireDate') || 'Expiry' }}</td>
<td>{{ t('pages.inbounds.expireDate') }}</td>
<td>
<a-tag v-if="!client.expiryTime || client.expiryTime <= 0" color="purple"></a-tag>
<a-tag v-else>{{ expiryLabel(client.expiryTime) }}</a-tag>
@ -213,7 +213,7 @@ function close() {
</tr>
<tr>
<td>IP limit</td>
<td>{{ t('pages.clients.ipLimit') }}</td>
<td>
<a-tag v-if="!client.limitIp"></a-tag>
<a-tag v-else>{{ client.limitIp }}</a-tag>
@ -221,28 +221,28 @@ function close() {
</tr>
<tr>
<td>{{ t('pages.inbounds.createdAt') || 'Created' }}</td>
<td>{{ t('pages.inbounds.createdAt') }}</td>
<td>
<a-tag>{{ dateLabel(client.createdAt) }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.updatedAt') || 'Updated' }}</td>
<td>{{ t('pages.inbounds.updatedAt') }}</td>
<td>
<a-tag>{{ dateLabel(client.updatedAt) }}</a-tag>
</td>
</tr>
<tr v-if="client.comment">
<td>{{ t('pages.clients.comment') || 'Comment' }}</td>
<td>{{ t('pages.clients.comment') }}</td>
<td>
<a-tag class="info-large-tag">{{ client.comment }}</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.clients.attachedInbounds') || 'Attached inbounds' }}</td>
<td>{{ t('pages.clients.attachedInbounds') }}</td>
<td>
<div class="chips">
<a-tag v-for="id in (client.inboundIds || [])" :key="id" color="blue">
@ -259,10 +259,10 @@ function close() {
</table>
<template v-if="links.length > 0">
<a-divider>{{ t('pages.inbounds.copyLink') || 'URL' }}</a-divider>
<a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
<div v-for="(link, idx) in links" :key="idx" class="link-panel">
<div class="link-panel-header">
<a-tag color="green">{{ `${t('pages.clients.link') || 'Link'} ${idx + 1}` }}</a-tag>
<a-tag color="green">{{ `${t('pages.clients.link')} ${idx + 1}` }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyValue(link)">
<template #icon>
@ -276,10 +276,10 @@ function close() {
</template>
<template v-if="showSubscription && subLink">
<a-divider>{{ t('subscription.title') || 'Subscription info' }}</a-divider>
<a-divider>{{ t('subscription.title') }}</a-divider>
<div class="link-panel">
<div class="link-panel-header">
<a-tag color="green">{{ t('subscription.title') || 'Subscription info' }}</a-tag>
<a-tag color="green">{{ t('subscription.title') }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copyValue(subLink)">
<template #icon>

View file

@ -67,20 +67,20 @@ function close() {
@cancel="close">
<a-spin :spinning="loading">
<div v-if="!client?.subId && !loading" class="empty">
{{ t('pages.clients.noSubId') || 'This client has no subId, no shareable link.' }}
{{ t('pages.clients.noSubId') }}
</div>
<div v-else-if="!hasAnything && !loading" class="empty">
{{ t('pages.clients.noLinks') || 'No shareable links — attach this client to a protocol-capable inbound first.' }}
{{ t('pages.clients.noLinks') }}
</div>
<a-collapse v-else :active-key="activeKeys" accordion>
<a-collapse-panel v-if="subLink" key="sub" :header="t('subscription.title') || 'Subscription info'">
<QrPanel :value="subLink" :remark="`${client?.email || ''} — ${t('subscription.title') || 'Subscription'}`" />
<a-collapse-panel v-if="subLink" key="sub" :header="t('subscription.title')">
<QrPanel :value="subLink" :remark="`${client?.email || ''} — ${t('subscription.title')}`" />
</a-collapse-panel>
<a-collapse-panel v-if="subJsonLink" key="subJson" :header="`${t('subscription.title') || 'Subscription info'} (JSON)`">
<a-collapse-panel v-if="subJsonLink" key="subJson" :header="`${t('subscription.title')} (JSON)`">
<QrPanel :value="subJsonLink" :remark="`${client?.email || ''} — JSON`" />
</a-collapse-panel>
<a-collapse-panel v-for="(link, idx) in links" :key="`l${idx}`"
:header="`${t('pages.clients.link') || 'Link'} ${idx + 1}`">
:header="`${t('pages.clients.link')} ${idx + 1}`">
<QrPanel :value="link" :remark="`${client?.email || ''} #${idx + 1}`" />
</a-collapse-panel>
</a-collapse>

View file

@ -130,10 +130,8 @@ function onBulkDelete() {
const emails = [...selectedRowKeys.value];
if (emails.length === 0) return;
Modal.confirm({
title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length })
|| `Delete ${emails.length} clients?`,
content: t('pages.clients.bulkDeleteConfirmContent')
|| 'Each client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.',
title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length }),
content: t('pages.clients.bulkDeleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
@ -147,9 +145,9 @@ function onBulkDelete() {
}
selectedRowKeys.value = [];
if (failed === 0) {
message.success(t('pages.clients.toasts.bulkDeleted', { count: ok }) || `${ok} clients deleted`);
message.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
} else {
message.warning(`${ok} deleted, ${failed} failed`);
message.warning(t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
}
},
});
@ -161,9 +159,8 @@ async function onBulkAddSaved() {
function onDelDepleted() {
Modal.confirm({
title: t('pages.clients.delDepletedConfirmTitle') || 'Delete depleted clients?',
content: t('pages.clients.delDepletedConfirmContent')
|| 'Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.',
title: t('pages.clients.delDepletedConfirmTitle'),
content: t('pages.clients.delDepletedConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
@ -171,8 +168,7 @@ function onDelDepleted() {
const msg = await delDepleted();
if (msg?.success) {
const deleted = msg.obj?.deleted ?? 0;
message.success(t('pages.clients.toasts.delDepleted', { count: deleted })
|| `${deleted} depleted clients deleted`);
message.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
}
},
});
@ -311,33 +307,31 @@ function onEdit(row) {
function onDelete(row) {
Modal.confirm({
title: t('pages.clients.deleteConfirmTitle', { email: row.email }) || `Delete ${row.email}?`,
content: t('pages.clients.deleteConfirmContent')
|| 'This removes the client from every attached inbound and drops its traffic record.',
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
content: t('pages.clients.deleteConfirmContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(row.email);
if (msg?.success) message.success(t('pages.clients.toasts.deleted') || 'Client deleted');
if (msg?.success) message.success(t('pages.clients.toasts.deleted'));
},
});
}
function onResetTraffic(row) {
if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
message.warning(t('pages.clients.resetNotPossible') || 'Attach this client to an inbound first.');
message.warning(t('pages.clients.resetNotPossible'));
return;
}
Modal.confirm({
title: `${t('pages.inbounds.resetTraffic') || 'Reset traffic'}${row.email}`,
content: t('pages.inbounds.resetTrafficContent')
|| 'Counters drop to zero. Quota and expiry stay as-is.',
okText: t('reset') || 'Reset',
title: `${t('pages.inbounds.resetTraffic')}${row.email}`,
content: t('pages.inbounds.resetTrafficContent'),
okText: t('reset'),
cancelText: t('cancel'),
onOk: async () => {
const msg = await resetTraffic(row);
if (msg?.success) message.success(t('pages.clients.toasts.trafficReset') || 'Traffic reset');
if (msg?.success) message.success(t('pages.clients.toasts.trafficReset'));
},
});
}
@ -356,7 +350,7 @@ function onResetAllTraffics() {
Modal.confirm({
title: t('pages.clients.resetAllTrafficsTitle'),
content: t('pages.clients.resetAllTrafficsContent'),
okText: t('reset') || 'Reset',
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
@ -479,14 +473,14 @@ function onTableChange(_pag, _filters, sorter) {
}
const columns = computed(() => [
{ title: t('pages.clients.actions') || 'Actions', key: 'actions', width: 200 },
sortableCol({ title: t('pages.clients.enabled') || 'Enabled', key: 'enable', width: 80 }, 'enable'),
{ title: t('pages.clients.online') || 'Online', key: 'online', width: 90 },
sortableCol({ title: t('pages.clients.client') || 'Client', key: 'email' }, 'email'),
sortableCol({ title: t('pages.clients.attachedInbounds') || 'Attached inbounds', key: 'inboundIds' }, 'inboundIds'),
sortableCol({ title: t('pages.clients.traffic') || 'Traffic', key: 'traffic' }, 'traffic'),
sortableCol({ title: t('pages.clients.remaining') || 'Remaining', key: 'remaining', width: 130 }, 'remaining'),
sortableCol({ title: t('pages.clients.duration') || 'Duration', key: 'expiryTime' }, 'expiryTime'),
{ title: t('pages.clients.actions'), key: 'actions', width: 200 },
sortableCol({ title: t('pages.clients.enabled'), key: 'enable', width: 80 }, 'enable'),
{ title: t('pages.clients.online'), key: 'online', width: 90 },
sortableCol({ title: t('pages.clients.client'), key: 'email' }, 'email'),
sortableCol({ title: t('pages.clients.attachedInbounds'), key: 'inboundIds' }, 'inboundIds'),
sortableCol({ title: t('pages.clients.traffic'), key: 'traffic' }, 'traffic'),
sortableCol({ title: t('pages.clients.remaining'), key: 'remaining', width: 130 }, 'remaining'),
sortableCol({ title: t('pages.clients.duration'), key: 'expiryTime' }, 'expiryTime'),
]);
</script>
@ -497,7 +491,7 @@ const columns = computed(() => [
<a-layout class="content-shell">
<a-layout-content id="content-layout" class="content-area">
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
<a-spin :spinning="!fetched" :delay="200" :tip="t('loading')" size="large">
<div v-if="!fetched" class="loading-spacer" />
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
@ -592,14 +586,13 @@ const columns = computed(() => [
<template #icon>
<UsergroupAddOutlined />
</template>
<template v-if="!isMobile">{{ t('pages.clients.bulk') || 'Add Bulk' }}</template>
<template v-if="!isMobile">{{ t('pages.clients.bulk') }}</template>
</a-button>
<a-button v-if="selectedRowKeys.length > 0" danger size="small" @click="onBulkDelete">
<template #icon>
<DeleteOutlined />
</template>
{{ t('pages.clients.deleteSelected', { count: selectedRowKeys.length })
|| `Delete (${selectedRowKeys.length})` }}
{{ t('pages.clients.deleteSelected', { count: selectedRowKeys.length }) }}
</a-button>
<a-button size="small" @click="onResetAllTraffics">
<template #icon>
@ -611,7 +604,7 @@ const columns = computed(() => [
<template #icon>
<RestOutlined />
</template>
<template v-if="!isMobile">{{ t('pages.clients.delDepleted') || 'Delete depleted' }}</template>
<template v-if="!isMobile">{{ t('pages.clients.delDepleted') }}</template>
</a-button>
</div>
</template>
@ -660,13 +653,13 @@ const columns = computed(() => [
{{ t('depleted') }}
</a-tag>
<a-tag v-else-if="record.enable && isOnline(record.email)" color="green">
{{ t('pages.clients.online') || 'Online' }}
{{ t('pages.clients.online') }}
</a-tag>
<a-tag v-else-if="!record.enable">{{ t('disabled') }}</a-tag>
<a-tag v-else-if="clientBucket(record) === 'expiring'" color="orange">
{{ t('depletingSoon') }}
</a-tag>
<a-tag v-else>{{ t('pages.clients.offline') || 'Offline' }}</a-tag>
<a-tag v-else>{{ t('pages.clients.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">
@ -694,27 +687,27 @@ const columns = computed(() => [
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-tooltip :title="t('pages.clients.qrCode') || 'QR Code'">
<a-tooltip :title="t('pages.clients.qrCode')">
<a-button size="small" type="text" @click="onShowQr(record)">
<QrcodeOutlined />
</a-button>
</a-tooltip>
<a-tooltip :title="t('pages.clients.moreInformation') || 'More Information'">
<a-tooltip :title="t('pages.clients.moreInformation')">
<a-button size="small" type="text" @click="onShowInfo(record)">
<InfoCircleOutlined />
</a-button>
</a-tooltip>
<a-tooltip :title="t('pages.inbounds.resetTraffic') || 'Reset traffic'">
<a-tooltip :title="t('pages.inbounds.resetTraffic')">
<a-button size="small" type="text" @click="onResetTraffic(record)">
<RetweetOutlined />
</a-button>
</a-tooltip>
<a-tooltip :title="t('pages.clients.edit') || 'Edit'">
<a-tooltip :title="t('edit')">
<a-button size="small" type="text" @click="onEdit(record)">
<EditOutlined />
</a-button>
</a-tooltip>
<a-tooltip :title="t('pages.clients.delete') || 'Delete'">
<a-tooltip :title="t('delete')">
<a-button size="small" type="text" danger @click="onDelete(record)">
<DeleteOutlined />
</a-button>
@ -726,7 +719,7 @@ const columns = computed(() => [
<template #emptyText>
<div class="clients-empty">
<UserOutlined style="font-size: 32px; margin-bottom: 8px" />
<div>{{ t('pages.clients.empty') || 'No clients yet.' }}</div>
<div>{{ t('pages.clients.empty') }}</div>
</div>
</template>
</a-table>
@ -736,7 +729,7 @@ const columns = computed(() => [
<div v-if="filteredClients.length > 0" class="card-bulk-bar">
<a-checkbox :checked="allSelected" :indeterminate="someSelected"
@change="(e) => selectAll(e.target.checked)">
{{ t('pages.clients.selectAll') || 'Select all' }}
{{ t('pages.clients.selectAll') }}
</a-checkbox>
<span v-if="selectedRowKeys.length > 0" class="bulk-count">
{{ selectedRowKeys.length }}
@ -745,7 +738,7 @@ const columns = computed(() => [
<div v-if="filteredClients.length === 0" class="card-empty">
<UserOutlined style="font-size: 28px; opacity: 0.5" />
<div>{{ t('pages.clients.empty') || 'No clients yet.' }}</div>
<div>{{ t('pages.clients.empty') }}</div>
</div>
<div v-for="row in filteredClients" :key="row.email" class="client-card"
@ -762,7 +755,7 @@ const columns = computed(() => [
{{ t('depletingSoon') }}
</a-tag>
<div class="card-actions" @click.stop>
<a-tooltip :title="t('pages.clients.moreInformation') || 'Info'">
<a-tooltip :title="t('pages.clients.moreInformation')">
<InfoCircleOutlined class="row-action-trigger" @click="onShowInfo(row)" />
</a-tooltip>
<a-switch :checked="row.enable" size="small" :loading="togglingEmail === row.email"
@ -772,16 +765,16 @@ const columns = computed(() => [
<template #overlay>
<a-menu>
<a-menu-item key="qr" @click="onShowQr(row)">
<QrcodeOutlined /> {{ t('pages.clients.qrCode') || 'QR Code' }}
<QrcodeOutlined /> {{ t('pages.clients.qrCode') }}
</a-menu-item>
<a-menu-item key="reset" @click="onResetTraffic(row)">
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') || 'Reset traffic' }}
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
</a-menu-item>
<a-menu-item key="edit" @click="onEdit(row)">
<EditOutlined /> {{ t('pages.clients.edit') || 'Edit' }}
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item key="delete" class="danger-item" @click="onDelete(row)">
<DeleteOutlined /> {{ t('pages.clients.delete') || 'Delete' }}
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>

View file

@ -18,6 +18,7 @@
"search": "Search",
"filter": "Filter",
"loading": "Loading...",
"refresh": "Refresh",
"second": "Second",
"minute": "Minute",
"hour": "Hour",
@ -444,12 +445,25 @@
"delDepleted": "Delete depleted",
"delDepletedConfirmTitle": "Delete depleted clients?",
"delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
"flow": "Flow",
"reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Optional reverse tag",
"telegramId": "Telegram user ID",
"telegramIdPlaceholder": "Numeric Telegram user ID (0 = none)",
"created": "Created",
"updated": "Updated",
"ipLimit": "IP limit",
"toasts": {
"deleted": "Client deleted",
"trafficReset": "Traffic reset",
"allTrafficsReset": "All client traffic reset",
"bulkDeleted": "{count} clients deleted",
"bulkDeletedMixed": "{ok} deleted, {failed} failed",
"bulkCreated": "{count} clients created",
"bulkCreatedMixed": "{ok} created, {failed} failed",
"delDepleted": "{count} depleted clients deleted"
}
},