mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-14 01:56:03 +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 ClientBulkModal from './ClientBulkModal.vue';
|
||||||
import InboundInfoModal from './InboundInfoModal.vue';
|
import InboundInfoModal from './InboundInfoModal.vue';
|
||||||
import QrCodeModal from './QrCodeModal.vue';
|
import QrCodeModal from './QrCodeModal.vue';
|
||||||
|
import TextModal from '@/components/TextModal.vue';
|
||||||
|
import PromptModal from '@/components/PromptModal.vue';
|
||||||
import { useInbounds } from './useInbounds.js';
|
import { useInbounds } from './useInbounds.js';
|
||||||
|
|
||||||
const antdThemeConfig = computed(() => ({
|
const antdThemeConfig = computed(() => ({
|
||||||
|
|
@ -77,6 +79,127 @@ const infoClientIndex = ref(0);
|
||||||
const qrOpen = ref(false);
|
const qrOpen = ref(false);
|
||||||
const qrDbInbound = ref(null);
|
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
|
// `checkFallback` mirrors the legacy helper: when an inbound listens
|
||||||
// on a unix-socket fallback (`@<name>`), point the link generator at
|
// on a unix-socket fallback (`@<name>`), point the link generator at
|
||||||
// the root inbound that owns the listen address so QRs/links carry
|
// the root inbound that owns the listen address so QRs/links carry
|
||||||
|
|
@ -285,6 +408,15 @@ function confirmClone(dbInbound) {
|
||||||
|
|
||||||
function onGeneralAction(key) {
|
function onGeneralAction(key) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
|
case 'import':
|
||||||
|
importInbound();
|
||||||
|
break;
|
||||||
|
case 'export':
|
||||||
|
exportAllLinks();
|
||||||
|
break;
|
||||||
|
case 'subs':
|
||||||
|
exportAllSubs();
|
||||||
|
break;
|
||||||
case 'resetInbounds':
|
case 'resetInbounds':
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Reset all inbound traffic?',
|
title: 'Reset all inbound traffic?',
|
||||||
|
|
@ -335,6 +467,21 @@ function onRowAction({ key, dbInbound }) {
|
||||||
qrDbInbound.value = checkFallback(dbInbound);
|
qrDbInbound.value = checkFallback(dbInbound);
|
||||||
qrOpen.value = true;
|
qrOpen.value = true;
|
||||||
break;
|
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':
|
case 'delete':
|
||||||
confirmDelete(dbInbound);
|
confirmDelete(dbInbound);
|
||||||
break;
|
break;
|
||||||
|
|
@ -501,6 +648,22 @@ function onRowAction({ key, dbInbound }) {
|
||||||
:db-inbound="qrDbInbound"
|
:db-inbound="qrDbInbound"
|
||||||
:remark-model="remarkModel"
|
: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-layout>
|
||||||
</a-config-provider>
|
</a-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue