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:
MHSanaei
2026-05-17 07:28:55 +02:00
parent ba3c581372
commit 2bcf287cf1
15 changed files with 1221 additions and 4 deletions
+13
View 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>
+16 -4
View File
@@ -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);
}
}
+3
View File
@@ -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
View 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');
});
+79
View File
@@ -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',
@@ -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
View 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
View 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,
};
}
+3
View File
@@ -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'),