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

This commit is contained in:
kone
2026-06-03 23:52:28 +08:00
parent d1b574bcad
commit 6f4a680156
17 changed files with 767 additions and 127 deletions
+7
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.1.139
0.1.140
@@ -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
+21 -8
View File
@@ -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)
@@ -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 {
+51 -38
View File
@@ -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,
+9
View File
@@ -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',
+9
View File
@@ -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
View File
@@ -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()
+101
View File
@@ -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;
}
+2
View File
@@ -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
}
+21 -2
View File
@@ -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
+111
View File
@@ -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
}
}
+10 -19
View File
@@ -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()
+2 -16
View File
@@ -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()
}
+357
View File
@@ -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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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
View File
@@ -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: {