Files
3x-ui/frontend/src/components/AppSidebar.vue
MHSanaei ebe57ef273 feat(frontend): Phase 5b — port four shared components to Vue 3
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>
2026-05-08 11:52:52 +02:00

157 lines
3.9 KiB
Vue

<script setup>
import { ref } from 'vue';
import {
DashboardOutlined,
UserOutlined,
SettingOutlined,
ToolOutlined,
LogoutOutlined,
CloseOutlined,
MenuFoldOutlined,
} from '@ant-design/icons-vue';
import { currentTheme } from '@/composables/useTheme.js';
import ThemeSwitch from '@/components/ThemeSwitch.vue';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const props = defineProps({
// Path prefix (e.g. /custom-base/) the panel is served under. Defaults
// to '' which means tab keys end up as '/panel/...'. Pages pass the
// value the Go backend gave them (in production via a meta tag).
basePath: { type: String, default: '' },
// Current request URI so the matching menu item highlights.
requestUri: { type: String, default: '' },
});
// AD-Vue 4 dropped <a-icon :type="x"> in favor of explicit icon
// imports — keep a small name-to-component map so tab definitions stay
// declarative.
const iconByName = {
dashboard: DashboardOutlined,
user: UserOutlined,
setting: SettingOutlined,
tool: ToolOutlined,
logout: LogoutOutlined,
};
const tabs = [
{ key: `${props.basePath}panel/`, icon: 'dashboard', title: 'Dashboard' },
{ key: `${props.basePath}panel/inbounds`, icon: 'user', title: 'Inbounds' },
{ key: `${props.basePath}panel/settings`, icon: 'setting', title: 'Settings' },
{ key: `${props.basePath}panel/xray`, icon: 'tool', title: 'Xray' },
{ key: `${props.basePath}logout/`, icon: 'logout', title: 'Logout' },
];
const activeTab = ref([props.requestUri]);
const drawerOpen = ref(false);
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
function openLink(key) {
if (key.startsWith('http')) {
window.open(key);
} else {
window.location.href = key;
}
}
function onCollapse(isCollapsed, type) {
// Only persist explicit toggle clicks, not breakpoint-triggered collapses.
if (type === 'clickTrigger') {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, isCollapsed);
collapsed.value = isCollapsed;
}
}
function toggleDrawer() {
drawerOpen.value = !drawerOpen.value;
}
function closeDrawer() {
drawerOpen.value = false;
}
</script>
<template>
<div class="ant-sidebar">
<a-layout-sider
:theme="currentTheme"
collapsible
:collapsed="collapsed"
breakpoint="md"
@collapse="onCollapse"
>
<ThemeSwitch />
<a-menu
:theme="currentTheme"
mode="inline"
:selected-keys="activeTab"
@click="({ key }) => openLink(key)"
>
<a-menu-item v-for="tab in tabs" :key="tab.key">
<component :is="iconByName[tab.icon]" />
<span>{{ tab.title }}</span>
</a-menu-item>
</a-menu>
</a-layout-sider>
<a-drawer
placement="left"
:closable="false"
:open="drawerOpen"
:wrap-class-name="currentTheme"
:wrap-style="{ padding: 0 }"
:style="{ height: '100%' }"
@close="closeDrawer"
>
<ThemeSwitch />
<a-menu
:theme="currentTheme"
mode="inline"
:selected-keys="activeTab"
@click="({ key }) => openLink(key)"
>
<a-menu-item v-for="tab in tabs" :key="tab.key">
<component :is="iconByName[tab.icon]" />
<span>{{ tab.title }}</span>
</a-menu-item>
</a-menu>
</a-drawer>
<button class="drawer-handle" type="button" @click="toggleDrawer">
<CloseOutlined v-if="drawerOpen" />
<MenuFoldOutlined v-else />
</button>
</div>
</template>
<style scoped>
.ant-sidebar > .ant-layout-sider {
height: 100%;
}
.drawer-handle {
position: fixed;
top: 16px;
left: 16px;
z-index: 1100;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
}
@media (max-width: 768px) {
.drawer-handle {
display: inline-flex;
}
}
</style>