mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-15 10:36:30 +00:00
feat(frontend): Phase 5f-iii — inbound add/edit modal + delete/clone/reset
Wires up the inbound CRUD flows. The protocol-specific and transport- specific forms are still ahead in 5f-iii-b — for now the modal exposes those as JSON textareas so users can both edit existing inbounds without losing settings and create new ones from default templates. - InboundFormModal.vue: tabbed modal with a full Basics tab (enable, remark, protocol, listen, port, total GB, traffic reset, expiry date) and three JSON-edit tabs (Settings, Stream, Sniffing). Add mode stamps a fresh template per protocol via Inbound.Settings.getSettings(protocol); changing the protocol in add mode restamps the JSON. Edit mode pretty-prints the existing JSON so the user sees the same fields they save back. - POST /panel/api/inbounds/add or /panel/api/inbounds/update/:id on submit; on success the parent refreshes the list and the modal closes. Malformed JSON in any of the three textareas surfaces a message.error and aborts the save without losing user input. - InboundsPage.vue: wires the row action menu to real handlers — edit (opens the modal in edit mode), delete, reset-traffic, clone, reset-clients, del-depleted-clients all go through Modal.confirm and refresh on success. General actions menu wires reset-inbounds / reset-clients / del-depleted-clients the same way. Remaining actions (qrcode/info/import/export/copyClients) still toast as "coming soon" — those land in 5f-iv and 5f-v. - Adds dayjs ^1.11.20 dep for the a-date-picker v-model interop. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d2d69ecfa1
commit
52075a0acd
4 changed files with 488 additions and 10 deletions
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
"ant-design-vue": "^4.2.6",
|
"ant-design-vue": "^4.2.6",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"qrious": "^4.0.2",
|
"qrious": "^4.0.2",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
"ant-design-vue": "^4.2.6",
|
"ant-design-vue": "^4.2.6",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"qrious": "^4.0.2",
|
"qrious": "^4.0.2",
|
||||||
|
|
|
||||||
326
frontend/src/pages/inbounds/InboundFormModal.vue
Normal file
326
frontend/src/pages/inbounds/InboundFormModal.vue
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { HttpUtil, RandomUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
||||||
|
import { Inbound, Protocols } from '@/models/inbound.js';
|
||||||
|
import { DBInbound } from '@/models/dbinbound.js';
|
||||||
|
|
||||||
|
// Phase 5f-iii scope: full Basics tab + a JSON-edit fallback for the
|
||||||
|
// protocol settings, transport settings, and sniffing. The protocol-
|
||||||
|
// specific and transport-specific forms (TCP/WS/Reality/etc.) come in
|
||||||
|
// 5f-iii-b, which will replace these textareas with proper field
|
||||||
|
// editors. Saving JSON works today though — so users can both add new
|
||||||
|
// inbounds (with a default template stamped per protocol) and edit
|
||||||
|
// existing ones without losing settings.
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
|
||||||
|
// Required when mode === 'edit'; the modal clones it on open so
|
||||||
|
// cancel doesn't leak edits back to the row.
|
||||||
|
dbInbound: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'saved']);
|
||||||
|
|
||||||
|
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
|
||||||
|
const PROTOCOLS = Object.values(Protocols);
|
||||||
|
|
||||||
|
// Reactive form state — flat fields the Basics tab edits directly,
|
||||||
|
// plus the three JSON strings the textarea tabs edit as text.
|
||||||
|
const form = reactive({
|
||||||
|
enable: true,
|
||||||
|
remark: '',
|
||||||
|
protocol: Protocols.VMESS,
|
||||||
|
listen: '',
|
||||||
|
port: 0,
|
||||||
|
totalGB: 0,
|
||||||
|
trafficReset: 'never',
|
||||||
|
expiryTime: 0, // ms epoch; 0 == never expire
|
||||||
|
// JSON-edit fields:
|
||||||
|
settingsText: '',
|
||||||
|
streamSettingsText: '',
|
||||||
|
sniffingText: '',
|
||||||
|
});
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
// AD-Vue's a-date-picker emits a Day.js value; convert to/from epoch ms.
|
||||||
|
const expiryDate = computed({
|
||||||
|
get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
|
||||||
|
set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
|
||||||
|
});
|
||||||
|
|
||||||
|
// On open, populate `form` from the supplied dbInbound (edit mode) or
|
||||||
|
// stamp a fresh default per protocol (add mode).
|
||||||
|
function loadFromDbInbound(dbIn) {
|
||||||
|
form.enable = dbIn.enable ?? true;
|
||||||
|
form.remark = dbIn.remark || '';
|
||||||
|
form.protocol = dbIn.protocol || Protocols.VMESS;
|
||||||
|
form.listen = dbIn.listen || '';
|
||||||
|
form.port = dbIn.port || 0;
|
||||||
|
form.totalGB = NumberFormatter.toFixed((dbIn.total || 0) / SizeFormatter.ONE_GB, 2);
|
||||||
|
form.trafficReset = dbIn.trafficReset || 'never';
|
||||||
|
form.expiryTime = dbIn.expiryTime || 0;
|
||||||
|
|
||||||
|
// For edit mode the wire JSON strings are already strings; pretty-print
|
||||||
|
// them so the textarea is readable.
|
||||||
|
form.settingsText = prettyJson(dbIn.settings);
|
||||||
|
form.streamSettingsText = prettyJson(dbIn.streamSettings);
|
||||||
|
form.sniffingText = prettyJson(dbIn.sniffing);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyJson(maybeJson) {
|
||||||
|
if (!maybeJson) return '';
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(maybeJson), null, 2);
|
||||||
|
} catch (_e) {
|
||||||
|
return maybeJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stampDefaultsForNew() {
|
||||||
|
const inbound = new Inbound();
|
||||||
|
inbound.protocol = form.protocol;
|
||||||
|
inbound.settings = Inbound.Settings.getSettings(inbound.protocol);
|
||||||
|
form.port = RandomUtil.randomInteger(10000, 60000);
|
||||||
|
form.settingsText = prettyJson(inbound.settings.toString());
|
||||||
|
form.streamSettingsText = prettyJson(inbound.stream.toString());
|
||||||
|
form.sniffingText = prettyJson(inbound.sniffing.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (next) => {
|
||||||
|
if (!next) return;
|
||||||
|
if (props.mode === 'edit' && props.dbInbound) {
|
||||||
|
loadFromDbInbound(props.dbInbound);
|
||||||
|
} else {
|
||||||
|
form.enable = true;
|
||||||
|
form.remark = '';
|
||||||
|
form.protocol = Protocols.VMESS;
|
||||||
|
form.listen = '';
|
||||||
|
form.totalGB = 0;
|
||||||
|
form.trafficReset = 'never';
|
||||||
|
form.expiryTime = 0;
|
||||||
|
stampDefaultsForNew();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the user changes protocol in add mode, restamp the JSON
|
||||||
|
// templates so they match the new protocol's shape.
|
||||||
|
watch(() => form.protocol, (next) => {
|
||||||
|
if (props.mode === 'edit') return;
|
||||||
|
const inbound = new Inbound();
|
||||||
|
inbound.protocol = next;
|
||||||
|
inbound.settings = Inbound.Settings.getSettings(next);
|
||||||
|
form.settingsText = prettyJson(inbound.settings.toString());
|
||||||
|
form.streamSettingsText = prettyJson(inbound.stream.toString());
|
||||||
|
form.sniffingText = prettyJson(inbound.sniffing.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each JSON field; show a message and bail if any is malformed.
|
||||||
|
function parseOrFail(label, text) {
|
||||||
|
const trimmed = (text || '').trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(`${label} is not valid JSON: ${e.message}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
let parsedSettings;
|
||||||
|
let parsedStream;
|
||||||
|
let parsedSniffing;
|
||||||
|
try {
|
||||||
|
parsedSettings = parseOrFail('Settings', form.settingsText);
|
||||||
|
parsedStream = parseOrFail('Stream settings', form.streamSettingsText);
|
||||||
|
parsedSniffing = parseOrFail('Sniffing', form.sniffingText);
|
||||||
|
} catch (_e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute total bytes from totalGB; preserve fractional GB precision.
|
||||||
|
const total = NumberFormatter.toFixed((form.totalGB || 0) * SizeFormatter.ONE_GB, 0);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
up: props.dbInbound?.up ?? 0,
|
||||||
|
down: props.dbInbound?.down ?? 0,
|
||||||
|
total,
|
||||||
|
remark: form.remark,
|
||||||
|
enable: form.enable,
|
||||||
|
expiryTime: form.expiryTime,
|
||||||
|
trafficReset: form.trafficReset,
|
||||||
|
lastTrafficResetTime: props.dbInbound?.lastTrafficResetTime ?? 0,
|
||||||
|
listen: form.listen,
|
||||||
|
port: form.port,
|
||||||
|
protocol: form.protocol,
|
||||||
|
settings: parsedSettings ? JSON.stringify(parsedSettings) : '',
|
||||||
|
streamSettings: parsedStream ? JSON.stringify(parsedStream) : '',
|
||||||
|
sniffing: parsedSniffing ? JSON.stringify(parsedSniffing) : '',
|
||||||
|
};
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const url = props.mode === 'edit'
|
||||||
|
? `/panel/api/inbounds/update/${props.dbInbound.id}`
|
||||||
|
: '/panel/api/inbounds/add';
|
||||||
|
const msg = await HttpUtil.post(url, payload);
|
||||||
|
if (msg?.success) {
|
||||||
|
emit('saved');
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface helper buttons for filling defaults manually so users can
|
||||||
|
// recover after editing the textareas badly.
|
||||||
|
function resetSettingsTemplate() {
|
||||||
|
const s = Inbound.Settings.getSettings(form.protocol);
|
||||||
|
form.settingsText = prettyJson(s.toString());
|
||||||
|
}
|
||||||
|
function resetStreamTemplate() {
|
||||||
|
form.streamSettingsText = prettyJson(new Inbound().stream.toString());
|
||||||
|
}
|
||||||
|
function resetSniffingTemplate() {
|
||||||
|
form.sniffingText = prettyJson(new Inbound().sniffing.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = computed(() => (props.mode === 'edit' ? 'Edit inbound' : 'Add inbound'));
|
||||||
|
const okText = computed(() => (props.mode === 'edit' ? 'Update' : 'Create'));
|
||||||
|
|
||||||
|
// Avoid an unused-import warning — DBInbound is referenced by the parent
|
||||||
|
// via its prop, but importing it here keeps the file self-documenting.
|
||||||
|
void DBInbound;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="title"
|
||||||
|
:ok-text="okText"
|
||||||
|
cancel-text="Close"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
:mask-closable="false"
|
||||||
|
width="720px"
|
||||||
|
@ok="submit"
|
||||||
|
@cancel="close"
|
||||||
|
>
|
||||||
|
<a-tabs default-active-key="basic">
|
||||||
|
<a-tab-pane key="basic" tab="Basics">
|
||||||
|
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
|
||||||
|
<a-form-item label="Enable">
|
||||||
|
<a-switch v-model:checked="form.enable" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Remark">
|
||||||
|
<a-input v-model:value="form.remark" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Protocol">
|
||||||
|
<a-select v-model:value="form.protocol" :disabled="mode === 'edit'">
|
||||||
|
<a-select-option v-for="p in PROTOCOLS" :key="p" :value="p">{{ p }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Listen IP">
|
||||||
|
<a-input v-model:value="form.listen" placeholder="(blank = all interfaces)" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Port">
|
||||||
|
<a-input-number v-model:value="form.port" :min="1" :max="65535" />
|
||||||
|
</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="Traffic reset">
|
||||||
|
<a-select v-model:value="form.trafficReset">
|
||||||
|
<a-select-option v-for="r in TRAFFIC_RESETS" :key="r" :value="r">{{ r }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<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>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="settings" tab="Settings (JSON)">
|
||||||
|
<a-alert
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="Protocol settings — protocol-specific form coming in 5f-iii-b."
|
||||||
|
class="mb-12"
|
||||||
|
/>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.settingsText"
|
||||||
|
:auto-size="{ minRows: 10, maxRows: 24 }"
|
||||||
|
spellcheck="false"
|
||||||
|
class="json-editor"
|
||||||
|
/>
|
||||||
|
<div class="textarea-toolbar">
|
||||||
|
<a-button size="small" @click="resetSettingsTemplate">Reset to default for {{ form.protocol }}</a-button>
|
||||||
|
</div>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="stream" tab="Stream (JSON)">
|
||||||
|
<a-alert
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
message="Transport / TLS / Reality settings — proper form coming in 5f-iii-b."
|
||||||
|
class="mb-12"
|
||||||
|
/>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.streamSettingsText"
|
||||||
|
:auto-size="{ minRows: 10, maxRows: 24 }"
|
||||||
|
spellcheck="false"
|
||||||
|
class="json-editor"
|
||||||
|
/>
|
||||||
|
<div class="textarea-toolbar">
|
||||||
|
<a-button size="small" @click="resetStreamTemplate">Reset to default</a-button>
|
||||||
|
</div>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="sniffing" tab="Sniffing (JSON)">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.sniffingText"
|
||||||
|
:auto-size="{ minRows: 8, maxRows: 24 }"
|
||||||
|
spellcheck="false"
|
||||||
|
class="json-editor"
|
||||||
|
/>
|
||||||
|
<div class="textarea-toolbar">
|
||||||
|
<a-button size="small" @click="resetSniffingTemplate">Reset to default</a-button>
|
||||||
|
</div>
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mb-12 { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.json-editor {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { theme as antdTheme, message } from 'ant-design-vue';
|
import { theme as antdTheme, Modal, message } from 'ant-design-vue';
|
||||||
import {
|
import {
|
||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
PieChartOutlined,
|
PieChartOutlined,
|
||||||
|
|
@ -9,12 +9,14 @@ import {
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
import { SizeFormatter } from '@/utils';
|
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||||
|
import { Inbound } from '@/models/inbound.js';
|
||||||
import { theme as themeState } from '@/composables/useTheme.js';
|
import { theme as themeState } from '@/composables/useTheme.js';
|
||||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||||
import AppSidebar from '@/components/AppSidebar.vue';
|
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 { useInbounds } from './useInbounds.js';
|
import { useInbounds } from './useInbounds.js';
|
||||||
|
|
||||||
const antdThemeConfig = computed(() => ({
|
const antdThemeConfig = computed(() => ({
|
||||||
|
|
@ -45,17 +47,158 @@ onMounted(async () => {
|
||||||
await refresh();
|
await refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modal triggers come in 5f-iii…vii. Until then, action handlers are
|
// === Add/Edit modal ===================================================
|
||||||
// no-op placeholders that surface a "coming soon" toast so the user
|
const formOpen = ref(false);
|
||||||
// gets feedback when clicking through the menu items.
|
const formMode = ref('add');
|
||||||
|
const formDbInbound = ref(null);
|
||||||
|
|
||||||
function onAddInbound() {
|
function onAddInbound() {
|
||||||
message.info('Inbound add/edit modal — coming in 5f-iii');
|
formMode.value = 'add';
|
||||||
|
formDbInbound.value = null;
|
||||||
|
formOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEdit(dbInbound) {
|
||||||
|
formMode.value = 'edit';
|
||||||
|
formDbInbound.value = dbInbound;
|
||||||
|
formOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-row destructive actions go through Modal.confirm (matches legacy).
|
||||||
|
function confirmDelete(dbInbound) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Delete inbound "${dbInbound.remark}"?`,
|
||||||
|
content: 'This removes the inbound and all its clients. This cannot be undone.',
|
||||||
|
okText: 'Delete',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmResetTraffic(dbInbound) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Reset traffic for "${dbInbound.remark}"?`,
|
||||||
|
content: 'Resets up/down counters to 0 for this inbound.',
|
||||||
|
okText: 'Reset',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllTraffics`);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelDepleted(dbInboundId) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Delete depleted clients?',
|
||||||
|
content: 'Removes every client whose traffic is exhausted or whose expiry has passed.',
|
||||||
|
okText: 'Delete',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/delDepletedClients/${dbInboundId}`);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone — adds a new inbound with the same protocol+stream+sniffing
|
||||||
|
// but a fresh remark/port and an empty client list.
|
||||||
|
function confirmClone(dbInbound) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Clone inbound "${dbInbound.remark}"?`,
|
||||||
|
content: 'Creates a copy with a new port and an empty client list.',
|
||||||
|
okText: 'Clone',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const baseInbound = dbInbound.toInbound();
|
||||||
|
const data = {
|
||||||
|
up: 0,
|
||||||
|
down: 0,
|
||||||
|
total: 0,
|
||||||
|
remark: `${dbInbound.remark} (clone)`,
|
||||||
|
enable: false,
|
||||||
|
expiryTime: 0,
|
||||||
|
listen: '',
|
||||||
|
port: RandomUtil.randomInteger(10000, 60000),
|
||||||
|
protocol: baseInbound.protocol,
|
||||||
|
settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
|
||||||
|
streamSettings: baseInbound.stream.toString(),
|
||||||
|
sniffing: baseInbound.sniffing.toString(),
|
||||||
|
};
|
||||||
|
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function onGeneralAction(key) {
|
function onGeneralAction(key) {
|
||||||
message.info(`General action "${key}" — coming in a later 5f subphase`);
|
switch (key) {
|
||||||
|
case 'resetInbounds':
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Reset all inbound traffic?',
|
||||||
|
okText: 'Reset',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'resetClients':
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Reset all client traffic across all inbounds?',
|
||||||
|
okText: 'Reset',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllClientTraffics/-1');
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'delDepletedClients':
|
||||||
|
confirmDelDepleted(-1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message.info(`General action "${key}" — coming in a later 5f subphase`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function onRowAction({ key }) {
|
|
||||||
message.info(`Row action "${key}" — coming in a later 5f subphase`);
|
function onRowAction({ key, dbInbound }) {
|
||||||
|
switch (key) {
|
||||||
|
case 'edit':
|
||||||
|
openEdit(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
confirmDelete(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'resetTraffic':
|
||||||
|
confirmResetTraffic(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'clone':
|
||||||
|
confirmClone(dbInbound);
|
||||||
|
break;
|
||||||
|
case 'resetClients':
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Reset client traffic on "${dbInbound.remark}"?`,
|
||||||
|
okText: 'Reset',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllClientTraffics/${dbInbound.id}`);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'delDepletedClients':
|
||||||
|
confirmDelDepleted(dbInbound.id);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message.info(`Action "${key}" — coming in a later 5f subphase`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -145,6 +288,13 @@ function onRowAction({ key }) {
|
||||||
</a-spin>
|
</a-spin>
|
||||||
</a-layout-content>
|
</a-layout-content>
|
||||||
</a-layout>
|
</a-layout>
|
||||||
|
|
||||||
|
<InboundFormModal
|
||||||
|
v-model:open="formOpen"
|
||||||
|
:mode="formMode"
|
||||||
|
:db-inbound="formDbInbound"
|
||||||
|
@saved="refresh"
|
||||||
|
/>
|
||||||
</a-layout>
|
</a-layout>
|
||||||
</a-config-provider>
|
</a-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue