Quellcode durchsuchen

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
MHSanaei vor 1 Tag
Ursprung
Commit
04828246fc

+ 38 - 81
frontend/package-lock.json

@@ -1,20 +1,18 @@
 {
   "name": "3x-ui-frontend",
-  "version": "0.0.0",
+  "version": "0.0.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "3x-ui-frontend",
-      "version": "0.0.0",
+      "version": "0.0.1",
       "dependencies": {
         "@ant-design/icons-vue": "^7.0.1",
         "ant-design-vue": "^4.2.6",
         "axios": "^1.7.9",
         "dayjs": "^1.11.20",
-        "moment": "^2.30.1",
         "otpauth": "^9.5.1",
-        "qrious": "^4.0.2",
         "qs": "^6.13.1",
         "vue": "^3.5.13",
         "vue-i18n": "^11.1.4",
@@ -207,42 +205,6 @@
         "node": "^20.19.0 || ^22.13.0 || >=24"
       }
     },
-    "node_modules/@eslint/config-array/node_modules/balanced-match": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
-      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
-      "dev": true,
-      "engines": {
-        "node": "18 || 20 || >=22"
-      }
-    },
-    "node_modules/@eslint/config-array/node_modules/brace-expansion": {
-      "version": "5.0.6",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
-      "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
-      "dev": true,
-      "dependencies": {
-        "balanced-match": "^4.0.2"
-      },
-      "engines": {
-        "node": "18 || 20 || >=22"
-      }
-    },
-    "node_modules/@eslint/config-array/node_modules/minimatch": {
-      "version": "10.2.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
-      "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
-      "dev": true,
-      "dependencies": {
-        "brace-expansion": "^5.0.5"
-      },
-      "engines": {
-        "node": "18 || 20 || >=22"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
     "node_modules/@eslint/config-helpers": {
       "version": "0.5.5",
       "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
@@ -968,12 +930,33 @@
         "proxy-from-env": "^2.1.0"
       }
     },
+    "node_modules/balanced-match": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+      "dev": true,
+      "engines": {
+        "node": "18 || 20 || >=22"
+      }
+    },
     "node_modules/boolbase": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
       "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
       "dev": true
     },
+    "node_modules/brace-expansion": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+      "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^4.0.2"
+      },
+      "engines": {
+        "node": "18 || 20 || >=22"
+      }
+    },
     "node_modules/call-bind-apply-helpers": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1306,42 +1289,6 @@
         "url": "https://opencollective.com/eslint"
       }
     },
-    "node_modules/eslint/node_modules/balanced-match": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
-      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
-      "dev": true,
-      "engines": {
-        "node": "18 || 20 || >=22"
-      }
-    },
-    "node_modules/eslint/node_modules/brace-expansion": {
-      "version": "5.0.6",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
-      "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
-      "dev": true,
-      "dependencies": {
-        "balanced-match": "^4.0.2"
-      },
-      "engines": {
-        "node": "18 || 20 || >=22"
-      }
-    },
-    "node_modules/eslint/node_modules/minimatch": {
-      "version": "10.2.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
-      "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
-      "dev": true,
-      "dependencies": {
-        "brace-expansion": "^5.0.5"
-      },
-      "engines": {
-        "node": "18 || 20 || >=22"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
     "node_modules/espree": {
       "version": "11.2.0",
       "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
@@ -2073,6 +2020,21 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/minimatch": {
+      "version": "10.2.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+      "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^5.0.5"
+      },
+      "engines": {
+        "node": "18 || 20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
     "node_modules/moment": {
       "version": "2.30.1",
       "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -2318,11 +2280,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/qrious": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/qrious/-/qrious-4.0.2.tgz",
-      "integrity": "sha512-xWPJIrK1zu5Ypn898fBp8RHkT/9ibquV2Kv24S/JY9VYEhMBMKur1gHVsOiNUh7PHP9uCgejjpZUHUIXXKoU/g=="
-    },
     "node_modules/qs": {
       "version": "6.15.1",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",

+ 0 - 2
frontend/package.json

@@ -15,9 +15,7 @@
     "ant-design-vue": "^4.2.6",
     "axios": "^1.7.9",
     "dayjs": "^1.11.20",
-    "moment": "^2.30.1",
     "otpauth": "^9.5.1",
-    "qrious": "^4.0.2",
     "qs": "^6.13.1",
     "vue": "^3.5.13",
     "vue-i18n": "^11.1.4",

+ 1 - 1
frontend/src/composables/useTheme.js

@@ -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,

+ 5 - 69
frontend/src/pages/inbounds/QrPanel.vue

@@ -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>

+ 8 - 69
frontend/src/pages/settings/TwoFactorModal.vue

@@ -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,24 +104,13 @@ async function copyToken() {
   gap: 12px;
 }
 
-.qr-bg {
-  width: 180px;
-  height: 180px;
+.qr-code {
+  cursor: pointer;
+  padding: 0 !important;
   background: #fff;
-  padding: 4px;
   border-radius: 6px;
 }
 
-.qr-cv {
-  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;
-}
-
 .qr-token {
   font-size: 12px;
   font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;

+ 10 - 32
frontend/src/pages/sub/SubPage.vue

@@ -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;
 }

+ 0 - 1
frontend/vite.config.js

@@ -163,7 +163,6 @@ export default defineConfig({
             || 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';
           if (
             id.includes('vue3-persian-datetime-picker')

+ 12 - 5
sub/subController.go

@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"os"
 	"strconv"
 	"strings"
 
@@ -154,11 +155,17 @@ func (a *SUBController) subs(c *gin.Context) {
 // page's static asset references resolve correctly when the panel runs
 // behind a URL prefix.
 func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
-	dist := webpkg.EmbeddedDist()
-	body, err := dist.ReadFile("dist/subpage.html")
-	if err != nil {
-		c.String(http.StatusInternalServerError, "missing embedded subpage")
-		return
+	var body []byte
+	if diskBody, diskErr := os.ReadFile("web/dist/subpage.html"); diskErr == nil {
+		body = diskBody
+	} else {
+		dist := webpkg.EmbeddedDist()
+		readBody, err := dist.ReadFile("dist/subpage.html")
+		if err != nil {
+			c.String(http.StatusInternalServerError, "missing embedded subpage")
+			return
+		}
+		body = readBody
 	}
 
 	// Vite emits absolute asset URLs (`/assets/...`); when the panel is