feat(theme): add runtime token theme editor

Introduce Seed-to-Component token derivation, isolated live previews, semantic base component theming, and responsive theme controls.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-24 04:04:22 +08:00
co-authored by Codex
parent fbd922117e
commit 8422240967
150 changed files with 6281 additions and 1640 deletions
+1
View File
@@ -14,6 +14,7 @@
"format:check": "pnpm run lint"
},
"dependencies": {
"@ant-design/colors": "^8.0.1",
"@boxicons/vue": "1.0.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
+16
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@ant-design/colors':
specifier: ^8.0.1
version: 8.0.1
'@boxicons/vue':
specifier: 1.0.1
version: 1.0.1(vue@3.5.41(typescript@6.0.3))
@@ -96,6 +99,13 @@ importers:
packages:
'@ant-design/colors@8.0.1':
resolution: {integrity: sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==}
'@ant-design/fast-color@3.0.1':
resolution: {integrity: sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==}
engines: {node: '>=8.x'}
'@antfu/eslint-config@9.3.0':
resolution: {integrity: sha512-+CIC4G6MowUl1RHiT01Ah9Obo/wwVynw7uNlN8ODwEn2opo9W1KzaatCrmu4WhGGJjOkOkyT5w7kKfQ//agHrQ==}
hasBin: true
@@ -2431,6 +2441,12 @@ packages:
snapshots:
'@ant-design/colors@8.0.1':
dependencies:
'@ant-design/fast-color': 3.0.1
'@ant-design/fast-color@3.0.1': {}
'@antfu/eslint-config@9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
'@antfu/install-pkg': 2.0.1
-3
View File
@@ -2,9 +2,6 @@
import { RouterView } from 'vue-router'
import { BaseToast } from '@/components/base/BaseToast'
import { useUiStore } from '@/stores/modules/ui'
useUiStore().initializeTheme()
</script>
<template>
@@ -32,7 +32,7 @@ function updateGroup(groupId: string, selected: boolean) {
<div
v-for="group in groups"
:key="group.id"
class="flex min-h-11 items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3.5 py-2.5"
class="flex min-h-11 items-center justify-between gap-3 rounded-cp bg-cp-fill-quaternary px-3.5 py-2.5"
>
<span class="flex min-w-0 items-center gap-2.5">
<span
@@ -50,7 +50,7 @@ function updateGroup(groupId: string, selected: boolean) {
</span>
<span
v-if="!group.enabled"
class="shrink-0 text-[11px] font-emphasis text-cp-muted-text"
class="shrink-0 text-cp-xs font-emphasis text-cp-text-quaternary"
>
已禁用
</span>
@@ -58,7 +58,7 @@ function updateGroup(groupId: string, selected: boolean) {
</div>
<p
v-else
class="m-0 rounded-cp-control bg-cp-subtle px-3.5 py-3 text-[12px] font-emphasis text-cp-secondary"
class="m-0 rounded-cp bg-cp-fill-quaternary px-3.5 py-3 text-cp-sm font-emphasis text-cp-text-secondary"
>
{{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建。' }}
</p>
@@ -27,5 +27,5 @@ const fullNames = computed(() => props.groups
aria-hidden="true"
/>
</div>
<span v-else class="text-xs text-cp-muted-text">—</span>
<span v-else class="text-xs text-cp-text-quaternary">—</span>
</template>
@@ -90,7 +90,7 @@ function providerSummary(group: AccountGroup) {
:disabled="disabled || loading"
>
<template #prefix>
<Search class="size-4 text-cp-tertiary" />
<Search class="size-4 text-cp-text-tertiary" />
</template>
</BaseInput>
@@ -102,10 +102,10 @@ function providerSummary(group: AccountGroup) {
<div
v-for="group in visibleGroups"
:key="group.id"
class="flex w-full items-start gap-3 rounded-cp-control border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="flex w-full items-start gap-3 rounded-cp border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:class="selectedIds.includes(group.id)
? 'bg-cp-info-bg'
: 'bg-cp-subtle hover:bg-cp-default-hover'"
? 'bg-cp-control-item-bg-active'
: 'bg-cp-fill-quaternary hover:bg-cp-bg-text-hover'"
>
<BaseCheckbox
:model-value="selectedIds.includes(group.id)"
@@ -115,7 +115,7 @@ function providerSummary(group: AccountGroup) {
/>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-2">
<strong class="min-w-0 truncate text-[13px] text-cp-primary">
<strong class="min-w-0 truncate text-cp text-cp-text">
{{ group.name }}
</strong>
<span
@@ -125,7 +125,7 @@ function providerSummary(group: AccountGroup) {
已禁用
</span>
</span>
<span class="mt-1 block text-[11px] font-emphasis text-cp-secondary">
<span class="mt-1 block text-cp-xs font-emphasis text-cp-text-secondary">
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
</span>
</span>
@@ -148,15 +148,15 @@ function providerSummary(group: AccountGroup) {
<div
v-if="selectedIds.length === 0"
class="rounded-cp-control px-3.5 py-3"
class="rounded-cp px-3.5 py-3"
:class="emptyCopy.tone === 'warning'
? 'bg-cp-warning-bg text-cp-warning-text'
: 'bg-cp-subtle text-cp-secondary'"
: 'bg-cp-fill-quaternary text-cp-text-secondary'"
>
<p class="m-0 text-[12px] font-bold">
<p class="m-0 text-cp-sm font-bold">
{{ emptyCopy.title }}
</p>
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis">
<p class="mt-1 mb-0 text-cp-xs leading-normal font-emphasis">
{{ emptyCopy.description }}
</p>
</div>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useId } from 'vue'
const id = useId().replaceAll(':', '')
const noiseFilterId = `${id}-brand-noise`
const wornFilterId = `${id}-brand-worn`
const mistGradientId = `${id}-brand-mist`
</script>
<template>
<svg
class="app-brand-mark"
xmlns="http://www.w3.org/2000/svg"
viewBox="48 48 416 416"
aria-hidden="true"
focusable="false"
>
<defs>
<filter :id="noiseFilterId" x="0" y="0" width="100%" height="100%">
<feTurbulence
type="fractalNoise"
baseFrequency="1.7"
numOctaves="2"
seed="31"
result="noise"
/>
<feColorMatrix
in="noise"
type="matrix"
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 2.56 2.56 2.56 0 -4.76"
result="speckle"
/>
<feComposite operator="in" in="SourceGraphic" in2="speckle" />
</filter>
<filter :id="wornFilterId" x="-12%" y="-12%" width="124%" height="124%">
<feTurbulence
type="fractalNoise"
baseFrequency="0.12"
numOctaves="2"
seed="41"
result="noise"
/>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="2.2" />
</filter>
<radialGradient :id="mistGradientId" cx="0.5" cy="0.45" r="0.75">
<stop offset="0" stop-color="currentColor" stop-opacity="0.1" />
<stop offset="0.55" stop-color="currentColor" stop-opacity="0.045" />
<stop offset="1" stop-color="currentColor" stop-opacity="0.012" />
</radialGradient>
</defs>
<rect class="app-brand-mark__surface" x="48" y="48" width="416" height="416" rx="88" />
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="36">
<path d="M156 307A112 112 0 0 1 307 156" />
<path d="M356 205A112 112 0 0 1 205 356" />
</g>
<path d="m407 105-119 153-183 149 124-157Z" fill="currentColor" />
<g
:filter="`url(#${wornFilterId})`"
fill="none"
stroke="currentColor"
stroke-linecap="square"
>
<path d="M92 170 136 148" stroke-width="1" opacity="0.38" />
<path d="M98 178 142 156" stroke-width="0.5" opacity="0.3" />
<path d="M308 398 346 378" stroke-width="0.9" opacity="0.36" />
<path d="M314 406 352 386" stroke-width="0.5" opacity="0.3" />
<path d="M364 132 398 118" stroke-width="0.9" opacity="0.38" />
<path d="M402 118 410 126" stroke-width="0.5" opacity="0.28" />
<path d="M108 350 142 334" stroke-width="1" opacity="0.4" />
<path d="M158 238 190 226" stroke-width="0.8" opacity="0.34" />
<path d="M296 292 320 280" stroke-width="0.7" opacity="0.3" />
<path d="M126 118 136 110" stroke-width="0.6" opacity="0.26" />
<path d="M400 96 410 88" stroke-width="0.6" opacity="0.24" />
</g>
<g :filter="`url(#${wornFilterId})`" fill="currentColor">
<path
d="M98 176 104 173 108 175 114 170 118 173 124 167 130 170 136 164 142 167 148 161 154 164 160 158Z"
opacity="0.34"
/>
<path
d="M314 395 320 392 324 394 330 389 336 392 342 386 348 389 354 383Z"
opacity="0.3"
/>
</g>
<rect
x="48"
y="48"
width="416"
height="416"
rx="88"
fill="currentColor"
:filter="`url(#${noiseFilterId})`"
opacity="0.08"
/>
<rect
x="48"
y="48"
width="416"
height="416"
rx="88"
:fill="`url(#${mistGradientId})`"
/>
</svg>
</template>
<style scoped>
.app-brand-mark {
color: var(--cp-color-text-light-solid);
}
.app-brand-mark__surface {
fill: var(--cp-brand-mark-bg);
}
</style>
@@ -39,18 +39,18 @@ const authenticationLabel = computed(() => {
<template>
<span class="inline-flex items-center whitespace-nowrap" :class="groupGapClass">
<span
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
class="inline-flex shrink-0 items-center justify-center bg-cp-fill-tertiary text-cp-text"
:class="iconContainerClass"
:title="providerLabel"
>
<Openai v-if="normalizedProvider === 'openai'" :class="iconClass" aria-hidden="true" />
<Xai v-else-if="normalizedProvider === 'xai'" :class="iconClass" aria-hidden="true" />
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
<span v-else class="text-[10px] font-heavy text-cp-text-quaternary">?</span>
</span>
<span
v-if="showAuthenticationKind"
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
class="inline-flex shrink-0 items-center justify-center bg-cp-fill-tertiary text-cp-text"
:class="iconContainerClass"
:title="authenticationLabel"
>
@@ -64,7 +64,7 @@ const authenticationLabel = computed(() => {
:class="iconClass"
aria-hidden="true"
/>
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
<span v-else class="text-[10px] font-heavy text-cp-text-quaternary">?</span>
</span>
<span class="sr-only">
+9 -9
View File
@@ -30,19 +30,19 @@ defineSlots<{
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
'bg-(--cp-button-primary-bg) text-(--cp-button-primary-color) shadow-cp-tertiary hover:bg-(--cp-button-primary-hover-bg) active:bg-(--cp-button-primary-active-bg)',
secondary:
'bg-cp-muted text-cp-primary shadow-cp-control hover:bg-cp-default-active active:bg-cp-nav-active',
'bg-cp-fill-tertiary text-cp-text shadow-cp-tertiary hover:bg-cp-bg-text-active active:bg-cp-bg-text-active',
ghost:
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
'bg-transparent text-cp-text-secondary shadow-none hover:bg-cp-fill-quaternary hover:text-cp-text active:bg-cp-fill-tertiary',
destructive:
'bg-cp-danger-bg text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
'bg-cp-error-bg text-cp-error-text shadow-none hover:bg-cp-error-bg-hover active:bg-cp-error-bg-active',
}
const sizeClasses: Record<ButtonSize, string> = {
sm: 'h-cp-control-sm gap-1.5 px-3 text-xs',
md: 'h-cp-control-md gap-2 px-4 text-[13px]',
lg: 'h-cp-control-lg gap-2.5 px-5 text-[14px]',
md: 'h-cp-control gap-2 px-4 text-cp',
lg: 'h-cp-control-lg gap-2.5 px-5 text-cp-lg',
}
const spinnerSizes: Record<ButtonSize, number> = {
@@ -52,9 +52,9 @@ const spinnerSizes: Record<ButtonSize, number> = {
}
const classes = computed(() => [
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-control-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
])
+3 -3
View File
@@ -45,7 +45,7 @@ const contentClasses = computed(() => [
<template>
<component
:is="props.as"
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-card"
class="overflow-hidden rounded-cp-card bg-(--cp-card-bg) shadow-cp-card"
:class="[paddingClasses[padding], hasHeader ? 'flex min-h-0 flex-col' : undefined]"
>
<template v-if="$slots.header || hasManagedHeader || $slots.body">
@@ -55,7 +55,7 @@ const contentClasses = computed(() => [
<div class="min-w-0 pt-0.5">
<h2
v-if="props.title || $slots.title"
class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary text-balance"
class="m-0 text-xl leading-[1.15] font-heavy text-cp-text text-balance"
>
<slot name="title">
{{ props.title }}
@@ -63,7 +63,7 @@ const contentClasses = computed(() => [
</h2>
<p
v-if="props.description || $slots.description"
class="mt-1.75 mb-0 text-[13px] leading-[1.3] font-emphasis text-cp-secondary text-pretty"
class="mt-1.75 mb-0 text-cp leading-[1.3] font-emphasis text-cp-text-secondary text-pretty"
>
<slot name="description">
{{ props.description }}
@@ -26,12 +26,12 @@ watchEffect(() => {
})
const boxClasses = computed(() => [
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-control-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
props.disabled
? 'bg-cp-disabled text-cp-disabled-icon'
? 'bg-cp-bg-container-disabled text-cp-text-disabled'
: checked.value
? 'bg-cp-accent text-cp-accent-on'
: 'bg-cp-surface text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
? 'bg-cp-primary text-(--cp-button-primary-color)'
: 'bg-cp-bg-container text-transparent shadow-[inset_0_0_0_1px_var(--cp-color-border)]',
])
</script>
@@ -39,7 +39,7 @@ const boxClasses = computed(() => [
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-[13px] leading-none font-emphasis"
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-cp leading-none font-emphasis"
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
>
<input
@@ -52,7 +52,7 @@ const boxClasses = computed(() => [
>
<span
:class="boxClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-control-outline peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-bg-container"
aria-hidden="true"
>
<Minus
@@ -64,6 +64,6 @@ const boxClasses = computed(() => [
:class="!indeterminate && model ? 'opacity-100' : 'opacity-0'"
/>
</span>
<span v-if="showLabel" class="text-[13px] leading-none font-emphasis">{{ label }}</span>
<span v-if="showLabel" class="text-cp leading-none font-emphasis">{{ label }}</span>
</label>
</template>
@@ -1,9 +1,9 @@
import { normalizeRgbaHexColor } from '@/utils/color'
import { normalizeHexColor, normalizeRgbaHexColor } from '@/utils/color'
const RGBA_COLOR_PATTERN
= /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*\)$/i
interface HsvColor {
export interface HsvaColor {
h: number
s: number
v: number
@@ -17,8 +17,23 @@ interface RgbaColor {
a: number
}
export function hexToHsv(value: string): HsvColor | null {
const normalized = normalizeRgbaHexColor(value)
export function normalizePickerHexColor(value: string, allowAlpha: boolean) {
if (allowAlpha) {
const rgba = normalizeRgbaHexColor(value)
if (rgba)
return rgba
const rgb = normalizeHexColor(value)
return rgb ? `${rgb}FF` : null
}
const rgb = normalizeHexColor(value)
if (rgb)
return rgb
return normalizeRgbaHexColor(value)?.slice(0, 7) ?? null
}
export function hexToHsva(value: string): HsvaColor | null {
const normalized = normalizePickerHexColor(value, true)
if (!normalized)
return null
@@ -48,7 +63,7 @@ export function hexToHsv(value: string): HsvColor | null {
}
}
export function hsvToHex(color: HsvColor) {
export function hsvaToHex(color: HsvaColor, allowAlpha: boolean) {
const hue = ((color.h % 360) + 360) % 360
const saturation = clamp(color.s, 0, 100) / 100
const brightness = clamp(color.v, 0, 100) / 100
@@ -71,7 +86,9 @@ export function hsvToHex(color: HsvColor) {
const rgb = [red, green, blue]
.map(channel => Math.round((channel + offset) * 255).toString(16).padStart(2, '0'))
.join('')
const alpha = Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
const alpha = allowAlpha
? Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
: ''
return `#${rgb}${alpha}`.toUpperCase()
}
@@ -97,7 +114,7 @@ export function rgbaToHexColor(color: RgbaColor) {
}
export function formatRgbaColor(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, true)
if (!normalized)
return null
@@ -3,14 +3,15 @@ import { Check, ChevronDown } from '@lucide/vue'
import { clamp } from 'es-toolkit'
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import { normalizeRgbaHexColor } from '@/utils/color'
import { normalizeHexColor } from '@/utils/color'
import BaseButton from '../BaseButton.vue'
import BaseInput from '../BaseInput.vue'
import BasePopover from '../BasePopover.vue'
import {
formatRgbaColor,
hexToHsv,
hsvToHex,
hexToHsva,
hsvaToHex,
normalizePickerHexColor,
parseRgbaColor,
rgbaToHexColor,
} from './color'
@@ -20,11 +21,13 @@ const props = withDefaults(
presets?: readonly string[]
disabled?: boolean
label?: string
allowAlpha?: boolean
}>(),
{
presets: () => [],
disabled: false,
label: '选择颜色',
allowAlpha: true,
},
)
@@ -39,13 +42,22 @@ const svPanel = useTemplateRef<HTMLElement>('svPanel')
const hueSlider = useTemplateRef<HTMLElement>('hueSlider')
const alphaSlider = useTemplateRef<HTMLElement>('alphaSlider')
const normalizedModel = computed(() => normalizeRgbaHexColor(model.value) ?? '#60A5FA80')
const draftColor = computed(() => hsvToHex({
const normalizedModel = computed(() =>
normalizePickerHexColor(model.value, props.allowAlpha)
?? (props.allowAlpha ? '#60A5FA80' : '#2563EB'),
)
const triggerSwatchStyle = computed(() => props.allowAlpha
? {
backgroundImage: `linear-gradient(${normalizedModel.value}, ${normalizedModel.value}), conic-gradient(#d8dee8 25%, #fff 0 50%, #d8dee8 0 75%, #fff 0)`,
backgroundSize: '100% 100%, 8px 8px',
}
: { backgroundColor: normalizedModel.value })
const draftColor = computed(() => hsvaToHex({
h: hue.value,
s: saturation.value,
v: brightness.value,
a: alpha.value,
}))
}, props.allowAlpha))
const hueColor = computed(() => `hsl(${hue.value} 100% 50%)`)
const svCursorStyle = computed(() => ({
left: `${saturation.value}%`,
@@ -63,8 +75,11 @@ const alphaTrackStyle = computed(() => {
backgroundSize: '100% 100%, 8px 8px',
}
})
const inputError = computed(() => customInput.value && !parseRgbaColor(customInput.value)
? '请输入 rgba(r, g, b, a)'
const inputValid = computed(() => props.allowAlpha
? Boolean(parseRgbaColor(customInput.value))
: Boolean(normalizeHexColor(customInput.value)))
const inputError = computed(() => customInput.value && !inputValid.value
? props.allowAlpha ? '请输入 rgba(r, g, b, a)' : '请输入六位 HEX 颜色,例如 #2563EB'
: undefined)
watch(open, (isOpen) => {
@@ -78,16 +93,19 @@ watch(model, (value) => {
})
function resetDraft(value: string) {
const color = hexToHsv(value) ?? { h: 213, s: 62, v: 98, a: 0.5 }
const color = hexToHsva(normalizePickerHexColor(value, props.allowAlpha) ?? '')
?? { h: 213, s: 62, v: 98, a: props.allowAlpha ? 0.5 : 1 }
hue.value = color.h
saturation.value = color.s
brightness.value = color.v
alpha.value = color.a
customInput.value = formatRgbaColor(hsvToHex(color)) ?? ''
alpha.value = props.allowAlpha ? color.a : 1
syncInput()
}
function syncInput() {
customInput.value = formatRgbaColor(draftColor.value) ?? ''
customInput.value = props.allowAlpha
? formatRgbaColor(draftColor.value) ?? ''
: normalizeHexColor(draftColor.value) ?? ''
}
function updateSaturationBrightness(event: PointerEvent) {
@@ -189,31 +207,31 @@ function handleAlphaKeydown(event: KeyboardEvent) {
}
function updateFromInput() {
const rgba = parseRgbaColor(customInput.value)
const color = rgba ? hexToHsv(rgbaToHexColor(rgba)) : null
const inputColor = normalizedInputColor()
const color = inputColor ? hexToHsva(inputColor) : null
if (!color)
return
hue.value = color.h
saturation.value = color.s
brightness.value = color.v
alpha.value = color.a
customInput.value = formatRgbaColor(hsvToHex(color)) ?? ''
alpha.value = props.allowAlpha ? color.a : 1
syncInput()
}
function choosePreset(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, props.allowAlpha)
if (normalized)
resetDraft(normalized)
}
function isPresetSelected(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, props.allowAlpha)
return normalized?.slice(0, 7) === draftColor.value.slice(0, 7)
}
function presetCheckClass(value: string) {
const normalized = normalizeRgbaHexColor(value)
const normalized = normalizePickerHexColor(value, props.allowAlpha)
if (!normalized)
return 'text-white'
@@ -227,13 +245,21 @@ function presetCheckClass(value: string) {
function confirm() {
updateFromInput()
const rgba = parseRgbaColor(customInput.value)
if (!rgba)
const normalized = normalizedInputColor()
if (!normalized)
return
model.value = rgbaToHexColor(rgba)
model.value = normalized
open.value = false
}
function normalizedInputColor() {
if (!props.allowAlpha)
return normalizeHexColor(customInput.value)
const rgba = parseRgbaColor(customInput.value)
return rgba ? rgbaToHexColor(rgba) : null
}
</script>
<template>
@@ -246,14 +272,17 @@ function confirm() {
<template #trigger>
<button
type="button"
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-info-border disabled:cursor-not-allowed disabled:opacity-60"
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-control-outline disabled:cursor-not-allowed disabled:opacity-60"
:disabled="props.disabled"
:aria-label="props.label"
aria-haspopup="dialog"
:aria-expanded="open"
>
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-sm" aria-hidden="true">
<span class="absolute inset-0" :style="{ backgroundColor: normalizedModel }" />
<span
class="relative flex size-full items-center justify-center overflow-hidden rounded-sm"
:style="triggerSwatchStyle"
aria-hidden="true"
>
<ChevronDown class="relative size-3.5 text-white drop-shadow-[0_1px_2px_rgb(0_0_0/0.65)]" />
</span>
</button>
@@ -263,7 +292,7 @@ function confirm() {
<div class="grid h-43 grid-cols-[1fr_14px] gap-2">
<div
ref="svPanel"
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:style="{ backgroundColor: hueColor }"
role="slider"
tabindex="0"
@@ -285,7 +314,7 @@ function confirm() {
<div
ref="hueSlider"
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
role="slider"
tabindex="0"
aria-label="色相"
@@ -305,10 +334,10 @@ function confirm() {
</div>
</div>
<div class="grid grid-cols-[1fr_36px] items-center gap-2">
<div v-if="props.allowAlpha" class="grid grid-cols-[1fr_36px] items-center gap-2">
<div
ref="alphaSlider"
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:style="alphaTrackStyle"
role="slider"
tabindex="0"
@@ -328,7 +357,7 @@ function confirm() {
aria-hidden="true"
/>
</div>
<span class="text-right font-mono text-[11px] tabular-nums text-cp-secondary">
<span class="text-right font-mono text-cp-xs tabular-nums text-cp-text-secondary">
{{ Math.round(alpha * 100) }}%
</span>
</div>
@@ -343,7 +372,7 @@ function confirm() {
v-for="color in props.presets"
:key="color"
type="button"
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
:style="{ backgroundColor: color }"
role="radio"
:aria-label="color"
@@ -364,8 +393,9 @@ function confirm() {
<div class="min-w-0 flex-1">
<BaseInput
v-model="customInput"
aria-label="RGBA 颜色"
placeholder="rgba(96, 165, 250, 1)"
class="bg-cp-fill-tertiary!"
:aria-label="props.allowAlpha ? 'RGBA 颜色' : 'HEX 颜色'"
:placeholder="props.allowAlpha ? 'rgba(96, 165, 250, 1)' : '#2563EB'"
autocomplete="off"
:disabled="props.disabled"
:aria-invalid="Boolean(inputError) || undefined"
@@ -376,13 +406,13 @@ function confirm() {
<p
v-if="inputError"
id="color-input-error"
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-danger-text"
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-error-text"
aria-live="polite"
>
{{ inputError }}
</p>
</div>
<BaseButton size="md" variant="secondary" :disabled="!parseRgbaColor(customInput)" @click="confirm">
<BaseButton size="md" variant="secondary" :disabled="!inputValid" @click="confirm">
确定
</BaseButton>
</div>
@@ -391,16 +421,6 @@ function confirm() {
</template>
<style scoped>
.base-color-picker__checker,
.base-color-picker__hue,
.base-color-picker__sv {
--cp-color-picker-checker: conic-gradient(#d8dee8 25%, #fff 0 50%, #d8dee8 0 75%, #fff 0) 0 / 8px 8px;
}
.base-color-picker__checker {
background: var(--cp-color-picker-checker);
}
.base-color-picker__sv::before,
.base-color-picker__sv::after {
position: absolute;
@@ -52,7 +52,7 @@ function handleConfirm() {
>
<div
v-if="$slots.default"
class="text-[14px] leading-[1.55] font-emphasis text-cp-secondary"
class="text-cp-lg leading-[1.55] font-emphasis text-cp-text-secondary"
>
<slot />
</div>
+4 -4
View File
@@ -28,22 +28,22 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)
<div
class="grid justify-items-center text-center"
:class="[
surface === 'none' ? 'bg-transparent' : 'rounded-cp-overlay bg-cp-subtle',
surface === 'none' ? 'bg-transparent' : 'rounded-cp-lg bg-cp-bg-elevated',
size === 'sm' ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
]"
>
<span
class="inline-flex items-center justify-center rounded-cp-control bg-cp-muted text-cp-muted-text"
class="inline-flex items-center justify-center rounded-cp bg-cp-fill-tertiary text-cp-text-quaternary"
:class="size === 'sm' ? 'size-8' : 'size-10'"
>
<component :is="resolvedIcon" :size="size === 'sm' ? 16 : 18" />
</span>
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-cp-primary">
<p class="m-0 text-cp leading-[1.15] font-heavy text-cp-text">
{{ resolvedTitle }}
</p>
<p
v-if="description"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-secondary"
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-text-secondary"
>
{{ description }}
</p>
@@ -43,10 +43,10 @@ provide(formFieldKey, {
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
<label
:for="resolvedControlId"
class="flex min-w-0 items-center gap-1.5 text-[13px] leading-none font-medium text-cp-secondary"
class="flex min-w-0 items-center gap-1.5 text-cp leading-none font-medium text-cp-text-secondary"
>
<slot name="label">{{ label }}</slot>
<span v-if="required" class="font-bold text-cp-danger" aria-hidden="true">*</span>
<span v-if="required" class="font-bold text-cp-error" aria-hidden="true">*</span>
<span v-if="required" class="sr-only">必填</span>
</label>
<div v-if="$slots.extra" class="inline-flex shrink-0 items-center">
@@ -56,7 +56,7 @@ provide(formFieldKey, {
<p
v-if="description"
:id="descriptionId"
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-muted-text"
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-text-quaternary"
>
{{ description }}
</p>
@@ -66,7 +66,7 @@ provide(formFieldKey, {
<p
v-if="error"
:id="errorId"
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-danger-text"
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-error-text"
aria-live="polite"
>
{{ error }}
@@ -32,20 +32,20 @@ defineSlots<{
const variantClasses: Record<IconButtonVariant, string> = {
primary:
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
'bg-(--cp-button-primary-bg) text-(--cp-button-primary-color) shadow-cp-tertiary hover:bg-(--cp-button-primary-hover-bg) active:bg-(--cp-button-primary-active-bg)',
secondary:
'bg-cp-surface text-cp-secondary shadow-cp-control hover:bg-cp-default-hover hover:text-cp-primary active:bg-cp-default-active',
'bg-cp-bg-container text-cp-text-secondary shadow-cp-tertiary hover:bg-cp-bg-text-hover hover:text-cp-text active:bg-cp-bg-text-active',
success:
'bg-cp-success-bg text-cp-success-text shadow-none hover:bg-cp-success-bg-hover active:bg-cp-success-bg-active',
ghost:
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
'bg-transparent text-cp-text-secondary shadow-none hover:bg-cp-fill-quaternary hover:text-cp-text active:bg-cp-fill-tertiary',
destructive:
'bg-transparent text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
'bg-transparent text-cp-error-text shadow-none hover:bg-cp-error-bg-hover active:bg-cp-error-bg-active',
}
const sizeClasses: Record<IconButtonSize, string> = {
sm: 'size-cp-control-sm [&>svg]:size-3.5',
md: 'size-cp-control-md [&>svg]:size-4',
md: 'size-cp-control [&>svg]:size-4',
lg: 'size-cp-control-lg [&>svg]:size-4.5',
}
@@ -56,12 +56,12 @@ const spinnerSizes: Record<IconButtonSize, number> = {
}
const classes = computed(() => [
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp-control border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
props.pressed ? 'bg-cp-default-active text-cp-primary' : undefined,
props.pressed ? 'bg-cp-bg-text-active text-cp-text' : undefined,
])
</script>
+28 -17
View File
@@ -48,31 +48,31 @@ const controlAttrs = computed(() => Object.fromEntries(
const sizeClasses: Record<InputSize, string> = {
sm: 'h-cp-control-sm gap-2 px-3 text-xs',
md: 'h-cp-control-md gap-2.5 px-3.5 text-[13px]',
lg: 'h-cp-control-lg gap-3 px-4 text-[14px]',
md: 'h-cp-control gap-2.5 px-3.5 text-cp',
lg: 'h-cp-control-lg gap-3 px-4 text-cp-lg',
}
const containerClasses = computed(() => [
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp-control border-0 text-cp-primary shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp border-0 text-cp-text shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
sizeClasses[props.size],
props.disabled
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
? 'cursor-not-allowed bg-cp-bg-container-disabled text-cp-text-disabled shadow-none'
: invalid.value
? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error'
? 'bg-(--cp-input-error-active-bg) shadow-cp-input-error-active'
: [
'bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))]',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-cp-input-focus',
'bg-[var(--cp-input-bg)]',
'hover:bg-[var(--cp-input-hover-bg)] hover:shadow-cp-input-hover',
'focus-within:bg-(--cp-input-active-bg) focus-within:shadow-cp-input-active',
],
])
const iconClasses = computed(() => [
'inline-flex shrink-0',
props.disabled
? 'text-cp-disabled-icon'
? 'text-cp-text-disabled'
: invalid.value
? 'text-cp-danger'
: 'text-cp-muted-text',
? 'text-cp-error'
: 'text-cp-text-quaternary',
])
function updateModel(event: Event) {
@@ -90,7 +90,7 @@ function updateModel(event: Event) {
v-bind="controlAttrs"
:id="controlId"
:value="model"
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-primary outline-0 placeholder:text-cp-muted-text disabled:cursor-not-allowed disabled:text-cp-disabled-text"
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-text outline-0 placeholder:text-cp-text-quaternary disabled:cursor-not-allowed disabled:text-cp-text-disabled"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
@@ -109,24 +109,35 @@ function updateModel(event: Event) {
<style scoped>
.base-input__control {
--base-input-autofill-bg: var(--cp-input-current-bg, var(--cp-input-context-bg));
--base-input-autofill-bg: var(--cp-input-bg);
}
.base-input__control:hover {
--base-input-autofill-bg: var(--cp-input-current-bg-hover, var(--cp-input-context-bg-hover));
--base-input-autofill-bg: var(--cp-input-hover-bg);
}
.base-input__control:focus-within {
--base-input-autofill-bg: var(--cp-input-soft-bg-focus);
--base-input-autofill-bg: var(--cp-input-active-bg);
}
.base-input__field:-webkit-autofill,
.base-input__field:-webkit-autofill:hover,
.base-input__field:-webkit-autofill:focus,
.base-input__field:autofill {
caret-color: var(--cp-text-primary);
-webkit-text-fill-color: var(--cp-text-primary) !important;
caret-color: var(--cp-color-text);
-webkit-text-fill-color: var(--cp-color-text) !important;
-webkit-box-shadow: 0 0 0 1000px var(--base-input-autofill-bg) inset !important;
box-shadow: 0 0 0 1000px var(--base-input-autofill-bg) inset !important;
}
/* 普通数字表单保留键盘步进,但不混入浏览器原生微调器外观。 */
.base-input__field[type='number'] {
appearance: textfield;
}
.base-input__field[type='number']::-webkit-inner-spin-button,
.base-input__field[type='number']::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
</style>
@@ -22,12 +22,12 @@ defineSlots<{
}>()
const classes = computed(() => [
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-accent-border',
'disabled:cursor-not-allowed disabled:text-cp-disabled-text',
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-sm border-0 bg-transparent px-3 text-left text-cp leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-control-outline',
'disabled:cursor-not-allowed disabled:text-cp-text-disabled',
props.tone === 'destructive'
? 'text-cp-danger-text hover:bg-cp-danger-bg-hover'
: 'text-cp-primary hover:bg-cp-default-hover',
? 'text-cp-error-text hover:bg-cp-error-bg-hover'
: 'text-cp-text hover:bg-cp-bg-text-hover',
])
</script>
@@ -72,8 +72,8 @@ const toneClasses: Record<ModalTone, { iconBg: string, icon: string }> = {
icon: 'text-cp-warning',
},
danger: {
iconBg: 'bg-cp-danger-bg',
icon: 'text-cp-danger',
iconBg: 'bg-cp-error-bg',
icon: 'text-cp-error',
},
success: {
iconBg: 'bg-cp-success-bg',
@@ -186,12 +186,12 @@ onBeforeUnmount(() => {
type="button"
tabindex="-1"
aria-label="关闭弹窗"
class="absolute inset-0 cursor-default border-0 bg-cp-overlay p-0"
class="absolute inset-0 cursor-default border-0 bg-cp-bg-mask p-0"
@click="closeModal"
/>
<section
ref="panel"
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-popover sm:max-h-[calc(100dvh-3rem)]"
class="cp-modal-panel relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 overflow-hidden rounded-cp-card bg-cp-bg-container shadow-cp sm:max-h-[calc(100dvh-3rem)]"
:class="[
sizeClasses[size],
$slots.default ? 'grid-rows-[auto_minmax(0,1fr)_auto]' : 'grid-rows-[auto_auto]',
@@ -213,21 +213,21 @@ onBeforeUnmount(() => {
>
<span
v-if="tone !== 'neutral'"
class="inline-flex size-11 items-center justify-center rounded-cp-control"
:class="$slots.icon ? 'bg-cp-subtle' : toneClasses[tone].iconBg"
class="inline-flex size-11 items-center justify-center rounded-cp"
:class="$slots.icon ? 'bg-cp-fill-quaternary' : toneClasses[tone].iconBg"
>
<slot name="icon">
<component :is="iconMap[tone]" :size="18" :class="toneClasses[tone].icon" />
</slot>
</span>
<div class="min-w-0">
<h2 :id="titleId" class="m-0 text-lg leading-[1.15] font-heavy text-cp-primary">
<h2 :id="titleId" class="m-0 text-lg leading-[1.15] font-heavy text-cp-text">
{{ title }}
</h2>
<p
v-if="description"
:id="descriptionId"
class="mt-2 mb-0 text-[13px] leading-[1.45] font-semibold text-cp-secondary"
class="mt-2 mb-0 text-cp leading-[1.45] font-semibold text-cp-text-secondary"
>
{{ description }}
</p>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { Minus, Plus } from '@lucide/vue'
import { computed, useAttrs } from 'vue'
import BaseIconButton from './BaseIconButton.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label: string
min?: number
max?: number
step?: number
unit?: string
disabled?: boolean
}>(),
{
min: undefined,
max: undefined,
step: 1,
unit: undefined,
disabled: false,
},
)
const model = defineModel<number>({ required: true })
const attrs = useAttrs()
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
const controlAttrs = computed(() => Object.fromEntries(
Object.entries(attrs).filter(([key]) => !['class', 'style'].includes(key)),
))
const canDecrease = computed(() => !props.disabled && (props.min === undefined || model.value > props.min))
const canIncrease = computed(() => !props.disabled && (props.max === undefined || model.value < props.max))
function clampValue(value: number) {
return Math.min(props.max ?? Number.POSITIVE_INFINITY, Math.max(props.min ?? Number.NEGATIVE_INFINITY, value))
}
function stepPrecision() {
const decimal = String(props.step).split('.')[1]
return decimal?.length ?? 0
}
function updateModel(event: Event) {
const value = (event.target as HTMLInputElement).valueAsNumber
if (Number.isFinite(value))
model.value = clampValue(value)
}
function restoreValue(event: Event) {
const input = event.target as HTMLInputElement
input.value = String(model.value)
}
function stepBy(direction: -1 | 1) {
const next = clampValue(model.value + props.step * direction)
model.value = Number(next.toFixed(stepPrecision()))
}
</script>
<template>
<div
v-bind="rootAttrs"
class="inline-flex h-8 items-center rounded-cp bg-cp-fill-tertiary p-0.5 text-cp-text transition-[background-color,box-shadow] duration-150 focus-within:bg-cp-bg-container focus-within:shadow-cp-input-active motion-reduce:transition-none"
>
<BaseIconButton
:label="`减少${label}`"
size="sm"
variant="ghost"
class="size-7!"
:disabled="!canDecrease"
@click="stepBy(-1)"
>
<Minus class="size-3" />
</BaseIconButton>
<span class="inline-flex min-w-0 items-baseline justify-center gap-1 px-1">
<input
v-bind="controlAttrs"
:value="model"
type="number"
inputmode="decimal"
:aria-label="label"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
class="base-number-input__field w-9 min-w-0 border-0 bg-transparent p-0 text-right font-mono text-cp-xs font-bold tabular-nums text-cp-text outline-none disabled:text-cp-text-disabled"
@input="updateModel"
@blur="restoreValue"
>
<span
v-if="unit"
class="shrink-0 text-[9px] font-emphasis text-cp-text-quaternary"
aria-hidden="true"
>
{{ unit }}
</span>
</span>
<BaseIconButton
:label="`增加${label}`"
size="sm"
variant="ghost"
class="size-7!"
:disabled="!canIncrease"
@click="stepBy(1)"
>
<Plus class="size-3" />
</BaseIconButton>
</div>
</template>
<style scoped>
/* 隐藏浏览器原生微调器,统一使用两侧的减号和加号。 */
.base-number-input__field {
appearance: textfield;
}
.base-number-input__field::-webkit-inner-spin-button,
.base-number-input__field::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
</style>
@@ -10,12 +10,12 @@ defineProps<{
class="flex min-h-17 shrink-0 flex-wrap items-start justify-between gap-x-4 gap-y-3"
>
<div class="min-w-0 flex-1 basis-64">
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-cp-primary">
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-cp-text">
{{ title }}
</h1>
<p
v-if="description || $slots.description"
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-cp-secondary"
class="mt-2.5 mb-0 flex items-center gap-2 text-cp-xl leading-[1.15] font-semibold text-cp-text-secondary"
>
<slot name="description">
{{ description }}
@@ -24,7 +24,7 @@ defineProps<{
</div>
<div
v-if="$slots.actions"
class="mt-0.5 flex shrink-0 items-center justify-end gap-2"
class="flex min-w-0 basis-full flex-wrap items-center justify-start gap-2 min-[640px]:mt-0.5 min-[640px]:basis-auto min-[640px]:shrink-0 min-[640px]:justify-end"
>
<slot name="actions" />
</div>
+1 -1
View File
@@ -56,7 +56,7 @@ const hoverOpenTimer = shallowRef<number>()
const viewportTarget = computed(() => (open.value && typeof window !== 'undefined' ? window : null))
const popoverClasses = computed(() => [
'fixed z-50 overflow-visible rounded-cp-overlay border-0 bg-cp-surface text-left text-cp-primary shadow-cp-popover',
'fixed z-50 overflow-visible rounded-cp-lg border-0 bg-cp-bg-container text-left text-cp-text shadow-cp',
props.animatePosition
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
: undefined,
+5 -5
View File
@@ -24,10 +24,10 @@ const checked = computed(() => model.value === props.value)
const indicatorClasses = computed(() => [
'relative inline-grid size-4 shrink-0 place-items-center rounded-full transition-colors duration-150 motion-reduce:transition-none',
props.disabled
? 'bg-cp-disabled text-cp-disabled-icon'
? 'bg-cp-bg-container-disabled text-cp-text-disabled'
: checked.value
? 'bg-cp-accent text-cp-accent-on'
: 'bg-cp-muted text-transparent group-hover:bg-cp-default-active',
? 'bg-cp-primary text-(--cp-button-primary-color)'
: 'bg-cp-fill-tertiary text-transparent group-hover:bg-cp-bg-text-active',
])
</script>
@@ -49,7 +49,7 @@ const indicatorClasses = computed(() => [
>
<span
:class="indicatorClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-control-outline peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-bg-container"
aria-hidden="true"
>
<span class="size-1.5 rounded-full bg-current" />
@@ -57,7 +57,7 @@ const indicatorClasses = computed(() => [
<span v-if="$slots.default" class="min-w-0 flex-1">
<slot />
</span>
<span v-else-if="showLabel" class="text-[13px] leading-none font-emphasis">
<span v-else-if="showLabel" class="text-cp leading-none font-emphasis">
{{ label }}
</span>
</label>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
label: string
min?: number
max?: number
step?: number
unit?: string
disabled?: boolean
}>(),
{
min: 0,
max: 100,
step: 1,
unit: undefined,
disabled: false,
},
)
const model = defineModel<number>({ required: true })
const rangeStyle = computed<CSSProperties>(() => {
const span = props.max - props.min
const progress = span > 0 ? ((model.value - props.min) / span) * 100 : 0
return {
'--base-range-progress': `${Math.min(100, Math.max(0, progress))}%`,
}
})
const valueText = computed(() => `${model.value}${props.unit ?? ''}`)
function updateModel(event: Event) {
const value = (event.target as HTMLInputElement).valueAsNumber
if (Number.isFinite(value))
model.value = value
}
</script>
<template>
<input
:value="model"
type="range"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
:aria-label="label"
:aria-valuetext="valueText"
class="base-range h-5 w-full"
:style="rangeStyle"
@input="updateModel"
>
</template>
<style scoped>
.base-range {
appearance: none;
cursor: pointer;
background: transparent;
outline: none;
}
.base-range::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: linear-gradient(
to right,
var(--cp-color-primary) 0 var(--base-range-progress),
var(--cp-color-fill-secondary) var(--base-range-progress) 100%
);
}
.base-range::-webkit-slider-thumb {
width: 14px;
height: 14px;
margin-top: -5px;
appearance: none;
border: 2px solid var(--cp-color-bg-container);
border-radius: 999px;
background: var(--cp-color-primary);
transition:
box-shadow 150ms ease,
transform 150ms ease;
}
.base-range::-moz-range-track {
height: 4px;
border-radius: 999px;
background: var(--cp-color-fill-secondary);
}
.base-range::-moz-range-progress {
height: 4px;
border-radius: 999px;
background: var(--cp-color-primary);
}
.base-range::-moz-range-thumb {
width: 10px;
height: 10px;
border: 2px solid var(--cp-color-bg-container);
border-radius: 999px;
background: var(--cp-color-primary);
transition:
box-shadow 150ms ease,
transform 150ms ease;
}
.base-range:hover::-webkit-slider-thumb {
transform: scale(1.08);
}
.base-range:hover::-moz-range-thumb {
transform: scale(1.08);
}
.base-range:focus-visible::-webkit-slider-thumb {
box-shadow: 0 0 0 3px var(--cp-color-primary-bg-hover);
}
.base-range:focus-visible::-moz-range-thumb {
box-shadow: 0 0 0 3px var(--cp-color-primary-bg-hover);
}
.base-range:disabled {
cursor: not-allowed;
opacity: 0.5;
}
@media (prefers-reduced-motion: reduce) {
.base-range::-webkit-slider-thumb {
transition: none;
}
.base-range::-moz-range-thumb {
transition: none;
}
}
</style>
@@ -9,12 +9,14 @@ const props = withDefaults(
height?: string
horizontal?: boolean
vertical?: boolean
alwaysVisible?: boolean
}>(),
{
maxHeight: undefined,
height: undefined,
horizontal: false,
vertical: true,
alwaysVisible: false,
},
)
@@ -64,10 +66,10 @@ const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollba
const canScrollY = computed(() => thumbHeight.value > 0)
const canScrollX = computed(() => horizontalThumbWidth.value > 0)
const verticalScrollbarVisible = computed(
() => dragging.value || verticalTrackHovering.value || visible.value,
() => props.alwaysVisible || dragging.value || verticalTrackHovering.value || visible.value,
)
const horizontalScrollbarVisible = computed(
() => horizontalDragging.value || horizontalTrackHovering.value || visible.value,
() => props.alwaysVisible || horizontalDragging.value || horizontalTrackHovering.value || visible.value,
)
const thumbStyle = computed(() => ({
height: `${thumbHeight.value}px`,
@@ -510,8 +512,8 @@ defineExpose({
]"
>
<div
class="w-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
:class="dragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
class="w-1.5 rounded-full bg-(--cp-scrollbar-thumb-bg) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover-bg)"
:class="dragging ? 'bg-(--cp-scrollbar-thumb-hover-bg)' : ''"
:style="thumbStyle"
@pointerdown="handleThumbPointerDown"
/>
@@ -528,8 +530,8 @@ defineExpose({
]"
>
<div
class="h-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
class="h-1.5 rounded-full bg-(--cp-scrollbar-thumb-bg) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover-bg)"
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover-bg)' : ''"
:style="horizontalThumbStyle"
@pointerdown="handleHorizontalThumbPointerDown"
/>
@@ -549,6 +551,6 @@ defineExpose({
}
.base-scrollbar-track-y {
top: var(--cp-scrollbar-track-top, 0.25rem);
top: var(--cp-scrollbar-track-inset-block-start, 0.25rem);
}
</style>
@@ -45,13 +45,13 @@ const indicatorStyle = computed(() => ({
const rootClasses: Record<SegmentedSize, string> = {
sm: 'h-cp-control-sm',
md: 'h-cp-control-md',
md: 'h-cp-control',
lg: 'h-cp-control-lg',
}
const optionClasses: Record<SegmentedSize, string> = {
sm: 'h-6.5 px-2 text-[11px]',
sm: 'h-6.5 px-2 text-cp-xs',
md: 'h-8 px-3 text-xs',
lg: 'h-9.5 px-4 text-[13px]',
lg: 'h-9.5 px-4 text-cp',
}
function enabledIndexes() {
@@ -97,7 +97,7 @@ async function handleKeydown(event: KeyboardEvent, index: number) {
<template>
<div
class="relative inline-grid items-center rounded-cp-control bg-cp-muted p-0.75"
class="relative inline-grid items-center rounded-cp bg-cp-fill-tertiary p-0.75"
:class="rootClasses[size]"
:style="gridStyle"
role="radiogroup"
@@ -106,20 +106,20 @@ async function handleKeydown(event: KeyboardEvent, index: number) {
>
<span
v-if="options.length > 0"
class="pointer-events-none absolute inset-y-0.75 left-0.75 rounded-cp-control bg-cp-surface shadow-cp-control transition-transform duration-200 ease-out motion-reduce:transition-none"
class="pointer-events-none absolute inset-y-0.75 left-0.75 rounded-cp bg-cp-bg-container shadow-cp-tertiary transition-transform duration-200 ease-out motion-reduce:transition-none"
:style="indicatorStyle"
aria-hidden="true"
/>
<button
v-for="(option, index) in options"
:key="option.value"
class="relative z-10 inline-flex min-w-0 touch-manipulation items-center justify-center gap-1.5 rounded-cp-control border-0 bg-transparent leading-none font-emphasis outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-cp-accent-border motion-reduce:transition-none"
class="relative z-10 inline-flex min-w-0 touch-manipulation items-center justify-center gap-1.5 rounded-cp border-0 bg-transparent leading-none font-emphasis outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
:class="[
optionClasses[size],
model === option.value
? 'text-cp-primary'
: 'text-cp-secondary hover:text-cp-primary',
disabled || option.disabled ? 'cursor-not-allowed opacity-60 hover:text-cp-secondary' : undefined,
? 'text-cp-text'
: 'text-cp-text-secondary hover:text-cp-text',
disabled || option.disabled ? 'cursor-not-allowed opacity-60 hover:text-cp-text-secondary' : undefined,
display === 'icon' ? 'px-0' : undefined,
]"
type="button"
+22 -22
View File
@@ -76,18 +76,18 @@ const sizeConfig: Record<
> = {
md: {
trigger:
'h-cp-control-md px-3.5 pr-9 text-[13px] rounded-cp-control',
option: 'h-8.5 px-3 text-[13px]',
'h-cp-control px-3.5 pr-9 text-cp rounded-cp',
option: 'h-8.5 px-3 text-cp',
icon: 16,
},
sm: {
trigger: 'h-cp-control-sm px-2.5 pr-7 text-xs rounded-cp-control',
trigger: 'h-cp-control-sm px-2.5 pr-7 text-xs rounded-cp',
option: 'h-8 px-2.5 text-xs',
icon: 14,
},
lg: {
trigger: 'h-cp-control-lg px-4 pr-10 text-[14px] rounded-cp-control',
option: 'h-10 px-3.5 text-[14px]',
trigger: 'h-cp-control-lg px-4 pr-10 text-cp-lg rounded-cp',
option: 'h-10 px-3.5 text-cp-lg',
icon: 17,
},
}
@@ -98,20 +98,20 @@ const triggerClasses = computed(() => [
'relative inline-flex w-full min-w-0 items-center gap-2 overflow-visible border-0 text-left font-emphasis leading-none shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-[160ms]',
sizeConfig[props.size].trigger,
props.disabled
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
? 'cursor-not-allowed bg-cp-bg-container-disabled text-cp-text-disabled shadow-none'
: invalid.value
? 'cursor-pointer bg-(--cp-input-error-soft-bg) text-cp-danger-text shadow-cp-input-error'
? 'cursor-pointer bg-(--cp-input-error-active-bg) text-cp-error-text shadow-cp-input-error-active'
: open.value
? 'cursor-pointer bg-(--cp-input-soft-bg-focus) text-cp-primary shadow-cp-input-focus'
? 'cursor-pointer bg-(--cp-input-active-bg) text-cp-text shadow-cp-input-active'
: [
'cursor-pointer bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
'focus-visible:bg-(--cp-input-soft-bg-focus) focus-visible:shadow-cp-input-focus',
'cursor-pointer bg-[var(--cp-input-bg)] text-cp-text',
'hover:bg-[var(--cp-input-hover-bg)] hover:shadow-cp-input-hover',
'focus-visible:bg-(--cp-input-active-bg) focus-visible:shadow-cp-input-active',
],
])
const popoverClasses = computed(() => [
'fixed z-50 flex flex-col gap-1 rounded-cp-overlay border-0 bg-cp-surface p-1 shadow-cp-popover',
'fixed z-50 flex flex-col gap-1 rounded-cp-lg border-0 bg-cp-bg-container p-1 shadow-cp',
props.options.length > 6 ? 'cp-scrollbar overflow-y-auto' : 'overflow-visible',
])
@@ -255,15 +255,15 @@ function handleTriggerKeydown(event: KeyboardEvent) {
function optionClasses(option: SelectOption, index: number) {
return [
'flex w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors motion-reduce:transition-none',
'flex w-full touch-manipulation items-center gap-2 rounded-cp-sm border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors motion-reduce:transition-none',
sizeConfig[props.size].option,
option.disabled
? 'cursor-not-allowed bg-transparent text-cp-disabled-text'
? 'cursor-not-allowed bg-transparent text-cp-text-disabled'
: option.value === model.value
? 'cursor-pointer bg-cp-info-bg text-cp-info-text'
? 'cursor-pointer bg-cp-control-item-bg-active text-cp-primary-text'
: activeIndex.value === index
? 'cursor-pointer bg-cp-default-hover text-cp-primary'
: 'cursor-pointer bg-transparent text-cp-primary hover:bg-cp-default-hover',
? 'cursor-pointer bg-cp-bg-text-hover text-cp-text'
: 'cursor-pointer bg-transparent text-cp-text hover:bg-cp-bg-text-hover',
]
}
@@ -316,10 +316,10 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 transition-transform"
:class="
disabled
? 'text-cp-disabled-icon'
? 'text-cp-text-disabled'
: open
? 'rotate-180 text-cp-info'
: 'text-cp-muted-text'
? 'rotate-180 text-cp-primary-text'
: 'text-cp-text-quaternary'
"
:size="sizeConfig[size].icon"
aria-hidden="true"
@@ -346,7 +346,7 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
>
<div
v-if="options.length === 0"
class="flex h-8.5 items-center rounded-cp-control-sm px-3 text-[13px] leading-none font-emphasis text-cp-muted-text"
class="flex h-8.5 items-center rounded-cp-sm px-3 text-cp leading-none font-emphasis text-cp-text-quaternary"
>
{{ emptyText }}
</div>
@@ -369,7 +369,7 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
<Check
v-if="option.value === model"
class="shrink-0 text-cp-info"
class="shrink-0 text-cp-primary-text"
:size="size === 'sm' ? 13 : size === 'lg' ? 17 : 15"
aria-hidden="true"
/>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
type SkeletonShape = 'text' | 'block' | 'circle'
const props = withDefaults(
defineProps<{
as?: keyof HTMLElementTagNameMap
shape?: SkeletonShape
animated?: boolean
}>(),
{
as: 'span',
shape: 'block',
animated: true,
},
)
const shapeClasses: Record<SkeletonShape, string> = {
text: 'h-3 rounded-full',
block: 'rounded-cp',
circle: 'rounded-full',
}
const classes = computed(() => [
'block bg-cp-fill-secondary',
shapeClasses[props.shape],
props.animated ? 'motion-safe:animate-cp-skeleton motion-reduce:animate-none' : undefined,
])
</script>
<template>
<component
:is="as"
aria-hidden="true"
:class="classes"
/>
</template>
+6 -6
View File
@@ -18,10 +18,10 @@ const model = defineModel<boolean>({ default: false })
const trackClasses = computed(() => [
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5 transition-[background-color,box-shadow] duration-180 motion-reduce:transition-none',
props.disabled
? 'bg-cp-disabled shadow-none'
? 'bg-cp-bg-container-disabled shadow-none'
: model.value
? 'bg-cp-accent shadow-cp-control group-hover:bg-cp-accent-hover'
: 'bg-cp-muted shadow-cp-control group-hover:bg-cp-default-active',
? 'bg-cp-primary shadow-cp-tertiary group-hover:bg-cp-primary-hover'
: 'bg-cp-fill-tertiary shadow-cp-tertiary group-hover:bg-cp-bg-text-active',
])
</script>
@@ -42,14 +42,14 @@ const trackClasses = computed(() => [
>
<span
:class="trackClasses"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-control-outline peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-bg-container"
aria-hidden="true"
>
<span
class="size-5 rounded-full bg-cp-surface shadow-cp-control transition-transform duration-180 ease-out motion-reduce:transition-none"
class="size-5 rounded-full bg-cp-bg-container shadow-cp-tertiary transition-transform duration-180 ease-out motion-reduce:transition-none"
:class="model ? 'translate-x-5' : 'translate-x-0'"
/>
</span>
<span v-if="showLabel" class="text-[13px] font-emphasis text-cp-primary">{{ label }}</span>
<span v-if="showLabel" class="text-cp font-emphasis text-cp-text">{{ label }}</span>
</label>
</template>
@@ -51,10 +51,10 @@ function goToPage(page: number) {
function paginationPageClass(page: number) {
return [
'inline-flex size-8 items-center justify-center rounded-cp-control border-0 text-xs font-bold leading-none transition-colors duration-150 outline-none',
'inline-flex size-8 items-center justify-center rounded-cp border-0 text-xs font-bold leading-none transition-colors duration-150 outline-none',
page === currentPage.value
? 'cursor-default bg-cp-accent text-cp-accent-on'
: 'cursor-pointer bg-cp-subtle text-cp-primary hover:bg-cp-default-hover focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
? 'cursor-default bg-(--cp-button-primary-bg) text-(--cp-button-primary-color)'
: 'cursor-pointer bg-cp-fill-quaternary text-cp-text hover:bg-cp-bg-text-hover focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container',
]
}
</script>
@@ -64,7 +64,7 @@ function paginationPageClass(page: number) {
class="mt-2 flex min-h-10 shrink-0 flex-wrap items-center justify-between gap-3 px-0 py-1"
>
<div
class="flex min-w-0 items-center gap-2.5 text-[12px] font-emphasis text-cp-secondary"
class="flex min-w-0 items-center gap-2.5 text-cp-sm font-emphasis text-cp-text-secondary"
>
<span class="whitespace-nowrap">共 {{ pagination.total }} 条</span>
</div>
@@ -93,7 +93,7 @@ function paginationPageClass(page: number) {
<template v-for="(item, index) in pagerItems" :key="`${item}-${index}`">
<span
v-if="item === 'ellipsis'"
class="inline-flex size-8 items-center justify-center text-xs font-bold text-cp-muted-text"
class="inline-flex size-8 items-center justify-center text-xs font-bold text-cp-text-quaternary"
>
…
</span>
@@ -83,7 +83,7 @@ const columnRecipes: Record<TableColumnKind, ColumnRecipe> = {
size: 'lg',
align: 'left',
truncate: true,
contentClass: 'text-cp-secondary',
contentClass: 'text-cp-text-secondary',
},
status: {
size: 'md',
@@ -94,25 +94,25 @@ const columnRecipes: Record<TableColumnKind, ColumnRecipe> = {
size: 'md',
align: 'right',
truncate: false,
contentClass: 'font-mono tabular-nums text-cp-secondary',
contentClass: 'font-mono tabular-nums text-cp-text-secondary',
},
datetime: {
size: 'xl',
align: 'left',
truncate: false,
contentClass: 'whitespace-nowrap font-mono text-[12px] tabular-nums text-cp-secondary',
contentClass: 'whitespace-nowrap font-mono text-cp-sm tabular-nums text-cp-text-secondary',
},
mono: {
size: 'xl',
align: 'left',
truncate: true,
contentClass: 'font-mono text-[12px] font-emphasis',
contentClass: 'font-mono text-cp-sm font-emphasis',
},
index: {
size: 'xs',
align: 'center',
truncate: false,
contentClass: 'font-mono tabular-nums text-cp-secondary',
contentClass: 'font-mono tabular-nums text-cp-text-secondary',
},
selection: {
size: 'xs',
@@ -85,14 +85,14 @@ watch([() => displayRows.value.length, () => props.columns], async () => {
})
const headerRowClass = computed(() => [
props.density === 'compact' ? 'h-8 text-[11px]' : 'h-10 text-[12px]',
'font-bold text-cp-secondary',
props.density === 'compact' ? 'h-8 text-cp-xs' : 'h-10 text-cp-sm',
'font-bold text-cp-text-secondary',
])
const bodyRowClass = computed(() =>
props.density === 'compact' ? 'h-cp-table-row-min-compact' : 'h-cp-table-row-min',
props.density === 'compact' ? 'h-cp-table-row-sm' : 'h-cp-table-row',
)
const cellPaddingClass = computed(() => props.density === 'compact' ? 'px-3' : 'px-4')
const bodyTextClass = computed(() => props.density === 'compact' ? 'text-[12px]' : 'text-[13px]')
const bodyTextClass = computed(() => props.density === 'compact' ? 'text-cp-sm' : 'text-cp')
const bodyCellFrameClass = computed(() => props.density === 'compact'
? 'border-y-2 border-transparent bg-clip-padding'
: 'border-y-[3px] border-transparent bg-clip-padding')
@@ -100,8 +100,8 @@ const firstRowTopGapClass = computed(() =>
props.density === 'compact' ? 'border-t-4' : 'border-t-[6px]',
)
const bodyCellContentClass = computed(() => props.density === 'compact'
? 'min-h-[calc(var(--cp-table-row-min-height-compact)-4px)]'
: 'min-h-[calc(var(--cp-table-row-min-height)-6px)]')
? 'min-h-[calc(var(--cp-table-row-height-sm)-4px)]'
: 'min-h-[calc(var(--cp-table-row-height)-6px)]')
function getRowKey(row: Row, index: number) {
if (typeof props.rowKey === 'function')
@@ -120,16 +120,16 @@ function isRowExpanded(row: Row, index: number) {
function rowBackgroundClass(row: Row, index: number) {
if (isRowSelected(row, index))
return 'bg-cp-tertiary-bg'
return 'bg-(--cp-table-row-selected-bg)'
if (index % 2 === 1)
return 'bg-cp-subtle'
return 'bg-cp-surface'
return 'bg-(--cp-table-row-stripe-bg)'
return 'bg-(--cp-table-row-bg)'
}
function rowClass() {
return [
bodyRowClass.value,
'hover:[&>td]:bg-cp-default-hover',
'hover:[&>td]:bg-(--cp-table-row-hover-bg)',
]
}
@@ -142,8 +142,8 @@ function stickyClass(column: ResolvedTableColumn<Row>, header = false) {
header ? 'z-40' : 'z-20',
showShadow
? column.sticky === 'left'
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
? 'shadow-[8px_0_14px_-14px_var(--cp-color-shadow)]'
: 'shadow-[-8px_0_14px_-14px_var(--cp-color-shadow)]'
: undefined,
]
}
@@ -194,8 +194,8 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
ref="scrollbar"
class="min-h-0 flex-1"
:class="density === 'compact'
? '[--cp-scrollbar-track-top:2.25rem]'
: '[--cp-scrollbar-track-top:2.75rem]'"
? '[--cp-scrollbar-track-inset-block-start:2.25rem]'
: '[--cp-scrollbar-track-inset-block-start:2.75rem]'"
horizontal
@scroll="handleTableScroll"
>
@@ -216,12 +216,12 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<th
v-for="(column, columnIndex) in computedColumns"
:key="column.key"
class="sticky top-0 z-30 whitespace-nowrap bg-cp-muted shadow-[0_10px_16px_-18px_var(--cp-shadow-sticky)]"
class="sticky top-0 z-30 whitespace-nowrap bg-(--cp-table-header-bg) shadow-[0_10px_16px_-18px_var(--cp-color-shadow)]"
:class="[
column.paddingClass ?? cellPaddingClass,
alignClass(column),
columnIndex === 0 ? 'rounded-l-cp-control' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp-control' : undefined,
columnIndex === 0 ? 'rounded-l-cp' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp' : undefined,
columnIndex === computedColumns.length - 1 ? 'pr-6' : undefined,
stickyClass(column, true),
]"
@@ -233,7 +233,7 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<button
v-if="column.sortable"
type="button"
class="inline-flex max-w-full touch-manipulation items-center gap-1 border-0 bg-transparent p-0 text-inherit outline-none transition-colors hover:text-cp-primary focus-visible:text-cp-accent motion-reduce:transition-none"
class="inline-flex max-w-full touch-manipulation items-center gap-1 border-0 bg-transparent p-0 text-inherit outline-none transition-colors hover:text-cp-text focus-visible:text-cp-primary-text motion-reduce:transition-none"
:aria-label="sortButtonLabel(column)"
:title="sortButtonLabel(column)"
@click="toggleColumnSort(column)"
@@ -244,12 +244,12 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<span class="inline-flex shrink-0 -translate-y-px flex-col gap-px" aria-hidden="true">
<Triangle
class="size-1.25 fill-current"
:class="columnSortDirection(column) === 'asc' ? 'text-cp-accent' : 'text-cp-tertiary'"
:class="columnSortDirection(column) === 'asc' ? 'text-cp-primary-text' : 'text-cp-text-tertiary'"
:stroke-width="0"
/>
<Triangle
class="size-1.25 rotate-180 fill-current"
:class="columnSortDirection(column) === 'desc' ? 'text-cp-accent' : 'text-cp-tertiary'"
:class="columnSortDirection(column) === 'desc' ? 'text-cp-primary-text' : 'text-cp-text-tertiary'"
:stroke-width="0"
/>
</span>
@@ -275,8 +275,8 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
column.contentClass,
alignClass(column),
index === 0 ? firstRowTopGapClass : undefined,
columnIndex === 0 ? 'rounded-l-cp-control' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp-control pr-6' : undefined,
columnIndex === 0 ? 'rounded-l-cp' : undefined,
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp pr-6' : undefined,
stickyClass(column),
rowBackgroundClass(row, index),
]"
@@ -300,7 +300,7 @@ function sortButtonLabel(column: ResolvedTableColumn<Row>) {
<tr v-if="isRowExpanded(row, index)">
<td
:colspan="computedColumns.length"
class="rounded-cp-control border-y-transparent bg-cp-subtle bg-clip-padding p-0"
class="rounded-cp border-y-transparent bg-cp-fill-quaternary bg-clip-padding p-0"
:class="bodyCellFrameClass"
>
<slot name="expanded" :row="row" :index="index" />
@@ -47,17 +47,17 @@ const controlAttrs = computed(() => Object.fromEntries(
const sizeClasses: Record<TextareaSize, string> = {
sm: 'px-3 py-2.5 text-xs',
md: 'px-3.5 py-3 text-[13px]',
lg: 'px-4 py-3.5 text-[14px]',
md: 'px-3.5 py-3 text-cp',
lg: 'px-4 py-3.5 text-cp-lg',
}
const textareaClasses = computed(() => [
'cp-scrollbar w-full resize-none rounded-cp-control border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-muted-text motion-reduce:transition-none',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover focus:bg-(--cp-input-soft-bg-focus) focus:shadow-cp-input-focus',
'disabled:cursor-not-allowed disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
'cp-scrollbar w-full resize-none rounded-cp border-0 bg-[var(--cp-input-bg)] text-cp-text shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-text-quaternary motion-reduce:transition-none',
'hover:bg-[var(--cp-input-hover-bg)] hover:shadow-cp-input-hover focus:bg-(--cp-input-active-bg) focus:shadow-cp-input-active',
'disabled:cursor-not-allowed disabled:bg-cp-bg-container-disabled disabled:text-cp-text-disabled disabled:shadow-none',
'leading-[1.55] font-emphasis',
sizeClasses[props.size],
invalid.value ? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error' : undefined,
invalid.value ? 'bg-(--cp-input-error-active-bg) shadow-cp-input-error-active' : undefined,
])
</script>
@@ -24,8 +24,8 @@ const colorClasses = {
icon: 'text-cp-success',
},
error: {
iconBg: 'bg-cp-danger-bg',
icon: 'text-cp-danger',
iconBg: 'bg-cp-error-bg',
icon: 'text-cp-error',
},
warning: {
iconBg: 'bg-cp-warning-bg',
@@ -45,12 +45,12 @@ const colorClasses = {
<article
v-for="message in toast.messages"
:key="message.id"
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-surface bg-cp-surface px-3.5 py-3 shadow-cp-popover"
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-card bg-cp-bg-container px-3.5 py-3 shadow-cp"
:role="message.type === 'error' ? 'alert' : 'status'"
aria-atomic="true"
>
<div
class="flex size-8.5 shrink-0 items-center justify-center rounded-cp-control"
class="flex size-8.5 shrink-0 items-center justify-center rounded-cp"
:class="colorClasses[message.type].iconBg"
>
<component
@@ -61,11 +61,11 @@ const colorClasses = {
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<p class="m-0 truncate text-[13px] leading-[1.15] font-bold text-cp-primary">
<p class="m-0 truncate text-cp leading-[1.15] font-bold text-cp-text">
{{ message.title ?? titleMap[message.type] }}
</p>
<p
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-cp-secondary"
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-cp-text-secondary"
>
{{ message.message }}
</p>
@@ -77,7 +77,7 @@ const colorClasses = {
:label="`关闭${message.title ?? titleMap[message.type]}通知`"
@click="toast.remove(message.id)"
>
<X :size="16" class="text-cp-muted-text" />
<X :size="16" class="text-cp-text-quaternary" />
</BaseIconButton>
</article>
</TransitionGroup>
+1 -24
View File
@@ -3,10 +3,8 @@ import type { EChartsOption } from 'echarts'
import type { EChartsType } from 'echarts/core'
import { useRafFn, useResizeObserver } from '@vueuse/core'
import { init } from 'echarts/core'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import { useUiStore } from '@/stores/modules/ui'
import '@/plugins/echarts'
const props = withDefaults(
@@ -23,7 +21,6 @@ const chartElement = useTemplateRef<HTMLElement>('chart')
const chartOption = shallowRef<EChartsOption>(props.option)
const chart = shallowRef<EChartsType>()
const pendingInitElement = shallowRef<HTMLElement>()
const { themeRevision } = storeToRefs(useUiStore())
const style = computed(() => ({
height: `${props.height}px`,
@@ -71,18 +68,6 @@ function applyOption(option: EChartsOption) {
chart.value.setOption(option, true)
}
async function recreateChartAfterThemeChange() {
await nextTick()
requestAnimationFrame(() => {
const element = chartElement.value
if (!element)
return
dispose()
ensureChart(element)
resize()
})
}
function dispose() {
cancelPendingInit()
chart.value?.dispose()
@@ -104,14 +89,6 @@ watch(
{ flush: 'post' },
)
watch(
themeRevision,
() => {
recreateChartAfterThemeChange()
},
{ flush: 'post' },
)
watch(
chartElement,
(element) => {
+1 -1
View File
@@ -30,7 +30,7 @@ export function chartTooltipStyle(
fontFamily: options.fontFamily ?? 'Inter Variable, Inter, system-ui, sans-serif',
fontWeight: options.fontWeight ?? 650,
},
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-box-shadow);',
}
if (!options.axisPointer)
+15 -15
View File
@@ -5,21 +5,21 @@ import { useThemeColor } from '@/composables/useThemeColor'
export function useChartPalette() {
const color = useThemeColor()
const palette = computed(() => ({
textPrimary: color('--cp-text-primary', '#0E1726'),
textSecondary: color('--cp-text-secondary', '#64748B'),
textMuted: color('--cp-text-muted', '#94A3B8'),
surface: color('--cp-bg-surface', '#FFFFFF'),
surfaceMuted: color('--cp-bg-muted', '#F1F5F9'),
grid: color('--cp-bg-muted', '#F1F5F9'),
divider: color('--cp-divider-subtle', '#E2E8F0'),
border: color('--cp-default-border', '#E2E8F0'),
pointer: color('--cp-default-border-hover', '#CBD5E1'),
info: color('--cp-info', '#2563EB'),
success: color('--cp-success', '#10B981'),
warning: color('--cp-warning', '#F59E0B'),
danger: color('--cp-danger', '#EF4444'),
normal: color('--cp-normal', '#0F9F9A'),
reasoning: color('--cp-reasoning', '#8B5CF6'),
textPrimary: color('--cp-color-text', '#0E1726'),
textSecondary: color('--cp-color-text-secondary', '#64748B'),
textMuted: color('--cp-color-text-quaternary', '#94A3B8'),
surface: color('--cp-color-bg-container', '#FFFFFF'),
surfaceMuted: color('--cp-color-fill-tertiary', '#F1F5F9'),
grid: color('--cp-color-fill-tertiary', '#F1F5F9'),
divider: color('--cp-color-split', '#E2E8F0'),
border: color('--cp-color-border-secondary', '#E2E8F0'),
pointer: color('--cp-color-border', '#CBD5E1'),
info: color('--cp-color-info', '#2563EB'),
success: color('--cp-color-success', '#10B981'),
warning: color('--cp-color-warning', '#F59E0B'),
danger: color('--cp-color-error', '#EF4444'),
normal: color('--cp-color-status-normal', '#0F9F9A'),
reasoning: color('--cp-color-reasoning', '#8B5CF6'),
}))
return { color, palette }
+25 -4
View File
@@ -1,12 +1,33 @@
import { storeToRefs } from 'pinia'
import type { InjectionKey, Ref } from 'vue'
import { useUiStore } from '@/stores/modules/ui'
import { storeToRefs } from 'pinia'
import { inject, provide } from 'vue'
import { useThemeStore } from '@/stores/modules/theme'
export type ThemeColorTokens = Readonly<Record<string, string | number | undefined>>
const themeColorTokensKey: InjectionKey<Readonly<Ref<ThemeColorTokens>>> = Symbol('theme-color-tokens')
export function provideThemeColorTokens(tokens: Readonly<Ref<ThemeColorTokens>>) {
provide(themeColorTokensKey, tokens)
}
// 主题感知的 CSS 变量读取:themeRevision 作为响应式依赖,主题切换后
// 引用该函数的 computed 会重算并读到新变量值。
// 引用该函数的 computed 会重算并读到新变量值;影子预览优先读取其局部 Token。
export function useThemeColor() {
const { themeRevision } = storeToRefs(useUiStore())
const scopedTokens = inject(themeColorTokensKey, null)
const { themeRevision } = storeToRefs(useThemeStore())
return (name: string, fallback: string) => {
if (scopedTokens) {
const scopedValue = scopedTokens.value[name]
if (typeof scopedValue === 'string' && scopedValue.trim())
return scopedValue.trim()
if (typeof scopedValue === 'number')
return String(scopedValue)
return fallback
}
void themeRevision.value
return readCssVariable(name, fallback)
}
+3 -3
View File
@@ -28,7 +28,7 @@ const OVERLAY_BASE_CLASS = [
'place-items-center',
'rounded-[inherit]',
].join(' ')
const OVERLAY_MASK_CLASS = 'bg-cp-surface/82 backdrop-blur-[4px]'
const OVERLAY_MASK_CLASS = 'bg-cp-bg-container/82 backdrop-blur-[4px]'
const PANEL_CLASS = [
'inline-flex',
'min-h-10',
@@ -41,14 +41,14 @@ const PANEL_CLASS = [
'text-xs',
'leading-[1.15]',
'font-heavy',
'text-cp-secondary',
'text-cp-text-secondary',
].join(' ')
const MARK_CLASS = [
'inline-block',
'size-4',
'rounded-full',
'animate-spin',
'bg-[radial-gradient(circle_at_center,var(--cp-bg-surface)_0_44%,transparent_46%),conic-gradient(from_0deg,var(--cp-normal)_0deg_105deg,var(--cp-info)_105deg_200deg,var(--cp-bg-muted)_200deg_360deg)]',
'bg-[radial-gradient(circle_at_center,var(--cp-color-bg-container)_0_44%,transparent_46%),conic-gradient(from_0deg,var(--cp-color-status-normal)_0deg_105deg,var(--cp-color-info)_105deg_200deg,var(--cp-color-fill-tertiary)_200deg_360deg)]',
].join(' ')
const LABEL_CLASS = 'whitespace-nowrap'
@@ -53,15 +53,15 @@ const linkItems = [
<div class="grid gap-5">
<section class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp-control bg-cp-subtle font-mono text-[15px] leading-none font-extrabold text-cp-primary"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp bg-cp-fill-quaternary font-mono text-cp-xl leading-none font-extrabold text-cp-text"
>
Z
</span>
<div class="min-w-0">
<p class="m-0 truncate text-[15px] leading-none font-heavy text-cp-primary">
<p class="m-0 truncate text-cp-xl leading-none font-heavy text-cp-text">
{{ author }}
</p>
<p class="mt-1.5 mb-0 text-[12px] leading-none font-emphasis text-cp-secondary">
<p class="mt-1.5 mb-0 text-cp-sm leading-none font-emphasis text-cp-text-secondary">
Built by Zyy · Codex Proxy RS
</p>
</div>
@@ -71,20 +71,20 @@ const linkItems = [
<div
v-for="item in linkItems"
:key="item.label"
class="group flex min-w-0 items-center justify-between gap-3 rounded-cp-control px-1 py-2.5 transition-colors hover:bg-cp-subtle"
class="group flex min-w-0 items-center justify-between gap-3 rounded-cp px-1 py-2.5 transition-colors hover:bg-cp-fill-quaternary"
>
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-8 shrink-0 items-center justify-center rounded-cp-control bg-cp-muted text-cp-primary"
class="inline-flex size-8 shrink-0 items-center justify-center rounded-cp bg-cp-fill-tertiary text-cp-text"
>
<component :is="item.icon" class="size-5" />
</span>
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-heavy text-cp-muted-text">
<p class="m-0 text-cp-xs leading-none font-heavy text-cp-text-quaternary">
{{ item.label }}
</p>
<p
class="mt-2 mb-0 truncate font-mono text-[12px] leading-none font-bold text-cp-primary"
class="mt-2 mb-0 truncate font-mono text-cp-sm leading-none font-bold text-cp-text"
:title="item.value"
>
{{ item.value }}
@@ -96,7 +96,7 @@ const linkItems = [
:href="item.href"
target="_blank"
rel="noreferrer"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-cp-control text-cp-muted-text transition-colors hover:bg-cp-muted hover:text-cp-info"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-cp text-cp-text-quaternary transition-colors hover:bg-cp-fill-tertiary hover:text-cp-link"
:aria-label="`打开 ${item.label}`"
>
<ExternalLink class="size-3.5" />
@@ -104,7 +104,7 @@ const linkItems = [
</div>
</section>
<p class="m-0 font-mono text-[11px] leading-none font-emphasis text-cp-muted-text">
<p class="m-0 font-mono text-cp-xs leading-none font-emphasis text-cp-text-quaternary">
{{ versionLine }}
</p>
</div>
+29 -26
View File
@@ -8,6 +8,7 @@ import {
LayoutDashboard,
LogOut,
Moon,
Palette,
PanelLeftClose,
PanelLeftOpen,
Settings,
@@ -29,11 +30,12 @@ import {
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppBrandMark from '@/components/AppBrandMark.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
import { useAuthStore } from '@/stores/modules/auth'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
import { useUiStore } from '@/stores/modules/ui'
import { useThemeStore } from '@/stores/modules/theme'
const props = withDefaults(
defineProps<{
@@ -56,10 +58,10 @@ const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const systemUpdateStore = useSystemUpdateStore()
const uiStore = useUiStore()
const themeStore = useThemeStore()
const { version, hasUpdate } = storeToRefs(systemUpdateStore)
const { effectiveTheme } = storeToRefs(uiStore)
const { toggleTheme } = uiStore
const { effectiveTheme } = storeToRefs(themeStore)
const { toggleTheme } = themeStore
const preferredMotion = usePreferredReducedMotion()
const navItems = [
@@ -68,6 +70,7 @@ const navItems = [
{ label: '分组管理', icon: FolderTree, path: '/account-groups' },
{ label: 'API 密钥', icon: KeyRound, path: '/api-keys' },
{ label: '使用统计', icon: ChartNoAxesColumn, path: '/usage' },
{ label: '主题设置', icon: Palette, path: '/theme' },
{ label: '系统设置', icon: Settings, path: '/settings' },
]
@@ -328,7 +331,7 @@ onBeforeUnmount(() => {
<template>
<aside
ref="sidebarEl"
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-cp-surface px-4 shadow-cp-sidebar"
class="z-20 h-dvh shrink-0 flex-col overflow-hidden bg-(--cp-layout-sider-bg) px-4 shadow-cp-layout-sider"
:class="[
mobile ? 'flex' : 'hidden min-[961px]:flex',
isCollapsed ? 'w-22 basis-22 items-center' : 'w-62.75 basis-62.75',
@@ -341,35 +344,30 @@ onBeforeUnmount(() => {
<BaseMotionIcon
aria-hidden="true"
variant="brand"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-cp-control"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-cp"
>
<img
src="/favicon.svg"
alt=""
class="block size-11 select-none"
draggable="false"
>
<AppBrandMark class="block size-11 select-none" />
</BaseMotionIcon>
<span
v-show="brandLabelVisible"
ref="brandLabelEl"
class="grid min-w-33 content-center overflow-hidden"
>
<strong class="text-base leading-[1.1] font-heavy text-cp-primary">
<strong class="text-base leading-[1.1] font-heavy text-cp-text">
Codex Proxy
</strong>
<span class="mt-1.5 flex h-4.5 min-w-0 items-center gap-2">
<span class="shrink-0 text-xs leading-none font-emphasis text-cp-secondary">
<span class="shrink-0 text-xs leading-none font-emphasis text-cp-text-secondary">
Rust build
</span>
<button
v-if="hasVersionLabel"
type="button"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-cp-control-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
class="inline-flex h-4.5 min-w-0 cursor-pointer items-center gap-1 rounded-cp-sm border-0 px-1.5 font-mono text-[10px] leading-none font-bold transition-colors outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
:class="[
hasUpdate
? 'bg-cp-success-bg text-cp-success-text hover:bg-cp-success-bg-hover'
: 'bg-cp-subtle text-cp-muted-text hover:bg-cp-muted hover:text-cp-secondary',
: 'bg-cp-fill-quaternary text-cp-text-quaternary hover:bg-cp-fill-tertiary hover:text-cp-text-secondary',
]"
:title="updateButtonLabel"
@click="openSystemUpdate"
@@ -388,7 +386,7 @@ onBeforeUnmount(() => {
>
<span
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-cp-control bg-cp-nav-active transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-cp bg-cp-menu-item-selected-bg transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:style="activeNavIndicatorStyle"
>
<span ref="navSignal" class="sidebar-active-signal absolute inset-y-0 left-0 w-2/3" />
@@ -397,16 +395,16 @@ onBeforeUnmount(() => {
v-for="item in navItems"
:key="item.label"
type="button"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-cp-control border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-cp border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container"
:class="[
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? navFeedbackMuted
? 'bg-transparent font-bold text-cp-primary transition-none'
: 'bg-transparent font-bold text-cp-primary transition-colors duration-200'
? 'bg-transparent font-bold text-cp-text transition-none'
: 'bg-transparent font-bold text-cp-text transition-colors duration-200'
: navFeedbackMuted
? 'bg-transparent font-semibold text-cp-secondary transition-none'
: 'bg-transparent font-semibold text-cp-secondary transition-colors duration-200 hover:bg-cp-subtle hover:text-cp-primary',
? 'bg-transparent font-semibold text-cp-text-secondary transition-none'
: 'bg-transparent font-semibold text-cp-text-secondary transition-colors duration-200 hover:bg-cp-fill-quaternary hover:text-cp-text',
]"
@click="navigate(item.path)"
>
@@ -422,11 +420,11 @@ onBeforeUnmount(() => {
<div class="mt-auto mb-6" :class="isCollapsed ? 'w-11' : 'w-full'">
<div
class="bg-cp-subtle"
class="bg-cp-fill-quaternary"
:class="
isCollapsed
? 'grid gap-1 rounded-cp-control p-1'
: 'flex h-11 items-center justify-between rounded-cp-overlay px-2'
? 'grid gap-1 rounded-cp p-1'
: 'flex h-11 items-center justify-between rounded-cp-lg px-2'
"
>
<span
@@ -505,6 +503,11 @@ onBeforeUnmount(() => {
<style scoped>
.sidebar-active-signal {
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--cp-info) 9%, transparent), transparent);
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--cp-color-info) 9%, transparent),
transparent
);
}
</style>
@@ -220,10 +220,10 @@ onScopeDispose(() => {
>
<button
type="button"
class="inline-flex size-11.5 items-center justify-center rounded-cp-control border-0 bg-cp-surface text-cp-primary shadow-[0_18px_34px_-18px_var(--cp-shadow-sticky)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-cp-default-hover hover:text-cp-normal active:scale-95 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-page"
class="inline-flex size-11.5 items-center justify-center rounded-cp border-0 bg-cp-bg-container text-cp-text shadow-[0_18px_34px_-18px_var(--cp-color-shadow)] outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 hover:bg-cp-bg-text-hover hover:text-cp-status-normal active:scale-95 focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-layout"
:class="
isDragging
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-shadow-sticky)]'
? 'scale-95 cursor-grabbing shadow-[0_14px_28px_-20px_var(--cp-color-shadow)]'
: 'cursor-grab'
"
aria-label="打开侧边栏"
@@ -198,18 +198,18 @@ watch(
:dismissible="!updating && !restarting"
>
<div class="grid gap-3.5">
<section class="grid gap-4 rounded-cp-surface bg-cp-subtle px-4 py-4">
<section class="grid gap-4 rounded-cp-card bg-cp-fill-quaternary px-4 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-heavy text-cp-muted-text">
<p class="m-0 text-cp-xs leading-none font-heavy text-cp-text-quaternary">
Codex Proxy RS
</p>
<p class="mt-2 mb-0 text-lg leading-none font-heavy text-cp-primary">
<p class="mt-2 mb-0 text-lg leading-none font-heavy text-cp-text">
应用包更新
</p>
</div>
<span
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-heavy"
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-cp-sm font-heavy"
:class="presentation.status.badge"
>
<component
@@ -225,10 +225,10 @@ watch(
<div
v-for="item in presentation.summaryItems"
:key="item.key"
class="min-w-0 rounded-cp-control bg-cp-surface px-3 py-2.5"
class="min-w-0 rounded-cp bg-cp-bg-container px-3 py-2.5"
>
<div class="flex min-w-0 items-center justify-between gap-2">
<p class="m-0 truncate text-[11px] leading-none font-heavy text-cp-muted-text">
<p class="m-0 truncate text-cp-xs leading-none font-heavy text-cp-text-quaternary">
{{ item.label }}
</p>
<a
@@ -236,14 +236,14 @@ watch(
:href="item.releaseUrl"
target="_blank"
rel="noreferrer"
class="inline-flex shrink-0 items-center gap-1 text-[11px] leading-none font-bold text-cp-info-text transition-colors hover:text-cp-info"
class="inline-flex shrink-0 items-center gap-1 text-cp-xs leading-none font-bold text-cp-link transition-colors hover:text-cp-link-hover"
>
发布页
<ExternalLink class="size-3" />
</a>
</div>
<p
class="mt-2 mb-0 truncate font-mono text-[13px] leading-none font-bold text-cp-primary"
class="mt-2 mb-0 truncate font-mono text-cp leading-none font-bold text-cp-text"
:title="item.title || item.value"
>
{{ item.value }}
@@ -253,7 +253,7 @@ watch(
<p
v-if="updateError || updateInfo?.warning"
class="m-0 rounded-cp-control bg-cp-danger-bg px-3 py-2 text-[12px] leading-normal font-bold text-cp-danger-text"
class="m-0 rounded-cp bg-cp-error-bg px-3 py-2 text-cp-sm leading-normal font-bold text-cp-error-text"
>
{{ updateError || updateInfo?.warning }}
</p>
@@ -261,13 +261,13 @@ watch(
<section
v-if="renderedReleaseNotes"
class="grid gap-2 rounded-cp-surface bg-cp-subtle px-4 py-3.5"
class="grid gap-2 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5"
>
<div class="flex items-center justify-between gap-3">
<p class="m-0 text-[13px] font-heavy text-cp-primary">
<p class="m-0 text-cp font-heavy text-cp-text">
发布说明
</p>
<span class="font-mono text-[11px] font-emphasis text-cp-muted-text">
<span class="font-mono text-cp-xs font-emphasis text-cp-text-quaternary">
{{ presentation.releaseVersion }}
</span>
</div>
@@ -280,22 +280,22 @@ watch(
<section
v-if="showUpdateProgress"
class="overflow-hidden rounded-cp-surface bg-cp-subtle"
class="overflow-hidden rounded-cp-card bg-cp-fill-quaternary"
>
<header class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
<div class="flex min-w-0 items-center gap-2">
<Terminal class="size-4 shrink-0 text-cp-success" />
<p class="m-0 text-[13px] leading-none font-heavy text-cp-primary">
<p class="m-0 text-cp leading-none font-heavy text-cp-text">
更新进度
</p>
</div>
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-cp-subtle px-2 text-[11px] leading-none font-bold text-cp-secondary"
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-cp-fill-quaternary px-2 text-cp-xs leading-none font-bold text-cp-text-secondary"
:title="updateStreamError || presentation.streamStatusLabel"
>
<i
class="size-1.5 rounded-full"
:class="updateStreaming ? 'bg-cp-success' : 'bg-cp-muted-text'"
:class="updateStreaming ? 'bg-cp-success' : 'bg-cp-text-quaternary'"
/>
{{ presentation.streamStatusLabel }}
</span>
@@ -310,16 +310,16 @@ watch(
<div
v-for="log in updateLogRows"
:key="log.id"
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-cp-control bg-cp-surface px-3 py-2 font-mono text-[11px] leading-[1.55]"
class="grid grid-cols-[68px_14px_minmax(0,1fr)] items-start gap-2 rounded-cp bg-cp-bg-container px-3 py-2 font-mono text-cp-xs leading-[1.55]"
>
<span class="tabular-nums text-cp-muted-text">{{ log.time }}</span>
<span class="tabular-nums text-cp-text-quaternary">{{ log.time }}</span>
<Circle
class="mt-1 size-2.5"
:class="log.classes.marker"
fill="currentColor"
/>
<p class="m-0 min-w-0 wrap-break-word" :class="log.classes.text">
<span v-if="log.step" class="mr-1 text-cp-muted-text">[{{ log.step }}]</span>
<span v-if="log.step" class="mr-1 text-cp-text-quaternary">[{{ log.step }}]</span>
{{ log.message }}
</p>
</div>
@@ -383,23 +383,23 @@ watch(
@confirm="handleConfirmUpdate"
>
<div class="grid gap-3">
<div class="grid gap-2 rounded-cp-control bg-cp-subtle p-3">
<div class="grid gap-2 rounded-cp bg-cp-fill-quaternary p-3">
<div
v-for="item in presentation.confirmRows"
:key="item.key"
class="flex min-w-0 items-center justify-between gap-3"
>
<span class="text-[12px] leading-none font-bold text-cp-muted-text">
<span class="text-cp-sm leading-none font-bold text-cp-text-quaternary">
{{ item.label }}
</span>
<span
class="truncate font-mono text-[13px] leading-none font-heavy text-cp-primary"
class="truncate font-mono text-cp leading-none font-heavy text-cp-text"
>
{{ item.value }}
</span>
</div>
</div>
<p class="m-0 text-[12px] leading-relaxed font-emphasis text-cp-muted-text">
<p class="m-0 text-cp-sm leading-relaxed font-emphasis text-cp-text-quaternary">
点击确认后弹窗会关闭,并按远端最新目标版本开始更新
</p>
</div>
@@ -408,7 +408,7 @@ watch(
<style scoped>
.release-notes {
color: var(--cp-text-primary);
color: var(--cp-color-text);
font-size: 12px;
font-weight: var(--font-weight-emphasis);
line-height: 1.65;
@@ -432,7 +432,7 @@ watch(
.release-notes :deep(h3),
.release-notes :deep(h4) {
margin: 12px 0 6px;
color: var(--cp-text-primary);
color: var(--cp-color-text);
font-size: 12px;
font-weight: var(--font-weight-heavy);
line-height: 1.4;
@@ -453,19 +453,19 @@ watch(
}
.release-notes :deep(a) {
color: var(--cp-info-text);
color: var(--cp-color-link);
font-weight: 700;
text-decoration: none;
}
.release-notes :deep(a:hover) {
color: var(--cp-info);
color: var(--cp-color-link-hover);
}
.release-notes :deep(code) {
border-radius: 5px;
background: var(--cp-bg-muted);
color: var(--cp-text-primary);
background: var(--cp-color-fill-tertiary);
color: var(--cp-color-text);
font-family: var(--font-mono);
font-size: 11px;
padding: 1px 5px;
@@ -474,8 +474,8 @@ watch(
.release-notes :deep(pre) {
margin: 8px 0;
overflow-x: auto;
border-radius: var(--cp-radius-control);
background: var(--cp-bg-surface);
border-radius: var(--cp-border-radius);
background: var(--cp-color-bg-container);
padding: 8px 10px;
}
@@ -486,8 +486,8 @@ watch(
.release-notes :deep(blockquote) {
margin: 8px 0;
border-left: 3px solid var(--cp-divider-subtle);
color: var(--cp-text-secondary);
border-left: 3px solid var(--cp-color-split);
color: var(--cp-color-text-secondary);
padding-left: 10px;
}
</style>
@@ -36,9 +36,9 @@ export function resolveSystemUpdateLogClasses(level: string) {
case 'warning':
return { marker: 'text-cp-warning', text: 'text-cp-warning' }
case 'error':
return { marker: 'text-cp-danger', text: 'text-cp-danger' }
return { marker: 'text-cp-error', text: 'text-cp-error' }
default:
return { marker: 'text-cp-info', text: 'text-cp-primary' }
return { marker: 'text-cp-info', text: 'text-cp-text' }
}
}
@@ -55,8 +55,8 @@ function resolveStatus(input: SystemUpdatePresentationInput) {
return {
label: '异常',
icon: XCircle,
badge: 'bg-cp-danger-bg text-cp-danger-text',
iconClass: 'text-cp-danger',
badge: 'bg-cp-error-bg text-cp-error-text',
iconClass: 'text-cp-error',
}
}
if (input.updateSuccess || input.hasUpdate || input.updateInfo) {
@@ -70,8 +70,8 @@ function resolveStatus(input: SystemUpdatePresentationInput) {
return {
label: '未检查',
icon: CheckCircle2,
badge: 'bg-cp-muted text-cp-secondary',
iconClass: 'text-cp-muted-text',
badge: 'bg-cp-fill-tertiary text-cp-text-secondary',
iconClass: 'text-cp-text-quaternary',
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ watch(
</script>
<template>
<div class="relative flex h-dvh overflow-hidden bg-cp-page">
<div class="relative flex h-dvh overflow-hidden bg-cp-bg-layout">
<AppSidebar
:collapsed="sidebarCollapsed"
@toggle="toggleSidebar"
+11 -6
View File
@@ -5,14 +5,19 @@ import { loading } from './directives/loading'
import { authPlugin } from './plugins/auth'
import { router } from './router'
import { pinia } from './stores'
import { useThemeStore } from './stores/modules/theme'
import '@fontsource-variable/inter'
import '@fontsource-variable/jetbrains-mono'
import './styles/index.css'
createApp(App)
.directive('loading', loading)
.use(pinia)
.use(router)
.use(authPlugin)
.mount('#app')
const app = createApp(App)
app.directive('loading', loading)
app.use(pinia)
useThemeStore(pinia).initializeTheme()
app.use(router)
app.use(authPlugin)
app.mount('#app')
+11 -1
View File
@@ -36,10 +36,20 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/views/usage/index.vue'),
},
{
path: 'settings/:section?',
path: 'theme',
name: 'theme',
component: () => import('@/views/theme/index.vue'),
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/settings/index.vue'),
},
{
path: 'settings/backup',
name: 'settings-backup',
component: () => import('@/views/settings/index.vue'),
},
],
},
{
+330
View File
@@ -0,0 +1,330 @@
// @env browser
import type {
ResolvedTheme,
ThemeColorId,
ThemeCustomization,
ThemeMode,
} from '@/theme'
import { usePreferredDark, usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import {
applyResolvedTheme,
DEFAULT_CUSTOM_THEME_COLOR,
DEFAULT_THEME_COLOR,
DEFAULT_THEME_MODE,
isThemeColorId,
isThemeMode,
normalizeThemeCustomization,
resolveTheme,
resolveThemeName,
} from '@/theme'
import { normalizeHexColor } from '@/utils/color'
interface ThemeTransitionOrigin {
x: number
y: number
}
interface ThemeConfigurationInput {
mode: ThemeMode
color: ThemeColorId
customColor: string
customization: ThemeCustomization
}
interface ViewTransition {
ready: Promise<void>
finished: Promise<void>
}
type ViewTransitionDocument = Document & {
startViewTransition?: (callback: () => void) => ViewTransition
}
export const useThemeStore = defineStore(
'theme',
() => {
const themeMode = shallowRef<ThemeMode>(DEFAULT_THEME_MODE)
const themeColor = shallowRef<ThemeColorId>(DEFAULT_THEME_COLOR)
const customThemeColor = shallowRef(DEFAULT_CUSTOM_THEME_COLOR)
const themeCustomization = ref<ThemeCustomization>({})
const themeRevision = shallowRef(0)
const themeTransitioning = shallowRef(false)
const preferredDark = usePreferredDark()
const preferredMotion = usePreferredReducedMotion()
let appliedThemeSignature: string | undefined
let themeTransitionOrigin: ThemeTransitionOrigin | undefined
let themeTransitionRequested = false
const effectiveTheme = computed(() => resolveThemeName(themeMode.value, preferredDark.value))
const resolvedTheme = computed(() =>
resolveTheme(
effectiveTheme.value,
themeColor.value,
customThemeColor.value,
themeCustomization.value,
),
)
const { start: startFallbackTransitionTimer, stop: stopFallbackTransitionTimer } = useTimeoutFn(
() => {
document.documentElement.classList.remove('theme-fallback-transition')
themeTransitioning.value = false
},
180,
{ immediate: false },
)
watch(resolvedTheme, theme => applyTheme(theme))
function initializeTheme(): void {
normalizePersistedTheme()
applyTheme(resolvedTheme.value)
}
function normalizePersistedTheme(): void {
if (!isThemeMode(themeMode.value))
themeMode.value = DEFAULT_THEME_MODE
if (!isThemeColorId(themeColor.value))
themeColor.value = DEFAULT_THEME_COLOR
customThemeColor.value = normalizeHexColor(customThemeColor.value) ?? DEFAULT_CUSTOM_THEME_COLOR
themeCustomization.value = normalizeThemeCustomization(themeCustomization.value)
}
function applyTheme(theme: ResolvedTheme): void {
if (typeof document === 'undefined')
return
const signature = themeSignature(theme)
if (appliedThemeSignature === signature) {
clearThemeTransitionRequest()
return
}
if (
appliedThemeSignature === undefined
|| !themeTransitionRequested
|| preferredMotion.value === 'reduce'
|| themeTransitioning.value
) {
commitTheme(theme)
clearThemeTransitionRequest()
return
}
runThemeTransition(theme)
clearThemeTransitionRequest()
}
function commitTheme(theme: ResolvedTheme): void {
applyResolvedTheme(document.documentElement, theme)
appliedThemeSignature = themeSignature(theme)
themeRevision.value += 1
}
function runThemeTransition(theme: ResolvedTheme): void {
const transitionDocument = document as ViewTransitionDocument
const shrinkingDarkLayer
= document.documentElement.dataset.theme === 'dark' && theme.name === 'light'
if (!transitionDocument.startViewTransition) {
runFallbackThemeTransition(theme)
return
}
const origin = themeTransitionOrigin ?? {
x: window.innerWidth - 44,
y: 44,
}
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-shrink', shrinkingDarkLayer)
themeTransitioning.value = true
let transition: ViewTransition
try {
transition = transitionDocument.startViewTransition(() => commitTheme(theme))
}
catch {
document.documentElement.classList.remove('theme-view-transition-shrink')
themeTransitioning.value = false
runFallbackThemeTransition(theme)
return
}
let cleaned = false
const cleanupTransition = (): void => {
if (cleaned)
return
cleaned = true
requestAnimationFrame(() => {
document.documentElement.classList.remove('theme-view-transition-shrink')
themeTransitioning.value = false
})
}
const cleanupTimer = window.setTimeout(cleanupTransition, 1200)
void transition.finished.catch(() => undefined)
let activeAnimation: Animation | undefined
void transition.ready
.then(() => {
const animation = document.documentElement.animate(
{
clipPath: shrinkingDarkLayer
? [
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
`circle(0px at ${origin.x}px ${origin.y}px)`,
]
: [
`circle(0px at ${origin.x}px ${origin.y}px)`,
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
],
},
{
duration: 420,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'both',
pseudoElement: shrinkingDarkLayer
? '::view-transition-old(root)'
: '::view-transition-new(root)',
},
)
activeAnimation = animation
return animation.finished.catch(() => undefined)
})
.catch(() => undefined)
.finally(() => {
activeAnimation?.cancel()
window.clearTimeout(cleanupTimer)
cleanupTransition()
})
}
function runFallbackThemeTransition(theme: ResolvedTheme): void {
themeTransitioning.value = true
stopFallbackTransitionTimer()
document.documentElement.classList.add('theme-fallback-transition')
commitTheme(theme)
startFallbackTransitionTimer()
}
function setThemeMode(mode: ThemeMode, event?: MouseEvent): void {
if (mode === themeMode.value) {
clearThemeTransitionRequest()
return
}
requestThemeTransition(event)
themeMode.value = mode
}
function setThemeColor(color: ThemeColorId, event?: MouseEvent): void {
if (color === themeColor.value) {
clearThemeTransitionRequest()
return
}
requestThemeTransition(event)
themeColor.value = color
}
function setCustomThemeColor(color: string, event?: MouseEvent): boolean {
const normalized = normalizeHexColor(color)
if (!normalized)
return false
if (themeColor.value === 'custom' && customThemeColor.value === normalized) {
clearThemeTransitionRequest()
return true
}
requestThemeTransition(event)
customThemeColor.value = normalized
themeColor.value = 'custom'
return true
}
function setThemeConfiguration(
configuration: ThemeConfigurationInput,
event?: MouseEvent,
): boolean {
const customColor = normalizeHexColor(configuration.customColor)
if (!isThemeMode(configuration.mode) || !isThemeColorId(configuration.color) || !customColor)
return false
requestThemeTransition(event)
themeMode.value = configuration.mode
themeColor.value = configuration.color
customThemeColor.value = customColor
themeCustomization.value = normalizeThemeCustomization(configuration.customization)
return true
}
function resetTheme(event?: MouseEvent): void {
const unchanged
= themeMode.value === DEFAULT_THEME_MODE
&& themeColor.value === DEFAULT_THEME_COLOR
&& customThemeColor.value === DEFAULT_CUSTOM_THEME_COLOR
&& Object.keys(themeCustomization.value).length === 0
if (unchanged) {
clearThemeTransitionRequest()
return
}
requestThemeTransition(event)
themeMode.value = DEFAULT_THEME_MODE
themeColor.value = DEFAULT_THEME_COLOR
customThemeColor.value = DEFAULT_CUSTOM_THEME_COLOR
themeCustomization.value = {}
}
function toggleTheme(event?: MouseEvent): void {
if (themeTransitioning.value)
return
requestThemeTransition(event)
themeMode.value = effectiveTheme.value === 'dark' ? 'light' : 'dark'
}
function requestThemeTransition(event?: MouseEvent): void {
themeTransitionOrigin = event
? { x: event.clientX, y: event.clientY }
: undefined
themeTransitionRequested = Boolean(event)
}
function clearThemeTransitionRequest(): void {
themeTransitionOrigin = undefined
themeTransitionRequested = false
}
return {
themeMode,
themeColor,
customThemeColor,
themeCustomization,
themeRevision,
themeTransitioning,
effectiveTheme,
initializeTheme,
setThemeMode,
setThemeColor,
setCustomThemeColor,
setThemeConfiguration,
resetTheme,
toggleTheme,
}
},
{
persist: {
key: 'codex-proxy-rs-theme',
pick: [
'themeMode',
'themeColor',
'customThemeColor',
'themeCustomization',
],
},
},
)
function themeSignature(theme: ResolvedTheme): string {
return `${theme.name}:${theme.colorId}:${theme.seed}:${JSON.stringify(theme.tokens)}`
}
+3 -197
View File
@@ -1,218 +1,24 @@
import { useDark, usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, shallowRef } from 'vue'
type ThemeMode = 'system' | 'light' | 'dark'
type ThemeName = 'light' | 'dark'
type ColorModeStorageValue = 'auto' | ThemeName
interface ThemeTransitionOrigin { x: number, y: number }
interface ViewTransition {
ready: Promise<void>
finished: Promise<void>
}
type ViewTransitionDocument = Document & {
startViewTransition?: (callback: () => void) => ViewTransition
}
import { shallowRef } from 'vue'
export const useUiStore = defineStore(
'ui',
() => {
const sidebarCollapsed = shallowRef(false)
const themeMode = shallowRef<ThemeMode>('system')
const themeRevision = shallowRef(0)
const themeTransitioning = shallowRef(false)
const preferredMotion = usePreferredReducedMotion()
let themeApplied = false
let themeTransitionOrigin: ThemeTransitionOrigin | undefined
let themeTransitionRequested = false
const { start: startFallbackTransitionTimer, stop: stopFallbackTransitionTimer } = useTimeoutFn(
() => {
document.documentElement.classList.remove('theme-fallback-transition')
themeTransitioning.value = false
},
180,
{ immediate: false },
)
const colorModeStorage = computed<ColorModeStorageValue>({
get: () => (themeMode.value === 'system' ? 'auto' : themeMode.value),
set: (mode) => {
themeMode.value = mode === 'auto' ? 'system' : mode
},
})
const isDark = useDark({
storageRef: colorModeStorage,
onChanged: (dark) => {
applyTheme(dark ? 'dark' : 'light')
},
})
const effectiveTheme = computed<ThemeName>(() => (isDark.value ? 'dark' : 'light'))
function toggleSidebar() {
function toggleSidebar(): void {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function applyTheme(theme: ThemeName) {
if (themeApplied && document.documentElement.dataset.theme === theme) {
themeTransitionRequested = false
return
}
if (
!themeApplied
|| !themeTransitionRequested
|| preferredMotion.value === 'reduce'
|| themeTransitioning.value
) {
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
themeRevision.value += 1
}
function runThemeTransition(theme: ThemeName) {
const transitionDocument = document as ViewTransitionDocument
const shrinkingDarkLayer
= document.documentElement.dataset.theme === 'dark' && theme === 'light'
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-shrink', shrinkingDarkLayer)
themeTransitioning.value = true
let transition: ViewTransition
try {
transition = transitionDocument.startViewTransition(() => commitTheme(theme))
}
catch {
document.documentElement.classList.remove('theme-view-transition-shrink')
themeTransitioning.value = false
runFallbackThemeTransition(theme)
return
}
let cleaned = false
const cleanupTransition = () => {
if (cleaned) {
return
}
cleaned = true
requestAnimationFrame(() => {
document.documentElement.classList.remove('theme-view-transition-shrink')
themeTransitioning.value = false
})
}
const cleanupTimer = window.setTimeout(cleanupTransition, 1200)
void transition.finished.catch(() => undefined)
let activeAnimation: Animation | undefined
void transition.ready
.then(() => {
const animation = document.documentElement.animate(
{
clipPath: shrinkingDarkLayer
? [
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
`circle(0px at ${origin.x}px ${origin.y}px)`,
]
: [
`circle(0px at ${origin.x}px ${origin.y}px)`,
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
],
},
{
duration: 420,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'both',
pseudoElement: shrinkingDarkLayer
? '::view-transition-old(root)'
: '::view-transition-new(root)',
},
)
activeAnimation = animation
return animation.finished.catch(() => undefined)
})
.catch(() => undefined)
.finally(() => {
activeAnimation?.cancel()
window.clearTimeout(cleanupTimer)
cleanupTransition()
})
}
function runFallbackThemeTransition(theme: ThemeName) {
themeTransitioning.value = true
stopFallbackTransitionTimer()
document.documentElement.classList.add('theme-fallback-transition')
commitTheme(theme)
startFallbackTransitionTimer()
}
function initializeTheme() {
if (!themeApplied) {
applyTheme(effectiveTheme.value)
}
}
function setThemeMode(mode: ThemeMode) {
themeMode.value = mode
}
function toggleTheme(event?: MouseEvent) {
if (themeTransitioning.value) {
return
}
if (event) {
themeTransitionOrigin = {
x: event.clientX,
y: event.clientY,
}
themeTransitionRequested = true
}
themeMode.value = effectiveTheme.value === 'dark' ? 'light' : 'dark'
}
return {
sidebarCollapsed,
themeMode,
themeRevision,
themeTransitioning,
effectiveTheme,
toggleSidebar,
initializeTheme,
setThemeMode,
toggleTheme,
}
},
{
persist: {
key: 'codex-proxy-rs-ui',
pick: ['sidebarCollapsed', 'themeMode'],
pick: ['sidebarCollapsed'],
},
},
)
+16 -6
View File
@@ -1,6 +1,6 @@
html {
background: var(--cp-bg-page);
color: var(--cp-text-primary);
background: var(--cp-color-bg-layout, #f6f8fb);
color: var(--cp-color-text, #0e1726);
font-family: var(--font-sans);
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -9,7 +9,17 @@ html {
}
body {
background: var(--cp-bg-page);
background: var(--cp-color-bg-layout, #f6f8fb);
font-size: var(--cp-font-size, 13px);
}
/* 主题作用域同时驱动 Tailwind 数值间距,保证预览草稿与已保存主题使用同一密度算法。 */
[data-theme] {
--spacing: var(--cp-size-unit, 4px);
}
[data-theme]:not(html) {
font-size: var(--cp-font-size, 13px);
}
html.theme-fallback-transition,
@@ -51,7 +61,7 @@ button:disabled {
}
.cp-scrollbar {
scrollbar-color: var(--cp-scrollbar-thumb) transparent;
scrollbar-color: var(--cp-scrollbar-thumb-bg) transparent;
scrollbar-width: thin;
}
@@ -65,14 +75,14 @@ button:disabled {
}
.cp-scrollbar::-webkit-scrollbar-thumb {
background: var(--cp-scrollbar-thumb);
background: var(--cp-scrollbar-thumb-bg);
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
.cp-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--cp-scrollbar-thumb-hover);
background: var(--cp-scrollbar-thumb-hover-bg);
border: 3px solid transparent;
background-clip: content-box;
}
+119 -79
View File
@@ -6,87 +6,127 @@
--font-sans:
'Inter Variable', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono Variable', 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
/* 界面字重刻度:正文强调 650 / 加粗 700(font-bold) / 重标题 760 / 极重 800(font-extrabold)。
新代码从这四档取值,不再使用任意字重。 */
/* 界面字重刻度:正文强调 650 / 加粗 700(font-bold) / 重标题 760 / 极重 800(font-extrabold)。 */
--font-weight-emphasis: 650;
--font-weight-heavy: 760;
--color-cp-page: var(--cp-bg-page);
--color-cp-surface: var(--cp-bg-surface);
--color-cp-subtle: var(--cp-bg-subtle);
--color-cp-muted: var(--cp-bg-muted);
--color-cp-tertiary-bg: var(--cp-bg-tertiary);
--color-cp-nav-active: var(--cp-bg-nav-active);
--color-cp-primary: var(--cp-text-primary);
--color-cp-strong: var(--cp-text-strong);
--color-cp-secondary: var(--cp-text-secondary);
--color-cp-tertiary: var(--cp-text-tertiary);
--color-cp-muted-text: var(--cp-text-muted);
--color-cp-accent: var(--cp-accent);
--color-cp-accent-hover: var(--cp-accent-hover);
--color-cp-accent-pressed: var(--cp-accent-pressed);
--color-cp-accent-border: var(--cp-accent-border);
--color-cp-accent-on: var(--cp-accent-on);
--color-cp-default-hover: var(--cp-default-bg-hover);
--color-cp-default-active: var(--cp-default-bg-active);
--color-cp-default-border: var(--cp-default-border);
--color-cp-default-border-hover: var(--cp-default-border-hover);
--color-cp-border: var(--cp-border-primary);
--color-cp-divider: var(--cp-divider-subtle);
--color-cp-progress: var(--cp-progress-track);
--color-cp-overlay: var(--cp-overlay-scrim);
--color-cp-disabled: var(--cp-disabled-bg);
--color-cp-disabled-text: var(--cp-disabled-text);
--color-cp-disabled-icon: var(--cp-disabled-icon);
--color-cp-info: var(--cp-info);
--color-cp-info-hover: var(--cp-info-hover);
--color-cp-info-pressed: var(--cp-info-pressed);
--color-cp-info-bg: var(--cp-info-bg);
--color-cp-info-border: var(--cp-info-border);
--color-cp-info-text: var(--cp-info-text);
--color-cp-info-on: var(--cp-info-on);
--color-cp-normal: var(--cp-normal);
--color-cp-normal-hover: var(--cp-normal-hover);
--color-cp-normal-pressed: var(--cp-normal-pressed);
--color-cp-normal-bg: var(--cp-normal-bg);
--color-cp-normal-border: var(--cp-normal-border);
--color-cp-normal-text: var(--cp-normal-text);
--color-cp-success: var(--cp-success);
--color-cp-success-bg: var(--cp-success-bg);
--color-cp-success-bg-hover: var(--cp-success-bg-hover);
--color-cp-success-bg-active: var(--cp-success-bg-active);
--color-cp-success-border: var(--cp-success-border);
--color-cp-success-text: var(--cp-success-text);
--color-cp-warning: var(--cp-warning);
--color-cp-warning-bg: var(--cp-warning-bg);
--color-cp-warning-bg-hover: var(--cp-warning-bg-hover);
--color-cp-warning-bg-active: var(--cp-warning-bg-active);
--color-cp-warning-border: var(--cp-warning-border);
--color-cp-warning-text: var(--cp-warning-text);
--color-cp-danger: var(--cp-danger);
--color-cp-danger-bg: var(--cp-danger-bg);
--color-cp-danger-bg-hover: var(--cp-danger-bg-hover);
--color-cp-danger-bg-active: var(--cp-danger-bg-active);
--color-cp-danger-border: var(--cp-danger-border);
--color-cp-danger-text: var(--cp-danger-text);
--color-cp-plan-pro-bg: var(--cp-plan-pro-bg);
--color-cp-plan-pro-text: var(--cp-plan-pro-text);
--color-cp-plan-prolite-bg: var(--cp-plan-prolite-bg);
--color-cp-plan-prolite-text: var(--cp-plan-prolite-text);
--radius-cp-control-sm: var(--cp-radius-control-sm);
--radius-cp-control: var(--cp-radius-control);
--radius-cp-overlay: var(--cp-radius-overlay);
--radius-cp-surface: var(--cp-radius-surface);
--shadow-cp-card: var(--cp-shadow-card);
--shadow-cp-control: var(--cp-shadow-control);
--shadow-cp-popover: var(--cp-shadow-popover);
--shadow-cp-input: var(--cp-shadow-input);
--shadow-cp-input-hover: var(--cp-shadow-input-hover);
--shadow-cp-input-focus: var(--cp-shadow-input-focus);
--shadow-cp-input-error: var(--cp-shadow-input-error);
--shadow-cp-sidebar: var(--cp-shadow-sidebar);
--animate-cp-skeleton: cp-skeleton-breathe 2.8s cubic-bezier(0.37, 0, 0.63, 1) infinite;
--text-cp-xs: var(--cp-font-size-xs);
--text-cp-sm: var(--cp-font-size-sm);
--text-cp: var(--cp-font-size);
--text-cp-lg: var(--cp-font-size-lg);
--text-cp-xl: var(--cp-font-size-xl);
--color-cp-white: var(--cp-color-white);
--color-cp-transparent: var(--cp-color-transparent);
--color-cp-bg-layout: var(--cp-color-bg-layout);
--color-cp-bg-container: var(--cp-color-bg-container);
--color-cp-bg-elevated: var(--cp-color-bg-elevated);
--color-cp-bg-spotlight: var(--cp-color-bg-spotlight);
--color-cp-bg-mask: var(--cp-color-bg-mask);
--color-cp-bg-text-hover: var(--cp-color-bg-text-hover);
--color-cp-bg-text-active: var(--cp-color-bg-text-active);
--color-cp-bg-container-disabled: var(--cp-color-bg-container-disabled);
--color-cp-fill-secondary: var(--cp-color-fill-secondary);
--color-cp-fill-tertiary: var(--cp-color-fill-tertiary);
--color-cp-fill-quaternary: var(--cp-color-fill-quaternary);
--color-cp-border: var(--cp-color-border);
--color-cp-border-secondary: var(--cp-color-border-secondary);
--color-cp-split: var(--cp-color-split);
--color-cp-text: var(--cp-color-text);
--color-cp-text-heading: var(--cp-color-text-heading);
--color-cp-text-secondary: var(--cp-color-text-secondary);
--color-cp-text-tertiary: var(--cp-color-text-tertiary);
--color-cp-text-quaternary: var(--cp-color-text-quaternary);
--color-cp-text-disabled: var(--cp-color-text-disabled);
--color-cp-text-light-solid: var(--cp-color-text-light-solid);
--color-cp-primary-bg: var(--cp-color-primary-bg);
--color-cp-primary-bg-hover: var(--cp-color-primary-bg-hover);
--color-cp-primary-border: var(--cp-color-primary-border);
--color-cp-primary-border-hover: var(--cp-color-primary-border-hover);
--color-cp-primary-hover: var(--cp-color-primary-hover);
--color-cp-primary: var(--cp-color-primary);
--color-cp-primary-active: var(--cp-color-primary-active);
--color-cp-primary-text-hover: var(--cp-color-primary-text-hover);
--color-cp-primary-text: var(--cp-color-primary-text);
--color-cp-primary-text-active: var(--cp-color-primary-text-active);
--color-cp-link: var(--cp-color-link);
--color-cp-link-hover: var(--cp-color-link-hover);
--color-cp-link-active: var(--cp-color-link-active);
--color-cp-control-item-bg-active: var(--cp-control-item-bg-active);
--color-cp-control-item-bg-active-hover: var(--cp-control-item-bg-active-hover);
--color-cp-control-outline: var(--cp-control-outline);
--color-cp-menu-item-selected-bg: var(--cp-menu-item-selected-bg);
--color-cp-progress-remaining: var(--cp-progress-remaining-color);
--color-cp-info: var(--cp-color-info);
--color-cp-info-hover: var(--cp-color-info-hover);
--color-cp-info-active: var(--cp-color-info-active);
--color-cp-info-bg: var(--cp-color-info-bg);
--color-cp-info-border: var(--cp-color-info-border);
--color-cp-info-text: var(--cp-color-info-text);
--color-cp-status-normal: var(--cp-color-status-normal);
--color-cp-status-normal-hover: var(--cp-color-status-normal-hover);
--color-cp-status-normal-active: var(--cp-color-status-normal-active);
--color-cp-status-normal-bg: var(--cp-color-status-normal-bg);
--color-cp-status-normal-border: var(--cp-color-status-normal-border);
--color-cp-status-normal-text: var(--cp-color-status-normal-text);
--color-cp-success: var(--cp-color-success);
--color-cp-success-bg: var(--cp-color-success-bg);
--color-cp-success-bg-hover: var(--cp-color-success-bg-hover);
--color-cp-success-bg-active: var(--cp-color-success-bg-active);
--color-cp-success-border: var(--cp-color-success-border);
--color-cp-success-text: var(--cp-color-success-text);
--color-cp-warning: var(--cp-color-warning);
--color-cp-warning-bg: var(--cp-color-warning-bg);
--color-cp-warning-bg-hover: var(--cp-color-warning-bg-hover);
--color-cp-warning-bg-active: var(--cp-color-warning-bg-active);
--color-cp-warning-border: var(--cp-color-warning-border);
--color-cp-warning-text: var(--cp-color-warning-text);
--color-cp-error: var(--cp-color-error);
--color-cp-error-bg: var(--cp-color-error-bg);
--color-cp-error-bg-hover: var(--cp-color-error-bg-hover);
--color-cp-error-bg-active: var(--cp-color-error-bg-active);
--color-cp-error-border: var(--cp-color-error-border);
--color-cp-error-text: var(--cp-color-error-text);
--color-cp-reasoning: var(--cp-color-reasoning);
--color-cp-account-plan-pro-bg: var(--cp-account-plan-pro-bg);
--color-cp-account-plan-pro: var(--cp-account-plan-pro-color);
--color-cp-account-plan-pro-lite-bg: var(--cp-account-plan-pro-lite-bg);
--color-cp-account-plan-pro-lite: var(--cp-account-plan-pro-lite-color);
--radius-cp-sm: var(--cp-border-radius-sm);
--radius-cp: var(--cp-border-radius);
--radius-cp-lg: var(--cp-border-radius-lg);
--radius-cp-card: var(--cp-card-border-radius);
--shadow-cp: var(--cp-box-shadow);
--shadow-cp-secondary: var(--cp-box-shadow-secondary);
--shadow-cp-tertiary: var(--cp-box-shadow-tertiary);
--shadow-cp-card: var(--cp-card-shadow);
--shadow-cp-input: var(--cp-input-shadow);
--shadow-cp-input-hover: var(--cp-input-hover-shadow);
--shadow-cp-input-active: var(--cp-input-active-shadow);
--shadow-cp-input-error-active: var(--cp-input-error-active-shadow);
--shadow-cp-layout-sider: var(--cp-layout-sider-shadow);
--spacing-cp-control-sm: var(--cp-control-height-sm);
--spacing-cp-control-md: var(--cp-control-height-md);
--spacing-cp-control: var(--cp-control-height);
--spacing-cp-control-lg: var(--cp-control-height-lg);
--spacing-cp-table-row-min: var(--cp-table-row-min-height);
--spacing-cp-table-row-min-compact: var(--cp-table-row-min-height-compact);
--spacing-cp-size-step: var(--cp-size-step);
--spacing-cp-table-row: var(--cp-table-row-height);
--spacing-cp-table-row-sm: var(--cp-table-row-height-sm);
}
@keyframes cp-skeleton-breathe {
0%,
100% {
background-color: var(--cp-color-fill-tertiary);
}
50% {
background-color: var(--cp-color-fill-secondary);
}
}
+30 -197
View File
@@ -1,209 +1,42 @@
:root {
/* :root 提供首屏浅色兜底;data-theme 允许预览区域独立于宿主切换明暗。 */
:root,
[data-theme='light'] {
color-scheme: light;
--cp-bg-page: #f6f8fb;
--cp-bg-surface: #ffffff;
--cp-bg-subtle: #f8fafc;
--cp-bg-muted: #f1f5f9;
--cp-bg-tertiary: #eef6ff;
--cp-bg-nav-active: #e9eef5;
--cp-bg-dark: #111827;
--cp-brand-mark-bg: #1c2a3d;
--cp-white: #ffffff;
--cp-transparent: #ffffff00;
--cp-overlay-scrim: #0e17264d;
/* 静态常量。皮肤 Token 由主题 Store 在 Vue 挂载前生成。 */
--cp-color-white: #ffffff;
--cp-color-transparent: #ffffff00;
--cp-text-primary: #0e1726;
--cp-text-strong: #111827;
--cp-text-secondary: #64748b;
--cp-text-tertiary: #94a3b8;
--cp-text-muted: #94a3b8;
--cp-brand-accent: #2563eb;
--cp-accent: #2563eb;
--cp-accent-hover: #1d4ed8;
--cp-accent-pressed: #1e40af;
--cp-accent-border: #93c5fd;
--cp-accent-on: #ffffff;
--cp-reasoning: #8b5cf6;
--cp-plan-pro-bg: #ede9fe;
--cp-plan-pro-text: #5b21b6;
--cp-plan-prolite-bg: #f5f3ff;
--cp-plan-prolite-text: #7c3aed;
/* 稳定的业务语义色,不参与主题编辑器派生。 */
--cp-color-status-normal: #0f9f9a;
--cp-color-status-normal-hover: #0d8d88;
--cp-color-status-normal-active: #0b7370;
--cp-color-status-normal-bg: #ecfdfd;
--cp-color-status-normal-border: #a7dfdd;
--cp-color-status-normal-text: #0b7370;
--cp-default-bg-hover: #f1f5f9;
--cp-default-bg-active: #e9eef5;
--cp-default-border: #e2e8f0;
--cp-default-border-hover: #cbd5e1;
--cp-border-primary: #d8e0ea;
--cp-divider-subtle: #d8e0ea6b;
--cp-progress-track: #e2e8f0;
--cp-color-reasoning: #8b5cf6;
--cp-control-focus-ring: #dbeafe;
--cp-input-soft-bg: #eef2f7;
--cp-input-soft-bg-hover: #f2f6fa;
--cp-input-soft-bg-focus: #f8fafc;
--cp-input-context-bg: #eef2f7;
--cp-input-context-bg-hover: #f2f6fa;
--cp-input-error-soft-bg: #fff1f1;
--cp-disabled-bg: #f1f5f9;
--cp-disabled-text: #94a3b8;
--cp-disabled-icon: #cbd5e1;
--cp-info: #2563eb;
--cp-info-hover: #1d4ed8;
--cp-info-pressed: #1e40af;
--cp-info-bg: #eef6ff;
--cp-info-border: #93c5fd;
--cp-info-text: #2563eb;
--cp-info-on: #ffffff;
--cp-normal: #0f9f9a;
--cp-normal-hover: #0d8d88;
--cp-normal-pressed: #0b7370;
--cp-normal-bg: #ecfdfd;
--cp-normal-border: #a7dfdd;
--cp-normal-text: #0b7370;
--cp-success: #12b981;
--cp-success-bg: #ecfdf5;
--cp-success-bg-hover: #ddfbea;
--cp-success-bg-active: #c8f5dc;
--cp-success-border: #b7ead0;
--cp-success-text: #047857;
--cp-warning: #f59e0b;
--cp-warning-bg: #fffbeb;
--cp-warning-bg-hover: #fef3c7;
--cp-warning-bg-active: #fde68a;
--cp-warning-border: #fcd34d;
--cp-warning-text: #b45309;
--cp-danger: #ef4444;
--cp-danger-bg: #fef2f2;
--cp-danger-bg-hover: #fee2e2;
--cp-danger-bg-active: #fecaca;
--cp-danger-border: #fca5a5;
--cp-danger-text: #b91c1c;
--cp-control-height-sm: 32px;
--cp-control-height-md: 38px;
--cp-control-height-lg: 44px;
--cp-table-row-min-height: 60px;
--cp-table-row-min-height-compact: 40px;
--cp-radius-control-sm: 6px;
--cp-radius-control: 8px;
--cp-radius-overlay: 12px;
--cp-radius-surface: 18px;
--cp-shadow-card: 0 10px 22px -18px #0e172614;
--cp-shadow-control: 0 9px 18px -14px #0e172616;
--cp-shadow-popover: 0 18px 38px -18px #0e17262b;
--cp-shadow-input: inset 0 1px 0 #ffffff66, 0 9px 18px -14px #0e172616;
--cp-shadow-input-hover: inset 0 1px 0 #ffffffcc, 0 0 0 3px var(--cp-control-focus-ring), 0 12px 24px -16px #0e17261f;
--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-thumb: #cbd7e6;
--cp-scrollbar-thumb-hover: #aebed0;
--cp-account-plan-pro-bg: #ede9fe;
--cp-account-plan-pro-color: #5b21b6;
--cp-account-plan-pro-lite-bg: #f5f3ff;
--cp-account-plan-pro-lite-color: #7c3aed;
}
html[data-theme='dark'] {
[data-theme='dark'] {
color-scheme: dark;
--cp-bg-page: #0b111c;
--cp-bg-surface: #111a29;
--cp-bg-subtle: #162133;
--cp-bg-muted: #1c2a3d;
--cp-bg-tertiary: #142946;
--cp-bg-nav-active: #1b2a40;
--cp-bg-dark: #050913;
--cp-white: #ffffff;
--cp-transparent: #ffffff00;
--cp-overlay-scrim: #030712a6;
--cp-color-status-normal: #2dd4bf;
--cp-color-status-normal-hover: #5eead4;
--cp-color-status-normal-active: #14b8a6;
--cp-color-status-normal-bg: #0c332f;
--cp-color-status-normal-border: #1f6f67;
--cp-color-status-normal-text: #5eead4;
--cp-text-primary: #d8e2ee;
--cp-text-strong: #f5f8fc;
--cp-text-secondary: #8fa0b7;
--cp-text-tertiary: #71839c;
--cp-text-muted: #64748b;
--cp-brand-accent: #6ea8ff;
--cp-accent: #3b82f6;
--cp-accent-hover: #4f8ee8;
--cp-accent-pressed: #2563eb;
--cp-accent-border: #2e5e92;
--cp-accent-on: var(--cp-text-primary);
--cp-reasoning: #a78bfa;
--cp-plan-pro-bg: #35136f;
--cp-plan-pro-text: #ddd6fe;
--cp-plan-prolite-bg: #24143e;
--cp-plan-prolite-text: #c4b5fd;
--cp-color-reasoning: #a78bfa;
--cp-default-bg-hover: #1b2a40;
--cp-default-bg-active: #23354f;
--cp-default-border: #26364c;
--cp-default-border-hover: #344760;
--cp-border-primary: #29384d;
--cp-divider-subtle: #26364c80;
--cp-progress-track: #24334a;
--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: #182437;
--cp-input-context-bg-hover: #1d2d43;
--cp-input-error-soft-bg: #311b24;
--cp-disabled-bg: #182231;
--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-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-border: #1f6f67;
--cp-normal-text: #5eead4;
--cp-success: #34d399;
--cp-success-bg: #0e3427;
--cp-success-bg-hover: #13432f;
--cp-success-bg-active: #18543b;
--cp-success-border: #247554;
--cp-success-text: #86efac;
--cp-warning: #fbbf24;
--cp-warning-bg: #38280c;
--cp-warning-bg-hover: #4a3510;
--cp-warning-bg-active: #5d4314;
--cp-warning-border: #7a581a;
--cp-warning-text: #fcd34d;
--cp-danger: #fb7185;
--cp-danger-bg: #3b1620;
--cp-danger-bg-hover: #4b1c28;
--cp-danger-bg-active: #612333;
--cp-danger-border: #7f2d42;
--cp-danger-text: #fda4af;
--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-thumb: #2b3e56;
--cp-scrollbar-thumb-hover: #405774;
--cp-account-plan-pro-bg: #35136f;
--cp-account-plan-pro-color: #ddd6fe;
--cp-account-plan-pro-lite-bg: #24143e;
--cp-account-plan-pro-lite-color: #c4b5fd;
}
+36
View File
@@ -0,0 +1,36 @@
// @env browser
import type { ResolvedTheme } from './index'
const RUNTIME_THEME_STYLE_ID = 'cp-runtime-theme'
export function applyResolvedTheme(root: HTMLElement, theme: ResolvedTheme): void {
root.dataset.theme = theme.name
root.dataset.themeColor = theme.colorId
const style = ensureRuntimeThemeStyle(root.ownerDocument)
style.dataset.theme = theme.name
style.dataset.themeColor = theme.colorId
style.textContent = serializeTheme(theme)
}
function ensureRuntimeThemeStyle(document: Document): HTMLStyleElement {
const existing = document.getElementById(RUNTIME_THEME_STYLE_ID)
if (existing?.tagName === 'STYLE')
return existing as HTMLStyleElement
existing?.remove()
const style = document.createElement('style')
style.id = RUNTIME_THEME_STYLE_ID
style.setAttribute('data-cp-runtime-theme', '')
document.head.append(style)
return style
}
function serializeTheme(theme: ResolvedTheme): string {
const declarations = Object.entries(theme.tokens)
.map(([name, value]) => ` ${name}: ${value};`)
.join('\n')
return `:root[data-theme='${theme.name}'][data-theme-color='${theme.colorId}'] {\n color-scheme: ${theme.name};\n${declarations}\n}\n`
}
+215
View File
@@ -0,0 +1,215 @@
import type {
ThemeColorPreset,
ThemeColorPresetId,
ThemeCustomization,
ThemeMode,
ThemeTokenName,
} from './index'
export const DEFAULT_THEME_MODE: ThemeMode = 'system'
export const DEFAULT_THEME_COLOR: ThemeColorPresetId = 'relay-blue'
export const DEFAULT_CUSTOM_THEME_COLOR = '#5983F4'
export const DEFAULT_THEME_CUSTOMIZATION: ThemeCustomization = {}
export const DEFAULT_SEED_COLORS = {
colorSuccess: '#12B981',
colorWarning: '#F59E0B',
colorError: '#EF4444',
colorInfo: '#5983F4',
} as const
export const DEFAULT_DIMENSIONS = {
fontSize: 13,
sizeUnit: 4,
sizeStep: 4,
controlHeight: 38,
tableRowHeight: 60,
borderRadius: 8,
cardBorderRadius: 18,
shadowStrength: 100,
} as const
export const THEME_COLOR_PRESETS: readonly ThemeColorPreset[] = [
{
id: 'relay-blue',
label: '中继蓝',
description: '石墨表面上的清晰交互信号',
seed: '#5983F4',
},
{
id: 'deep-teal',
label: '深海青',
description: '氧化铜落入深水玻璃的冷静层次',
seed: '#0E7C72',
appearance: {
light: {
colorBgBase: '#FBFEFD',
colorTextBase: '#122C2A',
},
dark: {
colorBgBase: '#102321',
colorTextBase: '#D7EAE6',
},
},
},
{
id: 'signal-violet',
label: '古风色',
description: '赭石落在宣纸与松烟墨上的温厚层次',
seed: '#A0583D',
appearance: {
light: {
colorBgBase: '#F9F3E8',
colorTextBase: '#2B211B',
},
dark: {
colorBgBase: '#1E1713',
colorTextBase: '#EEE3D3',
},
},
},
{
id: 'graphite',
label: '石墨',
description: '铅笔粉末与工作室灰纸的材料质感',
seed: '#525B66',
appearance: {
light: {
colorBgBase: '#FAF9F6',
colorTextBase: '#25272B',
},
dark: {
colorBgBase: '#1B1C1E',
colorTextBase: '#E5E1D9',
},
},
},
] as const
export const LIGHT_FOREGROUND = '#FFFFFF'
export const LIGHT_CONTAINER_BASE = '#FFFFFF'
export const LIGHT_TEXT_BASE = '#0E1726'
export const DARK_CONTAINER_BASE = '#111A29'
export const DARK_SPOTLIGHT_BASE = '#030710'
export const DARK_TEXT_BASE = '#D8E2EE'
export const BLACK = '#000000'
export const WHITE = '#FFFFFF'
export const NEUTRAL_LIGHT_SURFACES = {
colorBgLayout: '#F6F8FB',
colorBgContainer: LIGHT_CONTAINER_BASE,
colorBgElevated: '#F8FAFC',
colorBgSpotlight: '#111827',
colorBgMask: '#0E17264D',
colorBgTextHover: '#F1F5F9',
colorBgTextActive: '#E9EEF5',
colorBgContainerDisabled: '#F1F5F9',
colorFillSecondary: '#E9EEF5',
colorFillTertiary: '#F1F5F9',
colorFillQuaternary: '#F8FAFC',
colorBorder: '#D8E0EA',
colorBorderSecondary: '#E2E8F0',
colorSplit: '#D8E0EA6B',
colorTextHeading: '#111827',
colorTextSecondary: '#64748B',
colorTextTertiary: '#94A3B8',
colorTextQuaternary: '#94A3B8',
colorTextDisabled: '#94A3B8',
} as const
export const NEUTRAL_DARK_SURFACES = {
colorBgLayout: '#0B111C',
colorBgContainer: DARK_CONTAINER_BASE,
colorBgElevated: '#162133',
colorBgSpotlight: '#050913',
colorBgMask: '#030712A6',
colorBgTextHover: '#1B2A40',
colorBgTextActive: '#23354F',
colorBgContainerDisabled: '#182231',
colorFillSecondary: '#23354F',
colorFillTertiary: '#1C2A3D',
colorFillQuaternary: '#162133',
colorBorder: '#29384D',
colorBorderSecondary: '#26364C',
colorSplit: '#26364C80',
colorTextHeading: '#F5F8FC',
colorTextSecondary: '#8FA0B7',
colorTextTertiary: '#71839C',
colorTextQuaternary: '#64748B',
colorTextDisabled: '#53647A',
} as const
export const NEUTRAL_LIGHT_INPUT = {
bg: '#EEF2F7',
hoverBg: '#F2F6FA',
activeBg: '#F8FAFC',
} as const
export const NEUTRAL_DARK_INPUT = {
bg: '#182437',
hoverBg: '#1D2D43',
activeBg: '#111D2D',
} as const
export const NEUTRAL_DARK_SEMANTIC_TEXT = {
info: '#93C5FD',
success: '#86EFAC',
warning: '#FCD34D',
error: '#FDA4AF',
} as const
/** 阴影保持中性,不跟随主题主色染色。 */
export const NEUTRAL_LIGHT_SHADOWS = {
boxShadow: '0 18px 38px -18px #0E17262B',
boxShadowSecondary: '0 10px 22px -18px #0E172614',
boxShadowTertiary: '0 9px 18px -14px #0E172616',
cardShadow: '0 10px 22px -18px #0E172614',
inputShadow: '0 9px 18px -14px #0E172616',
inputHoverDropShadow: '0 12px 24px -16px #0E17261F',
layoutSiderShadow: '2px 0 12px -12px #0E172607',
} as const
export const NEUTRAL_DARK_SHADOWS = {
boxShadow: '0 24px 52px -26px #000000D1',
boxShadowSecondary: '0 18px 34px -26px #000000A3',
boxShadowTertiary: '0 12px 24px -20px #000000A8',
cardShadow: '0 18px 34px -26px #000000A3',
inputShadow: '0 12px 24px -20px #000000A8',
inputHoverDropShadow: '0 14px 28px -22px #000000B8',
layoutSiderShadow: '2px 0 18px -14px #000000B8',
} as const
export const EDITABLE_COLOR_TOKEN_NAMES = new Set<ThemeTokenName>([
'--cp-color-bg-elevated',
'--cp-color-bg-text-hover',
'--cp-color-bg-text-active',
'--cp-control-item-bg-active',
'--cp-control-item-bg-active-hover',
'--cp-menu-item-selected-bg',
'--cp-input-bg',
'--cp-input-hover-bg',
'--cp-input-active-bg',
'--cp-button-primary-color',
'--cp-button-primary-bg',
'--cp-button-primary-hover-bg',
'--cp-button-primary-active-bg',
'--cp-card-bg',
'--cp-table-header-bg',
'--cp-table-row-bg',
'--cp-table-row-stripe-bg',
'--cp-table-row-hover-bg',
'--cp-table-row-selected-bg',
'--cp-progress-remaining-color',
'--cp-layout-sider-bg',
'--cp-scrollbar-thumb-bg',
'--cp-scrollbar-thumb-hover-bg',
])
export const EDITABLE_SHADOW_TOKEN_NAMES = new Set<ThemeTokenName>([
'--cp-box-shadow',
'--cp-input-shadow',
'--cp-input-hover-shadow',
'--cp-input-active-shadow',
'--cp-card-shadow',
'--cp-layout-sider-shadow',
])
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -1,6 +1,16 @@
const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/i
const RGBA_HEX_COLOR_PATTERN = /^#[0-9A-F]{8}$/i
export function normalizeRgbaHexColor(value: string) {
export function normalizeHexColor(value: unknown) {
if (typeof value !== 'string')
return null
const normalized = value.trim().toUpperCase()
return HEX_COLOR_PATTERN.test(normalized) ? normalized : null
}
export function normalizeRgbaHexColor(value: unknown) {
if (typeof value !== 'string')
return null
const normalized = value.trim().toUpperCase()
return RGBA_HEX_COLOR_PATTERN.test(normalized) ? normalized : null
}
@@ -24,7 +24,7 @@ const emit = defineEmits<{
label="编辑分组"
@click.stop="emit('edit', group)"
>
<Pencil class="size-3.5 text-cp-info" />
<Pencil class="size-3.5 text-cp-link" />
</BaseIconButton>
<BaseIconButton
variant="ghost"
@@ -45,7 +45,7 @@ const emit = defineEmits<{
:disabled="deleting"
@click.stop="emit('delete', group)"
>
<Trash2 class="size-3.5 text-cp-danger" />
<Trash2 class="size-3.5 text-cp-error" />
</BaseIconButton>
</div>
</template>
@@ -27,7 +27,7 @@ const status = defineModel<string>('status', { required: true })
placeholder="搜索分组名称..."
>
<template #prefix>
<Search class="size-4.5 text-cp-tertiary" />
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</BaseInput>
<BaseSelect
@@ -27,8 +27,8 @@ const props = defineProps<{
}>()
const toneClasses: Record<MetricTone, string> = {
primary: 'text-cp-primary',
secondary: 'text-cp-secondary',
primary: 'text-cp-text',
secondary: 'text-cp-text-secondary',
success: 'text-cp-success-text',
warning: 'text-cp-warning',
active: 'text-cp-success',
@@ -82,7 +82,7 @@ const metrics = computed<MetricItem[]>(() => {
class="m-0 grid grid-cols-[auto_auto] justify-start gap-x-2 gap-y-0.5 text-xs leading-4"
>
<template v-for="metric in metrics" :key="metric.label">
<dt class="text-cp-muted-text">
<dt class="text-cp-text-quaternary">
{{ metric.label }}
</dt>
<dd class="m-0 font-mono font-semibold tabular-nums" :class="toneClasses[metric.tone]">
+4 -4
View File
@@ -99,10 +99,10 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
</template>
<template #identity="{ row }">
<div class="grid min-w-0 gap-1">
<strong class="truncate text-[13px] text-cp-primary">
<strong class="truncate text-cp text-cp-text">
{{ row.name }}
</strong>
<span class="truncate text-[11px] font-emphasis text-cp-muted-text">
<span class="truncate text-cp-xs font-emphasis text-cp-text-quaternary">
{{ row.description || '未填写描述' }}
</span>
</div>
@@ -119,10 +119,10 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
<template #enabled="{ row }">
<span
class="inline-flex h-6 items-center rounded-lg px-2 text-[11px] font-bold"
class="inline-flex h-6 items-center rounded-lg px-2 text-cp-xs font-bold"
:class="row.enabled
? 'bg-cp-success-bg text-cp-success-text'
: 'bg-cp-muted text-cp-muted-text'"
: 'bg-cp-fill-tertiary text-cp-text-quaternary'"
>
{{ row.enabled ? '已启用' : '已禁用' }}
</span>
@@ -36,7 +36,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
>
<div class="grid gap-5">
<div class="flex min-h-6 items-center justify-between gap-3">
<span class="text-[13px] leading-none font-medium text-cp-secondary">调度</span>
<span class="text-cp leading-none font-medium text-cp-text-secondary">调度</span>
<BaseSwitch
v-model="enabled"
label="切换所选账号调度"
@@ -38,10 +38,10 @@ function connectionLogClass(tone: string) {
if (tone === 'success')
return 'text-cp-success-text'
if (tone === 'danger')
return 'text-cp-danger-text'
return 'text-cp-error-text'
if (tone === 'info')
return 'text-cp-info-text'
return 'text-cp-secondary'
return 'text-cp-text-secondary'
}
</script>
@@ -55,7 +55,7 @@ function connectionLogClass(tone: string) {
>
<div v-if="account" class="flex flex-col gap-4">
<section
class="flex items-center justify-between gap-4 rounded-cp-surface bg-cp-subtle px-4 py-3"
class="flex items-center justify-between gap-4 rounded-cp-card bg-cp-fill-quaternary px-4 py-3"
>
<AccountIdentityCell :account="account" size="lg" show-plan />
<AccountStatusBadge
@@ -67,10 +67,10 @@ function connectionLogClass(tone: string) {
/>
</section>
<section class="rounded-cp-surface bg-cp-subtle px-4 py-3">
<section class="rounded-cp-card bg-cp-fill-quaternary px-4 py-3">
<div class="grid gap-2">
<div class="flex min-h-8 items-center justify-between gap-3">
<span class="text-[12px] font-heavy text-cp-muted-text">
<span class="text-cp-sm font-heavy text-cp-text-quaternary">
测试模型
</span>
<BaseIconButton
@@ -98,7 +98,7 @@ function connectionLogClass(tone: string) {
</div>
</section>
<section class="rounded-cp-surface bg-cp-subtle p-4">
<section class="rounded-cp-card bg-cp-fill-quaternary p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex min-w-0 items-start gap-3">
<span
@@ -112,18 +112,18 @@ function connectionLogClass(tone: string) {
/>
</span>
<div class="min-w-0">
<p class="m-0 text-[16px] font-heavy text-cp-primary">
<p class="m-0 text-[16px] font-heavy text-cp-text">
{{ statusView.label }}
</p>
<p
class="mt-1.5 mb-0 text-[13px] leading-normal font-emphasis text-cp-secondary"
class="mt-1.5 mb-0 text-cp leading-normal font-emphasis text-cp-text-secondary"
>
{{ statusView.description }}
</p>
</div>
</div>
<span
class="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-[12px] font-heavy"
class="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-cp-sm font-heavy"
:class="statusView.badge"
>
{{ status === 'running' ? '检测中' : statusView.label }}
@@ -131,60 +131,60 @@ function connectionLogClass(tone: string) {
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<div class="rounded-lg bg-cp-surface px-3 py-2.5">
<p class="m-0 text-[11px] font-heavy text-cp-muted-text">
<div class="rounded-lg bg-cp-bg-container px-3 py-2.5">
<p class="m-0 text-cp-xs font-heavy text-cp-text-quaternary">
开始时间
</p>
<p class="mt-1.5 mb-0 font-mono text-[12px] font-emphasis text-cp-primary">
<p class="mt-1.5 mb-0 font-mono text-cp-sm font-emphasis text-cp-text">
{{ startedAt || '-' }}
</p>
</div>
<div class="rounded-lg bg-cp-surface px-3 py-2.5">
<p class="m-0 text-[11px] font-heavy text-cp-muted-text">
<div class="rounded-lg bg-cp-bg-container px-3 py-2.5">
<p class="m-0 text-cp-xs font-heavy text-cp-text-quaternary">
完成时间
</p>
<p class="mt-1.5 mb-0 font-mono text-[12px] font-emphasis text-cp-primary">
<p class="mt-1.5 mb-0 font-mono text-cp-sm font-emphasis text-cp-text">
{{ finishedAt || '-' }}
</p>
</div>
<div class="rounded-lg bg-cp-surface px-3 py-2.5">
<p class="m-0 text-[11px] font-heavy text-cp-muted-text">
<div class="rounded-lg bg-cp-bg-container px-3 py-2.5">
<p class="m-0 text-cp-xs font-heavy text-cp-text-quaternary">
响应耗时
</p>
<p class="mt-1.5 mb-0 font-mono text-[12px] font-emphasis text-cp-primary">
<p class="mt-1.5 mb-0 font-mono text-cp-sm font-emphasis text-cp-text">
{{ durationMs !== null ? `${durationMs}ms` : '-' }}
</p>
</div>
</div>
<div class="mt-3 rounded-lg bg-cp-surface px-3 py-2.5">
<p class="m-0 text-[11px] font-heavy text-cp-muted-text">
<div class="mt-3 rounded-lg bg-cp-bg-container px-3 py-2.5">
<p class="m-0 text-cp-xs font-heavy text-cp-text-quaternary">
测试模型
</p>
<p
class="mt-1.5 mb-0 truncate font-mono text-[12px] font-emphasis text-cp-primary"
class="mt-1.5 mb-0 truncate font-mono text-cp-sm font-emphasis text-cp-text"
:title="model || '-'"
>
{{ model || '-' }}
</p>
</div>
<div class="mt-3 rounded-lg bg-cp-surface px-3 py-2.5">
<p class="m-0 text-[11px] font-heavy text-cp-muted-text">
<div class="mt-3 rounded-lg bg-cp-bg-container px-3 py-2.5">
<p class="m-0 text-cp-xs font-heavy text-cp-text-quaternary">
事件轨迹
</p>
<BaseScrollbar max-height="260px">
<div class="pt-2">
<div v-if="logs.length === 0" class="text-[12px] font-emphasis text-cp-muted-text">
<div v-if="logs.length === 0" class="text-cp-sm font-emphasis text-cp-text-quaternary">
-
</div>
<div v-else class="flex flex-col gap-1.5">
<div
v-for="item in logs"
:key="item.key"
class="grid grid-cols-[54px_minmax(0,1fr)] gap-2 text-[12px] leading-[1.45] font-emphasis"
class="grid grid-cols-[54px_minmax(0,1fr)] gap-2 text-cp-sm leading-[1.45] font-emphasis"
>
<span class="font-mono text-cp-muted-text">{{ item.time }}</span>
<span class="font-mono text-cp-text-quaternary">{{ item.time }}</span>
<div class="min-w-0">
<p
class="m-0 wrap-break-word"
@@ -192,11 +192,11 @@ function connectionLogClass(tone: string) {
>
{{ item.text }}
</p>
<div v-if="item.detail" class="mt-2 rounded-lg bg-cp-subtle px-3 py-2">
<div v-if="item.detail" class="mt-2 rounded-lg bg-cp-fill-quaternary px-3 py-2">
<BaseScrollbar max-height="138px">
<div>
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.6] font-emphasis text-cp-primary"
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-xs leading-[1.6] font-emphasis text-cp-text"
v-text="item.detail"
/>
</div>
@@ -45,14 +45,14 @@ const providers = [
v-for="provider in providers"
:key="provider.value"
type="button"
class="group inline-flex size-[88px] cursor-pointer items-center justify-center rounded-cp-overlay border-0 bg-transparent text-cp-primary outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-default-border-hover disabled:cursor-not-allowed disabled:opacity-55"
class="group inline-flex size-[88px] cursor-pointer items-center justify-center rounded-cp-lg border-0 bg-transparent text-cp-text outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-border disabled:cursor-not-allowed disabled:opacity-55"
:disabled="disabled"
:aria-label="`导入 ${provider.label} 账号`"
:title="provider.label"
@click="emit('select', provider.value)"
>
<span
class="inline-flex size-16 items-center justify-center rounded-cp-overlay bg-cp-subtle transition-colors duration-150 group-hover:bg-cp-muted"
class="inline-flex size-16 items-center justify-center rounded-cp-lg bg-cp-fill-quaternary transition-colors duration-150 group-hover:bg-cp-fill-tertiary"
>
<component :is="provider.icon" aria-hidden="true" :width="36" :height="36" />
</span>
@@ -129,9 +129,9 @@ async function copyText(value: string, successText: string) {
:dismissible="!saving"
>
<template #icon>
<LayoutGrid v-if="view.isBatch" class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
<Xai v-else-if="view.isXai" class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
<Openai v-else class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
<LayoutGrid v-if="view.isBatch" class="text-cp-text" aria-hidden="true" :width="20" :height="20" />
<Xai v-else-if="view.isXai" class="text-cp-text" aria-hidden="true" :width="20" :height="20" />
<Openai v-else class="text-cp-text" aria-hidden="true" :width="20" :height="20" />
</template>
<AccountProviderChooser
@@ -150,18 +150,18 @@ async function copyText(value: string, successText: string) {
/>
<div v-if="mode === 'oauth'" class="flex flex-col gap-4">
<div class="rounded-cp-control bg-cp-subtle px-4 py-3">
<div class="rounded-cp bg-cp-fill-quaternary px-4 py-3">
<div class="flex items-start gap-3">
<div
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-cp-control bg-cp-surface text-cp-info"
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-cp bg-cp-bg-container text-cp-primary-text"
>
<KeyRound class="size-4" />
</div>
<div class="min-w-0 flex-1">
<p class="m-0 text-[13px] font-bold text-cp-primary">
<p class="m-0 text-cp font-bold text-cp-text">
{{ view.oauth.panelTitle }}
</p>
<p class="m-0 mt-1 text-[12px] leading-[1.55] font-medium text-cp-secondary">
<p class="m-0 mt-1 text-cp-sm leading-[1.55] font-medium text-cp-text-secondary">
{{ view.oauth.panelDescription }}
</p>
</div>
@@ -196,9 +196,9 @@ async function copyText(value: string, successText: string) {
<BaseScrollbar
max-height="92px"
>
<div class="rounded-cp-control bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] px-3.5 py-3 shadow-cp-input">
<div class="rounded-cp bg-[var(--cp-input-bg)] px-3.5 py-3 shadow-cp-input">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.6] font-emphasis text-cp-secondary"
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-sm leading-[1.6] font-emphasis text-cp-text-secondary"
v-text="view.oauth.authUrl"
/>
</div>
@@ -40,7 +40,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
>
<div v-if="account" class="grid gap-5">
<div
class="flex flex-wrap items-center justify-between gap-4 rounded-cp-control bg-cp-subtle px-4 py-3.5"
class="flex flex-wrap items-center justify-between gap-4 rounded-cp bg-cp-fill-quaternary px-4 py-3.5"
>
<AccountIdentityCell
class="min-w-0 flex-1"
@@ -57,7 +57,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
</div>
<div class="flex min-h-6 items-center justify-between gap-3">
<span class="text-[13px] leading-none font-medium text-cp-secondary">调度</span>
<span class="text-cp leading-none font-medium text-cp-text-secondary">调度</span>
<BaseSwitch
v-model="enabled"
label="切换账号调度"
@@ -48,10 +48,10 @@ const groupOptions = computed(() => [
<BaseInput
v-model="search"
placeholder="搜索账号"
class="min-w-0 flex-1 [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] md:w-80 md:flex-none"
class="min-w-0 flex-1 md:w-80 md:flex-none"
>
<template #prefix>
<Search class="size-4.5 text-cp-tertiary" />
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</BaseInput>
@@ -59,7 +59,7 @@ const groupOptions = computed(() => [
v-model="status"
:options="accountStatusFilterOptions"
aria-label="按账号状态筛选"
class="w-34 shrink-0 [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] md:w-40"
class="w-34 shrink-0 md:w-40"
/>
<BaseSelect
@@ -67,7 +67,7 @@ const groupOptions = computed(() => [
:options="groupOptions"
:disabled="groupsLoading"
aria-label="按账号分组筛选"
class="w-34 shrink-0 [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] md:w-40"
class="w-34 shrink-0 md:w-40"
/>
<ProviderFilterSegmented
@@ -85,7 +85,7 @@ const groupOptions = computed(() => [
class="w-full whitespace-nowrap md:w-auto"
@click="emit('editSelected')"
>
<Pencil class="size-4 text-cp-info" />
<Pencil class="size-4 text-cp-link" />
批量编辑账号
</BaseButton>
<BaseButton
@@ -45,21 +45,21 @@ const secondaryText = computed(() =>
const initial = computed(() => displayTitle.value.slice(0, 1).toUpperCase())
const avatarSizeClass = computed(() =>
props.size === 'lg' ? 'size-10 text-[15px]' : 'size-9 text-[13px]',
props.size === 'lg' ? 'size-10 text-cp-xl' : 'size-9 text-cp',
)
const secondaryClass = computed(() =>
props.size === 'lg'
? 'mt-1 text-[12px] text-cp-secondary'
: 'mt-0.5 font-mono text-[11px] text-cp-muted-text',
? 'mt-1 text-cp-sm text-cp-text-secondary'
: 'mt-0.5 font-mono text-cp-xs text-cp-text-quaternary',
)
const avatarClass = computed(() => {
const palettes = [
'bg-cp-info-bg text-cp-info-text shadow-[inset_0_0_0_1px_var(--cp-info-border)]',
'bg-cp-success-bg text-cp-success-text shadow-[inset_0_0_0_1px_var(--cp-success-border)]',
'bg-cp-normal-bg text-cp-normal-text shadow-[inset_0_0_0_1px_var(--cp-normal-border)]',
'bg-cp-warning-bg text-cp-warning-text shadow-[inset_0_0_0_1px_var(--cp-warning-border)]',
'bg-cp-info-bg text-cp-info-text shadow-[inset_0_0_0_1px_var(--cp-color-info-border)]',
'bg-cp-success-bg text-cp-success-text shadow-[inset_0_0_0_1px_var(--cp-color-success-border)]',
'bg-cp-status-normal-bg text-cp-status-normal-text shadow-[inset_0_0_0_1px_var(--cp-color-status-normal-border)]',
'bg-cp-warning-bg text-cp-warning-text shadow-[inset_0_0_0_1px_var(--cp-color-warning-border)]',
]
const key = String(props.account.id || props.account.email || displayTitle.value)
const hash = sumBy([...key], char => char.charCodeAt(0))
@@ -77,7 +77,7 @@ const avatarClass = computed(() => {
</span>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 truncate text-[14px] font-heavy text-cp-primary">
<span class="min-w-0 flex-1 truncate text-cp-lg font-heavy text-cp-text">
{{ displayTitle }}
</span>
<span
@@ -50,7 +50,7 @@ function overviewIconClass(tone: string) {
return 'bg-cp-warning-bg text-cp-warning-text'
}
if (tone === 'danger') {
return 'bg-cp-danger-bg text-cp-danger-text'
return 'bg-cp-error-bg text-cp-error-text'
}
return 'bg-cp-info-bg text-cp-info-text'
}
@@ -66,15 +66,15 @@ function overviewIconClass(tone: string) {
>
<div class="flex items-stretch justify-between gap-3">
<div class="flex min-w-0 flex-col">
<p class="m-0 text-[12px] leading-none font-heavy text-cp-secondary">
<p class="m-0 text-cp-sm leading-none font-heavy text-cp-text-secondary">
{{ item.label }}
</p>
<strong
class="my-2 block font-mono text-[26px] leading-none font-extrabold text-cp-primary"
class="my-2 block font-mono text-[26px] leading-none font-extrabold text-cp-text"
>
{{ item.value }}
</strong>
<p class="m-0 truncate text-[12px] leading-none font-emphasis text-cp-muted-text">
<p class="m-0 truncate text-cp-sm leading-none font-emphasis text-cp-text-quaternary">
{{ item.caption }}
</p>
</div>
@@ -15,21 +15,21 @@ const props = withDefaults(
const fallbackPalettes = [
'bg-cp-info-bg text-cp-info-text shadow-cp-input',
'bg-cp-success-bg text-cp-success-text shadow-cp-input',
'bg-cp-normal-bg text-cp-normal-text shadow-cp-input',
'bg-cp-status-normal-bg text-cp-status-normal-text shadow-cp-input',
'bg-cp-warning-bg text-cp-warning-text shadow-cp-input',
]
const usageTierPalettes: Record<string, string> = {
pro: 'bg-cp-plan-pro-bg text-cp-plan-pro-text shadow-cp-input',
prolite: 'bg-cp-plan-prolite-bg text-cp-plan-prolite-text shadow-cp-input',
pro: 'bg-cp-account-plan-pro-bg text-cp-account-plan-pro shadow-cp-input',
prolite: 'bg-cp-account-plan-pro-lite-bg text-cp-account-plan-pro-lite shadow-cp-input',
}
const label = computed(() => props.planType?.trim() || 'Free')
const sizeClass = computed(() =>
props.size === 'sm'
? 'h-5 rounded-full px-1.75 text-[11px] font-bold'
: 'h-5.5 rounded-full px-2 text-[11px] font-heavy',
? 'h-5 rounded-full px-1.75 text-cp-xs font-bold'
: 'h-5.5 rounded-full px-2 text-cp-xs font-heavy',
)
const paletteClass = computed(() => {
@@ -25,14 +25,14 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
</script>
<template>
<section class="flex min-h-0 flex-col rounded-lg bg-cp-surface p-4 shadow-cp-control">
<section class="flex min-h-0 flex-col rounded-lg bg-cp-bg-container p-4 shadow-cp-tertiary">
<div class="mb-3 flex shrink-0 items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="m-0 text-[14px] font-heavy text-cp-primary">
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
账号额度
</h3>
<p
class="m-0 mt-1 flex min-w-0 items-center gap-1.5 text-[11px] font-emphasis text-cp-secondary"
class="m-0 mt-1 flex min-w-0 items-center gap-1.5 text-cp-xs font-emphasis text-cp-text-secondary"
>
<span>{{ account.provider === 'xai' ? 'xAI 用量窗口' : 'Codex 额度' }}</span>
<template v-if="account.provider === 'openai'">
@@ -72,7 +72,7 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
:label="entry.label"
:windows="entry.windows"
/>
<p v-if="quotaEntries.length === 0" class="m-0 text-[12px] font-emphasis text-cp-secondary">
<p v-if="quotaEntries.length === 0" class="m-0 text-cp-sm font-emphasis text-cp-text-secondary">
额度待观测
</p>
</div>
@@ -53,7 +53,7 @@ const items = computed(() => props.windows.map((window) => {
<section class="grid min-w-0 gap-2">
<h4
v-if="grouped && label"
class="m-0 truncate text-[11px] leading-4 font-heavy text-cp-secondary"
class="m-0 truncate text-cp-xs leading-4 font-heavy text-cp-text-secondary"
:title="label"
>
{{ label }}
@@ -65,12 +65,12 @@ const items = computed(() => props.windows.map((window) => {
>
<div v-for="item in items" :key="item.key" class="grid min-w-0 gap-1.5">
<template v-if="item.local">
<div class="flex min-w-0 items-baseline justify-between gap-2 text-[11px] leading-3.5">
<span class="min-w-0 truncate font-bold text-cp-muted-text">
<div class="flex min-w-0 items-baseline justify-between gap-2 text-cp-xs leading-3.5">
<span class="min-w-0 truncate font-bold text-cp-text-quaternary">
{{ item.localLabel }}
</span>
<strong
class="shrink-0 font-mono font-heavy tabular-nums text-cp-primary"
class="shrink-0 font-mono font-heavy tabular-nums text-cp-text"
:title="`窗口请求:${item.requestDisplay} 次`"
>
{{ item.requestDisplay }} 次
@@ -83,7 +83,7 @@ const items = computed(() => props.windows.map((window) => {
:label="item.timelineTitle"
/>
<p class="m-0 flex min-w-0 items-center justify-between gap-2 text-[10px] leading-3.5 text-cp-tertiary">
<p class="m-0 flex min-w-0 items-center justify-between gap-2 text-[10px] leading-3.5 text-cp-text-tertiary">
<span class="shrink-0 font-emphasis">统计范围</span>
<span
class="min-w-0 truncate font-mono font-emphasis tabular-nums"
@@ -94,14 +94,14 @@ const items = computed(() => props.windows.map((window) => {
</p>
</template>
<template v-else>
<div class="flex min-w-0 items-baseline justify-between gap-2 text-[11px] leading-3.5">
<span class="min-w-0 truncate font-bold text-cp-muted-text">
<div class="flex min-w-0 items-baseline justify-between gap-2 text-cp-xs leading-3.5">
<span class="min-w-0 truncate font-bold text-cp-text-quaternary">
{{ item.label }}
</span>
<span class="flex shrink-0 items-baseline gap-1.5 font-mono font-heavy tabular-nums">
<strong
v-if="item.localUsageDisplay"
class="text-cp-muted-text"
class="text-cp-text-quaternary"
:title="`窗口消耗:${item.localUsageDisplay}`"
>
{{ item.localUsageDisplay }}
@@ -116,7 +116,7 @@ const items = computed(() => props.windows.map((window) => {
</div>
<div
class="h-1.5 w-full overflow-hidden rounded-full bg-cp-default-border"
class="h-1.5 w-full overflow-hidden rounded-full bg-cp-border-secondary"
role="progressbar"
:aria-label="item.ariaLabel"
aria-valuemin="0"
@@ -131,7 +131,7 @@ const items = computed(() => props.windows.map((window) => {
/>
</div>
<p class="m-0 flex min-w-0 items-center justify-between gap-2 text-[10px] leading-3.5 text-cp-tertiary">
<p class="m-0 flex min-w-0 items-center justify-between gap-2 text-[10px] leading-3.5 text-cp-text-tertiary">
<span class="shrink-0 font-emphasis">重置</span>
<span class="min-w-0 truncate font-mono font-emphasis tabular-nums" :title="item.resetAtDisplay">
{{ item.resetAtDisplay }}
@@ -94,7 +94,7 @@ function quotaWindowCode(
<template #trigger="{ open }">
<button
type="button"
class="block w-full cursor-help rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="block w-full cursor-help rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:aria-label="`查看${detailTitle}详情`"
:aria-expanded="open"
aria-haspopup="dialog"
@@ -116,16 +116,16 @@ function quotaWindowCode(
</template>
<section
class="w-84 overflow-hidden rounded-cp-overlay"
class="w-84 overflow-hidden rounded-cp-lg"
role="dialog"
:aria-label="`${detailTitle}详情`"
>
<header
v-if="detailHeading"
class="bg-cp-subtle px-3 py-2.5"
class="bg-cp-fill-quaternary px-3 py-2.5"
>
<h3
class="m-0 truncate text-[13px] leading-5 font-heavy text-cp-primary"
class="m-0 truncate text-cp leading-5 font-heavy text-cp-text"
:title="detailHeading"
>
{{ detailHeading }}
@@ -142,17 +142,17 @@ function quotaWindowCode(
>
<div class="grid grid-cols-[32px_minmax(0,1fr)_auto] items-center gap-2.5">
<span
class="grid h-5 w-8 place-items-center rounded-sm bg-cp-muted font-mono text-[9px] leading-none font-heavy tracking-[0.04em] text-cp-info-text"
class="grid h-5 w-8 place-items-center rounded-sm bg-cp-fill-tertiary font-mono text-[9px] leading-none font-heavy tracking-[0.04em] text-cp-info-text"
aria-hidden="true"
>
{{ item.code }}
</span>
<h4 class="m-0 min-w-0 truncate text-[12px] leading-4 font-heavy text-cp-secondary">
<h4 class="m-0 min-w-0 truncate text-cp-sm leading-4 font-heavy text-cp-text-secondary">
{{ item.local ? item.localLabel : item.label }}
</h4>
<strong
class="font-mono text-[11px] leading-none font-heavy tabular-nums"
:class="item.local ? 'text-cp-primary' : item.percentTextClass"
class="font-mono text-cp-xs leading-none font-heavy tabular-nums"
:class="item.local ? 'text-cp-text' : item.percentTextClass"
>
{{ item.local ? `${item.requestDisplay} 次` : item.usedPercentDisplay }}
</strong>
@@ -166,7 +166,7 @@ function quotaWindowCode(
/>
<template v-else>
<div
class="mt-2 h-1 w-full overflow-hidden rounded-full bg-cp-default-border"
class="mt-2 h-1 w-full overflow-hidden rounded-full bg-cp-border-secondary"
role="progressbar"
:aria-label="item.label"
aria-valuemin="0"
@@ -184,30 +184,30 @@ function quotaWindowCode(
<dl class="mt-2.5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-[10px] leading-4">
<template v-if="item.local">
<dt class="font-emphasis text-cp-tertiary">
<dt class="font-emphasis text-cp-text-tertiary">
统计方式
</dt>
<dd class="m-0 text-right font-mono font-heavy tabular-nums text-cp-primary">
<dd class="m-0 text-right font-mono font-heavy tabular-nums text-cp-text">
滚动窗口
</dd>
<dt class="font-emphasis text-cp-tertiary">
<dt class="font-emphasis text-cp-text-tertiary">
统计范围
</dt>
<dd class="m-0 truncate text-right font-mono font-emphasis tabular-nums text-cp-secondary">
<dd class="m-0 truncate text-right font-mono font-emphasis tabular-nums text-cp-text-secondary">
{{ item.durationDisplay }}
</dd>
</template>
<template v-else>
<dt class="font-emphasis text-cp-tertiary">
<dt class="font-emphasis text-cp-text-tertiary">
窗口消耗
</dt>
<dd class="m-0 text-right font-mono font-heavy tabular-nums text-cp-primary">
<dd class="m-0 text-right font-mono font-heavy tabular-nums text-cp-text">
{{ item.localUsageDisplay }}
</dd>
<dt class="font-emphasis text-cp-tertiary">
<dt class="font-emphasis text-cp-text-tertiary">
重置时间
</dt>
<dd class="m-0 truncate text-right font-mono font-emphasis tabular-nums text-cp-secondary">
<dd class="m-0 truncate text-right font-mono font-emphasis tabular-nums text-cp-text-secondary">
{{ item.resetAtDisplay }}
</dd>
</template>
@@ -24,7 +24,7 @@ const trackGridStyle = computed(() => ({
<template>
<div class="flex min-w-0 flex-col">
<div class="mb-1 min-w-0 truncate text-[11px] leading-3.5 font-bold text-cp-muted-text">
<div class="mb-1 min-w-0 truncate text-cp-xs leading-3.5 font-bold text-cp-text-quaternary">
{{ label }}
</div>
@@ -32,7 +32,7 @@ const trackGridStyle = computed(() => ({
<div
v-for="item in windowItems"
:key="item.key"
class="h-0.75 w-full overflow-hidden rounded-full bg-cp-default-border"
class="h-0.75 w-full overflow-hidden rounded-full bg-cp-border-secondary"
role="progressbar"
:aria-label="item.labelTooltip"
aria-valuemin="0"
@@ -86,10 +86,10 @@ function expiryLabel(value: string | null) {
function creditOptionClasses(creditId: string) {
return [
'w-full min-w-0 rounded-cp-control px-4 py-3 outline-none transition-colors duration-150 motion-reduce:transition-none',
'w-full min-w-0 rounded-cp px-4 py-3 outline-none transition-colors duration-150 motion-reduce:transition-none',
selectedCreditId.value === creditId
? 'bg-cp-info-bg'
: 'bg-cp-subtle hover:bg-cp-default-hover',
? 'bg-cp-control-item-bg-active'
: 'bg-cp-fill-quaternary hover:bg-cp-bg-text-hover',
]
}
@@ -106,7 +106,7 @@ async function handleConfirmConsume() {
<template>
<button
type="button"
class="inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-control border-0 bg-transparent text-cp-secondary outline-none transition-[background-color,color,opacity,transform] duration-150 hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface motion-safe:active:scale-[0.96] motion-reduce:transition-none"
class="inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp border-0 bg-transparent text-cp-text-secondary outline-none transition-[background-color,color,opacity,transform] duration-150 hover:bg-cp-fill-quaternary hover:text-cp-text active:bg-cp-fill-tertiary focus-visible:ring-2 focus-visible:ring-cp-control-outline focus-visible:ring-offset-2 focus-visible:ring-offset-cp-bg-container motion-safe:active:scale-[0.96] motion-reduce:transition-none"
:class="showTriggerCount ? 'h-cp-control-sm gap-1 px-2' : 'size-cp-control-sm'"
:aria-label="triggerLabel"
:aria-pressed="panelOpen || undefined"
@@ -132,16 +132,16 @@ async function handleConfirmConsume() {
:dismissible="!consuming"
>
<div v-if="showConfirm" class="grid gap-3">
<section class="rounded-cp-control bg-cp-subtle px-4 py-3.5">
<p class="m-0 text-[11px] font-heavy text-cp-muted-text">
<section class="rounded-cp bg-cp-fill-quaternary px-4 py-3.5">
<p class="m-0 text-cp-xs font-heavy text-cp-text-quaternary">
本次使用
</p>
<p class="mt-1.5 mb-0 text-[14px] leading-snug font-heavy text-cp-primary">
<p class="mt-1.5 mb-0 text-cp-lg leading-snug font-heavy text-cp-text">
{{ confirmCreditTitle }}
</p>
<p
v-if="consumptionCredit"
class="mt-1 mb-0 font-mono text-[10px] leading-normal font-emphasis text-cp-muted-text"
class="mt-1 mb-0 font-mono text-[10px] leading-normal font-emphasis text-cp-text-quaternary"
>
{{ expiryLabel(consumptionCredit.expiresAt) }}
</p>
@@ -151,44 +151,44 @@ async function handleConfirmConsume() {
<div v-else class="grid gap-4">
<section
v-if="ambiguous"
class="flex items-start gap-3 rounded-cp-control bg-cp-warning-bg px-4 py-3.5"
class="flex items-start gap-3 rounded-cp bg-cp-warning-bg px-4 py-3.5"
role="status"
>
<AlertTriangle class="mt-0.5 size-4 shrink-0 text-cp-warning-text" />
<div class="min-w-0">
<p class="m-0 text-[12px] font-heavy text-cp-warning-text">
<p class="m-0 text-cp-sm font-heavy text-cp-warning-text">
上次操作结果待确认
</p>
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis text-cp-secondary">
<p class="mt-1 mb-0 text-cp-xs leading-normal font-emphasis text-cp-text-secondary">
再确认一次即可。
</p>
</div>
</section>
<section class="flex items-center gap-3 rounded-cp-control bg-cp-subtle px-4 py-3.5">
<section class="flex items-center gap-3 rounded-cp bg-cp-fill-quaternary px-4 py-3.5">
<span
class="inline-grid size-9 shrink-0 place-items-center rounded-cp-control bg-cp-muted text-cp-info-text"
class="inline-grid size-9 shrink-0 place-items-center rounded-cp bg-cp-fill-tertiary text-cp-primary-text"
aria-hidden="true"
>
<TicketCheck class="size-4" />
</span>
<div class="min-w-0">
<p class="m-0 text-[12px] font-heavy text-cp-primary">
<p class="m-0 text-cp-sm font-heavy text-cp-text">
可用重置卡
</p>
<p class="mt-1 mb-0 text-[11px] leading-none font-emphasis text-cp-secondary">
<p class="mt-1 mb-0 text-cp-xs leading-none font-emphasis text-cp-text-secondary">
每次操作消费一张
</p>
</div>
<strong class="ml-auto font-mono text-[22px] leading-none font-extrabold text-cp-primary">
<strong class="ml-auto font-mono text-[22px] leading-none font-extrabold text-cp-text">
{{ availableCount }}
<span class="ml-0.5 text-[11px] font-heavy text-cp-muted-text">张</span>
<span class="ml-0.5 text-cp-xs font-heavy text-cp-text-quaternary">张</span>
</strong>
</section>
<section class="grid gap-2.5">
<div class="flex min-h-8 items-center justify-between gap-3">
<h3 class="m-0 text-[12px] font-heavy text-cp-muted-text">
<h3 class="m-0 text-cp-sm font-heavy text-cp-text-quaternary">
选择重置卡
</h3>
<BaseIconButton
@@ -208,7 +208,7 @@ async function handleConfirmConsume() {
<p
v-if="loadError"
class="m-0 rounded-cp-control bg-cp-danger-bg px-4 py-3 text-[11px] leading-normal font-emphasis text-cp-danger-text"
class="m-0 rounded-cp bg-cp-error-bg px-4 py-3 text-cp-xs leading-normal font-emphasis text-cp-error-text"
role="status"
>
{{ loadError }},请刷新重试。
@@ -232,17 +232,17 @@ async function handleConfirmConsume() {
@update:model-value="selectCredit"
>
<span class="block min-w-0">
<span class="block truncate text-[12px] font-heavy text-cp-primary">
<span class="block truncate text-cp-sm font-heavy text-cp-text">
{{ credit.title || 'Codex 主动重置卡' }}
</span>
<span class="mt-1 block truncate font-mono text-[10px] font-emphasis text-cp-muted-text">
<span class="mt-1 block truncate font-mono text-[10px] font-emphasis text-cp-text-quaternary">
{{ expiryLabel(credit.expiresAt) }}
</span>
</span>
</BaseRadio>
</div>
<div v-else class="overflow-hidden rounded-cp-control bg-cp-subtle">
<div v-else class="overflow-hidden rounded-cp bg-cp-fill-quaternary">
<BaseEmpty
:icon="TicketCheck"
size="sm"
@@ -53,14 +53,14 @@ const presentation = computed(() => resolveAccountStatusPresentation({
>
<span
v-if="variant === 'pill'"
class="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-[12px] leading-none font-heavy transition-colors duration-150 motion-reduce:transition-none"
class="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-cp-sm leading-none font-heavy transition-colors duration-150 motion-reduce:transition-none"
:class="presentation.statusStyle.text"
>
{{ presentation.label }}
</span>
<span
v-else
class="inline-flex min-w-16 items-center gap-1.5 rounded-md px-1.5 py-1 text-[12px] leading-none font-emphasis transition-colors duration-150 motion-reduce:transition-none"
class="inline-flex min-w-16 items-center gap-1.5 rounded-md px-1.5 py-1 text-cp-sm leading-none font-emphasis transition-colors duration-150 motion-reduce:transition-none"
:class="presentation.statusStyle.text"
>
<span aria-hidden="true" class="size-1.5 rounded-full" :class="presentation.statusStyle.dot" />
@@ -69,21 +69,21 @@ const presentation = computed(() => resolveAccountStatusPresentation({
</component>
</template>
<section class="w-88 overflow-hidden rounded-cp-overlay">
<header class="flex items-start gap-3 bg-cp-subtle px-4 py-3">
<section class="w-88 overflow-hidden rounded-cp-lg">
<header class="flex items-start gap-3 bg-cp-fill-quaternary px-4 py-3">
<span
aria-hidden="true"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp-control"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp"
:class="presentation.statusStyle.icon"
>
<component :is="presentation.icon" class="size-4.5" />
</span>
<div class="min-w-0 flex-1">
<p class="m-0 text-[11px] leading-none font-heavy tracking-[0.08em] text-cp-tertiary">
<p class="m-0 text-cp-xs leading-none font-heavy tracking-[0.08em] text-cp-text-tertiary">
账号状态
</p>
<h3 class="mt-1.5 mb-0 text-[14px] leading-5 font-heavy text-cp-primary text-balance">
<h3 class="mt-1.5 mb-0 text-cp-lg leading-5 font-heavy text-cp-text text-balance">
{{ presentation.title }}
</h3>
</div>
@@ -96,51 +96,51 @@ const presentation = computed(() => resolveAccountStatusPresentation({
</span>
</header>
<div class="grid gap-3 px-4 py-3 text-[12px] leading-5">
<p class="m-0 text-pretty font-emphasis text-cp-secondary">
<div class="grid gap-3 px-4 py-3 text-cp-sm leading-5">
<p class="m-0 text-pretty font-emphasis text-cp-text-secondary">
{{ presentation.description }}
</p>
<div
v-if="presentation.rateLimitRecovery"
class="flex items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3 py-2"
class="flex items-center justify-between gap-3 rounded-cp bg-cp-fill-quaternary px-3 py-2"
>
<span class="font-heavy text-cp-tertiary">预计恢复</span>
<span class="font-mono font-emphasis tabular-nums text-cp-primary">
<span class="font-heavy text-cp-text-tertiary">预计恢复</span>
<span class="font-mono font-emphasis tabular-nums text-cp-text">
{{ presentation.rateLimitRecovery }}
</span>
</div>
<div
v-if="presentation.nextRefreshDisplay"
class="flex items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3 py-2"
class="flex items-center justify-between gap-3 rounded-cp bg-cp-fill-quaternary px-3 py-2"
>
<span class="font-heavy text-cp-tertiary">下次尝试</span>
<span class="font-mono font-emphasis tabular-nums text-cp-primary">
<span class="font-heavy text-cp-text-tertiary">下次尝试</span>
<span class="font-mono font-emphasis tabular-nums text-cp-text">
{{ presentation.nextRefreshDisplay }}
</span>
</div>
<div class="rounded-cp-control bg-cp-subtle px-3 py-2.5">
<p class="m-0 text-[11px] leading-none font-heavy text-cp-tertiary">
<div class="rounded-cp bg-cp-fill-quaternary px-3 py-2.5">
<p class="m-0 text-cp-xs leading-none font-heavy text-cp-text-tertiary">
建议操作
</p>
<p class="mt-1.5 mb-0 text-pretty font-emphasis text-cp-primary">
<p class="mt-1.5 mb-0 text-pretty font-emphasis text-cp-text">
{{ presentation.recoveryHint }}
</p>
</div>
<div
v-if="presentation.errorText"
class="overflow-hidden rounded-cp-control bg-cp-muted"
class="overflow-hidden rounded-cp bg-cp-fill-tertiary"
>
<div class="flex items-center justify-between gap-3 px-3 pt-2.5 pb-1.5">
<span class="text-[11px] leading-none font-heavy text-cp-tertiary">上游原始反馈</span>
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-muted-text">仅供排查</span>
<span class="text-cp-xs leading-none font-heavy text-cp-text-tertiary">上游原始反馈</span>
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-text-quaternary">仅供排查</span>
</div>
<BaseScrollbar class="bg-cp-subtle" max-height="124px">
<BaseScrollbar class="bg-cp-fill-quaternary" max-height="124px">
<div class="px-3 py-2">
<pre class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.55] font-emphasis text-cp-secondary">{{ presentation.errorText }}</pre>
<pre class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-xs leading-[1.55] font-emphasis text-cp-text-secondary">{{ presentation.errorText }}</pre>
</div>
</BaseScrollbar>
</div>
@@ -57,10 +57,10 @@ const statusStyles: Record<AccountStatusTone, AccountStatusStyle> = {
icon: 'bg-cp-success-bg text-cp-success-text',
},
danger: {
text: 'text-cp-danger-text',
dot: 'bg-cp-danger',
badge: 'bg-cp-danger-bg text-cp-danger-text',
icon: 'bg-cp-danger-bg text-cp-danger-text',
text: 'text-cp-error-text',
dot: 'bg-cp-error',
badge: 'bg-cp-error-bg text-cp-error-text',
icon: 'bg-cp-error-bg text-cp-error-text',
},
warning: {
text: 'text-cp-warning-text',
@@ -75,10 +75,10 @@ const statusStyles: Record<AccountStatusTone, AccountStatusStyle> = {
icon: 'bg-cp-info-bg text-cp-info-text',
},
normal: {
text: 'text-cp-secondary',
dot: 'bg-cp-muted-text',
badge: 'bg-cp-subtle text-cp-secondary',
icon: 'bg-cp-subtle text-cp-secondary',
text: 'text-cp-text-secondary',
dot: 'bg-cp-text-quaternary',
badge: 'bg-cp-fill-quaternary text-cp-text-secondary',
icon: 'bg-cp-fill-quaternary text-cp-text-secondary',
},
}
@@ -32,7 +32,7 @@ const emit = defineEmits<{
label="编辑账号"
@click.stop="emit('edit', account)"
>
<Pencil class="size-3.5 text-cp-info" />
<Pencil class="size-3.5 text-cp-link" />
</BaseIconButton>
<BaseIconButton
@@ -42,7 +42,7 @@ const emit = defineEmits<{
:disabled="deleting"
@click.stop="emit('delete', account)"
>
<Trash2 class="size-3.5 text-cp-danger" />
<Trash2 class="size-3.5 text-cp-error" />
</BaseIconButton>
<BasePopover placement="bottom-end">
@@ -60,7 +60,7 @@ const emit = defineEmits<{
@click.stop="(close(), emit('test', account))"
>
<template #icon>
<Wifi class="size-3.5 text-cp-muted-text" />
<Wifi class="size-3.5 text-cp-text-quaternary" />
</template>
测试连接
</BaseMenuItem>
@@ -70,16 +70,16 @@ const emit = defineEmits<{
@click.stop="(close(), emit('refresh', account.id))"
>
<template #loading>
<RefreshCw class="size-3.5 animate-spin text-cp-muted-text motion-reduce:animate-none" />
<RefreshCw class="size-3.5 animate-spin text-cp-text-quaternary motion-reduce:animate-none" />
</template>
<template #icon>
<RefreshCw class="size-3.5 text-cp-muted-text" />
<RefreshCw class="size-3.5 text-cp-text-quaternary" />
</template>
刷新令牌
</BaseMenuItem>
<BaseMenuItem @click.stop="(close(), emit('reauthorize', account))">
<template #icon>
<KeyRound class="size-3.5 text-cp-muted-text" />
<KeyRound class="size-3.5 text-cp-text-quaternary" />
</template>
重新授权
</BaseMenuItem>
@@ -89,7 +89,7 @@ const emit = defineEmits<{
@click.stop="(close(), emit('recover', account.id))"
>
<template #icon>
<RotateCcw class="size-3.5 text-cp-muted-text" />
<RotateCcw class="size-3.5 text-cp-text-quaternary" />
</template>
恢复状态
</BaseMenuItem>
@@ -25,43 +25,43 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
<template>
<section
class="grid gap-4 rounded-lg bg-cp-surface p-4 shadow-cp-control xl:grid-cols-[0.52fr_1.48fr]"
class="grid gap-4 rounded-lg bg-cp-bg-container p-4 shadow-cp-tertiary xl:grid-cols-[0.52fr_1.48fr]"
>
<div>
<div class="mb-3 flex items-baseline justify-between gap-3">
<h3 class="m-0 text-[14px] font-heavy text-cp-primary">
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
Token 结构
</h3>
<span class="text-[11px] font-emphasis text-cp-muted-text">当前额度窗口</span>
<span class="text-cp-xs font-emphasis text-cp-text-quaternary">当前额度窗口</span>
</div>
<div class="grid gap-2">
<div class="flex items-center justify-between rounded-lg bg-cp-success-bg px-3 py-2">
<span class="text-[12px] font-bold text-cp-success-text">输入 Tokens</span>
<strong class="font-mono text-[13px] text-cp-primary">
<span class="text-cp-sm font-bold text-cp-success-text">输入 Tokens</span>
<strong class="font-mono text-cp text-cp-text">
{{ account.usage.inputTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-cp-warning-bg px-3 py-2">
<span class="text-[12px] font-bold text-cp-warning-text">输出 Tokens</span>
<strong class="font-mono text-[13px] text-cp-primary">
<span class="text-cp-sm font-bold text-cp-warning-text">输出 Tokens</span>
<strong class="font-mono text-cp text-cp-text">
{{ account.usage.outputTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-cp-normal-bg px-3 py-2">
<span class="text-[12px] font-bold text-cp-normal-text">缓存 Tokens</span>
<strong class="font-mono text-[13px] text-cp-primary">
<div class="flex items-center justify-between rounded-lg bg-cp-status-normal-bg px-3 py-2">
<span class="text-cp-sm font-bold text-cp-status-normal-text">缓存 Tokens</span>
<strong class="font-mono text-cp text-cp-text">
{{ account.usage.cachedTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-cp-info-bg px-3 py-2">
<span class="text-[12px] font-bold text-cp-info-text">创建</span>
<strong class="font-mono text-[13px] text-cp-primary">
<span class="text-cp-sm font-bold text-cp-info-text">创建</span>
<strong class="font-mono text-cp text-cp-text">
{{ account.usage.createdTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-cp-info-bg px-3 py-2">
<span class="text-[12px] font-bold text-cp-info-text">读取</span>
<strong class="font-mono text-[13px] text-cp-primary">
<span class="text-cp-sm font-bold text-cp-info-text">读取</span>
<strong class="font-mono text-cp text-cp-text">
{{ account.usage.readTokensDisplay }}
</strong>
</div>
@@ -72,10 +72,10 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
class="min-w-0 pt-4 xl:pt-0 xl:pl-4"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="m-0 text-[14px] font-heavy text-cp-primary">
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
模型使用排行
</h3>
<span class="text-[11px] font-emphasis text-cp-muted-text">当前额度窗口</span>
<span class="text-cp-xs font-emphasis text-cp-text-quaternary">当前额度窗口</span>
</div>
<div class="h-52 min-w-0">
@@ -23,7 +23,7 @@ withDefaults(
<span
v-for="bar in bars"
:key="bar.key"
class="relative min-w-0 flex-1 bg-cp-default-border"
class="relative min-w-0 flex-1 bg-cp-border-secondary"
:title="showNativeTooltip ? bar.title : undefined"
>
<span
@@ -48,7 +48,7 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
>
<span
v-if="view.quota.localUsageVisible"
class="text-right text-cp-muted-text"
class="text-right text-cp-text-quaternary"
:class="view.classes.value"
:title="showNativeTooltip ? `窗口消耗:${view.quota.localUsageDisplay}` : undefined"
>
@@ -69,7 +69,7 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
>
<span
v-if="view.quota.localUsageVisible"
class="text-cp-muted-text"
class="text-cp-text-quaternary"
:class="view.classes.value"
>
{{ view.quota.localUsageDisplay }}
@@ -98,7 +98,7 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
</div>
<div
v-if="view.quota.resetVisible"
class="mt-3 text-[12px] font-emphasis text-cp-secondary"
class="mt-3 text-cp-sm font-emphasis text-cp-text-secondary"
>
重置时间: {{ window.resetAtDisplay }}
</div>
@@ -111,7 +111,7 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
</span>
<strong
v-if="view.local.requestValueVisible"
class="shrink-0 font-mono tabular-nums text-cp-primary"
class="shrink-0 font-mono tabular-nums text-cp-text"
:class="view.classes.value"
>
{{ view.local.requestDisplay }}
@@ -126,8 +126,8 @@ const view = computed(() => resolveAccountUsageWindowPresentation({
</template>
<div v-else :class="view.classes.header">
<span class="min-w-0 text-cp-secondary">额度待观测</span>
<span class="shrink-0 font-mono text-cp-muted-text" :class="view.classes.value">—</span>
<span class="min-w-0 text-cp-text-secondary">额度待观测</span>
<span class="shrink-0 font-mono text-cp-text-quaternary" :class="view.classes.value">—</span>
</div>
</div>
</template>
@@ -55,9 +55,9 @@ const variantDefinitions: Record<
> = {
metric: {
rootClass: 'grid min-w-0 grid-rows-[14px_14px] gap-1',
headerClass: 'flex items-center justify-between gap-2 text-[11px] leading-[14px] font-bold',
labelClass: 'truncate text-cp-muted-text',
valueClass: 'text-[11px] leading-none font-bold',
headerClass: 'flex items-center justify-between gap-2 text-cp-xs leading-[14px] font-bold',
labelClass: 'truncate text-cp-text-quaternary',
valueClass: 'text-cp-xs leading-none font-bold',
trackOffsetClass: 'self-center',
trackShapeClass: 'h-1.5 w-full overflow-hidden rounded-full',
minimumBarWidth: '6px',
@@ -67,8 +67,8 @@ const variantDefinitions: Record<
},
compact: {
rootClass: 'flex min-w-0 flex-col',
headerClass: 'mb-1 flex items-baseline justify-between gap-2 text-[11px] leading-[14px] font-bold',
labelClass: 'truncate text-cp-muted-text',
headerClass: 'mb-1 flex items-baseline justify-between gap-2 text-cp-xs leading-[14px] font-bold',
labelClass: 'truncate text-cp-text-quaternary',
valueClass: 'text-[10px] leading-[14px] font-heavy',
trackOffsetClass: 'mt-auto',
trackShapeClass: 'h-[3px] w-full overflow-hidden rounded-full',
@@ -78,10 +78,10 @@ const variantDefinitions: Record<
showResetTime: false,
},
detail: {
rootClass: 'rounded-lg bg-cp-subtle p-2',
headerClass: 'flex items-center justify-between gap-3 text-[12px] font-bold',
labelClass: 'text-cp-secondary',
valueClass: 'text-[12px] font-heavy',
rootClass: 'rounded-lg bg-cp-fill-quaternary p-2',
headerClass: 'flex items-center justify-between gap-3 text-cp-sm font-bold',
labelClass: 'text-cp-text-secondary',
valueClass: 'text-cp-sm font-heavy',
trackOffsetClass: 'mt-2',
trackShapeClass: 'h-2 overflow-hidden rounded-full',
minimumBarWidth: '8px',
@@ -102,12 +102,12 @@ const quotaToneClasses: Record<
{ bar: string, text: string }
> = {
unknown: {
bar: 'bg-cp-default-border-hover',
text: 'text-cp-muted-text',
bar: 'bg-cp-border',
text: 'text-cp-text-quaternary',
},
danger: {
bar: 'bg-cp-danger',
text: 'text-cp-danger-text',
bar: 'bg-cp-error',
text: 'text-cp-error-text',
},
warning: {
bar: 'bg-cp-warning',
@@ -147,7 +147,7 @@ export function resolveAccountUsageWindowPresentation(
value: definition.valueClass,
trackOffset: definition.trackOffsetClass,
trackShape: definition.trackShapeClass,
track: `${definition.trackShapeClass} bg-cp-default-border`,
track: `${definition.trackShapeClass} bg-cp-border-secondary`,
},
quota: {
valueVisible: typeof input.window?.usedPercent === 'number'
@@ -110,16 +110,16 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
label: '测试失败',
description: '请求未完成,请在下方查看上游错误与事件轨迹',
icon: XCircle,
badge: 'bg-cp-danger-bg text-cp-danger-text',
iconClass: 'text-cp-danger',
badge: 'bg-cp-error-bg text-cp-error-text',
iconClass: 'text-cp-error',
}
}
return {
label: '准备测试',
description: '选择模型后,点击“开始测试”发送真实请求',
icon: Wifi,
badge: 'bg-cp-subtle text-cp-secondary',
iconClass: 'text-cp-muted-text',
badge: 'bg-cp-fill-quaternary text-cp-text-secondary',
iconClass: 'text-cp-text-quaternary',
}
})
+3 -3
View File
@@ -143,14 +143,14 @@ export function groupedAccountQuotaWindows(windows: AccountQuotaWindow[]) {
export function modelSuccessRateTextClass(successRate: number | null) {
if (successRate === null)
return 'text-cp-muted-text'
return 'text-cp-text-quaternary'
if (successRate >= 99.5)
return 'text-cp-success-text'
if (successRate >= 98)
return 'text-cp-normal-text'
return 'text-cp-status-normal-text'
if (successRate >= 95)
return 'text-cp-warning-text'
return 'text-cp-danger-text'
return 'text-cp-error-text'
}
function compareQuotaWindows(left: AccountQuotaWindow, right: AccountQuotaWindow) {
+2 -2
View File
@@ -190,7 +190,7 @@ const {
<template #body>
<div class="flex h-full min-h-0 flex-col">
<BaseTable
class="min-h-0 flex-1 [--cp-table-row-min-height:72px]"
class="min-h-0 flex-1 [--cp-table-row-height:72px]"
:columns="accountColumns"
:rows="accounts"
:loading="loading"
@@ -203,7 +203,7 @@ const {
<template #expander="{ row }">
<button
type="button"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-cp-secondary transition hover:bg-cp-default-hover hover:text-cp-primary"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-cp-text-secondary transition hover:bg-cp-bg-text-hover hover:text-cp-text"
:title="expandedAccountIds.has(row.id) ? '收起统计' : '展开统计'"
@click.stop="toggleExpanded(row.id)"
>
@@ -30,7 +30,7 @@ const emit = defineEmits<{
label="编辑密钥"
@click.stop="emit('edit', apiKey)"
>
<Pencil class="size-3.5 text-cp-info" />
<Pencil class="size-3.5 text-cp-link" />
</BaseIconButton>
<BaseIconButton
variant="ghost"
@@ -40,7 +40,7 @@ const emit = defineEmits<{
:disabled="revealing"
@click.stop="emit('use', apiKey)"
>
<Terminal class="size-3.5 text-cp-normal" />
<Terminal class="size-3.5 text-cp-status-normal" />
</BaseIconButton>
<BaseIconButton
@@ -50,7 +50,7 @@ const emit = defineEmits<{
:disabled="revealing"
@click.stop="emit('importCcs', apiKey)"
>
<Upload class="size-3.5 text-cp-info" />
<Upload class="size-3.5 text-cp-link" />
</BaseIconButton>
<BaseIconButton
@@ -73,7 +73,7 @@ const emit = defineEmits<{
:disabled="deleting"
@click.stop="emit('delete', apiKey)"
>
<Trash2 class="size-3.5 text-cp-danger" />
<Trash2 class="size-3.5 text-cp-error" />
</BaseIconButton>
</div>
</template>
@@ -110,17 +110,17 @@ const title = computed(() => props.editing ? '编辑 API Key' : '创建 API Key'
size="md"
>
<div class="flex flex-col gap-4">
<div class="rounded-cp-control border border-cp-warning-border bg-cp-warning-bg px-4 py-3">
<p class="m-0 text-[13px] font-semibold text-cp-warning-text">
<div class="rounded-cp border border-cp-warning-border bg-cp-warning-bg px-4 py-3">
<p class="m-0 text-cp font-semibold text-cp-warning-text">
该密钥具有网关访问权限,请仅发送给可信调用方
</p>
</div>
<div>
<p class="mb-2 text-[13px] font-medium text-cp-secondary">
<p class="mb-2 text-cp font-medium text-cp-text-secondary">
API Key
</p>
<div class="flex items-center gap-2">
<code class="flex-1 rounded-cp-control bg-cp-subtle px-3 py-2.5 font-mono text-[13px] break-all text-cp-primary">
<code class="flex-1 rounded-cp bg-cp-fill-quaternary px-3 py-2.5 font-mono text-cp break-all text-cp-text">
{{ createdKey }}
</code>
<BaseIconButton size="md" label="复制" @click="emit('copy', createdKey)">
@@ -26,7 +26,7 @@ const search = defineModel<string>('search', { required: true })
<div class="min-w-0 flex-1 md:w-96 md:flex-none">
<BaseInput v-model="search" placeholder="搜索名称、标签或 ID" class="w-full">
<template #prefix>
<Search class="size-4.5 text-cp-tertiary" />
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</BaseInput>
</div>
@@ -10,10 +10,10 @@ defineProps<{
<template>
<div class="flex flex-col gap-0.5">
<span class="text-[13px] font-bold text-cp-primary">
<span class="text-cp font-bold text-cp-text">
{{ apiKey.name }}
</span>
<span v-if="apiKey.label" class="text-[12px] font-emphasis text-cp-tertiary">
<span v-if="apiKey.label" class="text-cp-sm font-emphasis text-cp-text-tertiary">
{{ apiKey.label }}
</span>
</div>
@@ -15,7 +15,7 @@ const emit = defineEmits<{
<template>
<div class="flex items-center gap-2">
<code class="block min-w-0 truncate font-mono text-[12px] font-emphasis text-cp-primary">
<code class="block min-w-0 truncate font-mono text-cp-sm font-emphasis text-cp-text">
{{ prefix }}••••••••••••••••
</code>
<BaseIconButton
@@ -11,7 +11,7 @@ defineProps<{
<template>
<div class="grid w-full justify-items-center gap-1.5">
<div v-if="apiKey.routingScope === 'all'" class="flex items-center justify-center gap-2">
<span class="inline-flex h-6 items-center rounded-lg bg-cp-warning-bg px-2 text-[11px] font-bold text-cp-warning-text">
<span class="inline-flex h-6 items-center rounded-lg bg-cp-warning-bg px-2 text-cp-xs font-bold text-cp-warning-text">
全部账号权限
</span>
</div>
@@ -8,10 +8,10 @@ defineProps<{
<template>
<span
class="inline-flex h-6 min-w-14 items-center justify-center rounded-full px-2 text-[12px] leading-none font-bold"
class="inline-flex h-6 min-w-14 items-center justify-center rounded-full px-2 text-cp-sm leading-none font-bold"
:class="{
'bg-cp-success-bg text-cp-success-text': apiKey.enabled,
'bg-cp-subtle text-cp-secondary': !apiKey.enabled,
'bg-cp-fill-quaternary text-cp-text-secondary': !apiKey.enabled,
}"
>
{{ apiKey.enabled ? '已启用' : '已禁用' }}
@@ -83,11 +83,11 @@ const visibleFiles = computed(() => [
<section
v-for="file in visibleFiles"
:key="file.path"
class="overflow-hidden rounded-cp-surface bg-cp-subtle shadow-cp-control"
class="overflow-hidden rounded-cp-card bg-cp-fill-quaternary shadow-cp-tertiary"
>
<div class="flex items-center justify-between gap-3 px-4 py-2.5">
<span
class="min-w-0 truncate font-mono text-[12px] font-emphasis text-cp-secondary"
class="min-w-0 truncate font-mono text-cp-sm font-emphasis text-cp-text-secondary"
>
{{ file.path }}
</span>
@@ -103,9 +103,9 @@ const visibleFiles = computed(() => [
<BaseScrollbar
max-height="360px"
>
<div class="mx-3 mb-3 rounded-cp-control bg-cp-surface px-3.5 py-3 shadow-cp-input">
<div class="mx-3 mb-3 rounded-cp bg-cp-bg-container px-3.5 py-3 shadow-cp-input">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.65] font-emphasis text-cp-primary"
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-cp-sm leading-[1.65] font-emphasis text-cp-text"
v-text="file.content"
/>
</div>
@@ -151,8 +151,8 @@ const statusBars = computed(() => {
return [
{ pct: (counts.normal / counts.total) * 100, cls: 'bg-cp-success' },
{ pct: (counts.quotaExhausted / counts.total) * 100, cls: 'bg-cp-warning' },
{ pct: (counts.rateLimited / counts.total) * 100, cls: 'bg-cp-normal' },
{ pct: ((counts.disabled + counts.error) / counts.total) * 100, cls: 'bg-cp-danger' },
{ pct: (counts.rateLimited / counts.total) * 100, cls: 'bg-cp-status-normal' },
{ pct: ((counts.disabled + counts.error) / counts.total) * 100, cls: 'bg-cp-error' },
].filter(b => b.pct > 0)
})
</script>
@@ -163,25 +163,25 @@ const statusBars = computed(() => {
class="grid xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7"
>
<section class="min-w-0 w-full pb-6 xl:h-100.5 xl:pb-0">
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary">
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-cp-text">
账号调度
</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-cp-secondary">
<p class="mt-1.75 mb-0 text-cp leading-[1.15] font-semibold text-cp-text-secondary">
容量、并发与分配策略
</p>
<div class="mt-6.75 grid gap-4 xl:h-82.5 xl:grid-rows-[122px_90px_minmax(0,1fr)]">
<div class="grid h-30.5 content-between rounded-cp-overlay bg-cp-subtle p-4 xl:h-auto">
<span class="block h-3.5 text-xs leading-[1.15] font-emphasis text-cp-secondary">槽位占用</span>
<div class="grid h-30.5 content-between rounded-cp-lg bg-cp-bg-elevated p-4 xl:h-auto">
<span class="block h-3.5 text-xs leading-[1.15] font-emphasis text-cp-text-secondary">槽位占用</span>
<div>
<div class="h-8.5">
<strong
class="font-mono text-[32px] leading-[1.05] font-heavy tabular-nums text-cp-primary"
class="font-mono text-[32px] leading-[1.05] font-heavy tabular-nums text-cp-text"
>
{{ usedRatio }}
</strong>
</div>
<div class="mt-4 h-2.5 w-full overflow-hidden rounded-full bg-cp-progress">
<div class="mt-4 h-2.5 w-full overflow-hidden rounded-full bg-cp-progress-remaining">
<i
class="block h-2.5 rounded-full bg-cp-success"
:style="usedProgressStyle"
@@ -191,21 +191,21 @@ const statusBars = computed(() => {
</div>
<div
class="grid h-22.5 grid-cols-3 gap-4 rounded-cp-overlay bg-cp-subtle p-4 xl:h-auto"
class="grid h-22.5 grid-cols-3 gap-4 rounded-cp-lg bg-cp-bg-elevated p-4 xl:h-auto"
>
<div v-for="stat in scheduleStats" :key="stat.label" class="grid content-between">
<span class="text-xs leading-[1.15] font-emphasis text-cp-secondary">{{ stat.label }}</span>
<span class="text-xs leading-[1.15] font-emphasis text-cp-text-secondary">{{ stat.label }}</span>
<strong
class="block font-mono text-[21px] leading-[1.1] font-heavy tabular-nums text-cp-primary"
class="block font-mono text-[21px] leading-[1.1] font-heavy tabular-nums text-cp-text"
>
{{ stat.value }}
</strong>
</div>
</div>
<div class="grid h-20.5 content-between rounded-cp-overlay bg-cp-subtle p-4 xl:h-auto">
<span class="text-xs leading-[1.15] font-emphasis text-cp-secondary">分配策略</span>
<strong class="block text-[17px] leading-[1.15] font-emphasis text-cp-primary">
<div class="grid h-20.5 content-between rounded-cp-lg bg-cp-bg-elevated p-4 xl:h-auto">
<span class="text-xs leading-[1.15] font-emphasis text-cp-text-secondary">分配策略</span>
<strong class="block text-[17px] leading-[1.15] font-emphasis text-cp-text">
{{ strategyLabel }}
</strong>
</div>
@@ -213,10 +213,10 @@ const statusBars = computed(() => {
</section>
<section class="min-w-0 w-full">
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary">
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-cp-text">
活跃账号用量
</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-cp-secondary">
<p class="mt-1.75 mb-0 text-cp leading-[1.15] font-semibold text-cp-text-secondary">
最近使用排序
</p>
@@ -232,7 +232,7 @@ const statusBars = computed(() => {
<article
v-for="account in accounts"
:key="account.id"
class="grid w-full shrink-0 grid-cols-1 gap-3 rounded-xl bg-cp-subtle px-3.5 py-3.5 transition-colors xl:h-19.5 xl:grid-cols-[minmax(0,1fr)_minmax(70px,0.36fr)_minmax(74px,0.38fr)_minmax(82px,0.46fr)] xl:items-center xl:gap-4 xl:py-0"
class="grid w-full shrink-0 grid-cols-1 gap-3 rounded-xl bg-cp-bg-elevated px-3.5 py-3.5 transition-colors xl:h-19.5 xl:grid-cols-[minmax(0,1fr)_minmax(70px,0.36fr)_minmax(74px,0.38fr)_minmax(82px,0.46fr)] xl:items-center xl:gap-4 xl:py-0"
>
<AccountIdentityCell
:account="account"
@@ -253,20 +253,20 @@ const statusBars = computed(() => {
class="grid min-w-0 grid-cols-[minmax(0,0.82fr)_minmax(0,0.64fr)_minmax(104px,1fr)] items-start gap-3 xl:contents"
>
<span class="grid min-w-0 gap-1">
<span class="text-[11px] leading-none font-bold text-cp-muted-text">
<span class="text-cp-xs leading-none font-bold text-cp-text-quaternary">
{{ account.metricLabel }}
</span>
<strong
class="font-mono text-sm leading-[1.15] font-heavy tabular-nums text-cp-primary"
class="font-mono text-sm leading-[1.15] font-heavy tabular-nums text-cp-text"
>
{{ account.metricValue }}
</strong>
</span>
<span class="grid min-w-0 gap-1">
<span class="text-[11px] leading-none font-bold text-cp-muted-text">最近</span>
<span class="text-cp-xs leading-none font-bold text-cp-text-quaternary">最近</span>
<span
class="min-w-0 truncate text-xs leading-[1.15] font-bold text-cp-secondary xl:whitespace-nowrap"
class="min-w-0 truncate text-xs leading-[1.15] font-bold text-cp-text-secondary xl:whitespace-nowrap"
>
{{ account.lastUsed }}
</span>
@@ -286,10 +286,10 @@ const statusBars = computed(() => {
<section class="min-w-0 w-full pt-6 xl:h-100.5 xl:pt-0">
<header class="flex h-12.5 items-start justify-between">
<div>
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary">
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-cp-text">
账号状态
</h2>
<p class="mt-1 mb-0 text-[13px] leading-[1.15] font-emphasis text-cp-secondary">
<p class="mt-1 mb-0 text-cp leading-[1.15] font-emphasis text-cp-text-secondary">
账号池健康结构
</p>
</div>
@@ -299,16 +299,16 @@ const statusBars = computed(() => {
>
{{ normalRate }}
</strong>
<span class="mt-0.5 text-xs leading-[1.15] font-bold text-cp-secondary">正常率</span>
<span class="mt-0.5 text-xs leading-[1.15] font-bold text-cp-text-secondary">正常率</span>
</div>
</header>
<div class="mt-5.5 h-10.5 w-full">
<div class="flex h-4 items-center justify-between">
<span class="text-xs leading-[1.15] font-emphasis text-cp-secondary">状态分布</span>
<span class="text-xs leading-[1.15] font-emphasis text-cp-danger-text">不可用 {{ accountStatusCounts?.unavailable ?? '—' }}</span>
<span class="text-xs leading-[1.15] font-emphasis text-cp-text-secondary">状态分布</span>
<span class="text-xs leading-[1.15] font-emphasis text-cp-error-text">不可用 {{ accountStatusCounts?.unavailable ?? '—' }}</span>
</div>
<div class="mt-2.5 flex h-3 w-full overflow-hidden rounded-full bg-cp-muted">
<div class="mt-2.5 flex h-3 w-full overflow-hidden rounded-full bg-cp-fill-tertiary">
<i
v-for="(bar, bi) in statusBars"
:key="bi"
@@ -323,19 +323,19 @@ const statusBars = computed(() => {
<div
v-for="row in statusRows"
:key="row.label"
class="grid h-14.5 grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-cp-overlay bg-cp-subtle px-3.5"
class="grid h-14.5 grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-cp-lg bg-cp-bg-elevated px-3.5"
>
<span
class="inline-flex size-7 items-center justify-center rounded-cp-control"
class="inline-flex size-7 items-center justify-center rounded-cp"
:class="toneIconClass(row.tone)"
>
<component :is="row.icon" :size="16" />
</span>
<span class="col-start-3 grid gap-1">
<strong class="text-sm leading-[1.15] font-emphasis text-cp-primary">
<strong class="text-sm leading-[1.15] font-emphasis text-cp-text">
{{ row.label }}
</strong>
<span class="text-xs leading-[1.15] font-emphasis text-cp-secondary">{{ row.description }}</span>
<span class="text-xs leading-[1.15] font-emphasis text-cp-text-secondary">{{ row.description }}</span>
</span>
<strong
class="col-start-4 text-right font-mono text-[17px] leading-[1.15] font-heavy tabular-nums"
@@ -0,0 +1,106 @@
<script setup lang="ts">
import type { dashboardSnapshotView, dashboardTrendView } from '../composables/useDashboard'
import type { DashboardTrendKind } from '@/api/modules/dashboard'
import { RefreshCw } from '@lucide/vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BasePageHeader from '@/components/base/BasePageHeader.vue'
import AccountOverviewCard from './AccountOverviewCard.vue'
import DashboardHeartbeat from './DashboardHeartbeat.vue'
import MetricCard from './MetricCard.vue'
import RequestHealthTimelineCard from './RequestHealthTimelineCard.vue'
import RequestTrendCard from './RequestTrendCard.vue'
import UsageRecordCard from './UsageRecordCard.vue'
import WireProfileCard from './WireProfileCard.vue'
type DashboardSnapshotView = ReturnType<typeof dashboardSnapshotView>
type DashboardTrendView = ReturnType<typeof dashboardTrendView>
withDefaults(
defineProps<{
loading?: boolean
refreshing?: boolean
lastRefreshedAt?: string
metrics: DashboardSnapshotView['metrics']
trendPoints: DashboardTrendView['points']
trendSummary: DashboardTrendView['summary']
healthTimeline: DashboardSnapshotView['healthTimeline']
accountUsage: DashboardSnapshotView['accountUsage']
wireProfiles: DashboardSnapshotView['wireProfiles']
usageRecords: DashboardSnapshotView['usageRecords']
poolSummary: DashboardSnapshotView['poolSummary']
capacityInfo: DashboardSnapshotView['capacityInfo']
rotationStrategy: DashboardSnapshotView['rotationStrategy']
}>(),
{
loading: false,
refreshing: false,
lastRefreshedAt: '',
},
)
const emit = defineEmits<{
refresh: []
trendChange: [kind: DashboardTrendKind]
}>()
const trendKind = defineModel<DashboardTrendKind>('trendKind', { required: true })
</script>
<template>
<div class="w-full">
<BasePageHeader title="系统概览">
<template #description>
<span>当日统计</span>
<DashboardHeartbeat :updated-at="lastRefreshedAt" />
</template>
<template #actions>
<BaseIconButton
class="text-cp-status-normal"
size="md"
label="刷新概览"
:loading="loading || refreshing"
:disabled="loading || refreshing"
@click="emit('refresh')"
>
<template #loading>
<RefreshCw class="animate-spin motion-reduce:animate-none" :size="19" />
</template>
<RefreshCw :size="19" />
</BaseIconButton>
</template>
</BasePageHeader>
<section
class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-4 2xl:gap-6"
aria-label="核心指标"
>
<MetricCard v-for="metric in metrics" :key="metric.title" :metric="metric" />
</section>
<section
class="mt-6 grid grid-cols-1 gap-6 2xl:grid-cols-[minmax(0,948fr)_minmax(0,608fr)] 2xl:gap-7"
>
<RequestTrendCard
v-model:kind="trendKind"
:points="trendPoints"
:summary="trendSummary"
@trend-change="emit('trendChange', $event)"
/>
<WireProfileCard :profiles="wireProfiles" />
</section>
<AccountOverviewCard
:accounts="accountUsage"
:pool="poolSummary"
:capacity="capacityInfo"
:rotation-strategy="rotationStrategy"
class="mt-6"
/>
<RequestHealthTimelineCard :timeline="healthTimeline" class="mt-6" />
<UsageRecordCard :rows="usageRecords" class="mt-6" />
</div>
</template>
@@ -29,14 +29,14 @@ const outcomeMeta = computed(() => [
{
label: '服务失败',
value: props.point.failedRequests,
dotClass: 'bg-cp-danger',
valueClass: 'text-cp-danger-text',
dotClass: 'bg-cp-error',
valueClass: 'text-cp-error-text',
},
{
label: '客户端取消',
value: props.point.cancelledRequests,
dotClass: 'bg-cp-normal',
valueClass: 'text-cp-normal-text',
dotClass: 'bg-cp-status-normal',
valueClass: 'text-cp-status-normal-text',
},
{
label: '调用方错误',
@@ -65,12 +65,12 @@ const metricItems = outcomeMeta
<section
role="dialog"
:aria-label="`${point.time} 请求健康详情`"
class="overflow-hidden rounded-cp-overlay"
class="overflow-hidden rounded-cp-lg"
>
<header class="flex items-center justify-between gap-3 bg-cp-subtle px-3.5 py-3">
<header class="flex items-center justify-between gap-3 bg-cp-fill-quaternary px-3.5 py-3">
<div class="flex min-w-0 items-center gap-2">
<span class="size-2 shrink-0 rounded-full" :class="status.cellClass" />
<strong class="font-mono text-[13px] leading-none font-heavy text-cp-primary">
<strong class="font-mono text-cp leading-none font-heavy text-cp-text">
{{ point.time }}
</strong>
<span
@@ -80,7 +80,7 @@ const metricItems = outcomeMeta
{{ status.label }}
</span>
</div>
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-muted-text">
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-text-quaternary">
15 分钟
</span>
</header>
@@ -88,7 +88,7 @@ const metricItems = outcomeMeta
<div class="grid gap-3.5 px-3.5 py-3.5">
<div class="flex items-end justify-between gap-4">
<div>
<p class="m-0 text-[11px] leading-none font-emphasis text-cp-muted-text">
<p class="m-0 text-cp-xs leading-none font-emphasis text-cp-text-quaternary">
有效请求可用性
</p>
<strong
@@ -99,11 +99,11 @@ const metricItems = outcomeMeta
</strong>
</div>
<div class="text-right">
<span class="block text-[10px] leading-none font-emphasis text-cp-muted-text">
<span class="block text-[10px] leading-none font-emphasis text-cp-text-quaternary">
有效请求
</span>
<strong
class="mt-2 block font-mono text-[15px] leading-none font-heavy tabular-nums text-cp-primary"
class="mt-2 block font-mono text-cp-xl leading-none font-heavy tabular-nums text-cp-text"
>
{{ formatHealthCount(eligibleRequests) }}
</strong>
@@ -111,7 +111,7 @@ const metricItems = outcomeMeta
</div>
<div
class="flex h-1.5 w-full overflow-hidden rounded-full bg-cp-muted"
class="flex h-1.5 w-full overflow-hidden rounded-full bg-cp-fill-tertiary"
aria-hidden="true"
>
<span
@@ -127,14 +127,14 @@ const metricItems = outcomeMeta
<div
v-for="item in metricItems"
:key="item.label"
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-cp-control bg-cp-subtle px-2.5 py-2.5"
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-cp bg-cp-fill-quaternary px-2.5 py-2.5"
>
<span class="size-1.5 rounded-full" :class="item.dotClass" />
<span class="truncate text-[10px] leading-none font-emphasis text-cp-secondary">
<span class="truncate text-[10px] leading-none font-emphasis text-cp-text-secondary">
{{ item.label }}
</span>
<strong
class="font-mono text-[12px] leading-none font-heavy tabular-nums"
class="font-mono text-cp-sm leading-none font-heavy tabular-nums"
:class="item.valueClass"
>
{{ formatHealthCount(item.value) }}
@@ -143,7 +143,7 @@ const metricItems = outcomeMeta
</div>
<p
class="m-0 border-t border-cp-divider pt-3 text-[10px] leading-[1.45] font-emphasis text-cp-muted-text"
class="m-0 border-t border-cp-split pt-3 text-[10px] leading-[1.45] font-emphasis text-cp-text-quaternary"
>
客户端取消与调用方错误单独记录,不计入可用性。
</p>
@@ -15,11 +15,11 @@ const props = defineProps<{
}>()
const trendToneClasses: Record<MetricTone, string> = {
normal: 'bg-cp-normal-text',
normal: 'bg-cp-status-normal-text',
info: 'bg-cp-info-text',
success: 'bg-cp-success-text',
warning: 'bg-cp-warning-text',
danger: 'bg-cp-danger-text',
danger: 'bg-cp-error-text',
}
const sparklineColors: Record<MetricTone, string> = {
@@ -73,17 +73,17 @@ const sparklineOption = computed<EChartsOption | null>(() => {
<div class="flex items-start gap-3">
<BaseMotionIcon
aria-hidden="true"
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-cp-overlay"
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-cp-lg"
:class="metricToneIconClasses[metric.tone]"
>
<component :is="metric.icon" :size="18" />
</BaseMotionIcon>
<span class="mt-1 text-[13px] leading-[1.15] font-emphasis text-cp-secondary">{{ metric.title }}</span>
<span class="mt-1 text-cp leading-[1.15] font-emphasis text-cp-text-secondary">{{ metric.title }}</span>
</div>
<div class="mt-3.25 flex h-7.75 items-end gap-2">
<strong
class="font-mono text-[28px] leading-[1.05] font-heavy tabular-nums text-cp-primary"
class="font-mono text-[28px] leading-[1.05] font-heavy tabular-nums text-cp-text"
>
<AnimatedMetricValue
:value="metric.value"
@@ -108,12 +108,12 @@ const sparklineOption = computed<EChartsOption | null>(() => {
</div>
<div
class="mt-3 grid h-7.5 w-full grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center rounded-cp-overlay bg-cp-subtle px-3"
class="mt-3 grid h-7.5 w-full grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center rounded-cp-lg bg-cp-fill-quaternary px-3"
>
<span
class="inline-grid min-w-0 w-full grid-cols-[auto_minmax(0,1fr)] items-baseline gap-2.5"
>
<span class="shrink-0 text-[11px] leading-none font-emphasis text-cp-muted-text">{{ metric.details[0]?.label }}</span>
<span class="shrink-0 text-cp-xs leading-none font-emphasis text-cp-text-quaternary">{{ metric.details[0]?.label }}</span>
<b
class="min-w-0 truncate font-mono text-xs leading-none font-bold tabular-nums"
:class="metric.details[0]?.tone ? metricToneValueClasses[metric.details[0].tone] : undefined"
@@ -124,7 +124,7 @@ const sparklineOption = computed<EChartsOption | null>(() => {
<span
class="inline-grid min-w-0 w-full grid-cols-[auto_minmax(0,auto)] items-baseline justify-end gap-2.5"
>
<span class="shrink-0 text-[11px] leading-none font-emphasis text-cp-muted-text">{{ metric.details[1]?.label }}</span>
<span class="shrink-0 text-cp-xs leading-none font-emphasis text-cp-text-quaternary">{{ metric.details[1]?.label }}</span>
<b
class="min-w-0 truncate font-mono text-xs leading-none font-bold tabular-nums"
:class="metric.details[1]?.tone ? metricToneValueClasses[metric.details[1].tone] : undefined"
@@ -214,7 +214,7 @@ onBeforeUnmount(() => {
<template #actions>
<div class="flex w-full flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div
class="flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 text-[11px] leading-none font-emphasis text-cp-muted-text"
class="flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 text-cp-xs leading-none font-emphasis text-cp-text-quaternary"
>
<span
v-for="item in healthLegend"
@@ -277,11 +277,11 @@ onBeforeUnmount(() => {
<span
aria-hidden="true"
data-health-timeline-cell
class="block h-3.5 w-full origin-bottom rounded-xs transition-[filter,box-shadow] duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none group-focus-visible:shadow-[0_0_0_2px_var(--cp-bg-surface),0_0_0_4px_var(--cp-info-border)]"
class="block h-3.5 w-full origin-bottom rounded-xs transition-[filter,box-shadow] duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none group-focus-visible:shadow-[0_0_0_2px_var(--cp-color-bg-container),0_0_0_4px_var(--cp-control-outline)]"
:class="[
healthStatusMeta[point.status].cellClass,
isActivePoint(point)
? 'brightness-105 shadow-[0_5px_10px_-5px_var(--cp-shadow-sticky)]'
? 'brightness-105 shadow-[0_5px_10px_-5px_var(--cp-color-shadow)]'
: isInteractivePoint(point)
? 'group-hover:brightness-95'
: undefined,
@@ -291,7 +291,7 @@ onBeforeUnmount(() => {
</div>
</template>
<div class="w-72 overflow-hidden rounded-cp-overlay">
<div class="w-72 overflow-hidden rounded-cp-lg">
<HealthTimelinePointPopover v-if="activePoint" :point="activePoint" />
</div>
</BasePopover>
@@ -53,7 +53,7 @@ const {
<template #body>
<div class="grid gap-3.5">
<div
class="grid h-14.25 min-w-0 grid-cols-3 gap-1.5 rounded-xl bg-cp-subtle/45 p-1.5"
class="grid h-14.25 min-w-0 grid-cols-3 gap-1.5 rounded-xl bg-cp-fill-quaternary/45 p-1.5"
>
<button
v-for="item in props.summary"
@@ -61,7 +61,7 @@ const {
type="button"
:aria-label="`突出显示${item.label}曲线`"
:aria-pressed="pinnedSummaryLabel === item.label"
class="group grid min-w-0 grid-cols-[8px_minmax(0,1fr)] items-center gap-x-2 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
class="group grid min-w-0 grid-cols-[8px_minmax(0,1fr)] items-center gap-x-2 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
@click="toggleSummarySeries(item.label)"
>
<i
@@ -74,17 +74,17 @@ const {
'bg-cp-info': item.tone === 'info',
'bg-cp-success': item.tone === 'success',
'bg-cp-warning': item.tone === 'warning',
'bg-cp-danger': item.tone === 'danger',
'bg-cp-normal': item.tone === 'normal',
'bg-cp-error': item.tone === 'danger',
'bg-cp-status-normal': item.tone === 'normal',
},
]"
/>
<span class="grid min-w-0 gap-1">
<span class="truncate text-[10px] leading-none font-bold text-cp-secondary">
<span class="truncate text-[10px] leading-none font-bold text-cp-text-secondary">
{{ item.label }}
</span>
<strong
class="truncate font-mono text-[15px] leading-none font-heavy tabular-nums text-cp-primary"
class="truncate font-mono text-cp-xl leading-none font-heavy tabular-nums text-cp-text"
:title="item.value"
>
{{ item.value }}
@@ -103,14 +103,14 @@ const releaseStatus = computed(() => {
return {
label: '检查失败',
title: current.release.error || '官方版本检查失败',
tone: 'bg-cp-danger-bg text-cp-danger-text',
tone: 'bg-cp-error-bg text-cp-error-text',
icon: TriangleAlert,
}
}
return {
label: '待检查',
title: '尚未检查官方发布渠道',
tone: 'bg-cp-normal-bg text-cp-normal-text',
tone: 'bg-cp-status-normal-bg text-cp-status-normal-text',
icon: RefreshCw,
}
})
@@ -205,22 +205,22 @@ function providerLabel(provider: string) {
<div v-else class="flex flex-1">
<section
aria-label="请求身份组成"
class="grid min-w-0 flex-1 content-between gap-6 rounded-cp-overlay bg-cp-subtle px-5 py-5.5 sm:px-6 sm:py-5"
class="grid min-w-0 flex-1 content-between gap-6 rounded-cp-lg bg-cp-bg-elevated px-5 py-5.5 sm:px-6 sm:py-5"
:class="verifiedLabel || checkedLabel
? 'sm:grid-rows-[auto_minmax(0,1fr)_auto_auto]'
: 'sm:grid-rows-[auto_minmax(0,1fr)_auto]'"
>
<div class="flex min-w-0 items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2 text-cp-primary">
<div class="flex min-w-0 items-center gap-2 text-cp-text">
<span
class="inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-cp-muted"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-cp-fill-tertiary"
>
<Box aria-hidden="true" class="size-3.75 text-cp-secondary" />
<Box aria-hidden="true" class="size-3.75 text-cp-text-secondary" />
</span>
<span class="truncate text-[11px] leading-none font-heavy">{{ profile.product }}</span>
<span class="truncate text-cp-xs leading-none font-heavy">{{ profile.product }}</span>
</div>
<span
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-[12px] leading-none font-bold"
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-cp-sm leading-none font-bold"
:class="releaseStatus.tone"
:title="releaseStatus.title"
>
@@ -236,20 +236,20 @@ function providerLabel(provider: string) {
class="inline-flex max-w-full min-w-0 items-baseline gap-2 font-mono leading-none tabular-nums"
:title="profile.version"
>
<span class="wrap-break-word text-[27px] leading-[1.05] font-heavy text-cp-primary">
<span class="wrap-break-word text-[27px] leading-[1.05] font-heavy text-cp-text">
{{ versionParts.release }}
</span>
<span
v-if="versionParts.prerelease"
aria-hidden="true"
class="truncate text-[12px] font-bold text-cp-secondary"
class="truncate text-cp-sm font-bold text-cp-text-secondary"
>
{{ versionParts.prerelease }}
</span>
</strong>
<span
v-if="profile.build"
class="shrink-0 font-mono text-[10px] leading-none font-emphasis tabular-nums text-cp-muted-text"
class="shrink-0 font-mono text-[10px] leading-none font-emphasis tabular-nums text-cp-text-quaternary"
>
Build {{ profile.build }}
</span>
@@ -264,13 +264,13 @@ function providerLabel(provider: string) {
>
<div v-if="profile.provider === 'xai'" class="min-w-0">
<dt
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-muted-text"
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-text-quaternary"
>
<ShieldCheck aria-hidden="true" class="size-3.25 text-cp-info" />
认证协议
</dt>
<dd
class="mt-2 mb-0 truncate font-mono text-[14px] leading-none font-bold tabular-nums text-cp-primary"
class="mt-2 mb-0 truncate font-mono text-cp-lg leading-none font-bold tabular-nums text-cp-text"
:title="authProtocol"
>
{{ authProtocol }}
@@ -279,19 +279,19 @@ function providerLabel(provider: string) {
<div class="min-w-0">
<dt
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-muted-text"
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-text-quaternary"
>
<Monitor aria-hidden="true" class="size-3.25 text-cp-normal" />
<Monitor aria-hidden="true" class="size-3.25 text-cp-status-normal" />
{{ profile.provider === 'openai' ? '模拟运行环境' : '运行环境' }}
</dt>
<dd
class="mt-2 mb-0 truncate font-mono text-[14px] leading-none font-bold tabular-nums text-cp-primary"
class="mt-2 mb-0 truncate font-mono text-cp-lg leading-none font-bold tabular-nums text-cp-text"
:title="runtimeEnvironment.title"
>
{{ runtimeEnvironment.primary }}
<span
v-if="runtimeEnvironment.details.length"
class="text-[11px] font-emphasis text-cp-secondary"
class="text-cp-xs font-emphasis text-cp-text-secondary"
>
<template v-for="detail in runtimeEnvironment.details" :key="detail">
· {{ detail }}
@@ -302,13 +302,13 @@ function providerLabel(provider: string) {
<div class="min-w-0">
<dt
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-muted-text"
class="flex items-center gap-1.5 text-[10px] leading-none font-bold text-cp-text-quaternary"
>
<Terminal aria-hidden="true" class="size-3.25 text-cp-info" />
客户端标识
</dt>
<dd
class="mt-2 mb-0 truncate font-mono text-[16px] leading-none font-heavy tabular-nums text-cp-primary"
class="mt-2 mb-0 truncate font-mono text-[16px] leading-none font-heavy tabular-nums text-cp-text"
:title="clientIdentity"
>
{{ clientIdentity }}
@@ -318,7 +318,7 @@ function providerLabel(provider: string) {
<footer
v-if="verifiedLabel || checkedLabel"
class="flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-1 text-[10px] leading-none font-emphasis text-cp-muted-text"
class="flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-1 text-[10px] leading-none font-emphasis text-cp-text-quaternary"
>
<span v-if="verifiedLabel" :title="profile.userAgent">{{ verifiedLabel }}</span>
<span v-if="checkedLabel">{{ checkedLabel }}</span>

Some files were not shown because too many files have changed in this diff Show More