AppSidebar.vue 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. <script setup>
  2. import { computed, ref } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import {
  5. DashboardOutlined,
  6. UserOutlined,
  7. SettingOutlined,
  8. ToolOutlined,
  9. ClusterOutlined,
  10. LogoutOutlined,
  11. CloseOutlined,
  12. MenuFoldOutlined,
  13. } from '@ant-design/icons-vue';
  14. import { currentTheme } from '@/composables/useTheme.js';
  15. import ThemeSwitch from '@/components/ThemeSwitch.vue';
  16. const { t } = useI18n();
  17. const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
  18. const props = defineProps({
  19. // Path prefix (e.g. /custom-base/) the panel is served under. Defaults
  20. // to '' which means tab keys end up as '/panel/...'. Pages pass the
  21. // value the Go backend gave them (in production via a meta tag).
  22. basePath: { type: String, default: '' },
  23. // Current request URI so the matching menu item highlights.
  24. requestUri: { type: String, default: '' },
  25. });
  26. // AD-Vue 4 dropped <a-icon :type="x"> in favor of explicit icon
  27. // imports — keep a small name-to-component map so tab definitions stay
  28. // declarative.
  29. const iconByName = {
  30. dashboard: DashboardOutlined,
  31. user: UserOutlined,
  32. setting: SettingOutlined,
  33. tool: ToolOutlined,
  34. cluster: ClusterOutlined,
  35. logout: LogoutOutlined,
  36. };
  37. // basePath comes from Go (`/` by default, `/myprefix/` when configured) so
  38. // these concatenations land on absolute paths. In dev we synthesize the prop
  39. // from a window global which can be empty — force a leading slash so the
  40. // browser doesn't resolve the link relative to the current pathname (which
  41. // would turn /panel/settings + 'panel/...' into /panel/panel/...).
  42. const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
  43. // Labels are i18n-driven so the sidebar matches the locale picked
  44. // in panel settings without a page reload of the sidebar component.
  45. const tabs = computed(() => [
  46. { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
  47. { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
  48. { key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
  49. { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
  50. { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
  51. { key: `${prefix}logout`, icon: 'logout', title: t('logout') },
  52. ]);
  53. const activeTab = ref([props.requestUri]);
  54. const drawerOpen = ref(false);
  55. const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
  56. function openLink(key) {
  57. if (key.startsWith('http')) {
  58. window.open(key);
  59. } else {
  60. window.location.href = key;
  61. }
  62. }
  63. function onCollapse(isCollapsed, type) {
  64. // Only persist explicit toggle clicks, not breakpoint-triggered collapses.
  65. if (type === 'clickTrigger') {
  66. localStorage.setItem(SIDEBAR_COLLAPSED_KEY, isCollapsed);
  67. collapsed.value = isCollapsed;
  68. }
  69. }
  70. function toggleDrawer() {
  71. drawerOpen.value = !drawerOpen.value;
  72. }
  73. function closeDrawer() {
  74. drawerOpen.value = false;
  75. }
  76. </script>
  77. <template>
  78. <div class="ant-sidebar">
  79. <a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
  80. <ThemeSwitch />
  81. <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
  82. <a-menu-item v-for="tab in tabs" :key="tab.key">
  83. <component :is="iconByName[tab.icon]" />
  84. <span>{{ tab.title }}</span>
  85. </a-menu-item>
  86. </a-menu>
  87. </a-layout-sider>
  88. <a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
  89. :wrap-style="{ padding: 0 }" :style="{ height: '100%' }" @close="closeDrawer">
  90. <ThemeSwitch />
  91. <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
  92. <a-menu-item v-for="tab in tabs" :key="tab.key">
  93. <component :is="iconByName[tab.icon]" />
  94. <span>{{ tab.title }}</span>
  95. </a-menu-item>
  96. </a-menu>
  97. </a-drawer>
  98. <button class="drawer-handle" type="button" @click="toggleDrawer">
  99. <CloseOutlined v-if="drawerOpen" />
  100. <MenuFoldOutlined v-else />
  101. </button>
  102. </div>
  103. </template>
  104. <style scoped>
  105. .ant-sidebar>.ant-layout-sider {
  106. height: 100%;
  107. }
  108. .drawer-handle {
  109. position: fixed;
  110. top: 16px;
  111. left: 16px;
  112. z-index: 1100;
  113. background: rgba(0, 0, 0, 0.55);
  114. color: #fff;
  115. border: none;
  116. width: 36px;
  117. height: 36px;
  118. border-radius: 50%;
  119. cursor: pointer;
  120. display: none;
  121. align-items: center;
  122. justify-content: center;
  123. }
  124. @media (max-width: 768px) {
  125. .drawer-handle {
  126. display: inline-flex;
  127. }
  128. /* On mobile the drawer is the menu — hide the inline sider's content
  129. * + the collapse trigger so the sider stops taking layout space and
  130. * leaves no remnant button next to the page. */
  131. .ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
  132. .ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
  133. display: none;
  134. }
  135. .ant-sidebar>.ant-layout-sider {
  136. flex: 0 0 0 !important;
  137. max-width: 0 !important;
  138. min-width: 0 !important;
  139. width: 0 !important;
  140. }
  141. }
  142. </style>