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:
MHSanaei 2026-05-08 13:41:21 +02:00
parent d2d69ecfa1
commit 52075a0acd
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
4 changed files with 488 additions and 10 deletions

View file

@ -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",

View file

@ -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",

View 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>

View file

@ -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-iiivii. 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) {
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`); 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>