feat: refine web theme experience

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-28 03:19:22 +08:00
co-authored by Codex
parent e6e417de1a
commit 9edb6ebc27
14 changed files with 549 additions and 81 deletions
+10
View File
@@ -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>
+3
View File
@@ -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>
+32 -4
View File
@@ -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"
>
+2 -2
View File
@@ -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
+33 -12
View File
@@ -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>
+150 -2
View File
@@ -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'],
},
},
)
+41
View File
@@ -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;
}
}
+123
View File
@@ -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>
+98 -17
View File
@@ -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 {
+7 -13
View File
@@ -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>