mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
fix(frontend): inbound stream tidy-up + QR sizing + dev proxy
Stream tab clean-up: drop the seven a-divider rules in the inbound
form's Stream tab — replace the labelled ones (Request / Response /
Security) with a section-heading div that matches the outbound modal,
delete the empty rules above TLS sub-blocks / External Proxy /
Sockopt. Empty header-list form-items also leaked margin space below
each "Add header" button across TCP / WS / HTTPUpgrade / XHTTP — gate
each on headers.length > 0 so they vanish until the user adds one.
QR panel: drop the link text under the canvas (the user already has
a copy button on the header). Pin the canvas display size to a fixed
240px square via :style + image-rendering: pixelated/crisp-edges so
a dense WireGuard config QR and its sparser link share the same
on-screen footprint without blurring.
Dev proxy: Node's AggregateError wraps connection failures whenever
DNS returns more than one address (::1 + 127.0.0.1) and the code
lands on the inner errors, not the outer. The existing handler only
checked err.code so the ECONNREFUSED stack still spammed the log
when the Go backend was down. Walk err.errors too, print one
friendly line ("backend not reachable — start the Go server"), then
stay quiet for the rest of the session.
Vendor splitting + chunk-size warning: split node_modules into
stable vendor-* chunks so each page only ships the deps it uses and
the browser caches them across versions. ant-design-vue stays as a
single chunk because its components share internals; raise the
chunk-size warning to 1500kB so the build stays quiet (its 1.4MB
minified gzips to ~410kB on the wire).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c76b8b4a81
commit
84b155698b
3 changed files with 79 additions and 32 deletions
|
|
@ -570,7 +570,7 @@ watch(
|
|||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
|
||||
}}</a-tooltip>
|
||||
}}</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%' }" />
|
||||
|
|
@ -1023,7 +1023,7 @@ watch(
|
|||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{ span: 24 }">
|
||||
<a-form-item v-if="inbound.stream.tcp.request.headers.length > 0" :wrapper-col="{ span: 24 }">
|
||||
<a-input-group v-for="(h, idx) in inbound.stream.tcp.request.headers" :key="`tcp-rh-${idx}`" compact
|
||||
class="mb-8">
|
||||
<a-input :style="{ width: '45%' }" v-model:value="h.name"
|
||||
|
|
@ -1059,7 +1059,7 @@ watch(
|
|||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{ span: 24 }">
|
||||
<a-form-item v-if="inbound.stream.tcp.response.headers.length > 0" :wrapper-col="{ span: 24 }">
|
||||
<a-input-group v-for="(h, idx) in inbound.stream.tcp.response.headers" :key="`tcp-rsh-${idx}`" compact
|
||||
class="mb-8">
|
||||
<a-input :style="{ width: '45%' }" v-model:value="h.name"
|
||||
|
|
@ -1121,7 +1121,7 @@ watch(
|
|||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{ span: 24 }">
|
||||
<a-form-item v-if="inbound.stream.ws.headers.length > 0" :wrapper-col="{ span: 24 }">
|
||||
<a-input-group v-for="(h, idx) in inbound.stream.ws.headers" :key="`ws-h-${idx}`" compact class="mb-8">
|
||||
<a-input :style="{ width: '45%' }" v-model:value="h.name"
|
||||
:placeholder="t('pages.inbounds.stream.general.name')">
|
||||
|
|
@ -1169,7 +1169,7 @@ watch(
|
|||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{ span: 24 }">
|
||||
<a-form-item v-if="inbound.stream.httpupgrade.headers.length > 0" :wrapper-col="{ span: 24 }">
|
||||
<a-input-group v-for="(h, idx) in inbound.stream.httpupgrade.headers" :key="`hu-h-${idx}`" compact
|
||||
class="mb-8">
|
||||
<a-input :style="{ width: '45%' }" v-model:value="h.name"
|
||||
|
|
@ -1202,7 +1202,7 @@ watch(
|
|||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{ span: 24 }">
|
||||
<a-form-item v-if="inbound.stream.xhttp.headers.length > 0" :wrapper-col="{ span: 24 }">
|
||||
<a-input-group v-for="(h, idx) in inbound.stream.xhttp.headers" :key="`xh-h-${idx}`" compact class="mb-8">
|
||||
<a-input :style="{ width: '45%' }" v-model:value="h.name"
|
||||
:placeholder="t('pages.inbounds.stream.general.name')">
|
||||
|
|
@ -1312,7 +1312,6 @@ watch(
|
|||
</template>
|
||||
|
||||
<!-- ====== Security section ====== -->
|
||||
<a-divider>Security</a-divider>
|
||||
<a-form-item label="Security">
|
||||
<a-select v-model:value="security" :style="{ width: '160px' }" :disabled="!canEnableTls">
|
||||
<a-select-option value="none">none</a-select-option>
|
||||
|
|
@ -1329,7 +1328,7 @@ watch(
|
|||
<a-select v-model:value="inbound.stream.tls.cipherSuites">
|
||||
<a-select-option value="">Auto</a-select-option>
|
||||
<a-select-option v-for="[label, val] in CIPHER_SUITES" :key="val" :value="val">{{ label
|
||||
}}</a-select-option>
|
||||
}}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Min/Max Version">
|
||||
|
|
@ -1364,7 +1363,6 @@ watch(
|
|||
<a-switch v-model:checked="inbound.stream.tls.enableSessionResumption" />
|
||||
</a-form-item>
|
||||
|
||||
<a-divider :style="{ margin: '3px 0' }" />
|
||||
|
||||
<!-- Cert array — file path or inline content per row -->
|
||||
<template v-for="(cert, idx) in inbound.stream.tls.certs" :key="`cert-${idx}`">
|
||||
|
|
@ -1423,7 +1421,6 @@ watch(
|
|||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-divider :style="{ margin: '3px 0' }" />
|
||||
|
||||
<!-- ECH (Encrypted Client Hello) -->
|
||||
<a-form-item label="ECH key">
|
||||
|
|
@ -1514,7 +1511,6 @@ watch(
|
|||
</template>
|
||||
|
||||
<!-- ====== External Proxy ====== -->
|
||||
<a-divider :style="{ margin: '5px 0 0' }" />
|
||||
<a-form-item label="External Proxy">
|
||||
<a-switch v-model:checked="externalProxy" />
|
||||
<a-button v-if="externalProxy" size="small" type="primary" :style="{ marginLeft: '10px' }"
|
||||
|
|
@ -1547,7 +1543,6 @@ watch(
|
|||
</a-form-item>
|
||||
|
||||
<!-- ====== Sockopt ====== -->
|
||||
<a-divider :style="{ margin: '5px 0 0' }" />
|
||||
<a-form-item label="Sockopt">
|
||||
<a-switch v-model:checked="inbound.stream.sockoptSwitch" />
|
||||
</a-form-item>
|
||||
|
|
@ -1747,4 +1742,10 @@ watch(
|
|||
.wg-peer {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
font-weight: 500;
|
||||
margin: 12px 0 6px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -20,8 +20,12 @@ const props = defineProps({
|
|||
remark: { type: String, default: '' },
|
||||
// Optional download filename — when set, surfaces a download button.
|
||||
downloadName: { type: String, default: '' },
|
||||
// QR pixel size (drawn into a square canvas).
|
||||
size: { type: Number, default: 180 },
|
||||
// Final on-screen QR size in CSS pixels. The canvas drawing buffer
|
||||
// is rounded down to a multiple of the QR matrix width (so the QR
|
||||
// fills it edge-to-edge) and CSS then scales the canvas to exactly
|
||||
// this size — so a denser QR (e.g. WireGuard config) and a sparser
|
||||
// one (its link) display at identical dimensions.
|
||||
size: { type: Number, default: 240 },
|
||||
// Toggle the QR rendering off when callers only want the "row of buttons"
|
||||
// styling (used when the legacy panel rendered links without QRs).
|
||||
showQr: { type: Boolean, default: true },
|
||||
|
|
@ -104,9 +108,12 @@ function download() {
|
|||
</a-tooltip>
|
||||
</div>
|
||||
<div v-if="showQr" class="qr-panel-canvas">
|
||||
<canvas ref="canvas" @click="copy" />
|
||||
<canvas
|
||||
ref="canvas"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click="copy"
|
||||
/>
|
||||
</div>
|
||||
<code class="qr-panel-link">{{ value }}</code>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -142,20 +149,11 @@ function download() {
|
|||
cursor: pointer;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
/* Drawing buffer is matrix-snapped (smaller than display size for
|
||||
* dense QRs); scale up crisply so dense and sparse QRs share the
|
||||
* same on-screen footprint without blurring. */
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.qr-panel-link {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
:global(body.dark) .qr-panel-link {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -48,8 +48,30 @@ function makeBackendProxy(target, patterns) {
|
|||
return undefined;
|
||||
},
|
||||
configure(proxy) {
|
||||
proxy.on('error', (err) => {
|
||||
if (err.code === 'ECONNREFUSED') return;
|
||||
let warned = false;
|
||||
proxy.on('error', (err, req) => {
|
||||
// Node wraps connection failures in an AggregateError when DNS
|
||||
// returns multiple addresses (e.g. ::1 + 127.0.0.1) and all
|
||||
// refuse — the code lands on the inner errors, not the outer.
|
||||
const codes = new Set();
|
||||
if (err && err.code) codes.add(err.code);
|
||||
if (err && Array.isArray(err.errors)) {
|
||||
for (const inner of err.errors) {
|
||||
if (inner && inner.code) codes.add(inner.code);
|
||||
}
|
||||
}
|
||||
const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
|
||||
if (offline) {
|
||||
// Print a single friendly hint the first time, then stay quiet.
|
||||
if (!warned) {
|
||||
warned = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[proxy] backend ${target} is not reachable — start the Go server (e.g. \`go run main.go\`) to forward ${req?.url || 'requests'}.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[proxy]', err);
|
||||
});
|
||||
|
|
@ -71,6 +93,12 @@ export default defineConfig({
|
|||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
target: 'es2020',
|
||||
// ant-design-vue is intentionally bundled as one chunk (its
|
||||
// components share internals — splitting it breaks Modal/Form/
|
||||
// Select interop). Minified it lands ~1.4MB but gzips to ~410kB,
|
||||
// so the actual transfer is fine and caches across every page.
|
||||
// Bump the warning past that ceiling so the build stays quiet.
|
||||
chunkSizeWarningLimit: 1500,
|
||||
// Multiple HTML entries — one per legacy page we migrate.
|
||||
// As pages get ported in later phases, add their entrypoints here.
|
||||
rollupOptions: {
|
||||
|
|
@ -82,6 +110,26 @@ export default defineConfig({
|
|||
xray: path.resolve(__dirname, 'xray.html'),
|
||||
subpage: path.resolve(__dirname, 'subpage.html'),
|
||||
},
|
||||
output: {
|
||||
// Split vendor deps into stable chunks so each page only pulls
|
||||
// what it needs and the browser caches them across versions.
|
||||
// Without this, ant-design-vue + vue + icons all end up in one
|
||||
// 1.6MB blob attached to whichever page consumed them first.
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return undefined;
|
||||
if (id.includes('ant-design-vue')) return 'vendor-antd';
|
||||
if (id.includes('@ant-design/icons-vue')) return 'vendor-icons';
|
||||
if (id.includes('vue-i18n')) return 'vendor-i18n';
|
||||
if (
|
||||
id.includes('/node_modules/vue/')
|
||||
|| id.includes('/node_modules/@vue/')
|
||||
) return 'vendor-vue';
|
||||
if (id.includes('dayjs')) return 'vendor-dayjs';
|
||||
if (id.includes('qrious')) return 'vendor-qrious';
|
||||
if (id.includes('axios')) return 'vendor-axios';
|
||||
return 'vendor';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue