release: prepare v0.1.140
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / test (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / test (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
This commit is contained in:
@@ -69,11 +69,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<!-- Normal item (no children) -->
|
||||
<router-link
|
||||
<component
|
||||
v-else
|
||||
:to="item.path"
|
||||
:is="item.externalHref ? 'a' : 'router-link'"
|
||||
v-bind="sidebarItemLinkProps(item)"
|
||||
class="sidebar-link mb-1"
|
||||
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
|
||||
:class="{ 'sidebar-link-active': !item.externalHref && isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
:id="
|
||||
item.path === '/admin/accounts'
|
||||
@@ -89,7 +90,7 @@
|
||||
<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>
|
||||
</component>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -101,12 +102,13 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
<component
|
||||
v-for="item in personalNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
:is="item.externalHref ? 'a' : 'router-link'"
|
||||
v-bind="sidebarItemLinkProps(item)"
|
||||
class="sidebar-link mb-1"
|
||||
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
|
||||
:class="{ 'sidebar-link-active': !item.externalHref && 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)"
|
||||
@@ -114,19 +116,20 @@
|
||||
<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>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Regular User View -->
|
||||
<template v-else-if="!appStore.backendModeEnabled">
|
||||
<div class="sidebar-section">
|
||||
<router-link
|
||||
<component
|
||||
v-for="item in userNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
:is="item.externalHref ? 'a' : 'router-link'"
|
||||
v-bind="sidebarItemLinkProps(item)"
|
||||
class="sidebar-link mb-1"
|
||||
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
|
||||
:class="{ 'sidebar-link-active': !item.externalHref && 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)"
|
||||
@@ -134,7 +137,7 @@
|
||||
<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>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
@@ -187,13 +190,22 @@ import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore }
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||
import { sanitizeSvg } from '@/utils/sanitize'
|
||||
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
|
||||
import { getCustomMenuRoute, isSidebarMenuPlacement, normalizeCustomMenuItems } from '@/utils/custom-menu'
|
||||
import { useTheme } from '@/utils/theme'
|
||||
import {
|
||||
getCustomMenuHref,
|
||||
getCustomMenuRoute,
|
||||
isExternalCustomMenuLink,
|
||||
isSidebarMenuPlacement,
|
||||
normalizeCustomMenuItems,
|
||||
} from '@/utils/custom-menu'
|
||||
import type { CustomMenuItem } from '@/types'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: unknown
|
||||
iconSvg?: string
|
||||
externalHref?: string
|
||||
hideInSimpleMode?: boolean
|
||||
requiresSuperAdmin?: boolean
|
||||
children?: NavItem[]
|
||||
@@ -254,7 +266,7 @@ 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'))
|
||||
const { isDarkTheme: isDark, toggleTheme } = useTheme()
|
||||
|
||||
// Track which parent nav groups are expanded
|
||||
const expandedGroups = ref<Set<string>>(new Set())
|
||||
@@ -693,16 +705,33 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
...customMenuItemsForUser.value.map((item): NavItem => ({
|
||||
path: getCustomMenuRoute(item.id),
|
||||
label: item.label,
|
||||
icon: null,
|
||||
iconSvg: item.icon_svg,
|
||||
})),
|
||||
...customMenuItemsForUser.value.map(customMenuItemToNavItem),
|
||||
)
|
||||
return items
|
||||
}
|
||||
|
||||
function customMenuItemToNavItem(item: CustomMenuItem): NavItem {
|
||||
const path = getCustomMenuRoute(item.id)
|
||||
return {
|
||||
path,
|
||||
label: item.label,
|
||||
icon: null,
|
||||
iconSvg: item.icon_svg,
|
||||
externalHref: isExternalCustomMenuLink(item) ? getCustomMenuHref(item) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function sidebarItemLinkProps(item: NavItem) {
|
||||
if (item.externalHref) {
|
||||
return {
|
||||
href: item.externalHref,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
}
|
||||
}
|
||||
return { to: item.path }
|
||||
}
|
||||
|
||||
// finalizeNav 合并三重过滤:featureFlag 过滤 + simple 模式过滤。
|
||||
function finalizeNav(items: NavItem[]): NavItem[] {
|
||||
const visible = applyFeatureFlags(items)
|
||||
@@ -796,7 +825,7 @@ const adminNavItems = computed((): NavItem[] => {
|
||||
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||
}
|
||||
for (const cm of customMenuItemsForAdmin.value) {
|
||||
filtered.push({ path: getCustomMenuRoute(cm.id), label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||
filtered.push(customMenuItemToNavItem(cm))
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -805,7 +834,7 @@ const adminNavItems = computed((): NavItem[] => {
|
||||
visible.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||
}
|
||||
for (const cm of customMenuItemsForAdmin.value) {
|
||||
visible.push({ path: getCustomMenuRoute(cm.id), label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||
visible.push(customMenuItemToNavItem(cm))
|
||||
}
|
||||
return visible
|
||||
})
|
||||
@@ -814,12 +843,6 @@ 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)
|
||||
}
|
||||
@@ -887,16 +910,6 @@ function handleGroupClick(item: NavItem) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -5553,6 +5553,12 @@ export default {
|
||||
iconSvg: 'SVG Icon',
|
||||
iconSvgPlaceholder: '<svg>...</svg>',
|
||||
iconPreview: 'Icon Preview',
|
||||
iconPreset: 'Existing Icons',
|
||||
iconPresetPlaceholder: 'Select an icon',
|
||||
iconPresetCustom: 'Custom / Uploaded SVG',
|
||||
iconPresetBuiltIn: 'Built-in Icons',
|
||||
iconPresetNavigation: 'Navigation Icons',
|
||||
iconPresetAssets: 'SVG Assets',
|
||||
uploadSvg: 'Upload SVG',
|
||||
removeSvg: 'Remove',
|
||||
visibility: 'Visible To',
|
||||
@@ -5562,6 +5568,9 @@ export default {
|
||||
placementSidebar: 'Sidebar only',
|
||||
placementHomeHeader: 'Home header only',
|
||||
placementBoth: 'Sidebar + Home header',
|
||||
linkOpenMode: 'Link Open Mode',
|
||||
linkOpenInternal: 'Internal',
|
||||
linkOpenExternal: 'External',
|
||||
add: 'Add Menu Item',
|
||||
remove: 'Remove',
|
||||
moveUp: 'Move Up',
|
||||
|
||||
@@ -5713,6 +5713,12 @@ export default {
|
||||
iconSvg: 'SVG 图标',
|
||||
iconSvgPlaceholder: '<svg>...</svg>',
|
||||
iconPreview: '图标预览',
|
||||
iconPreset: '已有素材',
|
||||
iconPresetPlaceholder: '选择图标',
|
||||
iconPresetCustom: '自定义 / 上传 SVG',
|
||||
iconPresetBuiltIn: '内置图标',
|
||||
iconPresetNavigation: '导航图标',
|
||||
iconPresetAssets: 'SVG 素材',
|
||||
uploadSvg: '上传 SVG',
|
||||
removeSvg: '清除',
|
||||
visibility: '可见角色',
|
||||
@@ -5722,6 +5728,9 @@ export default {
|
||||
placementSidebar: '仅侧边栏',
|
||||
placementHomeHeader: '仅首页头部',
|
||||
placementBoth: '侧边栏 + 首页头部',
|
||||
linkOpenMode: '链接打开方式',
|
||||
linkOpenInternal: '内部',
|
||||
linkOpenExternal: '外部',
|
||||
add: '添加菜单项',
|
||||
remove: '删除',
|
||||
moveUp: '上移',
|
||||
|
||||
+3
-10
@@ -4,19 +4,12 @@ import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n, { initI18n } from './i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { initTheme } from '@/utils/theme'
|
||||
import './style.css'
|
||||
|
||||
function initThemeClass() {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const shouldUseDark =
|
||||
savedTheme === 'dark' ||
|
||||
(!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
document.documentElement.classList.toggle('dark', shouldUseDark)
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
// Apply theme class globally before app mount to keep all routes consistent.
|
||||
initThemeClass()
|
||||
// Apply theme globally before app mount to keep all routes consistent.
|
||||
initTheme()
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
@@ -3,6 +3,107 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root,
|
||||
[data-theme='default-light'] {
|
||||
--color-primary-50: 240 253 250;
|
||||
--color-primary-100: 204 251 241;
|
||||
--color-primary-200: 153 246 228;
|
||||
--color-primary-300: 94 234 212;
|
||||
--color-primary-400: 45 212 191;
|
||||
--color-primary-500: 20 184 166;
|
||||
--color-primary-600: 13 148 136;
|
||||
--color-primary-700: 15 118 110;
|
||||
--color-primary-800: 17 94 89;
|
||||
--color-primary-900: 19 78 74;
|
||||
--color-primary-950: 4 47 46;
|
||||
|
||||
--color-gray-50: 249 250 251;
|
||||
--color-gray-100: 243 244 246;
|
||||
--color-gray-200: 229 231 235;
|
||||
--color-gray-300: 209 213 219;
|
||||
--color-gray-400: 156 163 175;
|
||||
--color-gray-500: 107 114 128;
|
||||
--color-gray-600: 75 85 99;
|
||||
--color-gray-700: 55 65 81;
|
||||
--color-gray-800: 31 41 55;
|
||||
--color-gray-900: 17 24 39;
|
||||
--color-gray-950: 3 7 18;
|
||||
|
||||
--color-accent-50: 248 250 252;
|
||||
--color-accent-100: 241 245 249;
|
||||
--color-accent-200: 226 232 240;
|
||||
--color-accent-300: 203 213 225;
|
||||
--color-accent-400: 148 163 184;
|
||||
--color-accent-500: 100 116 139;
|
||||
--color-accent-600: 71 85 105;
|
||||
--color-accent-700: 51 65 85;
|
||||
--color-accent-800: 30 41 59;
|
||||
--color-accent-900: 15 23 42;
|
||||
--color-accent-950: 2 6 23;
|
||||
|
||||
--color-dark-50: 248 250 252;
|
||||
--color-dark-100: 241 245 249;
|
||||
--color-dark-200: 226 232 240;
|
||||
--color-dark-300: 203 213 225;
|
||||
--color-dark-400: 148 163 184;
|
||||
--color-dark-500: 100 116 139;
|
||||
--color-dark-600: 71 85 105;
|
||||
--color-dark-700: 51 65 85;
|
||||
--color-dark-800: 30 41 59;
|
||||
--color-dark-900: 15 23 42;
|
||||
--color-dark-950: 2 6 23;
|
||||
}
|
||||
|
||||
[data-theme='default-dark'] {
|
||||
--color-primary-50: 240 253 250;
|
||||
--color-primary-100: 204 251 241;
|
||||
--color-primary-200: 153 246 228;
|
||||
--color-primary-300: 94 234 212;
|
||||
--color-primary-400: 45 212 191;
|
||||
--color-primary-500: 20 184 166;
|
||||
--color-primary-600: 13 148 136;
|
||||
--color-primary-700: 15 118 110;
|
||||
--color-primary-800: 17 94 89;
|
||||
--color-primary-900: 19 78 74;
|
||||
--color-primary-950: 4 47 46;
|
||||
|
||||
--color-gray-50: 249 250 251;
|
||||
--color-gray-100: 243 244 246;
|
||||
--color-gray-200: 229 231 235;
|
||||
--color-gray-300: 209 213 219;
|
||||
--color-gray-400: 156 163 175;
|
||||
--color-gray-500: 107 114 128;
|
||||
--color-gray-600: 75 85 99;
|
||||
--color-gray-700: 55 65 81;
|
||||
--color-gray-800: 31 41 55;
|
||||
--color-gray-900: 17 24 39;
|
||||
--color-gray-950: 3 7 18;
|
||||
|
||||
--color-accent-50: 248 250 252;
|
||||
--color-accent-100: 241 245 249;
|
||||
--color-accent-200: 226 232 240;
|
||||
--color-accent-300: 203 213 225;
|
||||
--color-accent-400: 148 163 184;
|
||||
--color-accent-500: 100 116 139;
|
||||
--color-accent-600: 71 85 105;
|
||||
--color-accent-700: 51 65 85;
|
||||
--color-accent-800: 30 41 59;
|
||||
--color-accent-900: 15 23 42;
|
||||
--color-accent-950: 2 6 23;
|
||||
|
||||
--color-dark-50: 248 250 252;
|
||||
--color-dark-100: 241 245 249;
|
||||
--color-dark-200: 226 232 240;
|
||||
--color-dark-300: 203 213 225;
|
||||
--color-dark-400: 148 163 184;
|
||||
--color-dark-500: 100 116 139;
|
||||
--color-dark-600: 71 85 105;
|
||||
--color-dark-700: 51 65 85;
|
||||
--color-dark-800: 30 41 59;
|
||||
--color-dark-900: 15 23 42;
|
||||
--color-dark-950: 2 6 23;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-gray-200 dark:border-dark-700;
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ export interface SendVerifyCodeResponse {
|
||||
}
|
||||
|
||||
export type CustomMenuPlacement = 'sidebar' | 'home_header' | 'both'
|
||||
export type CustomMenuLinkOpenMode = 'internal' | 'external'
|
||||
|
||||
export interface CustomMenuItem {
|
||||
id: string
|
||||
@@ -181,6 +182,7 @@ export interface CustomMenuItem {
|
||||
page_slug?: string
|
||||
visibility: 'user' | 'admin'
|
||||
placement?: CustomMenuPlacement
|
||||
link_open_mode?: CustomMenuLinkOpenMode
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CustomMenuItem, CustomMenuPlacement } from '@/types'
|
||||
import type { CustomMenuItem, CustomMenuLinkOpenMode, CustomMenuPlacement } from '@/types'
|
||||
|
||||
export const DEFAULT_CUSTOM_MENU_PLACEMENT: CustomMenuPlacement = 'sidebar'
|
||||
export const DEFAULT_CUSTOM_MENU_LINK_OPEN_MODE: CustomMenuLinkOpenMode = 'internal'
|
||||
|
||||
export function normalizeCustomMenuPlacement(
|
||||
value: unknown,
|
||||
@@ -11,10 +12,20 @@ export function normalizeCustomMenuPlacement(
|
||||
return DEFAULT_CUSTOM_MENU_PLACEMENT
|
||||
}
|
||||
|
||||
export function normalizeCustomMenuLinkOpenMode(
|
||||
value: unknown,
|
||||
): CustomMenuLinkOpenMode {
|
||||
if (value === 'external') {
|
||||
return value
|
||||
}
|
||||
return DEFAULT_CUSTOM_MENU_LINK_OPEN_MODE
|
||||
}
|
||||
|
||||
export function normalizeCustomMenuItem(item: CustomMenuItem): CustomMenuItem {
|
||||
return {
|
||||
...item,
|
||||
placement: normalizeCustomMenuPlacement(item.placement),
|
||||
link_open_mode: normalizeCustomMenuLinkOpenMode(item.link_open_mode),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +56,7 @@ export function getCustomMenuRoute(id: string): string {
|
||||
return `/custom/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
export function getHomeHeaderMenuHref(
|
||||
export function getCustomMenuHref(
|
||||
item: Pick<CustomMenuItem, 'id' | 'url' | 'page_slug'>,
|
||||
): string {
|
||||
if (item.page_slug || item.url?.startsWith('md:')) {
|
||||
@@ -59,3 +70,11 @@ export function getHomeHeaderMenuHref(
|
||||
|
||||
return getCustomMenuRoute(item.id)
|
||||
}
|
||||
|
||||
export function isExternalCustomMenuLink(
|
||||
item: { link_open_mode?: unknown },
|
||||
): boolean {
|
||||
return normalizeCustomMenuLinkOpenMode(item.link_open_mode) === 'external'
|
||||
}
|
||||
|
||||
export const getHomeHeaderMenuHref = getCustomMenuHref
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export type ThemePreference = 'light' | 'dark' | 'system'
|
||||
export type ThemeColorScheme = 'light' | 'dark'
|
||||
export type ThemeTemplate = 'default'
|
||||
|
||||
const THEME_STORAGE_KEY = 'theme'
|
||||
const THEME_TEMPLATE_STORAGE_KEY = 'theme-template'
|
||||
const DEFAULT_THEME_TEMPLATE: ThemeTemplate = 'default'
|
||||
const THEME_TEMPLATES = new Set<ThemeTemplate>([DEFAULT_THEME_TEMPLATE])
|
||||
|
||||
const themePreference = ref<ThemePreference>('system')
|
||||
const themeTemplate = ref<ThemeTemplate>(DEFAULT_THEME_TEMPLATE)
|
||||
const activeColorScheme = ref<ThemeColorScheme>('light')
|
||||
const isDarkTheme = computed(() => activeColorScheme.value === 'dark')
|
||||
|
||||
let mediaQuery: MediaQueryList | null = null
|
||||
let mediaQueryListenerReady = false
|
||||
|
||||
function getStoredThemePreference(): ThemePreference {
|
||||
if (typeof window === 'undefined') return 'system'
|
||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
|
||||
if (storedTheme === 'light' || storedTheme === 'dark' || storedTheme === 'system') {
|
||||
return storedTheme
|
||||
}
|
||||
return 'system'
|
||||
}
|
||||
|
||||
function getStoredThemeTemplate(): ThemeTemplate {
|
||||
if (typeof window === 'undefined') return DEFAULT_THEME_TEMPLATE
|
||||
const storedTemplate = window.localStorage.getItem(THEME_TEMPLATE_STORAGE_KEY)
|
||||
if (storedTemplate && THEME_TEMPLATES.has(storedTemplate as ThemeTemplate)) {
|
||||
return storedTemplate as ThemeTemplate
|
||||
}
|
||||
return DEFAULT_THEME_TEMPLATE
|
||||
}
|
||||
|
||||
function getSystemColorScheme(): ThemeColorScheme {
|
||||
if (typeof window === 'undefined') return 'light'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function resolveColorScheme(preference: ThemePreference): ThemeColorScheme {
|
||||
return preference === 'system' ? getSystemColorScheme() : preference
|
||||
}
|
||||
|
||||
function applyThemeToDocument(): void {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
const scheme = resolveColorScheme(themePreference.value)
|
||||
const docEl = document.documentElement
|
||||
|
||||
activeColorScheme.value = scheme
|
||||
docEl.classList.toggle('dark', scheme === 'dark')
|
||||
docEl.dataset.theme = `${themeTemplate.value}-${scheme}`
|
||||
docEl.dataset.themeTemplate = themeTemplate.value
|
||||
docEl.dataset.themeMode = scheme
|
||||
docEl.style.colorScheme = scheme
|
||||
}
|
||||
|
||||
function ensureSystemThemeListener(): void {
|
||||
if (typeof window === 'undefined' || mediaQueryListenerReady) return
|
||||
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaQuery.addEventListener('change', () => {
|
||||
if (themePreference.value === 'system') {
|
||||
applyThemeToDocument()
|
||||
}
|
||||
})
|
||||
mediaQueryListenerReady = true
|
||||
}
|
||||
|
||||
export function initTheme(): void {
|
||||
themePreference.value = getStoredThemePreference()
|
||||
themeTemplate.value = getStoredThemeTemplate()
|
||||
ensureSystemThemeListener()
|
||||
applyThemeToDocument()
|
||||
}
|
||||
|
||||
export function setThemePreference(preference: ThemePreference): void {
|
||||
themePreference.value = preference
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, preference)
|
||||
}
|
||||
applyThemeToDocument()
|
||||
}
|
||||
|
||||
export function setThemeTemplate(template: ThemeTemplate): void {
|
||||
themeTemplate.value = THEME_TEMPLATES.has(template) ? template : DEFAULT_THEME_TEMPLATE
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(THEME_TEMPLATE_STORAGE_KEY, themeTemplate.value)
|
||||
}
|
||||
applyThemeToDocument()
|
||||
}
|
||||
|
||||
export function toggleTheme(): void {
|
||||
setThemePreference(activeColorScheme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return {
|
||||
activeColorScheme,
|
||||
isDarkTheme,
|
||||
themePreference,
|
||||
themeTemplate,
|
||||
initTheme,
|
||||
setThemePreference,
|
||||
setThemeTemplate,
|
||||
toggleTheme
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,8 @@
|
||||
v-for="item in homeHeaderMenuItems"
|
||||
:key="item.id"
|
||||
:href="customMenuHref(item)"
|
||||
:target="customMenuTarget(item)"
|
||||
:rel="customMenuRel(item)"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-gray-200/70 bg-white/80 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:border-primary-300 hover:text-primary-700 dark:border-dark-700/80 dark:bg-dark-900/70 dark:text-dark-100 dark:hover:border-primary-500/50 dark:hover:text-white"
|
||||
>
|
||||
<span
|
||||
@@ -424,14 +426,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { sanitizeSvg } from '@/utils/sanitize'
|
||||
import { useTheme } from '@/utils/theme'
|
||||
import {
|
||||
getHomeHeaderMenuHref,
|
||||
isExternalCustomMenuLink,
|
||||
isHomeHeaderMenuPlacement,
|
||||
normalizeCustomMenuItems,
|
||||
} from '@/utils/custom-menu'
|
||||
@@ -460,7 +464,7 @@ const isHomeContentUrl = computed(() => {
|
||||
})
|
||||
|
||||
// Theme
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
const { isDarkTheme: isDark, toggleTheme } = useTheme()
|
||||
|
||||
// GitHub URL
|
||||
const githubUrl = 'https://github.com/Wei-Shaw/sub2api'
|
||||
@@ -482,28 +486,15 @@ function customMenuHref(item: { id: string; url: string; page_slug?: string }) {
|
||||
return getHomeHeaderMenuHref(item)
|
||||
}
|
||||
|
||||
// Toggle theme
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
function customMenuTarget(item: { link_open_mode?: string }) {
|
||||
return isExternalCustomMenuLink(item) ? '_blank' : undefined
|
||||
}
|
||||
|
||||
// Initialize theme
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (
|
||||
savedTheme === 'dark' ||
|
||||
(!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
) {
|
||||
isDark.value = true
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
function customMenuRel(item: { link_open_mode?: string }) {
|
||||
return isExternalCustomMenuLink(item) ? 'noopener noreferrer' : undefined
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
|
||||
// Check auth state
|
||||
authStore.checkAuth()
|
||||
|
||||
|
||||
@@ -366,6 +366,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useTheme } from '@/utils/theme'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -379,13 +380,7 @@ const githubUrl = 'https://github.com/Wei-Shaw/sub2api'
|
||||
|
||||
// ==================== Theme (same as HomeView) ====================
|
||||
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
const { isDarkTheme: isDark, toggleTheme } = useTheme()
|
||||
|
||||
const currentYear = computed(() => new Date().getFullYear())
|
||||
|
||||
@@ -802,14 +797,6 @@ async function queryKey() {
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
isDark.value = true
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
}
|
||||
|
||||
function formatResetTime(resetAt: string | null | undefined): string {
|
||||
if (!resetAt) return ''
|
||||
const diff = new Date(resetAt).getTime() - now.value.getTime()
|
||||
@@ -823,7 +810,6 @@ function formatResetTime(resetAt: string | null | undefined): string {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
if (!appStore.publicSettingsLoaded) {
|
||||
appStore.fetchPublicSettings()
|
||||
}
|
||||
|
||||
@@ -4428,6 +4428,23 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Link open mode -->
|
||||
<div>
|
||||
<label
|
||||
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
{{ t("admin.settings.customMenu.linkOpenMode") }}
|
||||
</label>
|
||||
<select v-model="item.link_open_mode" class="input text-sm">
|
||||
<option value="internal">
|
||||
{{ t("admin.settings.customMenu.linkOpenInternal") }}
|
||||
</option>
|
||||
<option value="external">
|
||||
{{ t("admin.settings.customMenu.linkOpenExternal") }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- URL (full width) -->
|
||||
<div class="sm:col-span-2">
|
||||
<label
|
||||
@@ -4452,6 +4469,60 @@
|
||||
>
|
||||
{{ t("admin.settings.customMenu.iconSvg") }}
|
||||
</label>
|
||||
<div class="mb-3 grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.customMenu.iconPreset") }}
|
||||
</label>
|
||||
<select
|
||||
class="input text-sm"
|
||||
:value="selectedCustomMenuIconId(item.icon_svg)"
|
||||
@change="applyCustomMenuIcon(item, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">
|
||||
{{ t("admin.settings.customMenu.iconPresetPlaceholder") }}
|
||||
</option>
|
||||
<option
|
||||
v-if="item.icon_svg && selectedCustomMenuIconId(item.icon_svg) === '__custom__'"
|
||||
value="__custom__"
|
||||
>
|
||||
{{ t("admin.settings.customMenu.iconPresetCustom") }}
|
||||
</option>
|
||||
<optgroup :label="t('admin.settings.customMenu.iconPresetBuiltIn')">
|
||||
<option
|
||||
v-for="option in customMenuBuiltinIconOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
>
|
||||
{{ customMenuIconOptionLabel(option) }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup :label="t('admin.settings.customMenu.iconPresetNavigation')">
|
||||
<option
|
||||
v-for="option in customMenuNavigationIconOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
>
|
||||
{{ customMenuIconOptionLabel(option) }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup :label="t('admin.settings.customMenu.iconPresetAssets')">
|
||||
<option
|
||||
v-for="option in customMenuAssetIconOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
>
|
||||
{{ customMenuIconOptionLabel(option) }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.icon_svg"
|
||||
class="custom-menu-icon-preview"
|
||||
v-html="sanitizeSvg(item.icon_svg)"
|
||||
></div>
|
||||
</div>
|
||||
<ImageUpload
|
||||
:model-value="item.icon_svg"
|
||||
mode="svg"
|
||||
@@ -6200,7 +6271,11 @@ import ProxySelector from "@/components/common/ProxySelector.vue";
|
||||
import ImageUpload from "@/components/common/ImageUpload.vue";
|
||||
import BackupSettings from "@/views/admin/BackupView.vue";
|
||||
import { useClipboard } from "@/composables/useClipboard";
|
||||
import builtinIconSource from "@/components/icons/Icon.vue?raw";
|
||||
import sidebarIconSource from "@/components/layout/AppSidebar.vue?raw";
|
||||
import { sanitizeSvg } from "@/utils/sanitize";
|
||||
import {
|
||||
DEFAULT_CUSTOM_MENU_LINK_OPEN_MODE,
|
||||
DEFAULT_CUSTOM_MENU_PLACEMENT,
|
||||
normalizeCustomMenuItems,
|
||||
} from "@/utils/custom-menu";
|
||||
@@ -6225,6 +6300,275 @@ function localText(zh: string, en: string): string {
|
||||
return isZhLocale.value ? zh : en;
|
||||
}
|
||||
|
||||
type CustomMenuIconOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
labelZh: string;
|
||||
source: "builtin" | "navigation" | "asset";
|
||||
svg: string;
|
||||
};
|
||||
|
||||
const customMenuIconZhLabels: Record<string, string> = {
|
||||
airwallex: "Airwallex",
|
||||
alipay: "支付宝",
|
||||
arrowDown: "下箭头",
|
||||
arrowLeft: "左箭头",
|
||||
arrowRight: "右箭头",
|
||||
arrowUp: "上箭头",
|
||||
arrowsUpDown: "上下排序",
|
||||
badge: "徽章",
|
||||
ban: "禁用",
|
||||
beaker: "实验",
|
||||
bell: "通知",
|
||||
bolt: "闪电",
|
||||
book: "文档",
|
||||
brain: "智能",
|
||||
calculator: "计算器",
|
||||
calendar: "日历",
|
||||
channel: "渠道",
|
||||
chart: "图表",
|
||||
chartBar: "柱状图",
|
||||
chat: "聊天",
|
||||
chatBubble: "消息",
|
||||
check: "勾选",
|
||||
checkCircle: "成功",
|
||||
chevronDown: "下箭头",
|
||||
chevronLeft: "左箭头",
|
||||
chevronRight: "右箭头",
|
||||
chevronUp: "上箭头",
|
||||
clipboard: "剪贴板",
|
||||
clock: "时钟",
|
||||
cloud: "云服务",
|
||||
cog: "设置",
|
||||
copy: "复制",
|
||||
creditCard: "信用卡",
|
||||
cube: "立方体",
|
||||
dashboard: "仪表盘",
|
||||
database: "数据库",
|
||||
document: "文档",
|
||||
dollar: "金额",
|
||||
download: "下载",
|
||||
easypay: "易支付",
|
||||
edit: "编辑",
|
||||
exclamationCircle: "提示",
|
||||
exclamationTriangle: "警告",
|
||||
externalLink: "外部链接",
|
||||
eye: "显示",
|
||||
eyeOff: "隐藏",
|
||||
filter: "筛选",
|
||||
fire: "热门",
|
||||
folder: "文件夹",
|
||||
gift: "礼品",
|
||||
globe: "全球",
|
||||
grid: "网格",
|
||||
home: "首页",
|
||||
inbox: "收件箱",
|
||||
infoCircle: "信息",
|
||||
key: "密钥",
|
||||
lightbulb: "灵感",
|
||||
link: "链接",
|
||||
lock: "锁定",
|
||||
login: "登录",
|
||||
mail: "邮件",
|
||||
menu: "菜单",
|
||||
moon: "月亮",
|
||||
more: "更多",
|
||||
order: "订单",
|
||||
orderList: "订单列表",
|
||||
play: "播放",
|
||||
plus: "添加",
|
||||
priceTag: "价格标签",
|
||||
questionCircle: "帮助",
|
||||
rechargeSubscription: "充值订阅",
|
||||
refresh: "刷新",
|
||||
search: "搜索",
|
||||
server: "服务器",
|
||||
shield: "安全",
|
||||
signal: "信号",
|
||||
sort: "排序",
|
||||
sparkles: "亮点",
|
||||
stripe: "Stripe",
|
||||
sun: "太阳",
|
||||
swap: "切换",
|
||||
sync: "同步",
|
||||
terminal: "终端",
|
||||
ticket: "券码",
|
||||
trash: "删除",
|
||||
trendingUp: "趋势上升",
|
||||
upload: "上传",
|
||||
user: "用户",
|
||||
userCircle: "用户头像",
|
||||
userPlus: "添加用户",
|
||||
users: "用户组",
|
||||
wxpay: "微信支付",
|
||||
x: "关闭",
|
||||
xCircle: "失败",
|
||||
};
|
||||
|
||||
const assetIconModules = import.meta.glob<string>("@/assets/icons/*.svg", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
query: "?raw",
|
||||
});
|
||||
|
||||
const customMenuIconOptions: CustomMenuIconOption[] = [
|
||||
...buildBuiltinIconOptions(),
|
||||
...buildNavigationIconOptions(),
|
||||
...buildAssetIconOptions(),
|
||||
];
|
||||
|
||||
const customMenuBuiltinIconOptions = customMenuIconOptions.filter(
|
||||
(option) => option.source === "builtin",
|
||||
);
|
||||
const customMenuNavigationIconOptions = customMenuIconOptions.filter(
|
||||
(option) => option.source === "navigation",
|
||||
);
|
||||
const customMenuAssetIconOptions = customMenuIconOptions.filter(
|
||||
(option) => option.source === "asset",
|
||||
);
|
||||
|
||||
function buildBuiltinIconOptions(): CustomMenuIconOption[] {
|
||||
const iconEntries = Array.from(
|
||||
builtinIconSource.matchAll(/^\s*([A-Za-z][A-Za-z0-9]*):\s*'([^']+)'/gm),
|
||||
);
|
||||
return iconEntries.map((match) => {
|
||||
const name = match[1];
|
||||
const path = match[2];
|
||||
return {
|
||||
id: `builtin:${name}`,
|
||||
label: formatIconLabel(name),
|
||||
labelZh: formatIconZhLabel(name),
|
||||
source: "builtin",
|
||||
svg: `<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="${path}" /></svg>`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildNavigationIconOptions(): CustomMenuIconOption[] {
|
||||
const iconNames = new Set<string>();
|
||||
const options: CustomMenuIconOption[] = [];
|
||||
const iconBlocks = Array.from(
|
||||
sidebarIconSource.matchAll(
|
||||
/const\s+([A-Za-z][A-Za-z0-9]*)\s*=\s*\{[\s\S]*?render:\s*\(\)\s*=>\s*h\(\s*'svg'\s*,\s*\{([\s\S]*?)\}\s*,\s*\[([\s\S]*?)\]\s*\)\s*\n\}/g,
|
||||
),
|
||||
);
|
||||
|
||||
for (const match of iconBlocks) {
|
||||
const rawName = match[1];
|
||||
const name = rawName.replace(/Icon$/, "");
|
||||
if (!name || iconNames.has(name)) continue;
|
||||
|
||||
const attrs = parseHObjectAttrs(match[2]);
|
||||
const pathMatches = Array.from(
|
||||
match[3].matchAll(/h\(\s*'path'\s*,\s*\{([\s\S]*?)\}\s*\)/g),
|
||||
);
|
||||
if (pathMatches.length === 0) continue;
|
||||
|
||||
const paths = pathMatches
|
||||
.map((pathMatch) => {
|
||||
const pathAttrs = parseHObjectAttrs(pathMatch[1]);
|
||||
return `<path ${attrsToString(pathAttrs)} />`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const svgAttrs = attrsToString({
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
viewBox: attrs.viewBox || "0 0 24 24",
|
||||
fill: attrs.fill || "none",
|
||||
stroke: attrs.stroke,
|
||||
"stroke-width": attrs["stroke-width"],
|
||||
});
|
||||
iconNames.add(name);
|
||||
options.push({
|
||||
id: `navigation:${name}`,
|
||||
label: formatIconLabel(name),
|
||||
labelZh: formatIconZhLabel(name),
|
||||
source: "navigation",
|
||||
svg: `<svg ${svgAttrs}>${paths}</svg>`,
|
||||
});
|
||||
}
|
||||
|
||||
return options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
function parseHObjectAttrs(raw: string): Record<string, string> {
|
||||
const attrs: Record<string, string> = {};
|
||||
for (const match of raw.matchAll(/['"]?([A-Za-z0-9_-]+)['"]?\s*:\s*'([^']*)'/g)) {
|
||||
attrs[match[1]] = match[2];
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function attrsToString(attrs: Record<string, string | undefined>): string {
|
||||
return Object.entries(attrs)
|
||||
.filter(([, value]) => value)
|
||||
.map(([key, value]) => `${key}="${escapeAttr(value || "")}"`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function buildAssetIconOptions(): CustomMenuIconOption[] {
|
||||
return Object.entries(assetIconModules)
|
||||
.map(([path, svg]) => {
|
||||
const filename = path.split("/").pop() || path;
|
||||
const name = filename.replace(/\.svg$/i, "");
|
||||
return {
|
||||
id: `asset:${name}`,
|
||||
label: formatIconLabel(name),
|
||||
labelZh: formatIconZhLabel(name),
|
||||
source: "asset" as const,
|
||||
svg,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
function formatIconLabel(value: string): string {
|
||||
return value
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function formatIconZhLabel(value: string): string {
|
||||
const normalized = value
|
||||
.replace(/[-_]+(.)/g, (_, char: string) => char.toUpperCase())
|
||||
.replace(/^./, (char) => char.toLowerCase());
|
||||
return customMenuIconZhLabels[normalized] || formatIconLabel(value);
|
||||
}
|
||||
|
||||
function customMenuIconOptionLabel(option: CustomMenuIconOption): string {
|
||||
return isZhLocale.value ? option.labelZh : option.label;
|
||||
}
|
||||
|
||||
function normalizedSvg(svg: string): string {
|
||||
return svg.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function selectedCustomMenuIconId(svg: string): string {
|
||||
const current = normalizedSvg(svg);
|
||||
if (!current) return "";
|
||||
return (
|
||||
customMenuIconOptions.find((option) => normalizedSvg(option.svg) === current)
|
||||
?.id || "__custom__"
|
||||
);
|
||||
}
|
||||
|
||||
function applyCustomMenuIcon(item: { icon_svg: string }, optionID: string) {
|
||||
if (!optionID || optionID === "__custom__") return;
|
||||
const option = customMenuIconOptions.find((entry) => entry.id === optionID);
|
||||
if (option) {
|
||||
item.icon_svg = option.svg;
|
||||
}
|
||||
}
|
||||
|
||||
const paymentGuideHref = computed(() =>
|
||||
locale.value.startsWith("zh")
|
||||
? "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md"
|
||||
@@ -6534,6 +6878,7 @@ const form = reactive<SettingsForm>({
|
||||
url: string;
|
||||
visibility: "user" | "admin";
|
||||
placement: CustomMenuPlacement;
|
||||
link_open_mode: "internal" | "external";
|
||||
sort_order: number;
|
||||
}>,
|
||||
custom_endpoints: [] as Array<{
|
||||
@@ -7138,6 +7483,7 @@ function addMenuItem() {
|
||||
url: "",
|
||||
visibility: "user",
|
||||
placement: DEFAULT_CUSTOM_MENU_PLACEMENT,
|
||||
link_open_mode: DEFAULT_CUSTOM_MENU_LINK_OPEN_MODE,
|
||||
sort_order: form.custom_menu_items.length,
|
||||
});
|
||||
}
|
||||
@@ -7258,6 +7604,7 @@ async function loadSettings() {
|
||||
).map((item) => ({
|
||||
...item,
|
||||
placement: item.placement || DEFAULT_CUSTOM_MENU_PLACEMENT,
|
||||
link_open_mode: item.link_open_mode || DEFAULT_CUSTOM_MENU_LINK_OPEN_MODE,
|
||||
}));
|
||||
form.login_agreement_mode =
|
||||
settings.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
|
||||
@@ -7629,6 +7976,7 @@ async function saveSettings() {
|
||||
custom_menu_items: form.custom_menu_items.map((item) => ({
|
||||
...item,
|
||||
placement: item.placement || DEFAULT_CUSTOM_MENU_PLACEMENT,
|
||||
link_open_mode: item.link_open_mode || DEFAULT_CUSTOM_MENU_LINK_OPEN_MODE,
|
||||
})),
|
||||
custom_endpoints: form.custom_endpoints,
|
||||
frontend_url: form.frontend_url,
|
||||
@@ -9169,4 +9517,13 @@ watch(
|
||||
.settings-tab-label {
|
||||
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap leading-none;
|
||||
}
|
||||
|
||||
.custom-menu-icon-preview {
|
||||
@apply flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 bg-white p-2 text-gray-700 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-100;
|
||||
}
|
||||
|
||||
.custom-menu-icon-preview :deep(svg) {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
+46
-33
@@ -5,47 +5,60 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
gray: {
|
||||
50: 'rgb(var(--color-gray-50) / <alpha-value>)',
|
||||
100: 'rgb(var(--color-gray-100) / <alpha-value>)',
|
||||
200: 'rgb(var(--color-gray-200) / <alpha-value>)',
|
||||
300: 'rgb(var(--color-gray-300) / <alpha-value>)',
|
||||
400: 'rgb(var(--color-gray-400) / <alpha-value>)',
|
||||
500: 'rgb(var(--color-gray-500) / <alpha-value>)',
|
||||
600: 'rgb(var(--color-gray-600) / <alpha-value>)',
|
||||
700: 'rgb(var(--color-gray-700) / <alpha-value>)',
|
||||
800: 'rgb(var(--color-gray-800) / <alpha-value>)',
|
||||
900: 'rgb(var(--color-gray-900) / <alpha-value>)',
|
||||
950: 'rgb(var(--color-gray-950) / <alpha-value>)'
|
||||
},
|
||||
// 主色调 - Teal/Cyan 青色系
|
||||
primary: {
|
||||
50: '#f0fdfa',
|
||||
100: '#ccfbf1',
|
||||
200: '#99f6e4',
|
||||
300: '#5eead4',
|
||||
400: '#2dd4bf',
|
||||
500: '#14b8a6',
|
||||
600: '#0d9488',
|
||||
700: '#0f766e',
|
||||
800: '#115e59',
|
||||
900: '#134e4a',
|
||||
950: '#042f2e'
|
||||
50: 'rgb(var(--color-primary-50) / <alpha-value>)',
|
||||
100: 'rgb(var(--color-primary-100) / <alpha-value>)',
|
||||
200: 'rgb(var(--color-primary-200) / <alpha-value>)',
|
||||
300: 'rgb(var(--color-primary-300) / <alpha-value>)',
|
||||
400: 'rgb(var(--color-primary-400) / <alpha-value>)',
|
||||
500: 'rgb(var(--color-primary-500) / <alpha-value>)',
|
||||
600: 'rgb(var(--color-primary-600) / <alpha-value>)',
|
||||
700: 'rgb(var(--color-primary-700) / <alpha-value>)',
|
||||
800: 'rgb(var(--color-primary-800) / <alpha-value>)',
|
||||
900: 'rgb(var(--color-primary-900) / <alpha-value>)',
|
||||
950: 'rgb(var(--color-primary-950) / <alpha-value>)'
|
||||
},
|
||||
// 辅助色 - 深蓝灰
|
||||
accent: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617'
|
||||
50: 'rgb(var(--color-accent-50) / <alpha-value>)',
|
||||
100: 'rgb(var(--color-accent-100) / <alpha-value>)',
|
||||
200: 'rgb(var(--color-accent-200) / <alpha-value>)',
|
||||
300: 'rgb(var(--color-accent-300) / <alpha-value>)',
|
||||
400: 'rgb(var(--color-accent-400) / <alpha-value>)',
|
||||
500: 'rgb(var(--color-accent-500) / <alpha-value>)',
|
||||
600: 'rgb(var(--color-accent-600) / <alpha-value>)',
|
||||
700: 'rgb(var(--color-accent-700) / <alpha-value>)',
|
||||
800: 'rgb(var(--color-accent-800) / <alpha-value>)',
|
||||
900: 'rgb(var(--color-accent-900) / <alpha-value>)',
|
||||
950: 'rgb(var(--color-accent-950) / <alpha-value>)'
|
||||
},
|
||||
// 深色模式背景
|
||||
dark: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617'
|
||||
50: 'rgb(var(--color-dark-50) / <alpha-value>)',
|
||||
100: 'rgb(var(--color-dark-100) / <alpha-value>)',
|
||||
200: 'rgb(var(--color-dark-200) / <alpha-value>)',
|
||||
300: 'rgb(var(--color-dark-300) / <alpha-value>)',
|
||||
400: 'rgb(var(--color-dark-400) / <alpha-value>)',
|
||||
500: 'rgb(var(--color-dark-500) / <alpha-value>)',
|
||||
600: 'rgb(var(--color-dark-600) / <alpha-value>)',
|
||||
700: 'rgb(var(--color-dark-700) / <alpha-value>)',
|
||||
800: 'rgb(var(--color-dark-800) / <alpha-value>)',
|
||||
900: 'rgb(var(--color-dark-900) / <alpha-value>)',
|
||||
950: 'rgb(var(--color-dark-950) / <alpha-value>)'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
|
||||
Reference in New Issue
Block a user