mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
feat(frontend): Phase 5f-iv — client add/edit + bulk-add modals
Wires per-inbound client management. Both flows go through the same
addClient/updateClient endpoints as legacy; the modals just funnel
the form state into the right shape (`{id, settings: '{"clients": [...]}'}`).
- ClientFormModal.vue: protocol-aware single-client editor — email/
password/id/auth/security/flow/subId/tgId/comment/ipLimit/totalGB/
expiry/renewal fields are shown/hidden per protocol like legacy.
Edit mode displays the per-client traffic stats with a reset
button; IP-limit log is read on click and clearable. Random
helpers (sync icon next to each label) regenerate UUID/email/
password/sub-id values.
- ClientBulkModal.vue: 1–500 clients in one POST, with the legacy
five email-generation modes (Random / +Prefix / +Num / +Postfix /
Pure-Prefix-Num-Postfix). Builds clients via the protocol-aware
factory and concatenates their toString() output into a single
settings.clients JSON array.
- InboundsPage.vue: opens both modals from the row action menu
(`addClient` / `addBulkClient`). They both refresh the inbound list
on success.
- Outstanding row actions still toast as "coming soon": qrcode,
showInfo, copyClients, clipboard. Those land in 5f-v / 5f-vi.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
52075a0acd
commit
d052de9a93
3 changed files with 787 additions and 0 deletions
293
frontend/src/pages/inbounds/ClientBulkModal.vue
Normal file
293
frontend/src/pages/inbounds/ClientBulkModal.vue
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { SyncOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
|
||||||
|
import {
|
||||||
|
Inbound,
|
||||||
|
Protocols,
|
||||||
|
USERS_SECURITY,
|
||||||
|
TLS_FLOW_CONTROL,
|
||||||
|
} from '@/models/inbound.js';
|
||||||
|
|
||||||
|
// Bulk-add up to 500 clients in one go. The legacy panel offers five
|
||||||
|
// generation modes — this component preserves them all:
|
||||||
|
// 0: Random — N fully-random emails (no prefix)
|
||||||
|
// 1: Random+Prefix — N random emails preceded by `prefix`
|
||||||
|
// 2: Random+Prefix+Num — emails like `<rand><prefix><num>` for num in [first..last]
|
||||||
|
// 3: Random+Prefix+Num+Postfix — same + appended postfix
|
||||||
|
// 4: Prefix+Num+Postfix — no random part, just `<prefix><num><postfix>`
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
dbInbound: { type: Object, default: null },
|
||||||
|
subEnable: { type: Boolean, default: false },
|
||||||
|
tgBotEnable: { type: Boolean, default: false },
|
||||||
|
ipLimitEnable: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'saved']);
|
||||||
|
|
||||||
|
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
||||||
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
|
|
||||||
|
// === Reactive form state ===========================================
|
||||||
|
// Cloned inbound (so canEnableTlsFlow() works).
|
||||||
|
const inbound = ref(null);
|
||||||
|
const saving = ref(false);
|
||||||
|
const delayedStart = ref(false);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
emailMethod: 0,
|
||||||
|
firstNum: 1,
|
||||||
|
lastNum: 1,
|
||||||
|
emailPrefix: '',
|
||||||
|
emailPostfix: '',
|
||||||
|
quantity: 1,
|
||||||
|
security: USERS_SECURITY.AUTO,
|
||||||
|
flow: '',
|
||||||
|
subId: '',
|
||||||
|
tgId: 0,
|
||||||
|
limitIp: 0,
|
||||||
|
totalGB: 0,
|
||||||
|
expiryTime: 0, // ms epoch; negative => delayed start days
|
||||||
|
reset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiryDate = computed({
|
||||||
|
get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
|
||||||
|
set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delayedExpireDays = computed({
|
||||||
|
get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
|
||||||
|
set: (days) => { form.expiryTime = -86400000 * (days || 0); },
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (!next) return;
|
||||||
|
if (!props.dbInbound) return;
|
||||||
|
inbound.value = Inbound.fromJson(props.dbInbound.toInbound().toJson());
|
||||||
|
// Reset all form fields on every open — bulk add is intentionally
|
||||||
|
// stateless between sessions (legacy resets on .show()).
|
||||||
|
form.emailMethod = 0;
|
||||||
|
form.firstNum = 1;
|
||||||
|
form.lastNum = 1;
|
||||||
|
form.emailPrefix = '';
|
||||||
|
form.emailPostfix = '';
|
||||||
|
form.quantity = 1;
|
||||||
|
form.security = USERS_SECURITY.AUTO;
|
||||||
|
form.flow = '';
|
||||||
|
form.subId = '';
|
||||||
|
form.tgId = 0;
|
||||||
|
form.limitIp = 0;
|
||||||
|
form.totalGB = 0;
|
||||||
|
form.expiryTime = 0;
|
||||||
|
form.reset = 0;
|
||||||
|
delayedStart.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNewClient(parsed) {
|
||||||
|
switch (parsed.protocol) {
|
||||||
|
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
||||||
|
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||||
|
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||||
|
case Protocols.SHADOWSOCKS: {
|
||||||
|
const method = parsed.settings.shadowsockses[0]?.method || parsed.settings.method;
|
||||||
|
return new Inbound.ShadowsocksSettings.Shadowsocks(method);
|
||||||
|
}
|
||||||
|
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClients() {
|
||||||
|
if (!inbound.value) return [];
|
||||||
|
const out = [];
|
||||||
|
const method = form.emailMethod;
|
||||||
|
let start;
|
||||||
|
let end;
|
||||||
|
if (method > 1) {
|
||||||
|
start = form.firstNum;
|
||||||
|
end = form.lastNum + 1;
|
||||||
|
} else {
|
||||||
|
start = 0;
|
||||||
|
end = form.quantity;
|
||||||
|
}
|
||||||
|
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
|
||||||
|
const useNum = method > 1;
|
||||||
|
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
|
||||||
|
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const c = makeNewClient(inbound.value);
|
||||||
|
if (!c) continue;
|
||||||
|
if (method === 4) c.email = '';
|
||||||
|
c.email += useNum ? prefix + String(i) + postfix : prefix + postfix;
|
||||||
|
|
||||||
|
if (form.subId.length > 0) c.subId = form.subId;
|
||||||
|
c.tgId = form.tgId;
|
||||||
|
c.security = form.security;
|
||||||
|
c.limitIp = form.limitIp;
|
||||||
|
// Use the clien's totalGB setter (ms epoch and bytes already handled
|
||||||
|
// identically for bulk and single client paths).
|
||||||
|
c.totalGB = Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB);
|
||||||
|
c.expiryTime = form.expiryTime;
|
||||||
|
if (inbound.value.canEnableTlsFlow()) c.flow = form.flow;
|
||||||
|
c.reset = form.reset;
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const clients = buildClients();
|
||||||
|
if (clients.length === 0) return;
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id: props.dbInbound.id,
|
||||||
|
// Clients all serialize via toString() — same shape the single-
|
||||||
|
// client modal posts. Joining with `,` lets the Go side parse the
|
||||||
|
// outer array directly.
|
||||||
|
settings: `{"clients": [${clients.map((c) => c.toString()).join(',')}]}`,
|
||||||
|
};
|
||||||
|
const msg = await HttpUtil.post('/panel/api/inbounds/addClient', payload);
|
||||||
|
if (msg?.success) {
|
||||||
|
emit('saved');
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
title="Add bulk clients"
|
||||||
|
ok-text="Create"
|
||||||
|
cancel-text="Close"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
:mask-closable="false"
|
||||||
|
@ok="submit"
|
||||||
|
@cancel="close"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
v-if="inbound"
|
||||||
|
:colon="false"
|
||||||
|
:label-col="{ md: { span: 8 } }"
|
||||||
|
:wrapper-col="{ md: { span: 14 } }"
|
||||||
|
>
|
||||||
|
<a-form-item label="Email method">
|
||||||
|
<a-select v-model:value="form.emailMethod">
|
||||||
|
<a-select-option :value="0">Random</a-select-option>
|
||||||
|
<a-select-option :value="1">Random + Prefix</a-select-option>
|
||||||
|
<a-select-option :value="2">Random + Prefix + Num</a-select-option>
|
||||||
|
<a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
|
||||||
|
<a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="form.emailMethod > 1" label="First number">
|
||||||
|
<a-input-number v-model:value="form.firstNum" :min="1" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.emailMethod > 1" label="Last number">
|
||||||
|
<a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.emailMethod > 0" label="Prefix">
|
||||||
|
<a-input v-model:value="form.emailPrefix" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.emailMethod > 2" label="Postfix">
|
||||||
|
<a-input v-model:value="form.emailPostfix" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="form.emailMethod < 2" label="Client count">
|
||||||
|
<a-input-number v-model:value="form.quantity" :min="1" :max="500" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="inbound.protocol === Protocols.VMESS" label="Security">
|
||||||
|
<a-select v-model:value="form.security">
|
||||||
|
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||||
|
<a-select v-model:value="form.flow">
|
||||||
|
<a-select-option value="">none</a-select-option>
|
||||||
|
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="subEnable">
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Same subscription token for every generated client (random when blank)">
|
||||||
|
Subscription
|
||||||
|
<SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="form.subId" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="tgBotEnable" label="Telegram chat ID">
|
||||||
|
<a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="ipLimitEnable" label="IP limit">
|
||||||
|
<a-input-number v-model:value="form.limitIp" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="0 means no limit">Total traffic (GB)</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="Delayed start">
|
||||||
|
<a-switch
|
||||||
|
v-model:checked="delayedStart"
|
||||||
|
@click="form.expiryTime = 0"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="delayedStart" label="Days from first connection">
|
||||||
|
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-else>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Leave blank to never expire">Expiry date</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-date-picker
|
||||||
|
v-model:value="expiryDate"
|
||||||
|
:show-time="{ format: 'HH:mm:ss' }"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
:style="{ width: '100%' }"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="form.expiryTime !== 0">
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Days between automatic renewals (0 = no renewal)">
|
||||||
|
Renewal cycle (days)
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model:value="form.reset" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.random-icon {
|
||||||
|
margin-left: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ant-primary-color, #1890ff);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
444
frontend/src/pages/inbounds/ClientFormModal.vue
Normal file
444
frontend/src/pages/inbounds/ClientFormModal.vue
Normal file
|
|
@ -0,0 +1,444 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { SyncOutlined, RetweetOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
HttpUtil,
|
||||||
|
RandomUtil,
|
||||||
|
SizeFormatter,
|
||||||
|
ColorUtils,
|
||||||
|
} from '@/utils';
|
||||||
|
import { Inbound, Protocols, USERS_SECURITY, TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||||
|
|
||||||
|
// Add OR edit a single client on a multi-user inbound (VMess / VLess /
|
||||||
|
// Trojan / Shadowsocks-multi / Hysteria). The legacy panel routes both
|
||||||
|
// flows through the same modal — same here.
|
||||||
|
//
|
||||||
|
// On submit we serialize the client via its toString() (which is just
|
||||||
|
// JSON.stringify of toJson()) and post it inside a one-element clients
|
||||||
|
// array so the Go side reuses the same parsing path as the inbound
|
||||||
|
// settings update.
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
|
||||||
|
dbInbound: { type: Object, default: null },
|
||||||
|
clientIndex: { type: Number, default: null },
|
||||||
|
// Sidecar config from the inbounds page — controls visibility of
|
||||||
|
// the Subscription, Telegram, and IP-limit fields.
|
||||||
|
subEnable: { type: Boolean, default: false },
|
||||||
|
tgBotEnable: { type: Boolean, default: false },
|
||||||
|
ipLimitEnable: { type: Boolean, default: false },
|
||||||
|
trafficDiff: { type: Number, default: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'saved']);
|
||||||
|
|
||||||
|
// === Reactive draft =================================================
|
||||||
|
// We keep a parsed Inbound copy so its existing toString() / canEnableTlsFlow()
|
||||||
|
// helpers continue to work; `client` is the entry inside that inbound's
|
||||||
|
// clients array we're editing.
|
||||||
|
const inbound = ref(null);
|
||||||
|
const client = ref(null);
|
||||||
|
const oldClientId = ref('');
|
||||||
|
const clientStats = ref(null);
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
const delayedStart = ref(false);
|
||||||
|
|
||||||
|
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
||||||
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
|
|
||||||
|
const protocol = computed(() => inbound.value?.protocol);
|
||||||
|
const isVmessOrVless = computed(() =>
|
||||||
|
protocol.value === Protocols.VMESS || protocol.value === Protocols.VLESS,
|
||||||
|
);
|
||||||
|
const isTrojanOrSS = computed(() =>
|
||||||
|
protocol.value === Protocols.TROJAN || protocol.value === Protocols.SHADOWSOCKS,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bridge dayjs <-> the client's epoch-ms expiryTime field (legacy uses
|
||||||
|
// moment via _expiryTime getter; we go direct so we don't pull moment in).
|
||||||
|
const expiryDate = computed({
|
||||||
|
get: () => (client.value?.expiryTime > 0 ? dayjs(client.value.expiryTime) : null),
|
||||||
|
set: (next) => { if (client.value) client.value.expiryTime = next ? next.valueOf() : 0; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delayedExpireDays = computed({
|
||||||
|
get: () => {
|
||||||
|
if (!client.value || client.value.expiryTime >= 0) return 0;
|
||||||
|
return client.value.expiryTime / -86400000;
|
||||||
|
},
|
||||||
|
set: (days) => {
|
||||||
|
if (!client.value) return;
|
||||||
|
client.value.expiryTime = -86400000 * (days || 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalGB = computed({
|
||||||
|
get: () => {
|
||||||
|
if (!client.value || !client.value.totalGB) return 0;
|
||||||
|
return Math.round((client.value.totalGB / SizeFormatter.ONE_GB) * 100) / 100;
|
||||||
|
},
|
||||||
|
set: (gb) => {
|
||||||
|
if (!client.value) return;
|
||||||
|
client.value.totalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display: "Expired" tag in edit mode when past expiry.
|
||||||
|
const isExpired = computed(() => {
|
||||||
|
if (props.mode !== 'edit' || !client.value) return false;
|
||||||
|
return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
|
||||||
|
});
|
||||||
|
const isTrafficExhausted = computed(() => {
|
||||||
|
if (!clientStats.value || clientStats.value.total <= 0) return false;
|
||||||
|
return clientStats.value.up + clientStats.value.down >= clientStats.value.total;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getClientId(proto, c) {
|
||||||
|
switch (proto) {
|
||||||
|
case Protocols.TROJAN: return c.password;
|
||||||
|
case Protocols.SHADOWSOCKS: return c.email;
|
||||||
|
case Protocols.HYSTERIA: return c.auth;
|
||||||
|
default: return c.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNewClient(proto, parsed) {
|
||||||
|
switch (proto) {
|
||||||
|
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
||||||
|
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||||
|
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||||
|
case Protocols.SHADOWSOCKS: {
|
||||||
|
const method = parsed.settings.method;
|
||||||
|
return new Inbound.ShadowsocksSettings.Shadowsocks(
|
||||||
|
method,
|
||||||
|
RandomUtil.randomShadowsocksPassword(method),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (!next) return;
|
||||||
|
if (!props.dbInbound) return;
|
||||||
|
// Clone the inbound so cancelling the modal doesn't leak edits onto
|
||||||
|
// the row's parsed-cache copy.
|
||||||
|
const parsed = Inbound.fromJson(props.dbInbound.toInbound().toJson());
|
||||||
|
inbound.value = parsed;
|
||||||
|
delayedStart.value = false;
|
||||||
|
|
||||||
|
if (props.mode === 'edit') {
|
||||||
|
const idx = props.clientIndex ?? 0;
|
||||||
|
client.value = parsed.clients[idx];
|
||||||
|
if (client.value && client.value.expiryTime < 0) delayedStart.value = true;
|
||||||
|
oldClientId.value = getClientId(parsed.protocol, client.value);
|
||||||
|
} else {
|
||||||
|
const c = makeNewClient(parsed.protocol, parsed);
|
||||||
|
if (c) parsed.clients.push(c);
|
||||||
|
client.value = parsed.clients[parsed.clients.length - 1];
|
||||||
|
oldClientId.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the existing per-client traffic stats row for the usage display.
|
||||||
|
clientStats.value = (props.dbInbound.clientStats || []).find(
|
||||||
|
(s) => s.email === client.value?.email,
|
||||||
|
) || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random helpers wired to the small <SyncOutlined /> icons next to each
|
||||||
|
// label (matches legacy ergonomics).
|
||||||
|
function randomEmail() {
|
||||||
|
if (client.value) client.value.email = RandomUtil.randomLowerAndNum(9);
|
||||||
|
}
|
||||||
|
function randomId() {
|
||||||
|
if (client.value) client.value.id = RandomUtil.randomUUID();
|
||||||
|
}
|
||||||
|
function randomPassword() {
|
||||||
|
if (!client.value || !inbound.value) return;
|
||||||
|
if (inbound.value.protocol === Protocols.SHADOWSOCKS) {
|
||||||
|
client.value.password = RandomUtil.randomShadowsocksPassword(
|
||||||
|
inbound.value.settings.method,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
client.value.password = RandomUtil.randomSeq(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function randomAuth() {
|
||||||
|
if (client.value) client.value.auth = RandomUtil.randomSeq(10);
|
||||||
|
}
|
||||||
|
function randomSubId() {
|
||||||
|
if (client.value) client.value.subId = RandomUtil.randomLowerAndNum(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Per-client IP-limit log helpers ================================
|
||||||
|
const clientIpsText = ref('');
|
||||||
|
async function loadClientIps() {
|
||||||
|
if (!client.value?.email) return;
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${client.value.email}`);
|
||||||
|
if (!msg?.success) {
|
||||||
|
clientIpsText.value = msg?.obj || '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ips = msg.obj;
|
||||||
|
if (typeof ips === 'string' && ips.startsWith('[') && ips.endsWith(']')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(ips);
|
||||||
|
ips = Array.isArray(parsed) ? parsed.join('\n') : ips;
|
||||||
|
} catch (_e) {
|
||||||
|
// leave as raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clientIpsText.value = ips || '';
|
||||||
|
}
|
||||||
|
async function clearClientIps() {
|
||||||
|
if (!client.value?.email) return;
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${client.value.email}`);
|
||||||
|
if (msg?.success) clientIpsText.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Reset traffic on the open client ===============================
|
||||||
|
async function resetClientTraffic() {
|
||||||
|
if (!clientStats.value || !client.value?.email) return;
|
||||||
|
const msg = await HttpUtil.post(
|
||||||
|
`/panel/api/inbounds/${props.dbInbound.id}/resetClientTraffic/${client.value.email}`,
|
||||||
|
);
|
||||||
|
if (msg?.success) {
|
||||||
|
clientStats.value.up = 0;
|
||||||
|
clientStats.value.down = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Submit =========================================================
|
||||||
|
async function submit() {
|
||||||
|
if (!client.value || !inbound.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id: props.dbInbound.id,
|
||||||
|
settings: `{"clients": [${client.value.toString()}]}`,
|
||||||
|
};
|
||||||
|
const url = props.mode === 'edit'
|
||||||
|
? `/panel/api/inbounds/updateClient/${oldClientId.value}`
|
||||||
|
: '/panel/api/inbounds/addClient';
|
||||||
|
const msg = await HttpUtil.post(url, payload);
|
||||||
|
if (msg?.success) {
|
||||||
|
emit('saved');
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = computed(() =>
|
||||||
|
props.mode === 'edit' ? 'Edit client' : 'Add client',
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="title"
|
||||||
|
:ok-text="mode === 'edit' ? 'Update' : 'Create'"
|
||||||
|
cancel-text="Close"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
:mask-closable="false"
|
||||||
|
@ok="submit"
|
||||||
|
@cancel="close"
|
||||||
|
>
|
||||||
|
<a-tag
|
||||||
|
v-if="mode === 'edit' && (isExpired || isTrafficExhausted)"
|
||||||
|
color="red"
|
||||||
|
class="status-banner"
|
||||||
|
>
|
||||||
|
Account is (expired | traffic ended) and disabled
|
||||||
|
</a-tag>
|
||||||
|
|
||||||
|
<a-form
|
||||||
|
v-if="client && inbound"
|
||||||
|
layout="horizontal"
|
||||||
|
:colon="false"
|
||||||
|
:label-col="{ md: { span: 8 } }"
|
||||||
|
:wrapper-col="{ md: { span: 14 } }"
|
||||||
|
>
|
||||||
|
<a-form-item label="Enable">
|
||||||
|
<a-switch v-model:checked="client.enable" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Friendly identifier — appears in logs and the client list">
|
||||||
|
Email <SyncOutlined class="random-icon" @click="randomEmail" />
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="client.email" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="isTrojanOrSS">
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Reset to a fresh random value">
|
||||||
|
Password <SyncOutlined class="random-icon" @click="randomPassword" />
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="client.password" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="protocol === Protocols.HYSTERIA">
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Reset to a fresh random value">
|
||||||
|
Auth password <SyncOutlined class="random-icon" @click="randomAuth" />
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="client.auth" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="isVmessOrVless">
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Reset to a fresh random UUID">
|
||||||
|
ID <SyncOutlined class="random-icon" @click="randomId" />
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="client.id" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="protocol === Protocols.VMESS" label="Security">
|
||||||
|
<a-select v-model:value="client.security">
|
||||||
|
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">
|
||||||
|
{{ key }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="client.email && subEnable">
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Subscription token — clients fetch their config under this id">
|
||||||
|
Subscription <SyncOutlined class="random-icon" @click="randomSubId" />
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="client.subId" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="client.email && tgBotEnable" label="Telegram chat ID">
|
||||||
|
<a-input-number v-model:value="client.tgId" :min="0" :style="{ width: '50%' }" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="client.email" label="Comment">
|
||||||
|
<a-input v-model:value="client.comment" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="ipLimitEnable" label="IP limit">
|
||||||
|
<a-input-number v-model:value="client.limitIp" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item
|
||||||
|
v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
|
||||||
|
label="IP log"
|
||||||
|
>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="clientIpsText"
|
||||||
|
readonly
|
||||||
|
placeholder="Click to load client IPs"
|
||||||
|
:auto-size="{ minRows: 3, maxRows: 8 }"
|
||||||
|
@click="loadClientIps"
|
||||||
|
/>
|
||||||
|
<a-button type="link" size="small" danger @click="clearClientIps">
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
Clear
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||||
|
<a-select v-model:value="client.flow">
|
||||||
|
<a-select-option value="">none</a-select-option>
|
||||||
|
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">
|
||||||
|
{{ key }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="protocol === Protocols.VLESS">
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Reverse tag — for xray reverse-proxy outbound matching">
|
||||||
|
Reverse tag
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model:value="client.reverseTag" placeholder="Optional reverse tag" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="0 means no limit">Total traffic (GB)</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="mode === 'edit' && clientStats" label="Usage">
|
||||||
|
<a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
|
||||||
|
{{ SizeFormatter.sizeFormat(clientStats.up) }} /
|
||||||
|
{{ SizeFormatter.sizeFormat(clientStats.down) }}
|
||||||
|
({{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }})
|
||||||
|
</a-tag>
|
||||||
|
<a-tooltip v-if="client.email" title="Reset traffic">
|
||||||
|
<RetweetOutlined class="action-icon" @click="resetClientTraffic" />
|
||||||
|
</a-tooltip>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="Delayed start">
|
||||||
|
<a-switch
|
||||||
|
v-model:checked="delayedStart"
|
||||||
|
@click="client.expiryTime = 0"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="delayedStart" label="Days from first connection">
|
||||||
|
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-else>
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Leave blank to never expire">Expiry date</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-date-picker
|
||||||
|
v-model:value="expiryDate"
|
||||||
|
:show-time="{ format: 'HH:mm:ss' }"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
:style="{ width: '100%' }"
|
||||||
|
/>
|
||||||
|
<a-tag v-if="mode === 'edit' && isExpired" color="red">Expired</a-tag>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="client.expiryTime !== 0">
|
||||||
|
<template #label>
|
||||||
|
<a-tooltip title="Days between automatic renewals (0 = no renewal)">
|
||||||
|
Renewal cycle (days)
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input-number v-model:value="client.reset" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.status-banner {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.random-icon,
|
||||||
|
.action-icon {
|
||||||
|
margin-left: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ant-primary-color, #1890ff);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -17,6 +17,8 @@ import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
import CustomStatistic from '@/components/CustomStatistic.vue';
|
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||||
import InboundList from './InboundList.vue';
|
import InboundList from './InboundList.vue';
|
||||||
import InboundFormModal from './InboundFormModal.vue';
|
import InboundFormModal from './InboundFormModal.vue';
|
||||||
|
import ClientFormModal from './ClientFormModal.vue';
|
||||||
|
import ClientBulkModal from './ClientBulkModal.vue';
|
||||||
import { useInbounds } from './useInbounds.js';
|
import { useInbounds } from './useInbounds.js';
|
||||||
|
|
||||||
const antdThemeConfig = computed(() => ({
|
const antdThemeConfig = computed(() => ({
|
||||||
|
|
@ -34,6 +36,8 @@ const {
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
pageSize,
|
pageSize,
|
||||||
subSettings,
|
subSettings,
|
||||||
|
tgBotEnable,
|
||||||
|
ipLimitEnable,
|
||||||
refresh,
|
refresh,
|
||||||
fetchDefaultSettings,
|
fetchDefaultSettings,
|
||||||
} = useInbounds();
|
} = useInbounds();
|
||||||
|
|
@ -52,6 +56,15 @@ const formOpen = ref(false);
|
||||||
const formMode = ref('add');
|
const formMode = ref('add');
|
||||||
const formDbInbound = ref(null);
|
const formDbInbound = ref(null);
|
||||||
|
|
||||||
|
// === Client modal (single + bulk) =====================================
|
||||||
|
const clientOpen = ref(false);
|
||||||
|
const clientMode = ref('add');
|
||||||
|
const clientDbInbound = ref(null);
|
||||||
|
const clientIndex = ref(null);
|
||||||
|
|
||||||
|
const bulkOpen = ref(false);
|
||||||
|
const bulkDbInbound = ref(null);
|
||||||
|
|
||||||
function onAddInbound() {
|
function onAddInbound() {
|
||||||
formMode.value = 'add';
|
formMode.value = 'add';
|
||||||
formDbInbound.value = null;
|
formDbInbound.value = null;
|
||||||
|
|
@ -64,6 +77,18 @@ function openEdit(dbInbound) {
|
||||||
formOpen.value = true;
|
formOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAddClient(dbInbound) {
|
||||||
|
clientMode.value = 'add';
|
||||||
|
clientDbInbound.value = dbInbound;
|
||||||
|
clientIndex.value = null;
|
||||||
|
clientOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddBulkClient(dbInbound) {
|
||||||
|
bulkDbInbound.value = dbInbound;
|
||||||
|
bulkOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-row destructive actions go through Modal.confirm (matches legacy).
|
// Per-row destructive actions go through Modal.confirm (matches legacy).
|
||||||
function confirmDelete(dbInbound) {
|
function confirmDelete(dbInbound) {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
|
|
@ -173,6 +198,12 @@ function onRowAction({ key, dbInbound }) {
|
||||||
case 'edit':
|
case 'edit':
|
||||||
openEdit(dbInbound);
|
openEdit(dbInbound);
|
||||||
break;
|
break;
|
||||||
|
case 'addClient':
|
||||||
|
openAddClient(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'addBulkClient':
|
||||||
|
openAddBulkClient(dbInbound);
|
||||||
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
confirmDelete(dbInbound);
|
confirmDelete(dbInbound);
|
||||||
break;
|
break;
|
||||||
|
|
@ -295,6 +326,25 @@ function onRowAction({ key, dbInbound }) {
|
||||||
:db-inbound="formDbInbound"
|
:db-inbound="formDbInbound"
|
||||||
@saved="refresh"
|
@saved="refresh"
|
||||||
/>
|
/>
|
||||||
|
<ClientFormModal
|
||||||
|
v-model:open="clientOpen"
|
||||||
|
:mode="clientMode"
|
||||||
|
:db-inbound="clientDbInbound"
|
||||||
|
:client-index="clientIndex"
|
||||||
|
:sub-enable="subSettings.enable"
|
||||||
|
:tg-bot-enable="tgBotEnable"
|
||||||
|
:ip-limit-enable="ipLimitEnable"
|
||||||
|
:traffic-diff="trafficDiff"
|
||||||
|
@saved="refresh"
|
||||||
|
/>
|
||||||
|
<ClientBulkModal
|
||||||
|
v-model:open="bulkOpen"
|
||||||
|
:db-inbound="bulkDbInbound"
|
||||||
|
:sub-enable="subSettings.enable"
|
||||||
|
:tg-bot-enable="tgBotEnable"
|
||||||
|
:ip-limit-enable="ipLimitEnable"
|
||||||
|
@saved="refresh"
|
||||||
|
/>
|
||||||
</a-layout>
|
</a-layout>
|
||||||
</a-config-provider>
|
</a-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue