diff --git a/.gitignore b/.gitignore index cf251f07..58e46ad8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ docs/claude-relay-service/ .codex +.playwright-mcp/ # =================== # Go 后端 @@ -13,7 +14,10 @@ docs/claude-relay-service/ backend/bin/ backend/server backend/sub2api +backend/sub2api.* backend/main +sub2api +sub2api.* # Go 测试二进制 *.test @@ -104,6 +108,8 @@ backend/internal/web/dist/* # 后端运行时缓存数据 backend/data/ +data/ +runtime-data/ # =================== # 本地配置文件(包含敏感信息) @@ -136,3 +142,4 @@ docs/* frontend/coverage/ aicodex output/ +docs-*.png diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index eac5392b..0693ceee 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.139 +0.1.140 diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index de074ccd..4b5669c0 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -1151,6 +1151,14 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { response.BadRequest(c, "Custom menu item placement must be 'sidebar', 'home_header' or 'both'") return } + switch item.LinkOpenMode { + case "", "internal": + items[i].LinkOpenMode = "internal" + case "external": + default: + response.BadRequest(c, "Custom menu item link open mode must be 'internal' or 'external'") + return + } if len(item.IconSVG) > maxMenuItemIconSVGLen { response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)") return diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 7f0987af..e36c0759 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -7,14 +7,15 @@ import ( // CustomMenuItem represents a user-configured custom menu entry. type CustomMenuItem struct { - ID string `json:"id"` - Label string `json:"label"` - IconSVG string `json:"icon_svg"` - URL string `json:"url"` - PageSlug string `json:"page_slug,omitempty"` - Visibility string `json:"visibility"` // "user" or "admin" - Placement string `json:"placement,omitempty"` - SortOrder int `json:"sort_order"` + ID string `json:"id"` + Label string `json:"label"` + IconSVG string `json:"icon_svg"` + URL string `json:"url"` + PageSlug string `json:"page_slug,omitempty"` + Visibility string `json:"visibility"` // "user" or "admin" + Placement string `json:"placement,omitempty"` + LinkOpenMode string `json:"link_open_mode,omitempty"` + SortOrder int `json:"sort_order"` } // CustomEndpoint represents an admin-configured API endpoint for quick copy. @@ -371,6 +372,7 @@ func ParseCustomMenuItems(raw string) []CustomMenuItem { } for i := range items { items[i].Placement = normalizeCustomMenuPlacement(items[i].Placement) + items[i].LinkOpenMode = normalizeCustomMenuLinkOpenMode(items[i].LinkOpenMode) } return items } @@ -388,6 +390,17 @@ func normalizeCustomMenuPlacement(raw string) string { } } +func normalizeCustomMenuLinkOpenMode(raw string) string { + switch strings.TrimSpace(raw) { + case "", "internal": + return "internal" + case "external": + return "external" + default: + return "internal" + } +} + // ParseUserVisibleMenuItems parses custom menu items and filters out admin-only entries. func ParseUserVisibleMenuItems(raw string) []CustomMenuItem { items := ParseCustomMenuItems(raw) diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 6f7b9a8c..1d0d74b5 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -1218,6 +1218,14 @@ func normalizeCustomMenuItemsRaw(raw string) json.RawMessage { default: item["placement"] = "sidebar" } + linkOpenMode, _ := item["link_open_mode"].(string) + switch strings.TrimSpace(linkOpenMode) { + case "", "internal": + item["link_open_mode"] = "internal" + case "external": + default: + item["link_open_mode"] = "internal" + } } normalized, err := json.Marshal(items) if err != nil { diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 1e9ab4bb..48bcb8d8 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -69,11 +69,12 @@ - {{ item.label }} - + @@ -101,12 +102,13 @@ - {{ item.label }} - + @@ -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>(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, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ce17412b..735ac0c5 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5553,6 +5553,12 @@ export default { iconSvg: 'SVG Icon', iconSvgPlaceholder: '...', 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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index eefa522e..a1ac0713 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5713,6 +5713,12 @@ export default { iconSvg: 'SVG 图标', iconSvgPlaceholder: '...', 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: '上移', diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 68ace885..26a4a14d 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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() diff --git a/frontend/src/style.css b/frontend/src/style.css index 1bd13b5f..3dbab742 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f8f69d45..09c93309 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 } diff --git a/frontend/src/utils/custom-menu.ts b/frontend/src/utils/custom-menu.ts index 05765bfa..cbe2341d 100644 --- a/frontend/src/utils/custom-menu.ts +++ b/frontend/src/utils/custom-menu.ts @@ -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, ): 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 diff --git a/frontend/src/utils/theme.ts b/frontend/src/utils/theme.ts new file mode 100644 index 00000000..fa817e36 --- /dev/null +++ b/frontend/src/utils/theme.ts @@ -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([DEFAULT_THEME_TEMPLATE]) + +const themePreference = ref('system') +const themeTemplate = ref(DEFAULT_THEME_TEMPLATE) +const activeColorScheme = ref('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 + } +} diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 692be3c7..ebeb9fe2 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -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" >