mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
feat(clients): add top-level Clients tab and CRUD API
Adds /panel/api/clients endpoints (list, get, add, update, del, attach, detach) backed by ClientService methods that orchestrate the per-inbound Add/Update/Del flows so a single client row is created once and attached to many inbounds in one operation. The frontend gains a dedicated Clients page (frontend/clients.html + src/pages/clients/) with an AntD table, multi-inbound attach modal, and full CRUD. Axios interceptor learns to honour Content-Type: application/json so the JSON endpoints work alongside the legacy form-encoded ones. The legacy per-inbound client modal stays untouched in this PR — both flows now write to the same source of truth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ba3c581372
commit
2bcf287cf1
15 changed files with 1221 additions and 4 deletions
13
frontend/clients.html
Normal file
13
frontend/clients.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Clients</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/clients.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -76,7 +76,14 @@ export function setupAxios() {
|
|||
if (config.data instanceof FormData) {
|
||||
config.headers['Content-Type'] = 'multipart/form-data';
|
||||
} else {
|
||||
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
||||
const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || '');
|
||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||
if (config.data !== undefined && typeof config.data !== 'string') {
|
||||
config.data = JSON.stringify(config.data);
|
||||
}
|
||||
} else {
|
||||
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
|
@ -104,9 +111,14 @@ export function setupAxios() {
|
|||
if (token) {
|
||||
cfg.headers = cfg.headers || {};
|
||||
cfg.headers['X-CSRF-Token'] = token;
|
||||
// axios re-stringifies on retry, so unwind our qs.stringify before
|
||||
// letting the same request flow through the interceptor again.
|
||||
if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data);
|
||||
const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || '');
|
||||
if (typeof cfg.data === 'string') {
|
||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||
try { cfg.data = JSON.parse(cfg.data); } catch (_e) { /* keep as-is */ }
|
||||
} else {
|
||||
cfg.data = qs.parse(cfg.data);
|
||||
}
|
||||
}
|
||||
return axios(cfg);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
|||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
SettingOutlined,
|
||||
ToolOutlined,
|
||||
ClusterOutlined,
|
||||
|
|
@ -30,6 +31,7 @@ const props = defineProps({
|
|||
const iconByName = {
|
||||
dashboard: DashboardOutlined,
|
||||
user: UserOutlined,
|
||||
team: TeamOutlined,
|
||||
setting: SettingOutlined,
|
||||
tool: ToolOutlined,
|
||||
cluster: ClusterOutlined,
|
||||
|
|
@ -42,6 +44,7 @@ const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.base
|
|||
const tabs = computed(() => [
|
||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||
{ key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
|
||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||
|
|
|
|||
21
frontend/src/entries/clients.js
Normal file
21
frontend/src/entries/clients.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import ClientsPage from '@/pages/clients/ClientsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
createApp(ClientsPage).use(Antd).use(i18n).mount('#app');
|
||||
});
|
||||
|
|
@ -494,6 +494,85 @@ export const sections = [
|
|||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'clients',
|
||||
title: 'Clients',
|
||||
description:
|
||||
'Manage clients as first-class entities that can be attached to one or more inbounds. A single client row drives the settings.clients entry in every inbound it belongs to. Endpoints live under /panel/api/clients.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/list',
|
||||
summary: 'List every client with its attached inbound IDs and traffic record.',
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "email": "alice@example.com",\n "subId": "abcd1234",\n "uuid": "...",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "enable": true,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true }\n }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/get/:id',
|
||||
summary: 'Fetch one client by its numeric id, including the inbound IDs it is attached to.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id from the clients table.' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/add',
|
||||
summary: 'Create a new client and attach it to one or more inbounds in a single call. Body is JSON.',
|
||||
params: [
|
||||
{ name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, totalGB, expiryTime, limitIp, comment, enable.' },
|
||||
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach the client to. At least one required.' },
|
||||
],
|
||||
body: '{\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000\n },\n "inboundIds": [3, 5]\n}',
|
||||
response: '{\n "success": true,\n "msg": "Client added"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/update/:id',
|
||||
summary: 'Update an existing client. Changes propagate to every attached inbound. Body is the JSON client payload.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
|
||||
],
|
||||
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "enable": true\n}',
|
||||
response: '{\n "success": true,\n "msg": "Client updated"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/del/:id',
|
||||
summary: 'Delete a client. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
|
||||
{ name: 'keepTraffic', in: 'query', type: 'integer', desc: 'Pass 1 to retain the xray_client_traffic row after deletion.' },
|
||||
],
|
||||
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/:id/attach',
|
||||
summary: 'Attach an existing client to one or more additional inbounds. Body is JSON.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
|
||||
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach.' },
|
||||
],
|
||||
body: '{\n "inboundIds": [7, 9]\n}',
|
||||
response: '{\n "success": true\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/:id/detach',
|
||||
summary: 'Detach a client from one or more inbounds without deleting the client.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
|
||||
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to detach.' },
|
||||
],
|
||||
body: '{\n "inboundIds": [5]\n}',
|
||||
response: '{\n "success": true\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'nodes',
|
||||
title: 'Nodes',
|
||||
|
|
|
|||
230
frontend/src/pages/clients/ClientFormModal.vue
Normal file
230
frontend/src/pages/clients/ClientFormModal.vue
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { RandomUtil } from '@/utils';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
mode: { type: String, default: 'add' },
|
||||
client: { type: Object, default: null },
|
||||
inbounds: { type: Array, default: () => [] },
|
||||
attachedIds: { type: Array, default: () => [] },
|
||||
save: { type: Function, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const submitting = ref(false);
|
||||
const form = reactive(emptyForm());
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
email: '',
|
||||
subId: '',
|
||||
uuid: '',
|
||||
password: '',
|
||||
auth: '',
|
||||
totalGB: 0,
|
||||
expiryTime: null,
|
||||
limitIp: 0,
|
||||
comment: '',
|
||||
enable: true,
|
||||
inboundIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const isEdit = computed(() => props.mode === 'edit');
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(next) => {
|
||||
if (!next) return;
|
||||
Object.assign(form, emptyForm());
|
||||
if (isEdit.value && props.client) {
|
||||
form.email = props.client.email || '';
|
||||
form.subId = props.client.subId || '';
|
||||
form.uuid = props.client.uuid || '';
|
||||
form.password = props.client.password || '';
|
||||
form.auth = props.client.auth || '';
|
||||
form.totalGB = bytesToGB(props.client.totalGB || 0);
|
||||
form.expiryTime = props.client.expiryTime ? dayjs(props.client.expiryTime) : null;
|
||||
form.limitIp = props.client.limitIp || 0;
|
||||
form.comment = props.client.comment || '';
|
||||
form.enable = !!props.client.enable;
|
||||
form.inboundIds = Array.isArray(props.attachedIds) ? [...props.attachedIds] : [];
|
||||
} else {
|
||||
form.uuid = RandomUtil.randomUUID();
|
||||
form.subId = RandomUtil.randomLowerAndNum(16);
|
||||
form.password = RandomUtil.randomLowerAndNum(16);
|
||||
form.auth = RandomUtil.randomLowerAndNum(16);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function bytesToGB(bytes) {
|
||||
if (!bytes || bytes <= 0) return 0;
|
||||
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
|
||||
}
|
||||
|
||||
function gbToBytes(gb) {
|
||||
if (!gb || gb <= 0) return 0;
|
||||
return Math.round(gb * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
const inboundOptions = computed(() =>
|
||||
(props.inbounds || []).map((ib) => ({
|
||||
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
|
||||
value: ib.id,
|
||||
title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
|
||||
})),
|
||||
);
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function regenerateUUID() {
|
||||
form.uuid = RandomUtil.randomUUID();
|
||||
}
|
||||
|
||||
function regeneratePassword() {
|
||||
form.password = RandomUtil.randomLowerAndNum(16);
|
||||
}
|
||||
|
||||
function regenerateAuth() {
|
||||
form.auth = RandomUtil.randomLowerAndNum(16);
|
||||
}
|
||||
|
||||
function regenerateSubId() {
|
||||
form.subId = RandomUtil.randomLowerAndNum(16);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.email || form.email.trim() === '') {
|
||||
message.error(t('pages.inbounds.client.email') + ' *');
|
||||
return;
|
||||
}
|
||||
if (!isEdit.value && (!form.inboundIds || form.inboundIds.length === 0)) {
|
||||
message.error(t('pages.clients.selectInbound'));
|
||||
return;
|
||||
}
|
||||
const clientPayload = {
|
||||
email: form.email.trim(),
|
||||
subId: form.subId,
|
||||
id: form.uuid,
|
||||
password: form.password,
|
||||
auth: form.auth,
|
||||
totalGB: gbToBytes(form.totalGB),
|
||||
expiryTime: form.expiryTime ? form.expiryTime.valueOf() : 0,
|
||||
limitIp: Number(form.limitIp) || 0,
|
||||
comment: form.comment,
|
||||
enable: !!form.enable,
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
let msg;
|
||||
if (isEdit.value) {
|
||||
msg = await props.save(clientPayload, { isEdit: true, id: props.client.id });
|
||||
} else {
|
||||
msg = await props.save(
|
||||
{ client: clientPayload, inboundIds: form.inboundIds },
|
||||
{ isEdit: false },
|
||||
);
|
||||
}
|
||||
if (msg?.success) close();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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')"
|
||||
:ok-button-props="{ loading: submitting }" :width="720" @ok="onSubmit" @cancel="close">
|
||||
<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>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="t('pages.inbounds.client.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>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="UUID">
|
||||
<a-input-group compact style="display: flex">
|
||||
<a-input v-model:value="form.uuid" style="flex: 1" />
|
||||
<a-button @click="regenerateUUID">↻</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="t('pages.inbounds.client.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>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="Auth (Hysteria)">
|
||||
<a-input-group compact style="display: flex">
|
||||
<a-input v-model:value="form.auth" style="flex: 1" />
|
||||
<a-button @click="regenerateAuth">↻</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="t('pages.inbounds.client.limitIp') || 'IP limit'">
|
||||
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item :label="t('pages.inbounds.client.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-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-input v-model:value="form.comment" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="!isEdit" :label="t('pages.clients.attachedInbounds') || 'Attach to inbounds'" required>
|
||||
<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())" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-switch v-model:checked="form.enable" />
|
||||
<span style="margin-left: 8px">{{ t('enable') }}</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
217
frontend/src/pages/clients/ClientsPage.vue
Normal file
217
frontend/src/pages/clients/ClientsPage.vue
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { PlusOutlined, UserOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import { SizeFormatter, IntlUtil } from '@/utils';
|
||||
import { useClients } from './useClients.js';
|
||||
import ClientFormModal from './ClientFormModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const {
|
||||
clients,
|
||||
inbounds,
|
||||
loading,
|
||||
fetched,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
} = useClients();
|
||||
|
||||
const { isMobile } = useMediaQuery();
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
const formOpen = ref(false);
|
||||
const formMode = ref('add');
|
||||
const editingClient = ref(null);
|
||||
const editingAttachedIds = ref([]);
|
||||
|
||||
const inboundsById = computed(() => {
|
||||
const out = {};
|
||||
for (const ib of inbounds.value) out[ib.id] = ib;
|
||||
return out;
|
||||
});
|
||||
|
||||
function inboundLabel(id) {
|
||||
const ib = inboundsById.value[id];
|
||||
if (!ib) return `#${id}`;
|
||||
return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
formMode.value = 'add';
|
||||
editingClient.value = null;
|
||||
editingAttachedIds.value = [];
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function onEdit(row) {
|
||||
formMode.value = 'edit';
|
||||
editingClient.value = { ...row };
|
||||
editingAttachedIds.value = Array.isArray(row.inboundIds) ? [...row.inboundIds] : [];
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
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.',
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await remove(row.id);
|
||||
if (msg?.success) message.success(t('pages.clients.toasts.deleted') || 'Client deleted');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onSave(payload, meta) {
|
||||
if (meta?.isEdit) {
|
||||
return update(meta.id, payload);
|
||||
}
|
||||
return create(payload);
|
||||
}
|
||||
|
||||
function trafficLabel(row) {
|
||||
const t0 = row.traffic;
|
||||
if (!t0) return '-';
|
||||
const used = (t0.up || 0) + (t0.down || 0);
|
||||
const total = row.totalGB || 0;
|
||||
if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`;
|
||||
return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`;
|
||||
}
|
||||
|
||||
function expiryLabel(row) {
|
||||
if (!row.expiryTime || row.expiryTime <= 0) return '-';
|
||||
return IntlUtil.formatDate(row.expiryTime);
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: t('pages.inbounds.client.email') || 'Email', dataIndex: 'email', key: 'email' },
|
||||
{ title: 'subId', dataIndex: 'subId', key: 'subId' },
|
||||
{ title: t('pages.clients.attachedInbounds') || 'Attached inbounds', key: 'inboundIds' },
|
||||
{ title: t('pages.inbounds.traffic') || 'Traffic', key: 'traffic' },
|
||||
{ title: t('pages.inbounds.client.expiryTime') || 'Expiry', key: 'expiryTime' },
|
||||
{ title: t('enable'), key: 'enable', width: 90 },
|
||||
{ title: t('actions') || 'Actions', key: 'actions', width: 160 },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="clients-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content id="content-layout" class="content-area">
|
||||
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<a-table :columns="columns" :data-source="clients" :loading="loading" row-key="id" :pagination="{ pageSize: 20, showSizeChanger: true, pageSizeOptions: ['10','20','50','100'] }" size="small">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'inboundIds'">
|
||||
<a-tag v-for="id in record.inboundIds" :key="id" color="blue" style="margin: 2px">
|
||||
{{ inboundLabel(id) }}
|
||||
</a-tag>
|
||||
<span v-if="!record.inboundIds || record.inboundIds.length === 0" style="color: rgba(0,0,0,0.45)">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'traffic'">
|
||||
{{ trafficLabel(record) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'expiryTime'">
|
||||
{{ expiryLabel(record) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'enable'">
|
||||
<a-tag :color="record.enable ? 'green' : 'default'">
|
||||
{{ record.enable ? t('enable') : t('disable') }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button size="small" @click="onEdit(record)">{{ t('edit') }}</a-button>
|
||||
<a-button size="small" danger @click="onDelete(record)">{{ t('delete') }}</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #emptyText>
|
||||
<div style="padding: 32px 0; color: rgba(0, 0, 0, 0.45); text-align: center">
|
||||
<UserOutlined style="font-size: 32px; margin-bottom: 8px" />
|
||||
<div>{{ t('pages.clients.empty') || 'No clients yet.' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<ClientFormModal v-model:open="formOpen" :mode="formMode" :client="editingClient"
|
||||
:attached-ids="editingAttachedIds" :inbounds="inbounds" :save="onSave" />
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.clients-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.clients-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.clients-page.is-dark.is-ultra {
|
||||
--bg-page: #050505;
|
||||
--bg-card: #0c0e12;
|
||||
}
|
||||
|
||||
.clients-page :deep(.ant-layout),
|
||||
.clients-page :deep(.ant-layout-content) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
</style>
|
||||
78
frontend/src/pages/clients/useClients.js
Normal file
78
frontend/src/pages/clients/useClients.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { onMounted, ref, shallowRef } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
|
||||
export function useClients() {
|
||||
const clients = shallowRef([]);
|
||||
const inbounds = shallowRef([]);
|
||||
const loading = ref(false);
|
||||
const fetched = ref(false);
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [clientsMsg, inboundsMsg] = await Promise.all([
|
||||
HttpUtil.get('/panel/api/clients/list'),
|
||||
HttpUtil.get('/panel/api/inbounds/list'),
|
||||
]);
|
||||
if (clientsMsg?.success) {
|
||||
clients.value = Array.isArray(clientsMsg.obj) ? clientsMsg.obj : [];
|
||||
}
|
||||
if (inboundsMsg?.success) {
|
||||
inbounds.value = Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : [];
|
||||
}
|
||||
fetched.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload) {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function update(id, client) {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/update/${id}`, client, JSON_HEADERS);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function remove(id, keepTraffic = false) {
|
||||
const url = keepTraffic
|
||||
? `/panel/api/clients/del/${id}?keepTraffic=1`
|
||||
: `/panel/api/clients/del/${id}`;
|
||||
const msg = await HttpUtil.post(url);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function attach(id, inboundIds) {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${id}/attach`, { inboundIds }, JSON_HEADERS);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function detach(id, inboundIds) {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${id}/detach`, { inboundIds }, JSON_HEADERS);
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
return {
|
||||
clients,
|
||||
inbounds,
|
||||
loading,
|
||||
fetched,
|
||||
refresh,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
attach,
|
||||
detach,
|
||||
};
|
||||
}
|
||||
|
|
@ -22,6 +22,8 @@ const BASE_MIGRATED_ROUTES = {
|
|||
'panel/settings/': '/settings.html',
|
||||
'panel/inbounds': '/inbounds.html',
|
||||
'panel/inbounds/': '/inbounds.html',
|
||||
'panel/clients': '/clients.html',
|
||||
'panel/clients/': '/clients.html',
|
||||
'panel/xray': '/xray.html',
|
||||
'panel/xray/': '/xray.html',
|
||||
'panel/nodes': '/nodes.html',
|
||||
|
|
@ -150,6 +152,7 @@ export default defineConfig({
|
|||
login: path.resolve(__dirname, 'login.html'),
|
||||
settings: path.resolve(__dirname, 'settings.html'),
|
||||
inbounds: path.resolve(__dirname, 'inbounds.html'),
|
||||
clients: path.resolve(__dirname, 'clients.html'),
|
||||
xray: path.resolve(__dirname, 'xray.html'),
|
||||
nodes: path.resolve(__dirname, 'nodes.html'),
|
||||
apiDocs: path.resolve(__dirname, 'api-docs.html'),
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
|
|||
inbounds := api.Group("/inbounds")
|
||||
a.inboundController = NewInboundController(inbounds)
|
||||
|
||||
clients := api.Group("/clients")
|
||||
NewClientController(clients)
|
||||
|
||||
// Server API
|
||||
server := api.Group("/server")
|
||||
a.serverController = NewServerController(server)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
|||
basePath = "/panel/api"
|
||||
case "inbound.go":
|
||||
basePath = "/panel/api/inbounds"
|
||||
case "client.go":
|
||||
basePath = "/panel/api/clients"
|
||||
case "server.go":
|
||||
basePath = "/panel/api/server"
|
||||
case "node.go":
|
||||
|
|
@ -127,6 +129,7 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
|||
// Skip SPA page routes (these are UI pages, not API endpoints)
|
||||
spaPages := map[string]bool{
|
||||
"/": true, "/panel/": true, "/panel/inbounds": true,
|
||||
"/panel/clients": true,
|
||||
"/panel/nodes": true, "/panel/settings": true,
|
||||
"/panel/xray": true, "/panel/api-docs": true,
|
||||
}
|
||||
|
|
|
|||
165
web/controller/client.go
Normal file
165
web/controller/client.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ClientController struct {
|
||||
clientService service.ClientService
|
||||
inboundService service.InboundService
|
||||
xrayService service.XrayService
|
||||
}
|
||||
|
||||
func NewClientController(g *gin.RouterGroup) *ClientController {
|
||||
a := &ClientController{}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/get/:id", a.get)
|
||||
g.POST("/add", a.create)
|
||||
g.POST("/update/:id", a.update)
|
||||
g.POST("/del/:id", a.delete)
|
||||
g.POST("/:id/attach", a.attach)
|
||||
g.POST("/:id/detach", a.detach)
|
||||
}
|
||||
|
||||
func (a *ClientController) list(c *gin.Context) {
|
||||
rows, err := a.clientService.List()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, rows, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) get(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
rec, err := a.clientService.GetByID(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
inboundIds, err := a.clientService.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) create(c *gin.Context) {
|
||||
var payload service.ClientCreatePayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Create(&a.inboundService, &payload)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ClientController) update(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
var updated model.Client
|
||||
if err := c.ShouldBindJSON(&updated); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Update(&a.inboundService, id, updated)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ClientController) delete(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
keepTraffic := c.Query("keepTraffic") == "1"
|
||||
needRestart, err := a.clientService.Delete(&a.inboundService, id, keepTraffic)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
type attachDetachBody struct {
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}
|
||||
|
||||
func (a *ClientController) attach(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
var body attachDetachBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Attach(&a.inboundService, id, body.InboundIds)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ClientController) detach(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
var body attachDetachBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Detach(&a.inboundService, id, body.InboundIds)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
|||
|
||||
g.GET("/", a.index)
|
||||
g.GET("/inbounds", a.inbounds)
|
||||
g.GET("/clients", a.clients)
|
||||
g.GET("/nodes", a.nodes)
|
||||
g.GET("/settings", a.settings)
|
||||
g.GET("/xray", a.xraySettings)
|
||||
|
|
@ -62,6 +63,10 @@ func (a *XUIController) inbounds(c *gin.Context) {
|
|||
serveDistPage(c, "inbounds.html")
|
||||
}
|
||||
|
||||
func (a *XUIController) clients(c *gin.Context) {
|
||||
serveDistPage(c, "clients.html")
|
||||
}
|
||||
|
||||
// nodes renders the multi-panel nodes management page.
|
||||
func (a *XUIController) nodes(c *gin.Context) {
|
||||
serveDistPage(c, "nodes.html")
|
||||
|
|
|
|||
|
|
@ -1,15 +1,42 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ClientWithAttachments struct {
|
||||
model.ClientRecord
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
|
||||
}
|
||||
|
||||
func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string {
|
||||
if rec == nil {
|
||||
return ""
|
||||
}
|
||||
switch p {
|
||||
case model.Trojan:
|
||||
return rec.Password
|
||||
case model.Shadowsocks:
|
||||
return rec.Email
|
||||
case model.Hysteria, model.Hysteria2:
|
||||
return rec.Auth
|
||||
default:
|
||||
return rec.UUID
|
||||
}
|
||||
}
|
||||
|
||||
type ClientService struct{}
|
||||
|
||||
func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
|
||||
|
|
@ -141,3 +168,347 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int,
|
|||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
|
||||
row := &model.ClientRecord{}
|
||||
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
|
||||
var ids []int
|
||||
err := database.GetDB().Table("client_inbounds").
|
||||
Where("client_id = ?", id).
|
||||
Order("inbound_id ASC").
|
||||
Pluck("inbound_id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) List() ([]ClientWithAttachments, error) {
|
||||
db := database.GetDB()
|
||||
var rows []model.ClientRecord
|
||||
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []ClientWithAttachments{}, nil
|
||||
}
|
||||
|
||||
clientIds := make([]int, 0, len(rows))
|
||||
emails := make([]string, 0, len(rows))
|
||||
for i := range rows {
|
||||
clientIds = append(clientIds, rows[i].Id)
|
||||
if rows[i].Email != "" {
|
||||
emails = append(emails, rows[i].Email)
|
||||
}
|
||||
}
|
||||
|
||||
var links []model.ClientInbound
|
||||
if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachments := make(map[int][]int, len(rows))
|
||||
for _, l := range links {
|
||||
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
|
||||
}
|
||||
|
||||
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
|
||||
if len(emails) > 0 {
|
||||
var stats []xray.ClientTraffic
|
||||
if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range stats {
|
||||
trafficByEmail[stats[i].Email] = &stats[i]
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]ClientWithAttachments, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, ClientWithAttachments{
|
||||
ClientRecord: rows[i],
|
||||
InboundIds: attachments[rows[i].Id],
|
||||
Traffic: trafficByEmail[rows[i].Email],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type ClientCreatePayload struct {
|
||||
Client model.Client `json:"client"`
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}
|
||||
|
||||
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
|
||||
if payload == nil {
|
||||
return false, common.NewError("empty payload")
|
||||
}
|
||||
client := payload.Client
|
||||
if strings.TrimSpace(client.Email) == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
if len(payload.InboundIds) == 0 {
|
||||
return false, common.NewError("at least one inbound is required")
|
||||
}
|
||||
|
||||
if client.SubID == "" {
|
||||
client.SubID = uuid.NewString()
|
||||
}
|
||||
if !client.Enable {
|
||||
client.Enable = true
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
if client.CreatedAt == 0 {
|
||||
client.CreatedAt = now
|
||||
}
|
||||
client.UpdatedAt = now
|
||||
|
||||
existing := &model.ClientRecord{}
|
||||
err := database.GetDB().Where("email = ?", client.Email).First(existing).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, err
|
||||
}
|
||||
emailTaken := !errors.Is(err, gorm.ErrRecordNotFound)
|
||||
if emailTaken {
|
||||
if existing.SubID == "" || existing.SubID != client.SubID {
|
||||
return false, common.NewError("email already in use:", client.Email)
|
||||
}
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range payload.InboundIds {
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return needRestart, getErr
|
||||
}
|
||||
if err := s.fillProtocolDefaults(&client, inbound.Protocol); err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}})
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
nr, addErr := inboundSvc.AddInboundClient(&model.Inbound{
|
||||
Id: ibId,
|
||||
Settings: string(settingsPayload),
|
||||
})
|
||||
if addErr != nil {
|
||||
return needRestart, addErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) fillProtocolDefaults(c *model.Client, p model.Protocol) error {
|
||||
switch p {
|
||||
case model.VMESS, model.VLESS:
|
||||
if c.ID == "" {
|
||||
c.ID = uuid.NewString()
|
||||
}
|
||||
case model.Trojan, model.Shadowsocks:
|
||||
if c.Password == "" {
|
||||
c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
case model.Hysteria, model.Hysteria2:
|
||||
if c.Auth == "" {
|
||||
c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
inboundIds, err := s.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(updated.Email) == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
if updated.SubID == "" {
|
||||
updated.SubID = existing.SubID
|
||||
}
|
||||
if updated.SubID == "" {
|
||||
updated.SubID = uuid.NewString()
|
||||
}
|
||||
updated.UpdatedAt = time.Now().UnixMilli()
|
||||
if updated.CreatedAt == 0 {
|
||||
updated.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return needRestart, getErr
|
||||
}
|
||||
oldKey := clientKeyForProtocol(inbound.Protocol, existing)
|
||||
if oldKey == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.fillProtocolDefaults(&updated, inbound.Protocol); err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}})
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
nr, upErr := inboundSvc.UpdateInboundClient(&model.Inbound{
|
||||
Id: ibId,
|
||||
Settings: string(settingsPayload),
|
||||
}, oldKey)
|
||||
if upErr != nil {
|
||||
return needRestart, upErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
inboundIds, err := s.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return needRestart, getErr
|
||||
}
|
||||
key := clientKeyForProtocol(inbound.Protocol, existing)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
nr, delErr := inboundSvc.DelInboundClient(ibId, key)
|
||||
if delErr != nil {
|
||||
return needRestart, delErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if !keepTraffic && existing.Email != "" {
|
||||
if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
}
|
||||
if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
currentIds, err := s.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
have := make(map[int]struct{}, len(currentIds))
|
||||
for _, x := range currentIds {
|
||||
have[x] = struct{}{}
|
||||
}
|
||||
|
||||
clientWire := existing.ToClient()
|
||||
clientWire.UpdatedAt = time.Now().UnixMilli()
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
if _, attached := have[ibId]; attached {
|
||||
continue
|
||||
}
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return needRestart, getErr
|
||||
}
|
||||
copyClient := *clientWire
|
||||
if err := s.fillProtocolDefaults(©Client, inbound.Protocol); err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}})
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
nr, addErr := inboundSvc.AddInboundClient(&model.Inbound{
|
||||
Id: ibId,
|
||||
Settings: string(settingsPayload),
|
||||
})
|
||||
if addErr != nil {
|
||||
return needRestart, addErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
currentIds, err := s.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
have := make(map[int]struct{}, len(currentIds))
|
||||
for _, x := range currentIds {
|
||||
have[x] = struct{}{}
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundIds {
|
||||
if _, attached := have[ibId]; !attached {
|
||||
continue
|
||||
}
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return needRestart, getErr
|
||||
}
|
||||
key := clientKeyForProtocol(inbound.Protocol, existing)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
nr, delErr := inboundSvc.DelInboundClient(ibId, key)
|
||||
if delErr != nil {
|
||||
return needRestart, delErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
return needRestart, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@
|
|||
"ultraDark": "Ultra Dark",
|
||||
"dashboard": "Overview",
|
||||
"inbounds": "Inbounds",
|
||||
"clients": "Clients",
|
||||
"nodes": "Nodes",
|
||||
"settings": "Panel Settings",
|
||||
"xray": "Xray Configs",
|
||||
|
|
@ -397,6 +398,19 @@
|
|||
"renew": "Auto Renew",
|
||||
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)"
|
||||
},
|
||||
"clients": {
|
||||
"title": "Clients",
|
||||
"addTitle": "Add Client",
|
||||
"editTitle": "Edit Client",
|
||||
"attachedInbounds": "Attached inbounds",
|
||||
"selectInbound": "Select one or more inbounds",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Nodes",
|
||||
"addNode": "Add Node",
|
||||
|
|
|
|||
Loading…
Reference in a new issue