mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: refine web theme experience
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -5,6 +5,16 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Codex Proxy RS</title>
|
||||
<script>
|
||||
;(() => {
|
||||
const state = JSON.parse(localStorage.getItem('codex-proxy-rs-ui') || '{}')
|
||||
const mode = state.themeMode || 'system'
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const theme = mode === 'dark' || (mode === 'system' && prefersDark) ? 'dark' : 'light'
|
||||
document.documentElement.dataset.theme = theme
|
||||
document.documentElement.style.colorScheme = theme
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import { BaseToast } from '@/components/base/BaseToast'
|
||||
import { useUiStore } from '@/stores/modules/ui'
|
||||
|
||||
useUiStore().initializeTheme()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -26,6 +26,7 @@ const visible = shallowRef(false)
|
||||
const dragging = shallowRef(false)
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let hideTimer: number | undefined
|
||||
let dragStartY = 0
|
||||
let dragStartScrollTop = 0
|
||||
|
||||
@@ -56,12 +57,34 @@ function showScrollbar() {
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function clearHideTimer() {
|
||||
window.clearTimeout(hideTimer)
|
||||
hideTimer = undefined
|
||||
}
|
||||
|
||||
function scheduleHideScrollbar() {
|
||||
clearHideTimer()
|
||||
if (props.forceVisible || dragging.value) {
|
||||
return
|
||||
}
|
||||
|
||||
hideTimer = window.setTimeout(() => {
|
||||
hideScrollbar()
|
||||
}, 900)
|
||||
}
|
||||
|
||||
function hideScrollbar() {
|
||||
if (!dragging.value) {
|
||||
clearHideTimer()
|
||||
if (!props.forceVisible && !dragging.value) {
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function activateScrollbar() {
|
||||
showScrollbar()
|
||||
scheduleHideScrollbar()
|
||||
}
|
||||
|
||||
function update() {
|
||||
const wrap = wrapRef.value
|
||||
if (!wrap) {
|
||||
@@ -74,6 +97,7 @@ function update() {
|
||||
thumbHeight.value = 0
|
||||
thumbTop.value = 0
|
||||
visible.value = false
|
||||
clearHideTimer()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -90,6 +114,7 @@ async function scrollToTop() {
|
||||
}
|
||||
dragging.value = false
|
||||
visible.value = false
|
||||
clearHideTimer()
|
||||
await nextTick()
|
||||
update()
|
||||
}
|
||||
@@ -101,7 +126,7 @@ function handleScroll() {
|
||||
}
|
||||
|
||||
update()
|
||||
showScrollbar()
|
||||
activateScrollbar()
|
||||
emit('scroll', {
|
||||
scrollTop: wrap.scrollTop,
|
||||
scrollLeft: wrap.scrollLeft,
|
||||
@@ -119,6 +144,7 @@ function handleTrackPointerDown(event: PointerEvent) {
|
||||
}
|
||||
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
activateScrollbar()
|
||||
const nextThumbTop = event.clientY - rect.top - thumbHeight.value / 2
|
||||
const scrollRange = maxScrollTop(wrap)
|
||||
const thumbRange = maxThumbTop(wrap)
|
||||
@@ -137,6 +163,7 @@ function handleThumbPointerDown(event: PointerEvent) {
|
||||
event.preventDefault()
|
||||
dragging.value = true
|
||||
visible.value = true
|
||||
clearHideTimer()
|
||||
dragStartY = event.clientY
|
||||
dragStartScrollTop = wrap.scrollTop
|
||||
document.addEventListener('pointermove', handleThumbPointerMove)
|
||||
@@ -161,7 +188,7 @@ function handleThumbPointerMove(event: PointerEvent) {
|
||||
function handleThumbPointerUp() {
|
||||
dragging.value = false
|
||||
document.removeEventListener('pointermove', handleThumbPointerMove)
|
||||
showScrollbar()
|
||||
activateScrollbar()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -177,6 +204,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearHideTimer()
|
||||
resizeObserver?.disconnect()
|
||||
document.removeEventListener('pointermove', handleThumbPointerMove)
|
||||
})
|
||||
@@ -193,7 +221,7 @@ defineExpose({
|
||||
<div
|
||||
ref="wrap"
|
||||
class="h-full min-h-0 overflow-auto max-h-[inherit] [-ms-overflow-style:none] scrollbar-none [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar]:bg-transparent"
|
||||
@mouseenter="showScrollbar"
|
||||
@mouseenter="activateScrollbar"
|
||||
@mouseleave="hideScrollbar"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
|
||||
@@ -113,7 +113,7 @@ function fixedHeaderClass(column: BaseTableColumn) {
|
||||
|
||||
return [
|
||||
'sticky left-0 z-30 bg-(--cp-bg-subtle)',
|
||||
horizontalScrolled.value ? 'shadow-[8px_0_14px_-14px_rgba(15,23,42,0.38)]' : undefined,
|
||||
horizontalScrolled.value ? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]' : undefined,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ function fixedBodyClass(column: BaseTableColumn, row: TableRow, index: number) {
|
||||
|
||||
return [
|
||||
'sticky left-0 z-20',
|
||||
horizontalScrolled.value ? 'shadow-[8px_0_14px_-14px_rgba(15,23,42,0.38)]' : undefined,
|
||||
horizontalScrolled.value ? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]' : undefined,
|
||||
isRowSelected(row, index)
|
||||
? 'bg-(--cp-bg-tertiary)'
|
||||
: props.stripe && index % 2 === 1
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { gsap } from 'gsap'
|
||||
import { nextTick, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
Cat,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
List,
|
||||
LogOut,
|
||||
Moon,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Settings,
|
||||
Sun,
|
||||
Users,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import { useAuthStore } from '@/stores/modules/auth'
|
||||
import { useUiStore } from '@/stores/modules/ui'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const uiStore = useUiStore()
|
||||
const { effectiveTheme } = storeToRefs(uiStore)
|
||||
const { toggleTheme } = uiStore
|
||||
|
||||
const navItems = [
|
||||
{ label: '概览', icon: LayoutDashboard, path: '/' },
|
||||
@@ -59,6 +66,9 @@ defineEmits<{
|
||||
const sidebarEl = ref<HTMLElement | null>(null)
|
||||
const brandLabelEl = ref<HTMLElement | null>(null)
|
||||
const brandLabelVisible = shallowRef(!props.collapsed)
|
||||
const themeToggleLabel = computed(() =>
|
||||
effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式',
|
||||
)
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
@@ -267,6 +277,28 @@ watch(
|
||||
</span>
|
||||
|
||||
<div class="flex items-center" :class="collapsed ? 'grid gap-1' : 'gap-1'">
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="collapsed ? 'default' : 'sm'"
|
||||
label="退出登录"
|
||||
class="hover:bg-(--cp-danger-bg) hover:text-(--cp-danger)"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut :size="collapsed ? 19 : 18" />
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="collapsed ? 'default' : 'sm'"
|
||||
:label="themeToggleLabel"
|
||||
@click="toggleTheme($event)"
|
||||
>
|
||||
<Sun v-if="effectiveTheme === 'dark'" :size="collapsed ? 19 : 18" />
|
||||
<Moon v-else :size="collapsed ? 19 : 18" />
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
@@ -278,17 +310,6 @@ watch(
|
||||
<PanelLeftOpen v-if="collapsed" :size="19" />
|
||||
<PanelLeftClose v-else :size="18" />
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
:size="collapsed ? 'default' : 'sm'"
|
||||
label="退出登录"
|
||||
class="hover:bg-(--cp-danger-bg) hover:text-(--cp-danger)"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut :size="collapsed ? 19 : 18" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,172 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { shallowRef } from 'vue'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
type ThemeMode = 'system' | 'light' | 'dark'
|
||||
type ThemeName = 'light' | 'dark'
|
||||
type ThemeTransitionOrigin = { x: number; y: number }
|
||||
type ViewTransition = {
|
||||
ready: Promise<void>
|
||||
finished: Promise<void>
|
||||
}
|
||||
|
||||
type ViewTransitionDocument = Document & {
|
||||
startViewTransition?: (callback: () => void) => ViewTransition
|
||||
}
|
||||
|
||||
export const useUiStore = defineStore(
|
||||
'ui',
|
||||
() => {
|
||||
const sidebarCollapsed = shallowRef(false)
|
||||
const themeMode = shallowRef<ThemeMode>('system')
|
||||
const systemTheme = shallowRef<ThemeName>(preferredTheme())
|
||||
|
||||
const effectiveTheme = computed<ThemeName>(() =>
|
||||
themeMode.value === 'system' ? systemTheme.value : themeMode.value,
|
||||
)
|
||||
|
||||
let mediaQuery: MediaQueryList | undefined
|
||||
let listeningTheme = false
|
||||
let themeApplied = false
|
||||
let themeTransitionTimer: number | undefined
|
||||
let themeTransitionOrigin: ThemeTransitionOrigin | undefined
|
||||
let themeTransitionRequested = false
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
function preferredTheme(): ThemeName {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeName) {
|
||||
if (
|
||||
!themeApplied ||
|
||||
!themeTransitionRequested ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
) {
|
||||
commitTheme(theme)
|
||||
themeApplied = true
|
||||
themeTransitionRequested = false
|
||||
return
|
||||
}
|
||||
|
||||
runThemeTransition(theme)
|
||||
themeApplied = true
|
||||
themeTransitionRequested = false
|
||||
}
|
||||
|
||||
function commitTheme(theme: ThemeName) {
|
||||
document.documentElement.dataset.theme = theme
|
||||
document.documentElement.style.colorScheme = theme
|
||||
}
|
||||
|
||||
function runThemeTransition(theme: ThemeName) {
|
||||
const transitionDocument = document as ViewTransitionDocument
|
||||
const previousTheme = document.documentElement.dataset.theme
|
||||
const expanding = theme === 'dark'
|
||||
|
||||
if (!transitionDocument.startViewTransition) {
|
||||
runFallbackThemeTransition(theme)
|
||||
return
|
||||
}
|
||||
|
||||
const origin = themeTransitionOrigin ?? {
|
||||
x: window.innerWidth - 44,
|
||||
y: 44,
|
||||
}
|
||||
themeTransitionOrigin = undefined
|
||||
const maxX = Math.max(origin.x, window.innerWidth - origin.x)
|
||||
const maxY = Math.max(origin.y, window.innerHeight - origin.y)
|
||||
const radius = Math.hypot(maxX, maxY)
|
||||
document.documentElement.classList.toggle(
|
||||
'theme-view-transition-reverse',
|
||||
previousTheme === 'dark' && theme === 'light',
|
||||
)
|
||||
const transition = transitionDocument.startViewTransition(() => commitTheme(theme))
|
||||
|
||||
transition.ready.then(() => {
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: expanding
|
||||
? [
|
||||
`circle(0px at ${origin.x}px ${origin.y}px)`,
|
||||
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
|
||||
]
|
||||
: [
|
||||
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
|
||||
`circle(0px at ${origin.x}px ${origin.y}px)`,
|
||||
],
|
||||
},
|
||||
{
|
||||
duration: 420,
|
||||
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
fill: 'both',
|
||||
pseudoElement: expanding
|
||||
? '::view-transition-new(root)'
|
||||
: '::view-transition-old(root)',
|
||||
},
|
||||
)
|
||||
})
|
||||
transition.finished.finally(() => {
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.classList.remove('theme-view-transition-reverse')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function runFallbackThemeTransition(theme: ThemeName) {
|
||||
window.clearTimeout(themeTransitionTimer)
|
||||
document.documentElement.classList.add('theme-fallback-transition')
|
||||
commitTheme(theme)
|
||||
themeTransitionTimer = window.setTimeout(() => {
|
||||
document.documentElement.classList.remove('theme-fallback-transition')
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function initializeTheme() {
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
systemTheme.value = mediaQuery.matches ? 'dark' : 'light'
|
||||
|
||||
if (listeningTheme) return
|
||||
|
||||
mediaQuery.addEventListener('change', (event) => {
|
||||
systemTheme.value = event.matches ? 'dark' : 'light'
|
||||
})
|
||||
listeningTheme = true
|
||||
}
|
||||
|
||||
function setThemeMode(mode: ThemeMode) {
|
||||
themeMode.value = mode
|
||||
}
|
||||
|
||||
function toggleTheme(event?: MouseEvent) {
|
||||
if (event) {
|
||||
themeTransitionOrigin = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
}
|
||||
themeTransitionRequested = true
|
||||
}
|
||||
themeMode.value = effectiveTheme.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
watch(effectiveTheme, applyTheme, { immediate: true })
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
themeMode,
|
||||
effectiveTheme,
|
||||
toggleSidebar,
|
||||
initializeTheme,
|
||||
setThemeMode,
|
||||
toggleTheme,
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'codex-proxy-rs-ui',
|
||||
pick: ['sidebarCollapsed'],
|
||||
pick: ['sidebarCollapsed', 'themeMode'],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -13,6 +13,35 @@ body {
|
||||
background: var(--cp-bg-page);
|
||||
}
|
||||
|
||||
html.theme-fallback-transition,
|
||||
html.theme-fallback-transition body {
|
||||
transition:
|
||||
background-color 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||
color 180ms cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
html.theme-view-transition-reverse::view-transition-old(root) {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
html.theme-view-transition-reverse::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
@@ -21,3 +50,15 @@ button {
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html.theme-fallback-transition,
|
||||
html.theme-fallback-transition body {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
--cp-default-text-hover: #111827;
|
||||
--cp-default-text-active: #0b1220;
|
||||
--cp-border-primary: #d8e0ea;
|
||||
--cp-divider-subtle: #d8e0ea6b;
|
||||
--cp-progress-track: #e2e8f0;
|
||||
|
||||
--cp-control-bg: #ffffff;
|
||||
--cp-control-bg-hover: #fbfdff;
|
||||
@@ -133,6 +135,7 @@
|
||||
--cp-shadow-input-focus: inset 0 1px 0 #ffffff, 0 0 0 3px #dbeafe;
|
||||
--cp-shadow-input-error: inset 0 1px 0 #ffffffcc, 0 0 0 3px #fee2e2;
|
||||
--cp-shadow-sidebar: 2px 0 12px -12px #0e172607;
|
||||
--cp-shadow-sticky: #0f172a61;
|
||||
--cp-scrollbar-track: #eef3f8;
|
||||
--cp-scrollbar-thumb: #cbd7e6;
|
||||
--cp-scrollbar-thumb-hover: #aebed0;
|
||||
@@ -143,3 +146,123 @@
|
||||
--cp-sidebar-width: 256px;
|
||||
--cp-sidebar-collapsed-width: 88px;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--cp-bg-page: #0b111c;
|
||||
--cp-bg-surface: #111a29;
|
||||
--cp-bg-subtle: #162133;
|
||||
--cp-bg-muted: #1c2a3d;
|
||||
--cp-bg-primary: #121b2a;
|
||||
--cp-bg-tertiary: #142946;
|
||||
--cp-bg-nav-active: #1b2a40;
|
||||
--cp-bg-dark: #050913;
|
||||
--cp-white: #ffffff;
|
||||
--cp-transparent: #ffffff00;
|
||||
--cp-overlay-scrim: #030712a6;
|
||||
|
||||
--cp-text-primary: #d8e2ee;
|
||||
--cp-text-strong: #f5f8fc;
|
||||
--cp-text-secondary: #8fa0b7;
|
||||
--cp-text-tertiary: #71839c;
|
||||
--cp-text-muted: #64748b;
|
||||
--cp-accent-primary: #6ea8ff;
|
||||
|
||||
--cp-default-bg: #151f30;
|
||||
--cp-default-bg-hover: #1b2a40;
|
||||
--cp-default-bg-active: #23354f;
|
||||
--cp-default-border: #26364c;
|
||||
--cp-default-border-hover: #344760;
|
||||
--cp-default-text: #e6edf7;
|
||||
--cp-default-text-hover: #f5f8fc;
|
||||
--cp-default-text-active: #ffffff;
|
||||
--cp-border-primary: #29384d;
|
||||
--cp-divider-subtle: #26364c80;
|
||||
--cp-progress-track: #24334a;
|
||||
|
||||
--cp-control-bg: #121b2a;
|
||||
--cp-control-bg-hover: #162133;
|
||||
--cp-control-highlight: #ffffff10;
|
||||
--cp-control-border: #29384d;
|
||||
--cp-control-border-hover: #3a4d66;
|
||||
--cp-control-boundary: #29384d;
|
||||
--cp-control-boundary-focus: #4f8ee8;
|
||||
--cp-control-focus-ring: #1f3f68;
|
||||
--cp-input-soft-bg: #182437;
|
||||
--cp-input-soft-bg-hover: #1d2d43;
|
||||
--cp-input-soft-bg-focus: #111d2d;
|
||||
--cp-input-context-bg: #121b2a;
|
||||
--cp-input-context-bg-hover: #162133;
|
||||
--cp-input-error-soft-bg: #311b24;
|
||||
|
||||
--cp-disabled-bg: #182231;
|
||||
--cp-disabled-border: #223047;
|
||||
--cp-disabled-text: #53647a;
|
||||
--cp-disabled-icon: #44556b;
|
||||
|
||||
--cp-info: #3b82f6;
|
||||
--cp-info-hover: #4f8ee8;
|
||||
--cp-info-pressed: #2563eb;
|
||||
--cp-info-bg: #102747;
|
||||
--cp-info-bg-hover: #163459;
|
||||
--cp-info-bg-active: #1d426d;
|
||||
--cp-info-border: #2e5e92;
|
||||
--cp-info-text: #93c5fd;
|
||||
--cp-info-on: var(--cp-text-primary);
|
||||
|
||||
--cp-normal: #2dd4bf;
|
||||
--cp-normal-hover: #5eead4;
|
||||
--cp-normal-pressed: #14b8a6;
|
||||
--cp-normal-bg: #0c332f;
|
||||
--cp-normal-bg-hover: #11423d;
|
||||
--cp-normal-bg-active: #15524c;
|
||||
--cp-normal-border: #1f6f67;
|
||||
--cp-normal-text: #5eead4;
|
||||
--cp-normal-on: #061412;
|
||||
|
||||
--cp-success: #34d399;
|
||||
--cp-success-hover: #6ee7b7;
|
||||
--cp-success-pressed: #10b981;
|
||||
--cp-success-bg: #0e3427;
|
||||
--cp-success-bg-hover: #13432f;
|
||||
--cp-success-bg-active: #18543b;
|
||||
--cp-success-border: #247554;
|
||||
--cp-success-text: #86efac;
|
||||
--cp-success-on: #06140e;
|
||||
|
||||
--cp-warning: #fbbf24;
|
||||
--cp-warning-hover: #fcd34d;
|
||||
--cp-warning-pressed: #f59e0b;
|
||||
--cp-warning-bg: #38280c;
|
||||
--cp-warning-bg-hover: #4a3510;
|
||||
--cp-warning-bg-active: #5d4314;
|
||||
--cp-warning-border: #7a581a;
|
||||
--cp-warning-text: #fcd34d;
|
||||
--cp-warning-on: #171006;
|
||||
|
||||
--cp-danger: #fb7185;
|
||||
--cp-danger-hover: #fda4af;
|
||||
--cp-danger-pressed: #f43f5e;
|
||||
--cp-danger-bg: #3b1620;
|
||||
--cp-danger-bg-hover: #4b1c28;
|
||||
--cp-danger-bg-active: #612333;
|
||||
--cp-danger-border: #7f2d42;
|
||||
--cp-danger-text: #fda4af;
|
||||
--cp-danger-on: #19070c;
|
||||
--cp-danger-ring: #5a1f2d;
|
||||
|
||||
--cp-shadow-card: 0 18px 34px -26px #000000a3, inset 0 1px 0 #ffffff08;
|
||||
--cp-shadow-control: 0 12px 24px -20px #000000a8, inset 0 1px 0 #ffffff08;
|
||||
--cp-shadow-popover: 0 24px 52px -26px #000000d1, inset 0 1px 0 #ffffff0a;
|
||||
--cp-shadow-input: inset 0 1px 0 #ffffff08, 0 12px 24px -20px #000000a8;
|
||||
--cp-shadow-input-hover:
|
||||
inset 0 1px 0 #ffffff10, 0 0 0 3px var(--cp-control-focus-ring), 0 14px 28px -22px #000000b8;
|
||||
--cp-shadow-input-focus: inset 0 1px 0 #ffffff12, 0 0 0 3px #1f3f68;
|
||||
--cp-shadow-input-error: inset 0 1px 0 #ffffff0d, 0 0 0 3px #5a1f2d;
|
||||
--cp-shadow-sidebar: 2px 0 18px -14px #000000b8;
|
||||
--cp-shadow-sticky: #000000bf;
|
||||
--cp-scrollbar-track: #111a29;
|
||||
--cp-scrollbar-thumb: #2b3e56;
|
||||
--cp-scrollbar-thumb-hover: #405774;
|
||||
}
|
||||
|
||||
@@ -45,14 +45,14 @@ defineProps<{
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 pt-4 shadow-[inset_0_1px_0_rgba(216,224,234,0.42)] xl:pt-0 xl:pl-4 xl:shadow-[inset_1px_0_0_rgba(216,224,234,0.42)]"
|
||||
class="min-w-0 pt-4 shadow-[inset_0_1px_0_var(--cp-divider-subtle)] xl:pt-0 xl:pl-4 xl:shadow-[inset_1px_0_0_var(--cp-divider-subtle)]"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="m-0 text-[14px] font-[760] text-(--cp-text-primary)">模型使用排行</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-[1.2fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.4fr] gap-3 pb-2 text-[11px] font-[760] text-(--cp-text-muted) shadow-[inset_0_-1px_0_rgba(216,224,234,0.42)]"
|
||||
class="grid grid-cols-[1.2fr_0.7fr_0.8fr_1fr_1fr_1fr_1fr_1fr_1.4fr] gap-3 pb-2 text-[11px] font-[760] text-(--cp-text-secondary) shadow-[inset_0_-1px_0_var(--cp-divider-subtle)]"
|
||||
>
|
||||
<span>模型</span>
|
||||
<span>调用</span>
|
||||
|
||||
@@ -138,7 +138,7 @@ const quotaToneClasses: Record<string, string> = {
|
||||
class="grid px-4 pt-5 pb-6 lg:px-7 lg:pt-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7 xl:pb-0"
|
||||
>
|
||||
<section
|
||||
class="min-w-0 w-full pb-6 shadow-[inset_0_-1px_0_rgba(15,23,42,0.08)] xl:h-100.5 xl:pb-0 xl:shadow-none"
|
||||
class="min-w-0 w-full pb-6 shadow-[inset_0_-1px_0_var(--cp-default-border)] xl:h-100.5 xl:pb-0 xl:shadow-none"
|
||||
>
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">账号调度</h2>
|
||||
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
|
||||
@@ -158,7 +158,7 @@ const quotaToneClasses: Record<string, string> = {
|
||||
>{{ usedPercent }}% 已占用</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-4.5 h-2.5 w-full overflow-hidden rounded-full bg-slate-200">
|
||||
<div class="mt-4.5 h-2.5 w-full overflow-hidden rounded-full bg-(--cp-progress-track)">
|
||||
<i
|
||||
class="block h-2.5 rounded-full bg-(--cp-success)"
|
||||
:style="{ width: `${usedPercent}%` }"
|
||||
@@ -190,7 +190,7 @@ const quotaToneClasses: Record<string, string> = {
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="min-w-0 w-full py-6 shadow-[inset_0_-1px_0_rgba(15,23,42,0.08)] xl:py-0 xl:shadow-none"
|
||||
class="min-w-0 w-full py-6 shadow-[inset_0_-1px_0_var(--cp-default-border)] xl:py-0 xl:shadow-none"
|
||||
>
|
||||
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">活跃账号用量</h2>
|
||||
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
|
||||
@@ -210,7 +210,7 @@ const quotaToneClasses: Record<string, string> = {
|
||||
v-for="(account, index) in accounts"
|
||||
:key="account.name"
|
||||
class="grid w-full grid-cols-[34px_minmax(0,1fr)] gap-x-3 rounded-[14px] px-3.5 py-3.5 hover:bg-(--cp-bg-subtle) xl:h-19.5 xl:grid-cols-[34px_minmax(0,1.28fr)_minmax(132px,0.86fr)] xl:items-center xl:gap-x-3 xl:py-0"
|
||||
:class="['bg-(--cp-bg-subtle)', 'bg-white'][index % 2]"
|
||||
:class="index % 2 ? 'bg-(--cp-bg-surface)' : 'bg-(--cp-bg-subtle)'"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-8.5 items-center justify-center rounded-full"
|
||||
|
||||
@@ -10,10 +10,10 @@ const props = defineProps<{
|
||||
const points = computed(() => props.timeline.points.split(''))
|
||||
|
||||
function cellClass(point: string) {
|
||||
if (point === '1') return 'bg-[#4FAD7A]'
|
||||
if (point === '2') return 'bg-[#D9A13B]'
|
||||
if (point === '3') return 'bg-[#D86A62]'
|
||||
return 'bg-[#CBD2DC]'
|
||||
if (point === '1') return 'bg-(--cp-success)'
|
||||
if (point === '2') return 'bg-(--cp-warning)'
|
||||
if (point === '3') return 'bg-(--cp-danger)'
|
||||
return 'bg-(--cp-default-border-hover)'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -38,10 +38,10 @@ function cellClass(point: string) {
|
||||
class="flex items-center gap-2 text-[11px] leading-none font-[650] text-(--cp-text-muted)"
|
||||
>
|
||||
<span>{{ timeline.oldestLabel }}</span>
|
||||
<i class="size-2 rounded-xs bg-[#CBD2DC]" />
|
||||
<i class="size-2 rounded-xs bg-[#D86A62]" />
|
||||
<i class="size-2 rounded-xs bg-[#D9A13B]" />
|
||||
<i class="size-2 rounded-xs bg-[#4FAD7A]" />
|
||||
<i class="size-2 rounded-xs bg-(--cp-default-border-hover)" />
|
||||
<i class="size-2 rounded-xs bg-(--cp-danger)" />
|
||||
<i class="size-2 rounded-xs bg-(--cp-warning)" />
|
||||
<i class="size-2 rounded-xs bg-(--cp-success)" />
|
||||
<span>{{ timeline.newestLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import BaseCard from '../../../components/base/BaseCard.vue'
|
||||
import BaseChart from '../../../components/charts/BaseChart.vue'
|
||||
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
|
||||
import BaseSegmented from '../../../components/base/BaseSegmented.vue'
|
||||
import { useUiStore } from '../../../stores/modules/ui'
|
||||
|
||||
const props = defineProps<{
|
||||
points: any[]
|
||||
@@ -22,6 +24,7 @@ const tabs = [
|
||||
{ label: '错误', value: '错误' },
|
||||
]
|
||||
const activeTab = ref('用量')
|
||||
const { effectiveTheme } = storeToRefs(useUiStore())
|
||||
|
||||
const hasSamples = computed(() =>
|
||||
props.points.some(
|
||||
@@ -42,34 +45,50 @@ const chartOption = computed<EChartsOption>(() => {
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: times,
|
||||
axisLabel: { color: '#94A3B8', fontSize: 10, fontFamily: 'JetBrains Mono' },
|
||||
axisLabel: {
|
||||
color: themeColor('--cp-text-muted', '#94A3B8'),
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono Variable, JetBrains Mono',
|
||||
},
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', splitLine: { lineStyle: { color: '#F1F5F9' } }, axisLabel: { show: false } },
|
||||
{
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: themeColor('--cp-bg-muted', '#F1F5F9') } },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
{ type: 'value', min: 0, max: 100, splitLine: { show: false }, axisLabel: { show: false } },
|
||||
],
|
||||
series,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#fff',
|
||||
backgroundColor: themeColor('--cp-bg-surface', '#fff'),
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
padding: [10, 14],
|
||||
textStyle: {
|
||||
color: '#334155',
|
||||
color: themeColor('--cp-text-primary', '#334155'),
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600,
|
||||
},
|
||||
extraCssText: 'border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08);',
|
||||
axisPointer: { type: 'line', lineStyle: { color: '#E2E8F0', type: 'dashed' } },
|
||||
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
lineStyle: { color: themeColor('--cp-default-border-hover', '#E2E8F0'), type: 'dashed' },
|
||||
},
|
||||
formatter: formatTooltip,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function themeColor(name: string, fallback: string) {
|
||||
effectiveTheme.value
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
|
||||
}
|
||||
|
||||
function formatTooltip(params: unknown) {
|
||||
const rows = Array.isArray(params) ? params : [params]
|
||||
const title = tooltipValue(rows[0], 'axisValueLabel')
|
||||
@@ -101,18 +120,18 @@ function getSeries() {
|
||||
lineSeries(
|
||||
'输入',
|
||||
props.points.map((p) => p.inputTokens),
|
||||
'#2563EB',
|
||||
themeColor('--cp-info', '#2563EB'),
|
||||
true,
|
||||
),
|
||||
lineSeries(
|
||||
'输出',
|
||||
props.points.map((p) => p.outputTokens),
|
||||
'#10B981',
|
||||
themeColor('--cp-success', '#10B981'),
|
||||
),
|
||||
lineSeries(
|
||||
'缓存',
|
||||
props.points.map((p) => p.cachedTokens),
|
||||
'#94A3B8',
|
||||
themeColor('--cp-text-tertiary', '#94A3B8'),
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -121,18 +140,18 @@ function getSeries() {
|
||||
lineSeries(
|
||||
'平均',
|
||||
props.points.map((p) => p.latency),
|
||||
'#0F9F9A',
|
||||
themeColor('--cp-normal', '#0F9F9A'),
|
||||
true,
|
||||
),
|
||||
lineSeries(
|
||||
'最高',
|
||||
props.points.map((p) => p.maxLatency),
|
||||
'#F59E0B',
|
||||
themeColor('--cp-warning', '#F59E0B'),
|
||||
),
|
||||
lineSeries(
|
||||
'最低',
|
||||
props.points.map((p) => p.minLatency),
|
||||
'#10B981',
|
||||
themeColor('--cp-success', '#10B981'),
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -140,20 +159,20 @@ function getSeries() {
|
||||
lineSeries(
|
||||
'错误数',
|
||||
props.points.map((p) => p.errors),
|
||||
'#EF4444',
|
||||
themeColor('--cp-danger', '#EF4444'),
|
||||
true,
|
||||
),
|
||||
lineSeries(
|
||||
'成功率',
|
||||
props.points.map((p) => p.successRate),
|
||||
'#10B981',
|
||||
themeColor('--cp-success', '#10B981'),
|
||||
false,
|
||||
1,
|
||||
),
|
||||
lineSeries(
|
||||
'总请求',
|
||||
props.points.map((p) => p.requests),
|
||||
'#2563EB',
|
||||
themeColor('--cp-info', '#2563EB'),
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -215,14 +234,14 @@ function formatLatency(ms: number): string {
|
||||
<div
|
||||
class="mt-4.75 grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(150px,180px)] lg:gap-7.5"
|
||||
>
|
||||
<div class="relative h-67 w-full overflow-hidden rounded-[10px] bg-white">
|
||||
<div class="relative h-67 w-full overflow-hidden rounded-[10px] bg-(--cp-bg-subtle)">
|
||||
<BaseChart v-if="hasSamples" :option="chartOption" :height="268" />
|
||||
<BaseEmpty
|
||||
v-if="!hasSamples"
|
||||
compact
|
||||
title="暂无趋势数据"
|
||||
description="最近 24 小时还没有可用于绘制趋势的请求日志。"
|
||||
class="h-full place-content-center bg-white"
|
||||
class="h-full place-content-center bg-(--cp-bg-subtle)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -157,6 +157,28 @@ async function handleSubmit() {
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
--login-glow-primary: #e6f4ff;
|
||||
--login-glow-normal: #e9fbf7;
|
||||
--login-bg-start: #f8fbff;
|
||||
--login-bg-mid: #ffffff;
|
||||
--login-bg-end: #eef4ff;
|
||||
--login-wash-line: #ffffffd6;
|
||||
--login-wash-a: #2563eb18;
|
||||
--login-wash-b: #0f9f9a18;
|
||||
--login-wash-c: #64748b10;
|
||||
--login-grid-y: #0e17260a;
|
||||
--login-grid-x: #0e172608;
|
||||
--login-runner-a: #2563ebcc;
|
||||
--login-runner-b: #0f9f9acc;
|
||||
--login-runner-shadow: #2563eb24;
|
||||
--login-form-top: #fffffff5;
|
||||
--login-form-bottom: #fffffff0;
|
||||
--login-form-edge-a: #ffffff;
|
||||
--login-form-edge-b: #dbeafe80;
|
||||
--login-form-edge-c: #c7efee66;
|
||||
--login-form-shadow: #0e17264d;
|
||||
--login-form-highlight-a: #93c5fd;
|
||||
--login-form-highlight-b: #a7dfdd;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
min-height: 100dvh;
|
||||
@@ -164,9 +186,39 @@ async function handleSubmit() {
|
||||
overflow-y: auto;
|
||||
color: var(--cp-text-primary);
|
||||
background:
|
||||
radial-gradient(circle at 12% 20%, #e6f4ff 0, transparent 30%),
|
||||
radial-gradient(circle at 88% 24%, #e9fbf7 0, transparent 26%),
|
||||
linear-gradient(116deg, #f8fbff 0%, #ffffff 50%, #eef4ff 100%);
|
||||
radial-gradient(circle at 12% 20%, var(--login-glow-primary) 0, transparent 30%),
|
||||
radial-gradient(circle at 88% 24%, var(--login-glow-normal) 0, transparent 26%),
|
||||
linear-gradient(
|
||||
116deg,
|
||||
var(--login-bg-start) 0%,
|
||||
var(--login-bg-mid) 50%,
|
||||
var(--login-bg-end) 100%
|
||||
);
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark'] .login-page) {
|
||||
--login-glow-primary: #123153;
|
||||
--login-glow-normal: #0b3a38;
|
||||
--login-bg-start: #08101b;
|
||||
--login-bg-mid: #0b111c;
|
||||
--login-bg-end: #101a2a;
|
||||
--login-wash-line: #dbeafe12;
|
||||
--login-wash-a: #6ea8ff20;
|
||||
--login-wash-b: #2dd4bf1f;
|
||||
--login-wash-c: #8fa0b714;
|
||||
--login-grid-y: #e6edf70b;
|
||||
--login-grid-x: #e6edf708;
|
||||
--login-runner-a: #6ea8ffcc;
|
||||
--login-runner-b: #2dd4bfcc;
|
||||
--login-runner-shadow: #6ea8ff30;
|
||||
--login-form-top: #121b2af2;
|
||||
--login-form-bottom: #101827f0;
|
||||
--login-form-edge-a: #ffffff10;
|
||||
--login-form-edge-b: #6ea8ff42;
|
||||
--login-form-edge-c: #2dd4bf36;
|
||||
--login-form-shadow: #000000a3;
|
||||
--login-form-highlight-a: #6ea8ff;
|
||||
--login-form-highlight-b: #2dd4bf;
|
||||
}
|
||||
|
||||
.login-backdrop {
|
||||
@@ -182,8 +234,14 @@ async function handleSubmit() {
|
||||
inset: -20%;
|
||||
content: '';
|
||||
background:
|
||||
linear-gradient(112deg, #ffffff00 22%, #ffffffd6 50%, #ffffff00 74%),
|
||||
conic-gradient(from 145deg at 46% 48%, #2563eb18, #0f9f9a18, #64748b10, #2563eb18);
|
||||
linear-gradient(112deg, #ffffff00 22%, var(--login-wash-line) 50%, #ffffff00 74%),
|
||||
conic-gradient(
|
||||
from 145deg at 46% 48%,
|
||||
var(--login-wash-a),
|
||||
var(--login-wash-b),
|
||||
var(--login-wash-c),
|
||||
var(--login-wash-a)
|
||||
);
|
||||
filter: blur(42px);
|
||||
opacity: 0.58;
|
||||
animation: login-wash 20s ease-in-out infinite alternate;
|
||||
@@ -194,8 +252,8 @@ async function handleSubmit() {
|
||||
inset: -1px;
|
||||
content: '';
|
||||
background-image:
|
||||
linear-gradient(#0e17260a 1px, transparent 1px),
|
||||
linear-gradient(90deg, #0e172608 1px, transparent 1px);
|
||||
linear-gradient(var(--login-grid-y) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--login-grid-x) 1px, transparent 1px);
|
||||
background-size: 58px 58px;
|
||||
mask-image: linear-gradient(90deg, transparent 0%, #000 14%, #000 78%, transparent 100%);
|
||||
opacity: 0.32;
|
||||
@@ -231,8 +289,14 @@ async function handleSubmit() {
|
||||
width: 42px;
|
||||
height: 2px;
|
||||
border-radius: var(--cp-radius-circle);
|
||||
background: linear-gradient(90deg, transparent, #2563ebcc, #0f9f9acc, transparent);
|
||||
box-shadow: 0 0 18px #2563eb24;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--login-runner-a),
|
||||
var(--login-runner-b),
|
||||
transparent
|
||||
);
|
||||
box-shadow: 0 0 18px var(--login-runner-shadow);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -359,7 +423,7 @@ async function handleSubmit() {
|
||||
width: 1px;
|
||||
height: 10px;
|
||||
content: '';
|
||||
background: #cbd8e8;
|
||||
background: var(--cp-default-border-hover);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@@ -371,11 +435,17 @@ async function handleSubmit() {
|
||||
padding: clamp(32px, 7vw, 42px);
|
||||
border: 1px solid transparent;
|
||||
background:
|
||||
linear-gradient(#fffffff5, #fffffff0) padding-box,
|
||||
linear-gradient(135deg, #ffffff, #dbeafe80, #c7efee66) border-box;
|
||||
linear-gradient(var(--login-form-top), var(--login-form-bottom)) padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--login-form-edge-a),
|
||||
var(--login-form-edge-b),
|
||||
var(--login-form-edge-c)
|
||||
)
|
||||
border-box;
|
||||
box-shadow:
|
||||
0 1px 0 #ffffff inset,
|
||||
0 28px 64px -42px #0e17264d;
|
||||
0 1px 0 var(--login-form-edge-a) inset,
|
||||
0 28px 64px -42px var(--login-form-shadow);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
@@ -386,7 +456,13 @@ async function handleSubmit() {
|
||||
left: 34px;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(90deg, transparent, #93c5fd, #a7dfdd, transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--login-form-highlight-a),
|
||||
var(--login-form-highlight-b),
|
||||
transparent
|
||||
);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -519,8 +595,13 @@ async function handleSubmit() {
|
||||
@media (max-width: 520px) {
|
||||
.login-page {
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, #e7f4ff 0, transparent 40%),
|
||||
linear-gradient(116deg, #f7fbff 0%, #ffffff 50%, #eef4ff 100%);
|
||||
radial-gradient(circle at 18% 12%, var(--login-glow-primary) 0, transparent 40%),
|
||||
linear-gradient(
|
||||
116deg,
|
||||
var(--login-bg-start) 0%,
|
||||
var(--login-bg-mid) 50%,
|
||||
var(--login-bg-end) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.login-backdrop::after {
|
||||
|
||||
@@ -61,13 +61,12 @@ const rotationOptions: Array<{
|
||||
},
|
||||
]
|
||||
|
||||
function numericModel(key: keyof Pick<
|
||||
SettingsForm,
|
||||
| 'refreshMarginSeconds'
|
||||
| 'refreshConcurrency'
|
||||
| 'maxConcurrentPerAccount'
|
||||
| 'requestIntervalMs'
|
||||
>) {
|
||||
function numericModel(
|
||||
key: keyof Pick<
|
||||
SettingsForm,
|
||||
'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'requestIntervalMs'
|
||||
>,
|
||||
) {
|
||||
return computed({
|
||||
get: () => String(form[key] ?? 0),
|
||||
set: (value: string) => {
|
||||
@@ -192,12 +191,7 @@ onMounted(loadSettings)
|
||||
</div>
|
||||
|
||||
<div class="mt-0.5 flex shrink-0 items-center gap-2">
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
:loading="saving"
|
||||
:disabled="loading"
|
||||
@click="handleSave"
|
||||
>
|
||||
<BaseButton variant="primary" :loading="saving" :disabled="loading" @click="handleSave">
|
||||
<template #icon>
|
||||
<Save class="size-4" />
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user