mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
feat(frontend): Phase 5f-vii — shared text/prompt modals + remaining export/import wiring
Wires up the last batch of inbound row + general actions that were toasting "coming soon": export-inbound-links, export-subs (per-inbound and global), export-all-links, import-inbound, and the clipboard JSON peek. Two small shared components back them — both can be reused by the xray page later. - TextModal.vue (shared): read-only multi-line viewer with a copy button and an optional download button when fileName is set. Replaces the legacy txtModal which the inbounds page used for every link export. - PromptModal.vue (shared): generic title + input/textarea + confirm callback, with the legacy keybindings (Enter submits in single-line mode; Ctrl+S submits in textarea mode). Used here for import-inbound but also by xray-config edits in Phase 6. - InboundsPage.vue: drops the toast stubs for `import`/`export`/`subs` on the general-actions menu and `export`/`subs`/`clipboard` on the per-row menu, routing each through openText / openPrompt + the appropriate model helper (genInboundLinks, etc.). The copyClients cross-inbound modal stays toast-stubbed — that's its own dedicated legacy modal worth its own commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c0c3fa2939
commit
188fb0f2bd
3 changed files with 300 additions and 0 deletions
70
frontend/src/components/PromptModal.vue
Normal file
70
frontend/src/components/PromptModal.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
// Generic prompt modal — used by features like "import inbound" that
|
||||
// need a free-form text/textarea input and a confirm callback. The
|
||||
// parent owns the action; this component only surfaces the value via
|
||||
// the `confirm` event when the user clicks OK.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
okText: { type: String, default: 'OK' },
|
||||
// 'text' = single-line input; 'textarea' = multi-line.
|
||||
type: { type: String, default: 'text', validator: (v) => ['text', 'textarea'].includes(v) },
|
||||
initialValue: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'confirm']);
|
||||
|
||||
const value = ref('');
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (next) value.value = props.initialValue;
|
||||
});
|
||||
|
||||
function close() { emit('update:open', false); }
|
||||
function ok() { emit('confirm', value.value); }
|
||||
|
||||
// Enter submits when single-line; ctrl+S submits in textarea mode
|
||||
// (matches legacy keybindings).
|
||||
function onKeydown(e) {
|
||||
if (props.type !== 'textarea' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
ok();
|
||||
return;
|
||||
}
|
||||
if (props.type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
ok();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="title"
|
||||
:ok-text="okText"
|
||||
cancel-text="Cancel"
|
||||
:mask-closable="false"
|
||||
:confirm-loading="loading"
|
||||
@ok="ok"
|
||||
@cancel="close"
|
||||
>
|
||||
<a-textarea
|
||||
v-if="type === 'textarea'"
|
||||
v-model:value="value"
|
||||
:auto-size="{ minRows: 10, maxRows: 20 }"
|
||||
autofocus
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<a-input
|
||||
v-else
|
||||
v-model:value="value"
|
||||
autofocus
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
67
frontend/src/components/TextModal.vue
Normal file
67
frontend/src/components/TextModal.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<script setup>
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
// Read-only text modal — used to surface multi-line export blobs
|
||||
// (subscription URLs, raw inbound JSON, generated share links) the
|
||||
// way the legacy txtModal did.
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
content: { type: String, default: '' },
|
||||
// When set, surfaces a download button that writes `content` to a
|
||||
// text file with this name.
|
||||
fileName: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
async function copy(value) {
|
||||
const ok = await ClipboardManager.copyText(value || '');
|
||||
if (ok) {
|
||||
message.success('Copied');
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function download(content, name) {
|
||||
if (!name) return;
|
||||
FileManager.downloadTextFile(content, name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :closable="true" @cancel="close">
|
||||
<a-textarea
|
||||
:value="content"
|
||||
readonly
|
||||
:auto-size="{ minRows: 10, maxRows: 20 }"
|
||||
class="text-modal-content"
|
||||
/>
|
||||
<template #footer>
|
||||
<a-button v-if="fileName" @click="download(content, fileName)">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
{{ fileName }}
|
||||
</a-button>
|
||||
<a-button type="primary" @click="copy(content)">
|
||||
<template #icon><CopyOutlined /></template>
|
||||
Copy
|
||||
</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-modal-content {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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 (`@<name>`), 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"
|
||||
/>
|
||||
|
||||
<TextModal
|
||||
v-model:open="textOpen"
|
||||
:title="textTitle"
|
||||
:content="textContent"
|
||||
:file-name="textFileName"
|
||||
/>
|
||||
<PromptModal
|
||||
v-model:open="promptOpen"
|
||||
:title="promptTitle"
|
||||
:ok-text="promptOkText"
|
||||
:type="promptType"
|
||||
:initial-value="promptInitial"
|
||||
:loading="promptLoading"
|
||||
@confirm="onPromptConfirm"
|
||||
/>
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Reference in a new issue