CustomStatistic.vue and SettingListItem.vue are mechanical
Vue.component → SFC ports.
AppSidebar.vue: AD-Vue 4 dropped <a-icon :type="dynamic">, so the
five sidebar icons (dashboard/user/setting/tool/logout) live in a
name→component map and render via <component :is>. The legacy
<a-drawer slot="handle"> hack is replaced with a sibling fixed-
position toggle button. Tab paths take basePath/requestUri as
props instead of pulling them from Go template scope.
TableSortable.vue: the biggest Vue 3 rewrite of this phase.
- $listeners is gone — replaced by inheritAttrs: false +
explicit attrs forwarding
- scopedSlots: this.$scopedSlots collapsed into Vue 3's unified
slots object — just iterate Object.keys(this.slots) and forward
- Vue 2 h(tag, { props, on, scopedSlots }, children) →
Vue 3 h(tag, { ...props, ...on }, slotsObject)
- 'a-table' string → resolveComponent('a-table') so app.use(Antd)
registration is honored
- inject: ['sortable'] (Options API) → inject('sortable', null)
(Composition API) inside the trigger child
- beforeDestroy → beforeUnmount
- customRow's return shape flattened (no nested props/on/class)
Two intentional skips, documented in the migration doc:
- aClientTable.html — slot fragments, not a component. Migrates
inline with inbounds.html (new Phase 5f).
- aPersianDatepicker.html — wraps a Persian-only third-party
lib; defer until settings.html lands.
Build verified with vite 8.0.11.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
15 KiB
Vue 3 + Ant Design Vue 4 Migration — Phase 1 Inventory
Branch: vue3-migration
Source state: Vue 2 + Ant Design Vue 1.7.8, no build step, Go template-driven.
Scope
- 69 HTML templates, ~17,650 total lines
- Largest pages:
web/html/xray.html— 2,360 linesweb/html/index.html— ~1,700 linesweb/html/settings.html— 720 linesweb/html/settings/xray/outbounds.html— 263 lines
Vue 2 → Vue 3 breakage surface
| Pattern | Count | Files | Severity | Notes |
|---|---|---|---|---|
{{ x | filter }} |
0 | 0 | ✅ none | Filters removed in Vue 3 — but we don't use any. |
<template slot="X"> |
233 | 36 | medium | Rewrite to <template #X>. Mechanical. |
slot-scope="X" (incl. above) |
275 total | 40 | medium | Rewrite to v-slot:name="X". Mechanical. |
scopedSlots: { ... } (in JS column defs) |
49 | 4 | medium | Vue 3 removed scopedSlots. All slots are now scoped. Replace with slots: { ... } or rely on template slot binding only. |
new Vue({...}) mounts |
~30+ | 30 | medium | Replace with createApp({...}).mount('#id'). Each page mounts its own Vue instance. |
Vue.use / Vue.component / Vue.prototype |
<49 (after subtracting mounts) | various | medium | Replace with app.use / app.component / app.config.globalProperties. |
$listeners / $on / $off / $once / $children |
4 | 4 | low | Mostly inside aTableSortable.html. $listeners merged into $attrs. Event-bus methods removed — need an explicit emitter (mitt, or component refs). |
inline-template / functional |
1 | 1 | trivial | One occurrence. |
.sync modifier |
0 | 0 | ✅ none | Removed in Vue 3, replaced by v-model:propName. We don't use it. |
v-model |
358 | 36 | high | Default v-model on custom components changed (value → modelValue, input event → update:modelValue). Most of these target AD-Vue components, which AD-Vue 4 already adapts to internally — but components we wrote ourselves (e.g. aClientTable, aTableSortable) need updates. |
Vue.set / Vue.delete / Vue.observable |
0 | 0 | ✅ none | Replaced by reactive(). Not needed. |
transition class names (-enter, -leave) |
1 (in qrcode_modal.html) |
1 | low | Renamed in Vue 3: *-enter → *-enter-from, *-leave-to stays, etc. |
Key modifiers using keyCode (@keyup.13) |
0 | 0 | ✅ none | Number key codes removed in Vue 3. We don't use them. |
ref="..." attrs |
21 | 9 | low | Behavior unchanged for Options API — refs still work the same. |
.native event modifier |
0 | 0 | ✅ none | Removed in Vue 3 (events on components are no longer "fake" by default). |
Ant Design Vue 1.x → 4.x breakage surface
Total <a-*> component instances: 3,145 across 63 files. This is the bulk of the migration cost.
The most-used components (rough estimate from grep):
a-input,a-select,a-button— heavily used; props mostly stable, but<a-select-option>slot syntax changeda-form+a-form-item— Form API was substantially redesigned in AD-Vue 3+. We use it lightly (layout="vertical",label-col,wrapper-col); migration is mechanical but every form needs touching.a-table— column definitions withscopedSlots(49 instances) need to becomeslots: { customRender: 'name' }or use template slot binding directly.a-modal—v-modelon a-modal changed:v-model="visible"→v-model:open="visible"(orv-model:visibledepending on version). Every modal needs updating.a-icon— removed as a generic component in AD-Vue 4. Each icon must be imported individually from@ant-design/icons-vue. We use ~233a-iconreferences — mostly viatype="..."attribute. Likely the single highest-friction change.a-tooltip,a-popover,a-drawer— slot-based titles (<template slot="title">→<template #title>).a-collapse+a-collapse-panel— header slot syntax changes.a-tabs+a-tab-pane—tabslot rewrite, possibly renamed.a-space— exists in AD-Vue 4, props stable.a-tag— stable.
Key custom code
web/assets/js/util/index.js— utilities (HttpUtil, ObjectUtil, ClipboardManager, SizeFormatter, etc.). Framework-agnostic. Trivial migration (no Vue dependency).web/assets/js/axios-init.js— axios setup. Trivial migration.web/assets/js/websocket.js— websocket client. Trivial migration (we recently refactored this).web/assets/js/model/{inbound,outbound,dbinbound,setting,reality_targets}.js— domain model classes. Plain JS. Trivial migration.web/assets/js/subscription.js— subscription page logic.- Custom components in
web/html/component/:aClientTable.html— non-trivial; uses scoped slots and v-modelaSidebar.html— sidebar navigationaThemeSwitch.html— theme pickeraPersianDatepicker.html— wraps a third-party datepickeraTableSortable.html— uses$listeners,$on— needs explicit refactoraSettingListItem.html,aCustomStatistic.html— small wrappers- These are the only places using the deprecated event-bus APIs (4 occurrences).
Server-side coupling
The Go layer interpolates translations directly into templates via {{ i18n "key" }}. After migration we have two options:
- Keep Go-side i18n. Vite builds .html partials that still get processed by Go's
templatepackage. Means<script type="module">entrypoints reference build artifacts but markup is still server-rendered. Pro: smallest change. Con: every page change forces a Vite rebuild and a Go restart. - Move i18n to client side. Export the translation TOML files as JSON, ship as static assets, use
vue-i18n. Pro: cleaner client/server split, hot reload during dev. Con: more change, every i18n key reference in templates must be rewritten.
We will defer this decision to Phase 7. For Phases 2–4 we keep the Go-side approach.
Risk-ranked migration order
Order chosen so that breakage is contained and we always have a working panel:
- Phase 2 — Toolchain. Vite scaffold; Go binary embeds
dist/viaembed.FS. New build runs in CI alongside existing static assets; legacy continues to work. - Phase 3 — Utils. Migrate framework-agnostic JS first. Zero Vue dependency, zero risk.
- Phase 4 —
login.html. Smallest page with state. Hits every Vue 2→3 syntax change and every AD-Vue 1→4 component change at small scale. Becomes the template the rest follow. - Phase 5 — Medium pages and modals.
index.html,settings.html, all modals. ~30 templates of 200-1000 lines each. - Phase 6 —
xray.html. The 2,360-line page with the inbound/outbound editors. Highest regression risk — will likely break and need fixing in the QA pass. - Phase 7 — i18n decision.
- Phase 8 — Regression pass + delete legacy templates + cut release.
Numbers to remember
- 6–8 weeks of focused work, single developer
- 63 HTML files to touch
- 3,145 AD-Vue component instances to validate
- One assumption that needs confirming with the user: build step OK (yes — confirmed by choice of Vite)
Confirmed user decisions
- ✅ Migrate to Vue 3 + Ant Design Vue 4
- ✅ Introduce Vite build step (npm acceptable)
- ✅ Work on a long-running
vue3-migrationbranch - ⏸ i18n strategy — to be decided in Phase 7
Phase progress
- ✅ Phase 1 — inventory (this document)
- ✅ Phase 2 — Vite + Vue 3 + AD-Vue 4 scaffold under
frontend/ - ✅ Phase 3 — utils + models + websocket ported as ES modules
- ✅ Phase 4 — first real page (login.html) ported
- ⏳ Phase 5 — medium pages + modals
- ✅ 5a — theme system (composable + ThemeSwitch / ThemeSwitchLogin); wired into login
- ✅ 5b — CustomStatistic, SettingListItem, AppSidebar, TableSortable
- ⏳ 5c — index.html dashboard
Phase 5a notes
aThemeSwitch.htmlhad two near-identical components (full menu version + login popover version) plus athemeSwitcherglobal object. Refactored into:composables/useTheme.js— single reactivethemestate,toggleTheme/toggleUltra, and apauseAnimationsUntilLeavehelper. Boot side-effects (apply stored theme + persist on change) run viawatchEffect. Importing the module is enough to apply the right theme before mount.components/ThemeSwitch.vue— full menu version (used in the main panel sidebar).components/ThemeSwitchLogin.vue— login popover version.
- AD-Vue 4 dropped
<a-icon>. TheBulbFilled/BulbOutlinedswap is done via<component :is="BulbIcon">. Vue.component('a-theme-switch', { ... })global registration → per-page imports.this.$message.config(...)(Vue 2 instance method) →message.config(...)fromant-design-vue, called once at app boot inlogin.js.- vue-i18n bumped 10 → 11.1.4 (npm warned that v9/v10 are EOL).
- Vite 8.0.11 build verified —
npm run buildsucceeds, outputsweb/dist/login.html+ chunked JS/CSS. AD-Vue withapp.use(Antd)produces a 1.5 MB chunk; we'll switch to per-component imports in a later cleanup pass.
Known gap: the legacy web/assets/css/custom.min.css styles body.dark / body.light / [data-theme="ultra-dark"]. The new login page doesn't import that CSS, so toggling theme switches AD-Vue's own components but not the panel chrome (e.g. card backgrounds). The composable still toggles the body class so behavior is correct — visual fidelity is restored when we either port custom.css to the new build or import it directly.
Phase 5b notes
Migrated four components into frontend/src/components/:
CustomStatistic.vue— wraps<a-statistic>with prefix/suffix slots. Trivial port; only change isVue.component(...)→ SFC.SettingListItem.vue— wraps<a-list-item>with title/description/control slots and apaddingsprop. Trivial port.AppSidebar.vue— main panel sidebar. Surfaces tabs (Dashboard / Inbounds / Settings / Xray / Logout) with icons and the theme switcher. Two key changes from the legacy:- AD-Vue 4 dropped
<a-icon :type="dynamic">. Replaced with a name→component map ({ dashboard: DashboardOutlined, ... }) rendered via<component :is="...">. <a-drawer>slot="handle"(a non-standard prop in legacy AD-Vue 1) was replaced with a fixed-position toggle button rendered as a sibling. The drawer's:visiblewas renamed to:openper AD-Vue 4 conventions.- Tab
keypaths andrequestUriare passed in as props (parent page knows the basePath); the legacy embedded{{ .base_path }}directly via Go templating.
- AD-Vue 4 dropped
TableSortable.vue— drag-to-reorder a-table wrapper. The biggest single Vue 3 / AD-Vue 4 rewrite in this phase:$listeners(Vue 2) is gone — replaced byinheritAttrs: false+v-bind="$attrs"style forwarding via theattrssetup return.scopedSlots: this.$scopedSlots→ Vue 3 unifies all slots; just iterateObject.keys(this.slots)and forward.- Render-function shape changed: Vue 2's
h(tag, { props, on, scopedSlots }, children)→ Vue 3'sh(tag, { ...props, ...on }, slotsObject)where slot fns are passed as the third arg. 'a-table'as a plain string →resolveComponent('a-table')soapp.use(Antd)registration is honored.inject: ['sortable'](Options API) inside child component swapped forinject('sortable', null)(Composition API) since the trigger now usessetup().beforeDestroy→beforeUnmount(Vue 3 lifecycle rename).customRow's return shape flattened: Vue 2 nestedattrs/on/classis now a flat object of attrs + listeners + class.
Skipped in 5b:
aClientTable.html— not actually a component. It's a fragment of<template slot="X" slot-scope="...">blocks pulled into pages that use it. It depends on outer-scope variables (record,app,themeSwitcher, etc.) and only makes sense inlined into its consumer. Will migrate as part ofinbounds.html.aPersianDatepicker.html— wraps a Persian-only third-party datepicker that isn't in the critical path. Defer untilsettings.htmlmigrates and we know whether to keep the legacy lib, replace with a Vue 3 wrapper, or fall back to a native HTML5 date input.
Phase 4 notes
- Vite 6 → Vite 8.0.11 (released March 2026). Requires Node 20.19+ or 22.12+.
@vitejs/plugin-vuebumped to ^6.0.6 which lists vite ^8 as a peer. - Multi-page Vite: each migrated page = its own entry.
frontend/login.htmlregistered alongsidefrontend/index.htmlinrollupOptions.input. Same approach for the rest of the migration. - New page lives at
frontend/src/pages/login/LoginPage.vuewith a thin entrypoint atfrontend/src/login.jsthat callssetupAxios(), installs Antd, and mounts. - Three legacy features deferred to keep Phase 4 small:
- i18n — strings hardcoded in English. Phase 7 wires up vue-i18n with the existing TOML files.
- Theme switcher —
<a-theme-switch-login>was a custom component referencing a globalthemeSwitcher. Deferred to Phase 5 with the rest of the shared components. - Headline word-cycling animation — purely aesthetic, not migrated.
- Password-manager DOM observer — the
pm_*script that strips inline styles from autofilled inputs. Niche workaround, easy to add back if needed.
- Vue 3 / AD-Vue 4 syntax changes applied:
<template slot="X">→<template #X>(Vue 3 slot syntax)<a-icon slot="prefix" type="user">→<template #prefix><UserOutlined /></template>with explicit imports from@ant-design/icons-vue. AD-Vue 4 removed the generic<a-icon>— every icon must be imported individually.v-model.trimona-input→v-model:value(AD-Vue 4 uses named v-model on inputs). The.trimmodifier is dropped; trim manually if needed.new Vue({ el: '#app', delimiters, data, methods })→createApp(LoginPage).use(Antd).mount('#app')with<script setup>and Composition API.mounted()→onMounted().el: '#app', delimiters: ['[[', ']]']is gone — SFCs use the standard{{ }}.
Phase 3 notes
web/assets/js/util/index.js(927 lines, 21 classes) →frontend/src/utils/legacy.js. All classes prefixed withexport. Barrelfrontend/src/utils/index.jsre-exports for cleaner consumer imports.Vue.prototype.$message[...]insideHttpUtil._handleMsgwas replaced with a directimport { message } from 'ant-design-vue'. Vue 3 has noVue.prototype.RandomUtil.randomShadowsocksPasswordpreviously defaulted toSSMethods.BLAKE3_AES_256_GCMfrom inbound.js, which would create a circular import. Replaced with the literal string default'2022-blake3-aes-256-gcm'.MediaQueryMixinremoved from utils. Replaced byfrontend/src/composables/useMediaQuery.js, a Vue 3 composable returning a reactiveisMobile.web/assets/js/axios-init.jswas an imperative side-effect script. Wrapped assetupAxios()which the app calls once at startup.Qsglobal →import qs from 'qs'.web/assets/js/websocket.jsexported asWebSocketClient. The bottomwindow.wsClient = ...line was removed; pages instantiate the client themselves with the basePath they need.- Models (
inbound,outbound,dbinbound,setting,reality_targets) copied verbatim withexportadded and the imports they need from utils/legacy.js wired up. subscription.jsdeferred to Phase 5 — it's a Vue 2 mount, not a util.- Smoke test added to
frontend/src/App.vueexercisingSizeFormatter,RandomUtil,Wireguard, anduseMediaQuery. Ifnpm run devrenders it correctly, Phase 3 is verified.