erio 84b03efa0b fix(settings): inject channel_monitor & available_channels into SSR payload
Root cause: GetPublicSettingsForInjection used an inline struct that silently
drifted from dto.PublicSettings and omitted channel_monitor_enabled /
available_channels_enabled. On refresh window.__APP_CONFIG__ lacked these
keys, so cachedPublicSettings.available_channels_enabled resolved to
undefined and the opt-in sidebar entry (=== true) disappeared.

Backend: extract PublicSettingsInjectionPayload as a named type with all
feature-flag fields wired, and add a reflect-based drift test in the dto
package so forgetting a future flag fails CI instead of the browser.

Frontend: introduce utils/featureFlags.ts as the single registry for
public-settings-driven toggles, with explicit opt-in / opt-out modes that
encode the pre-load fallback. AppSidebar switches to makeSidebarFlag() so
adding a new switch only touches the registry.
2026-04-21 21:08:10 +08:00

987 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<aside
class="sidebar"
:class="[
sidebarCollapsed ? 'w-[72px]' : 'w-64',
{ '-translate-x-full lg:translate-x-0': !mobileOpen }
]"
>
<!-- Logo/Brand -->
<div class="sidebar-header" :class="{ 'sidebar-header-collapsed': sidebarCollapsed }">
<!-- Custom Logo or Default Logo -->
<div class="sidebar-logo flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow">
<img v-if="settingsLoaded" :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div>
<div class="sidebar-brand" :class="{ 'sidebar-brand-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">
<span class="sidebar-brand-title text-lg font-bold text-gray-900 dark:text-white">
{{ siteName }}
</span>
<!-- Version Badge -->
<VersionBadge :version="siteVersion" />
</div>
</div>
<!-- Navigation -->
<nav class="sidebar-nav scrollbar-hide">
<!-- Admin View: Admin menu first, then personal menu -->
<template v-if="isAdmin">
<!-- Admin Section -->
<div class="sidebar-section">
<template v-for="item in adminNavItems" :key="item.path">
<!-- Collapsible group (has children) -->
<template v-if="item.children?.length">
<button
type="button"
class="sidebar-link mb-1 w-full"
:class="{
'sidebar-link-active': isGroupActive(item) && !isGroupExpanded(item),
'sidebar-link-collapsed': sidebarCollapsed
}"
:title="sidebarCollapsed ? item.label : undefined"
@click="handleGroupClick(item)"
>
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<span
class="sidebar-label sidebar-label-flex"
:class="{ 'sidebar-label-collapsed': sidebarCollapsed }"
:aria-hidden="sidebarCollapsed ? 'true' : 'false'"
>
<span class="min-w-0 truncate">{{ item.label }}</span>
<ChevronDownIcon
class="h-4 w-4 flex-shrink-0 transition-transform duration-200"
:class="isGroupExpanded(item) ? 'rotate-180' : ''"
/>
</span>
</button>
<!-- Children -->
<div v-if="!sidebarCollapsed && isGroupExpanded(item)" class="mb-1 ml-4 border-l border-gray-200 pl-2 dark:border-dark-600">
<router-link
v-for="child in item.children"
:key="child.path"
:to="child.path"
class="sidebar-link mb-0.5 py-1.5 text-sm"
:class="{ 'sidebar-link-active': route.path === child.path }"
@click="handleMenuItemClick(child.path)"
>
<component :is="child.icon" class="h-4 w-4 flex-shrink-0" />
<span>{{ child.label }}</span>
</router-link>
</div>
</template>
<!-- Normal item (no children) -->
<router-link
v-else
:to="item.path"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? item.label : undefined"
:id="
item.path === '/admin/accounts'
? 'sidebar-channel-manage'
: item.path === '/admin/groups'
? 'sidebar-group-manage'
: item.path === '/admin/redeem'
? 'sidebar-wallet'
: undefined
"
@click="handleMenuItemClick(item.path)"
>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span>
</router-link>
</template>
</div>
<!-- Personal Section for Admin (hidden in simple mode) -->
<div v-if="!authStore.isSimpleMode" class="sidebar-section">
<div class="sidebar-section-title" :class="{ 'sidebar-section-title-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">
<span class="sidebar-section-title-text" :class="{ 'sidebar-section-title-text-collapsed': sidebarCollapsed }">
{{ t('nav.myAccount') }}
</span>
</div>
<router-link
v-for="item in personalNavItems"
:key="item.path"
:to="item.path"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? item.label : undefined"
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span>
</router-link>
</div>
</template>
<!-- Regular User View -->
<template v-else-if="!appStore.backendModeEnabled">
<div class="sidebar-section">
<router-link
v-for="item in userNavItems"
:key="item.path"
:to="item.path"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? item.label : undefined"
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span>
</router-link>
</div>
</template>
</nav>
<!-- Bottom Section -->
<div class="mt-auto border-t border-gray-100 p-3 dark:border-dark-800">
<!-- Theme Toggle -->
<button
@click="toggleTheme"
class="sidebar-link mb-2 w-full"
:class="{ 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? (isDark ? t('nav.lightMode') : t('nav.darkMode')) : undefined"
>
<SunIcon v-if="isDark" class="h-5 w-5 flex-shrink-0 text-amber-500" />
<MoonIcon v-else class="h-5 w-5 flex-shrink-0" />
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{
isDark ? t('nav.lightMode') : t('nav.darkMode')
}}</span>
</button>
<!-- Collapse Button -->
<button
@click="toggleSidebar"
class="sidebar-link w-full"
:class="{ 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? t('nav.expand') : t('nav.collapse')"
>
<ChevronDoubleLeftIcon v-if="!sidebarCollapsed" class="h-5 w-5 flex-shrink-0" />
<ChevronDoubleRightIcon v-else class="h-5 w-5 flex-shrink-0" />
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ t('nav.collapse') }}</span>
</button>
</div>
</aside>
<!-- Mobile Overlay -->
<transition name="fade">
<div
v-if="mobileOpen"
class="fixed inset-0 z-30 bg-black/50 lg:hidden"
@click="closeMobile"
></div>
</transition>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import VersionBadge from '@/components/common/VersionBadge.vue'
import { sanitizeSvg } from '@/utils/sanitize'
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
interface NavItem {
path: string
label: string
icon: unknown
iconSvg?: string
hideInSimpleMode?: boolean
children?: NavItem[]
/**
* When true, the parent item only toggles the expand/collapse state and
* does NOT navigate to its `path`. The `path` is purely a stable key.
*/
expandOnly?: boolean
/**
* 可选的功能开关 getter。返回 false 时菜单项被隐藏;返回 undefined/true 时显示。
* 宽容策略undefined → 显示)避免 public settings 未加载完成时菜单闪烁消失。
* Getter 里访问的 reactive 来源store / composable会被 computed 自动追踪,
* 开关切换时菜单自动更新。
*/
featureFlag?: () => boolean | undefined
}
// applyFeatureFlags 递归过滤掉 featureFlag() === false 的节点(含子节点)。
// 使用 `!== false` 宽容语义undefined设置未加载或 true 都视为显示。
function applyFeatureFlags(items: NavItem[]): NavItem[] {
const out: NavItem[] = []
for (const item of items) {
if (item.featureFlag && item.featureFlag() === false) continue
if (item.children) {
out.push({ ...item, children: applyFeatureFlags(item.children) })
} else {
out.push(item)
}
}
return out
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const authStore = useAuthStore()
const onboardingStore = useOnboardingStore()
const adminSettingsStore = useAdminSettingsStore()
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
const mobileOpen = computed(() => appStore.mobileOpen)
const isAdmin = computed(() => authStore.isAdmin)
const isDark = ref(document.documentElement.classList.contains('dark'))
// Track which parent nav groups are expanded
const expandedGroups = ref<Set<string>>(new Set())
// Site settings from appStore (cached, no flicker)
const siteName = computed(() => appStore.siteName)
const siteLogo = computed(() => appStore.siteLogo)
const siteVersion = computed(() => appStore.siteVersion)
const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
// SVG Icon Components
const DashboardIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z'
})
]
)
}
const KeyIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z'
})
]
)
}
const ChartIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z'
})
]
)
}
const GiftIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z'
})
]
)
}
const UserIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z'
})
]
)
}
const UsersIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z'
})
]
)
}
const FolderIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z'
})
]
)
}
const ChannelIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M6.429 9.75L2.25 12l4.179 2.25m0-4.5l5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0l4.179 2.25L12 17.25 2.25 12m15.321-2.25l4.179 2.25L12 17.25l-9.75-5.25'
})
]
)
}
const CreditCardIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z'
})
]
)
}
const RechargeSubscriptionIcon = {
render: () =>
h(
'svg',
{ fill: 'currentColor', viewBox: '0 0 1024 1024' },
[
h('path', {
d: 'M512 992C247.3 992 32 776.7 32 512S247.3 32 512 32s480 215.3 480 480c0 84.4-22.2 167.4-64.2 240-8.9 15.3-28.4 20.6-43.7 11.7-15.3-8.8-20.5-28.4-11.7-43.7 36.4-62.9 55.6-134.8 55.6-208 0-229.4-186.6-416-416-416S96 282.6 96 512s186.6 416 416 416c17.7 0 32 14.3 32 32s-14.3 32-32 32z'
}),
h('path', {
d: 'M640 512H384c-17.7 0-32-14.3-32-32s14.3-32 32-32h256c17.7 0 32 14.3 32 32s-14.3 32-32 32zM640 640H384c-17.7 0-32-14.3-32-32s14.3-32 32-32h256c17.7 0 32 14.3 32 32s-14.3 32-32 32z'
}),
h('path', {
d: 'M512 480c-8.2 0-16.4-3.1-22.6-9.4l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3-6.3 6.3-14.5 9.4-22.7 9.4z'
}),
h('path', {
d: 'M512 480c-8.2 0-16.4-3.1-22.6-9.4-12.5-12.5-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3l-128 128c-6.3 6.3-14.5 9.4-22.7 9.4z'
}),
h('path', {
d: 'M512 736c-17.7 0-32-14.3-32-32V448c0-17.7 14.3-32 32-32s32 14.3 32 32v256c0 17.7-14.3 32-32 32zM896 992H512c-17.7 0-32-14.3-32-32s14.3-32 32-32h306.8l-73.4-73.4c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l128 128c9.2 9.2 11.9 22.9 6.9 34.9S908.9 992 896 992z'
})
]
)
}
const GlobeIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418'
})
]
)
}
const ServerIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z'
})
]
)
}
const BellIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9a6 6 0 10-12 0v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0'
})
]
)
}
const TicketIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z'
})
]
)
}
const CogIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z'
}),
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
})
]
)
}
const SunIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z'
})
]
)
}
const MoonIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z'
})
]
)
}
const ChevronDoubleLeftIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'm18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5'
})
]
)
}
const OrderIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z'
})
]
)
}
const OrderListIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z'
})
]
)
}
const ChevronDoubleRightIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'm5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5'
})
]
)
}
const SignalIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z'
})
]
)
}
const PriceTagIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z'
}),
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M6 6h.008v.008H6V6z'
})
]
)
}
const ChevronDownIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'm19.5 8.25-7.5 7.5-7.5-7.5'
})
]
)
}
// Public-settings flags go through the registry in utils/featureFlags.ts,
// which handles the opt-in vs opt-out fallback when settings haven't loaded
// yet. Admin-only flags (not in public settings) stay inline below.
const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
void flagAvailableChannels
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
// buildSelfNavItems 构造用户自己的导航项(用户端主菜单和管理员的"我的账户"子菜单共享这组声明)。
// withDashboard=true 时包含仪表盘用户端false 时不含(管理员的个人区已经有独立仪表盘入口)。
function buildSelfNavItems(withDashboard: boolean): NavItem[] {
const items: NavItem[] = []
if (withDashboard) {
items.push({ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon })
}
items.push(
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/monitor', label: t('nav.channelStatus'), icon: SignalIcon, featureFlag: flagChannelMonitor },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/available-channels', label: t('nav.availableChannels'), icon: ChannelIcon, hideInSimpleMode: true },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({
path: `/custom/${item.id}`,
label: item.label,
icon: null,
iconSvg: item.icon_svg,
})),
)
return items
}
// finalizeNav 合并三重过滤featureFlag 过滤 + simple 模式过滤。
function finalizeNav(items: NavItem[]): NavItem[] {
const visible = applyFeatureFlags(items)
return authStore.isSimpleMode ? visible.filter(item => !item.hideInSimpleMode) : visible
}
// User navigation items (for regular users)
const userNavItems = computed((): NavItem[] => finalizeNav(buildSelfNavItems(true)))
// Personal navigation items (for admin's "My Account" section, without Dashboard)
const personalNavItems = computed((): NavItem[] => finalizeNav(buildSelfNavItems(false)))
// Custom menu items filtered by visibility
const customMenuItemsForUser = computed(() => {
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
return items
.filter((item) => item.visibility === 'user')
.sort((a, b) => a.sort_order - b.sort_order)
})
const customMenuItemsForAdmin = computed(() => {
return adminSettingsStore.customMenuItems
.filter((item) => item.visibility === 'admin')
.sort((a, b) => a.sort_order - b.sort_order)
})
// Admin navigation items
const adminNavItems = computed((): NavItem[] => {
const baseItems: NavItem[] = [
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon, featureFlag: flagOpsMonitoring },
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
{
path: '/admin/channels',
label: t('nav.channelManagement'),
icon: ChannelIcon,
hideInSimpleMode: true,
expandOnly: true,
children: [
{ path: '/admin/channels/pricing', label: t('nav.channelPricing'), icon: PriceTagIcon },
{ path: '/admin/channels/monitor', label: t('nav.channelMonitor'), icon: SignalIcon, featureFlag: flagChannelMonitor },
],
},
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
{
path: '/admin/orders',
label: t('nav.orderManagement'),
icon: OrderIcon,
hideInSimpleMode: true,
expandOnly: true,
featureFlag: flagAdminPayment,
children: [
{ path: '/admin/orders/dashboard', label: t('nav.paymentDashboard'), icon: ChartIcon },
{ path: '/admin/orders', label: t('nav.orderManagement'), icon: OrderIcon },
{ path: '/admin/orders/plans', label: t('nav.paymentPlans'), icon: CreditCardIcon },
],
},
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }
]
const visible = applyFeatureFlags(baseItems)
// 简单模式下,在系统设置前插入 API密钥
if (authStore.isSimpleMode) {
const filtered = visible.filter(item => !item.hideInSimpleMode)
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
for (const cm of customMenuItemsForAdmin.value) {
filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
}
return filtered
}
visible.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
for (const cm of customMenuItemsForAdmin.value) {
visible.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
}
return visible
})
function toggleSidebar() {
appStore.toggleSidebar()
}
function toggleTheme() {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
function closeMobile() {
appStore.setMobileOpen(false)
}
function handleMenuItemClick(itemPath: string) {
if (mobileOpen.value) {
setTimeout(() => {
appStore.setMobileOpen(false)
}, 150)
}
// Map paths to tour selectors
const pathToSelector: Record<string, string> = {
'/admin/groups': '#sidebar-group-manage',
'/admin/accounts': '#sidebar-channel-manage',
'/keys': '[data-tour="sidebar-my-keys"]'
}
const selector = pathToSelector[itemPath]
if (selector && onboardingStore.isCurrentStep(selector)) {
onboardingStore.nextStep(500)
}
}
function isActive(path: string): boolean {
return route.path === path || route.path.startsWith(path + '/')
}
function isGroupActive(item: NavItem): boolean {
if (!item.children) return false
return item.children.some(child => route.path === child.path)
}
function isGroupExpanded(item: NavItem): boolean {
return expandedGroups.value.has(item.path) || isGroupActive(item)
}
function toggleGroup(item: NavItem) {
if (expandedGroups.value.has(item.path)) {
expandedGroups.value.delete(item.path)
} else {
expandedGroups.value.add(item.path)
}
}
/**
* Click handler for collapsible parent items.
* - When sidebar is collapsed: do nothing (children are not visible).
* - When `expandOnly` is true: only toggle expand state.
* - Otherwise (default, e.g. /admin/orders): navigate to the parent path
* (router-link semantics) and ensure the group is expanded.
*/
function handleGroupClick(item: NavItem) {
if (sidebarCollapsed.value) return
if (item.expandOnly) {
toggleGroup(item)
return
}
// Push to path and ensure expanded
if (route.path !== item.path) {
router.push(item.path)
}
if (!expandedGroups.value.has(item.path)) {
expandedGroups.value.add(item.path)
}
}
// Initialize theme
const savedTheme = localStorage.getItem('theme')
if (
savedTheme === 'dark' ||
(!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
isDark.value = true
document.documentElement.classList.add('dark')
}
// Fetch admin settings (for feature-gated nav items like Ops).
watch(
isAdmin,
(v) => {
if (v) {
adminSettingsStore.fetch()
}
},
{ immediate: true }
)
onMounted(() => {
if (isAdmin.value) {
adminSettingsStore.fetch()
}
})
</script>
<style scoped>
.sidebar-logo {
flex: 0 0 2.25rem;
min-width: 2.25rem;
}
.sidebar-header-collapsed {
gap: 0;
padding-left: 1.125rem;
padding-right: 1.125rem;
}
.sidebar-brand {
min-width: 0;
flex: 1 1 auto;
white-space: nowrap;
transition:
max-width 0.22s ease,
opacity 0.14s ease,
transform 0.14s ease;
max-width: 12rem;
}
.sidebar-brand-collapsed {
max-width: 0;
overflow: hidden;
opacity: 0;
transform: translateX(-4px);
pointer-events: none;
}
.sidebar-brand-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-link-collapsed {
gap: 0;
padding-left: 0.875rem;
padding-right: 0.875rem;
}
.sidebar-section-title {
position: relative;
display: flex;
align-items: center;
min-height: 1.25rem;
overflow: hidden;
white-space: nowrap;
}
.sidebar-section-title-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.sidebar-section-title::after {
content: '';
position: absolute;
left: 0.75rem;
right: 0.75rem;
top: 50%;
height: 1px;
background: rgb(229 231 235);
opacity: 0;
transform: translateY(-50%);
transition: opacity 0.18s ease;
}
.dark .sidebar-section-title::after {
background: rgb(55 65 81);
}
.sidebar-section-title-text-collapsed {
opacity: 0;
transform: translateX(-4px);
}
.sidebar-section-title-collapsed::after {
opacity: 1;
transition-delay: 0.08s;
}
.sidebar-label {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition:
max-width 0.2s ease,
opacity 0.12s ease,
transform 0.12s ease;
max-width: 12rem;
}
.sidebar-label-flex {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.sidebar-label-collapsed {
max-width: 0;
opacity: 0;
transform: translateX(-4px);
pointer-events: none;
}
/* Custom SVG icon in sidebar: constrain size without overriding uploaded SVG colors */
.sidebar-svg-icon {
color: currentColor;
}
.sidebar-svg-icon :deep(svg) {
display: block;
width: 1.25rem;
height: 1.25rem;
}
</style>