feat(frontend): swap QRious for ant-design-vue's a-qrcode

- Migrate SubPage, QrPanel and TwoFactorModal from a QRious canvas to
  <a-qrcode type="svg">, which renders the QR matrix as crispEdges
  SVG rectangles — pixel-perfect at any display size or DPR, no more
  white scan-line artifacts from non-integer canvas scaling
- Drop the now-unused qrious dependency and its manualChunks entry
- Default the panel to ultra-dark on first load (existing user
  preferences in localStorage are preserved)
- Let the sub controller read subpage.html from web/dist/ first and
  fall back to the embedded copy, so Vite rebuilds in dev no longer
  require a Go recompile to refresh the asset hashes
This commit is contained in:
MHSanaei
2026-05-11 01:58:27 +02:00
parent c1efc48694
commit 04828246fc
8 changed files with 75 additions and 261 deletions

View File

@@ -16,7 +16,7 @@ function readBool(key, fallback) {
}
const isDark = readBool(STORAGE_DARK, true);
const isUltra = readBool(STORAGE_ULTRA, false);
const isUltra = readBool(STORAGE_ULTRA, true);
export const theme = reactive({
isDark,

View File

@@ -1,7 +1,5 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import QRious from 'qrious';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
@@ -9,73 +7,14 @@ import { ClipboardManager, FileManager } from '@/utils';
const { t } = useI18n();
// Renders a single share-link as a clickable QR code + a copy button
// + (optional) a download button. Used per-link inside the inbound
// info modal — the canvas is repainted whenever `value` changes.
const props = defineProps({
// The link or config text to encode + display.
value: { type: String, required: true },
// Header label shown next to the copy button.
remark: { type: String, default: '' },
// Optional download filename — when set, surfaces a download button.
downloadName: { type: String, default: '' },
// 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 },
});
const canvas = ref(null);
// Byte-mode capacities (level M) for QR versions 1..40 — used to pick
// the matrix width up front so we can size the canvas as a multiple
// of pixelSize. Without this, QRious renders at floor(size/matrix)
// and centers, leaving a white margin whenever size isn't divisible.
const QR_M_BYTE_CAPACITY = [
14, 26, 42, 62, 84, 106, 122, 152, 180, 213,
251, 287, 331, 362, 412, 450, 504, 560, 624, 666,
711, 779, 857, 911, 997, 1059, 1125, 1190, 1264, 1370,
1452, 1538, 1628, 1722, 1809, 1911, 1989, 2099, 2213, 2331,
];
function pickQrMatrixWidth(value) {
const byteLen = new TextEncoder().encode(value).length;
for (let i = 0; i < QR_M_BYTE_CAPACITY.length; i++) {
if (byteLen <= QR_M_BYTE_CAPACITY[i]) return 17 + 4 * (i + 1);
}
return 17 + 4 * 40; // version 40 (177 modules)
}
function paint() {
if (!props.showQr || !canvas.value || !props.value) return;
// Canvas size = matrixWidth × pixelSize, so the QR fills it edge-to-
// edge. pixelSize is floored against the requested size so the QR
// never grows past the host's expected box.
const matrixWidth = pickQrMatrixWidth(props.value);
const pixelSize = Math.max(1, Math.floor(props.size / matrixWidth));
const exactSize = matrixWidth * pixelSize;
new QRious({
element: canvas.value,
size: exactSize,
value: props.value,
background: 'white',
backgroundAlpha: 1,
foreground: 'black',
padding: 0,
level: 'M',
});
}
onMounted(paint);
watch(() => props.value, paint);
watch(() => props.size, paint);
async function copy() {
const ok = await ClipboardManager.copyText(props.value);
if (ok) message.success(t('copied'));
@@ -107,7 +46,8 @@ function download() {
</a-tooltip>
</div>
<div v-if="showQr" class="qr-panel-canvas">
<canvas ref="canvas" :style="{ width: `${size}px`, height: `${size}px` }" @click="copy" />
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
:title="t('copy')" @click="copy" />
</div>
</div>
</template>
@@ -140,14 +80,10 @@ function download() {
padding: 6px 0;
}
.qr-panel-canvas canvas {
.qr-panel-canvas .qr-code {
cursor: pointer;
display: block;
padding: 0 !important;
background: #fff;
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;
}
</style>

View File

@@ -1,24 +1,13 @@
<script setup>
import { nextTick, ref, watch } from 'vue';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import * as OTPAuth from 'otpauth';
import QRious from 'qrious';
import { ClipboardManager } from '@/utils';
const { t } = useI18n();
// Two flavors of this modal:
// • type='set' shows a QR code + manual key + a 6-digit verifier
// (used when enabling 2FA the first time);
// • type='confirm' shows just the 6-digit verifier (used when
// toggling 2FA off and when changing the admin user/password).
//
// Either way the parent supplies a `confirm(success: boolean)`
// callback — we run it with `true` only if the entered code matches
// the live TOTP value, otherwise `false`.
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: '' },
@@ -30,29 +19,10 @@ const props = defineProps({
const emit = defineEmits(['update:open', 'confirm']);
const enteredCode = ref('');
const qrCanvas = ref(null);
const qrValue = ref('');
let totp = null;
// Byte-mode capacities (level L) for QR versions 1..40 — used to pick
// the matrix width up front so the canvas size is an exact multiple of
// pixelSize. Without this, QRious renders at floor(size/matrix) and
// centers, leaving a white margin around the QR.
const QR_L_BYTE_CAPACITY = [
17, 32, 53, 78, 106, 134, 154, 192, 230, 271,
321, 367, 425, 458, 520, 586, 644, 718, 792, 858,
929, 1003, 1091, 1171, 1273, 1367, 1465, 1528, 1628, 1732,
1840, 1952, 2068, 2188, 2303, 2431, 2563, 2699, 2809, 2953,
];
function pickQrMatrixWidth(value) {
const byteLen = new TextEncoder().encode(value).length;
for (let i = 0; i < QR_L_BYTE_CAPACITY.length; i++) {
if (byteLen <= QR_L_BYTE_CAPACITY[i]) return 17 + 4 * (i + 1);
}
return 17 + 4 * 40;
}
function buildTotp() {
totp = new OTPAuth.TOTP({
issuer: '3x-ui',
@@ -62,25 +32,7 @@ function buildTotp() {
period: 30,
secret: props.token,
});
}
async function paintQr() {
await nextTick();
if (!qrCanvas.value || !totp) return;
const value = totp.toString();
const matrixWidth = pickQrMatrixWidth(value);
const pixelSize = Math.max(1, Math.floor(200 / matrixWidth));
const exactSize = matrixWidth * pixelSize;
new QRious({
element: qrCanvas.value,
size: exactSize,
value,
background: 'white',
backgroundAlpha: 1,
foreground: 'black',
padding: 0,
level: 'L',
});
qrValue.value = totp.toString();
}
watch(() => props.open, (next) => {
@@ -88,7 +40,6 @@ watch(() => props.open, (next) => {
enteredCode.value = '';
if (props.token) {
buildTotp();
if (props.type === 'set') paintQr();
}
});
@@ -124,9 +75,8 @@ async function copyToken() {
<a-divider />
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
<div class="qr-wrap">
<div class="qr-bg">
<canvas ref="qrCanvas" class="qr-cv" @click="copyToken" />
</div>
<a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
error-level="L" :title="t('copy')" @click="copyToken" />
<span class="qr-token">{{ token }}</span>
</div>
<a-divider />
@@ -154,22 +104,11 @@ async function copyToken() {
gap: 12px;
}
.qr-bg {
width: 180px;
height: 180px;
background: #fff;
padding: 4px;
border-radius: 6px;
}
.qr-cv {
.qr-code {
cursor: pointer;
width: 100% !important;
height: 100% !important;
/* Drawing buffer is matrix-snapped (smaller than display size); scale
* up crisply so the QR fills the box without blurring. */
image-rendering: pixelated;
image-rendering: crisp-edges;
padding: 0 !important;
background: #fff;
border-radius: 6px;
}
.qr-token {

View File

@@ -9,7 +9,6 @@ import {
CopyOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import QRious from 'qrious';
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
import {
@@ -71,32 +70,7 @@ function onLangChange(next) {
LanguageManager.setLanguage(next);
}
// QR code rendering ===========================================
// Each ref points at a canvas element we paint after mount; QRious
// sizes itself from the element's `size` attribute.
const subQr = ref(null);
const subJsonQr = ref(null);
const subClashQr = ref(null);
function paintQr(canvas, value) {
if (!canvas || !value) return;
new QRious({
element: canvas,
size: 220,
value,
background: 'white',
backgroundAlpha: 1,
foreground: 'black',
padding: 4,
level: 'M',
});
}
onMounted(() => {
paintQr(subQr.value, subUrl);
paintQr(subJsonQr.value, subJsonUrl);
paintQr(subClashQr.value, subClashUrl);
});
const QR_SIZE = 240;
// Actions =====================================================
async function copy(value) {
@@ -184,7 +158,8 @@ const themeClass = computed(() => ({
<a-col :xs="24" :sm="subJsonUrl || subClashUrl ? 12 : 24" class="qr-col">
<div class="qr-box">
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
<canvas ref="subQr" class="qr-canvas" :title="t('copy')" @click="copy(subUrl)" />
<a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
:title="t('copy')" @click="copy(subUrl)" />
</div>
</a-col>
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
@@ -192,13 +167,15 @@ const themeClass = computed(() => ({
<a-tag color="purple" class="qr-tag">
{{ t('pages.settings.subSettings') }} JSON
</a-tag>
<canvas ref="subJsonQr" class="qr-canvas" :title="t('copy')" @click="copy(subJsonUrl)" />
<a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
:title="t('copy')" @click="copy(subJsonUrl)" />
</div>
</a-col>
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
<div class="qr-box">
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
<canvas ref="subClashQr" class="qr-canvas" :title="t('copy')" @click="copy(subClashUrl)" />
<a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
:title="t('copy')" @click="copy(subClashUrl)" />
</div>
</a-col>
</a-row>
@@ -336,7 +313,7 @@ const themeClass = computed(() => ({
flex-direction: column;
align-items: center;
gap: 4px;
width: 220px;
width: 240px;
}
.qr-tag {
@@ -345,8 +322,9 @@ const themeClass = computed(() => ({
margin: 0;
}
.qr-canvas {
.qr-code {
cursor: pointer;
padding: 0 !important;
background: #fff;
border-radius: 4px;
}