diff --git a/frontend/src/components/PromptModal.vue b/frontend/src/components/PromptModal.vue
new file mode 100644
index 00000000..f79a3974
--- /dev/null
+++ b/frontend/src/components/PromptModal.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/TextModal.vue b/frontend/src/components/TextModal.vue
new file mode 100644
index 00000000..25bd2720
--- /dev/null
+++ b/frontend/src/components/TextModal.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+ {{ fileName }}
+
+
+
+ Copy
+
+
+
+
+
+
diff --git a/frontend/src/pages/inbounds/InboundsPage.vue b/frontend/src/pages/inbounds/InboundsPage.vue
index 6dfd9a5a..f479b36f 100644
--- a/frontend/src/pages/inbounds/InboundsPage.vue
+++ b/frontend/src/pages/inbounds/InboundsPage.vue
@@ -21,6 +21,8 @@ import ClientFormModal from './ClientFormModal.vue';
import ClientBulkModal from './ClientBulkModal.vue';
import InboundInfoModal from './InboundInfoModal.vue';
import QrCodeModal from './QrCodeModal.vue';
+import TextModal from '@/components/TextModal.vue';
+import PromptModal from '@/components/PromptModal.vue';
import { useInbounds } from './useInbounds.js';
const antdThemeConfig = computed(() => ({
@@ -77,6 +79,127 @@ const infoClientIndex = ref(0);
const qrOpen = ref(false);
const qrDbInbound = ref(null);
+// === Shared text + prompt modal state =================================
+const textOpen = ref(false);
+const textTitle = ref('');
+const textContent = ref('');
+const textFileName = ref('');
+
+const promptOpen = ref(false);
+const promptTitle = ref('');
+const promptOkText = ref('OK');
+const promptType = ref('textarea');
+const promptInitial = ref('');
+const promptLoading = ref(false);
+let promptHandler = null;
+
+function openText({ title, content, fileName = '' }) {
+ textTitle.value = title;
+ textContent.value = content;
+ textFileName.value = fileName;
+ textOpen.value = true;
+}
+
+function openPrompt({ title, okText, type = 'textarea', value = '', confirm }) {
+ promptTitle.value = title;
+ promptOkText.value = okText || 'OK';
+ promptType.value = type;
+ promptInitial.value = value;
+ promptHandler = confirm;
+ promptOpen.value = true;
+}
+
+async function onPromptConfirm(value) {
+ if (!promptHandler) { promptOpen.value = false; return; }
+ promptLoading.value = true;
+ try {
+ const ok = await promptHandler(value);
+ if (ok !== false) promptOpen.value = false;
+ } finally {
+ promptLoading.value = false;
+ }
+}
+
+// === Export helpers — mirror legacy txtModal call sites ==============
+function exportInboundLinks(dbInbound) {
+ const projected = checkFallback(dbInbound);
+ openText({
+ title: 'Export inbound links',
+ content: projected.genInboundLinks(remarkModel.value),
+ fileName: projected.remark || 'inbound',
+ });
+}
+
+function exportInboundClipboard(dbInbound) {
+ openText({
+ title: 'Inbound JSON',
+ content: JSON.stringify(dbInbound, null, 2),
+ });
+}
+
+function exportInboundSubs(dbInbound) {
+ const inbound = dbInbound.toInbound();
+ const clients = inbound?.clients || [];
+ const subLinks = [];
+ for (const c of clients) {
+ if (c.subId && subSettings.value.subURI) {
+ subLinks.push(subSettings.value.subURI + c.subId);
+ }
+ }
+ openText({
+ title: 'Export subscription links',
+ content: [...new Set(subLinks)].join('\n'),
+ fileName: `${dbInbound.remark || 'inbound'}-Subs`,
+ });
+}
+
+function exportAllLinks() {
+ const out = [];
+ for (const ib of dbInbounds.value) {
+ out.push(ib.genInboundLinks(remarkModel.value));
+ }
+ openText({
+ title: 'Export all inbound links',
+ content: out.join('\r\n'),
+ fileName: 'All-Inbounds',
+ });
+}
+
+function exportAllSubs() {
+ const out = [];
+ for (const ib of dbInbounds.value) {
+ const inbound = ib.toInbound();
+ const clients = inbound?.clients || [];
+ for (const c of clients) {
+ if (c.subId && subSettings.value.subURI) {
+ out.push(subSettings.value.subURI + c.subId);
+ }
+ }
+ }
+ openText({
+ title: 'Export all subscription links',
+ content: [...new Set(out)].join('\r\n'),
+ fileName: 'All-Inbounds-Subs',
+ });
+}
+
+function importInbound() {
+ openPrompt({
+ title: 'Import inbound',
+ okText: 'Import',
+ type: 'textarea',
+ value: '',
+ confirm: async (value) => {
+ const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
+ if (msg?.success) {
+ await refresh();
+ return true;
+ }
+ return false;
+ },
+ });
+}
+
// `checkFallback` mirrors the legacy helper: when an inbound listens
// on a unix-socket fallback (`@`), point the link generator at
// the root inbound that owns the listen address so QRs/links carry
@@ -285,6 +408,15 @@ function confirmClone(dbInbound) {
function onGeneralAction(key) {
switch (key) {
+ case 'import':
+ importInbound();
+ break;
+ case 'export':
+ exportAllLinks();
+ break;
+ case 'subs':
+ exportAllSubs();
+ break;
case 'resetInbounds':
Modal.confirm({
title: 'Reset all inbound traffic?',
@@ -335,6 +467,21 @@ function onRowAction({ key, dbInbound }) {
qrDbInbound.value = checkFallback(dbInbound);
qrOpen.value = true;
break;
+ case 'export':
+ exportInboundLinks(dbInbound);
+ break;
+ case 'subs':
+ exportInboundSubs(dbInbound);
+ break;
+ case 'clipboard':
+ exportInboundClipboard(dbInbound);
+ break;
+ case 'copyClients':
+ // Copy-clients-from-inbound is a tiny dedicated modal in legacy
+ // (lets you tick clients to copy across inbounds). Defer to a
+ // future commit — surface a friendly message for now.
+ message.info('Copy clients across inbounds — coming soon');
+ break;
case 'delete':
confirmDelete(dbInbound);
break;
@@ -501,6 +648,22 @@ function onRowAction({ key, dbInbound }) {
:db-inbound="qrDbInbound"
:remark-model="remarkModel"
/>
+
+
+