refactor: polish admin dashboard ui

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-06-25 18:09:25 +08:00
co-authored by Codex
parent 0878a377c8
commit 081215c5c9
29 changed files with 784 additions and 669 deletions
+101 -1
View File
@@ -27,12 +27,14 @@ use crate::{
response::{AdminEnvelope, AdminError, AdminResponse},
},
http::middleware::request_id::RequestId,
infra::time::{china_datetime_rfc3339_str, china_relative_time, china_time},
infra::time::{china_datetime, china_datetime_rfc3339_str, china_relative_time, china_time},
runtime::state::AppState,
upstream::accounts::model::{Account, AccountStatus},
};
const DASHBOARD_LOG_LIMIT: u32 = 1000;
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
const HEALTH_TIMELINE_SLOTS: i64 = 7 * 24 * 4;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -54,6 +56,7 @@ pub enum DashboardTrendKind {
pub struct DashboardSummaryData {
pub cards: DashboardCardsData,
pub trend: DashboardTrendData,
pub health_timeline: DashboardHealthTimelineData,
pub account_usage: Vec<DashboardAccountUsageData>,
pub service_statuses: Vec<DashboardServiceStatusData>,
pub event_logs: Vec<DashboardEventLogData>,
@@ -62,6 +65,18 @@ pub struct DashboardSummaryData {
pub rotation_strategy: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardHealthTimelineData {
pub title: String,
pub description: String,
pub range_display: String,
pub reliability_display: String,
pub oldest_label: String,
pub newest_label: String,
pub points: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardCardsData {
@@ -276,6 +291,7 @@ pub async fn dashboard_summary(
settings.model.service_tier.as_deref(),
),
trend,
health_timeline: dashboard_health_timeline_data(&logs),
account_usage: account_usage_data(
&accounts,
&usage_records,
@@ -447,6 +463,82 @@ fn dashboard_trend_data(logs: &[EventLog], kind: DashboardTrendKind) -> Dashboar
}
}
fn dashboard_health_timeline_data(logs: &[EventLog]) -> DashboardHealthTimelineData {
let now = Utc::now();
let current_slot = quarter_hour_start(now);
let start = current_slot
- Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (HEALTH_TIMELINE_SLOTS - 1));
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
.map(|index| {
let bucket_start = start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index);
(bucket_start, UsageWindow::default())
})
.collect::<Vec<_>>();
for log in logs {
if log.created_at < start || log.created_at > now {
continue;
}
let log_slot = quarter_hour_start(log.created_at);
if let Some((_, bucket)) = buckets
.iter_mut()
.find(|(bucket_start, _)| *bucket_start == log_slot)
{
apply_log(bucket, log);
}
}
let requests = buckets
.iter()
.map(|(_, bucket)| bucket.requests)
.sum::<u64>();
let errors = buckets.iter().map(|(_, bucket)| bucket.errors).sum::<u64>();
let reliability = if requests > 0 {
((requests - errors) as f64 / requests as f64 * 1000.0).round() / 10.0
} else {
0.0
};
let end = current_slot + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES);
DashboardHealthTimelineData {
title: "请求健康时间线".to_string(),
description: "最近 7 天请求可靠性".to_string(),
range_display: format!("{} - {}", china_datetime(&start), china_datetime(&end)),
reliability_display: if requests > 0 {
format!("{reliability:.1}%")
} else {
"-".to_string()
},
oldest_label: "最早".to_string(),
newest_label: "最新".to_string(),
points: buckets
.into_iter()
.map(|(_, bucket)| {
let success_rate = if bucket.requests > 0 {
((bucket.requests - bucket.errors) as f64 / bucket.requests as f64 * 1000.0)
.round()
/ 10.0
} else {
0.0
};
health_tone(bucket, success_rate)
})
.collect(),
}
}
fn health_tone(bucket: UsageWindow, success_rate: f64) -> char {
if bucket.requests == 0 {
'0'
} else if bucket.errors == 0 {
'1'
} else if success_rate >= 90.0 {
'2'
} else {
'3'
}
}
fn trend_summary(
kind: DashboardTrendKind,
points: &[DashboardTrendPointData],
@@ -911,3 +1003,11 @@ fn hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
.expect("valid hour")
.and_utc()
}
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
value
.date_naive()
.and_hms_opt(value.hour(), value.minute() / 15 * 15, 0)
.expect("valid quarter hour")
.and_utc()
}
+24
View File
@@ -213,6 +213,30 @@ async fn dashboard_summary_should_return_backend_formatted_time_fields() {
assert_eq!(body["data"]["accountUsage"][0]["lastUsed"], "2000-01-01");
}
#[tokio::test]
async fn dashboard_summary_should_return_seven_day_health_timeline() {
let (app, store, _pool, _dir) = dashboard_test_app(
"dashboard-health-timeline.sqlite",
crate::support::fingerprint::test_fingerprint(),
)
.await;
store
.append(&usage_log_with_tokens(Utc::now(), 10))
.await
.unwrap();
store
.append(&usage_log_with_tokens(Utc::now() - Duration::days(8), 10))
.await
.unwrap();
let body = dashboard_summary(app).await;
let points = body["data"]["healthTimeline"]["points"].as_str().unwrap();
assert_eq!(body["data"]["healthTimeline"]["title"], "请求健康时间线");
assert_eq!(points.len(), 672);
assert_eq!(points.matches('1').count(), 1);
}
async fn dashboard_summary_total_cost_for_log(db_name: &str, model: &str, metadata: Value) -> f64 {
let (app, store, _pool, _dir) =
dashboard_test_app(db_name, crate::support::fingerprint::test_fingerprint()).await;
+11
View File
@@ -5,6 +5,7 @@ export type DashboardTrendKind = 'usage' | 'latency' | 'errors'
export interface DashboardSummary {
cards: DashboardCards
trend: DashboardTrend
healthTimeline: DashboardHealthTimeline
accountUsage: DashboardAccountUsage[]
serviceStatuses: DashboardServiceStatus[]
eventLogs: DashboardEventLog[]
@@ -13,6 +14,16 @@ export interface DashboardSummary {
rotationStrategy?: string
}
export interface DashboardHealthTimeline {
title: string
description: string
rangeDisplay: string
reliabilityDisplay: string
oldestLabel: string
newestLabel: string
points: string
}
export interface DashboardCards {
accounts: DashboardAccountsCard
traffic: DashboardTrafficCard
-37
View File
@@ -1,37 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
type BadgeVariant = 'normal' | 'success' | 'warning' | 'danger' | 'info' | 'default'
const props = withDefaults(
defineProps<{
variant?: BadgeVariant
dot?: boolean
}>(),
{
variant: 'default',
dot: false,
},
)
const variantClasses: Record<BadgeVariant, string> = {
default: 'bg-(--cp-default-bg) text-(--cp-text-secondary)',
normal: 'bg-(--cp-normal-bg) text-(--cp-normal-text)',
success: 'bg-(--cp-success-bg) text-(--cp-success-text)',
warning: 'bg-(--cp-warning-bg) text-(--cp-warning-text)',
danger: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
info: 'bg-(--cp-info-bg) text-(--cp-info-text)',
}
const badgeClasses = computed(() => [
'inline-flex min-h-[26px] items-center justify-center gap-[7px] whitespace-nowrap rounded-(--cp-tag-radius) px-2.5 text-xs font-semibold leading-none',
variantClasses[props.variant],
])
</script>
<template>
<span :class="badgeClasses">
<span v-if="dot" class="h-1.5 w-1.5 rounded-full bg-current" />
<slot />
</span>
</template>
+3 -25
View File
@@ -2,41 +2,23 @@
import { LoaderCircle } from '@lucide/vue'
import { computed } from 'vue'
type ButtonVariant =
| 'default'
| 'primary'
| 'success'
| 'warning'
| 'danger'
| 'plain'
| 'text'
| 'ghost'
type ButtonSize = 'small' | 'default' | 'large' | 'md'
type ButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'ghost'
const props = withDefaults(
defineProps<{
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
}>(),
{
variant: 'primary',
size: 'default',
loading: false,
disabled: false,
type: 'button',
},
)
const sizeClasses = {
large: 'h-10 px-4 gap-2 text-xs rounded-(--cp-button-radius-base)',
default: 'h-8 px-3 gap-2 text-xs rounded-(--cp-button-radius-base)',
md: 'h-8 px-3 gap-2 text-xs rounded-(--cp-button-radius-base)',
small: 'h-6 px-2 gap-1.5 text-[11px] rounded-(--cp-button-radius-small)',
}
const variantClasses = {
default:
'bg-(--cp-bg-subtle) text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover) active:bg-(--cp-default-bg-active)',
@@ -48,25 +30,21 @@ const variantClasses = {
'bg-(--cp-warning-bg) text-(--cp-warning-text) hover:bg-(--cp-warning-bg-hover) active:bg-(--cp-warning-bg-active)',
danger:
'bg-(--cp-danger-bg) text-(--cp-danger-text) hover:bg-(--cp-danger-bg-hover) active:bg-(--cp-danger-bg-active)',
plain:
'bg-(--cp-info-bg) text-(--cp-info-text) hover:bg-(--cp-info-bg-hover) active:bg-(--cp-info-bg-active)',
text: 'bg-transparent text-(--cp-info-text) hover:bg-transparent p-0 h-auto',
ghost:
'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) active:bg-(--cp-bg-muted)',
}
const classes = computed(() => [
'inline-flex items-center justify-center border-0 font-[720] leading-[1.15] transition-all cursor-pointer outline-none',
'inline-flex h-8 items-center justify-center gap-2 rounded-(--cp-button-radius-base) border-0 px-3 text-xs font-[720] leading-[1.15] transition-all cursor-pointer outline-none',
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
'disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
sizeClasses[props.size],
variantClasses[props.variant],
])
</script>
<template>
<button :type="type" :class="classes" :disabled="disabled || loading">
<LoaderCircle v-if="loading" class="animate-spin" :size="size === 'small' ? 12 : 14" />
<LoaderCircle v-if="loading" class="animate-spin" :size="14" />
<span v-if="$slots.icon && !loading" class="inline-flex shrink-0">
<slot name="icon" />
</span>
-26
View File
@@ -2,16 +2,12 @@
const props = withDefaults(
defineProps<{
as?: keyof HTMLElementTagNameMap
title?: string
description?: string
padded?: boolean
radiusClass?: string
shadowClass?: string
}>(),
{
as: 'section',
title: undefined,
description: undefined,
padded: true,
radiusClass: 'rounded-(--cp-card-radius)',
shadowClass: 'shadow-(--cp-shadow-card)',
@@ -25,28 +21,6 @@ const props = withDefaults(
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden bg-(--cp-bg-surface)"
:class="[props.radiusClass, props.shadowClass, props.padded ? 'p-6 md:px-7' : undefined]"
>
<header
v-if="props.title || props.description || $slots.actions"
class="mb-6 flex items-start justify-between gap-5"
>
<div class="min-w-0">
<h2
v-if="props.title"
class="m-0 text-xl font-[760] leading-[1.15] text-(--cp-text-primary)"
>
{{ props.title }}
</h2>
<p
v-if="props.description"
class="mt-1.75 mb-0 text-[13px] font-semibold leading-[1.15] text-(--cp-text-secondary)"
>
{{ props.description }}
</p>
</div>
<div v-if="$slots.actions" class="shrink-0">
<slot name="actions" />
</div>
</header>
<slot />
</component>
</template>
+6 -23
View File
@@ -2,44 +2,30 @@
import { Check, Minus } from '@lucide/vue'
import { computed } from 'vue'
type CheckboxSize = 'default' | 'table'
const props = withDefaults(
defineProps<{
indeterminate?: boolean
disabled?: boolean
label?: string
showLabel?: boolean
size?: CheckboxSize
}>(),
{
indeterminate: false,
disabled: false,
label: undefined,
showLabel: false,
size: 'default',
},
)
const model = defineModel<boolean>({ default: false })
const checked = computed(() => model.value || props.indeterminate)
const boxSizeClass = computed(() => (props.size === 'table' ? 'size-4' : 'size-[18px]'))
const rootSizeClass = computed(() =>
props.size === 'table' ? 'min-h-4 min-w-4' : 'min-h-[18px] min-w-[18px]',
)
const iconSizeClass = computed(() => (props.size === 'table' ? 'size-3' : 'size-[13px]'))
const boxClasses = computed(() => [
'relative inline-flex shrink-0 items-center justify-center rounded-(--cp-checkbox-radius) border-0 transition-[background-color,box-shadow,color] duration-150',
boxSizeClass.value,
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-(--cp-checkbox-radius) border-0 transition-[background-color,box-shadow,color] duration-150',
props.disabled
? 'bg-(--cp-disabled-bg) text-(--cp-disabled-icon)'
: checked.value
? 'bg-(--cp-info-bg) text-(--cp-info)'
: props.size === 'table'
? 'bg-(--cp-bg-surface) text-transparent shadow-[inset_0_0_0_1px_#CBD5E1]'
: 'bg-(--cp-bg-surface) text-transparent shadow-(--cp-shadow-control)',
: 'bg-(--cp-bg-surface) text-transparent shadow-[inset_0_0_0_1px_#CBD5E1]',
])
function toggle() {
@@ -54,8 +40,8 @@ function toggle() {
<template>
<button
type="button"
class="group inline-flex appearance-none items-center gap-2.5 border-0 bg-transparent p-0 text-[13px] font-[650] leading-none outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
:class="[rootSizeClass, disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer']"
class="group inline-flex min-h-4 min-w-4 appearance-none items-center gap-2.5 border-0 bg-transparent p-0 text-[13px] leading-none font-[650] transition-opacity outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
role="checkbox"
:aria-checked="indeterminate ? 'mixed' : model"
:aria-label="label"
@@ -65,15 +51,12 @@ function toggle() {
<span :class="boxClasses">
<Minus
class="absolute transition-opacity duration-150"
:class="[iconSizeClass, indeterminate ? 'opacity-100' : 'opacity-0']"
:class="['size-3', indeterminate ? 'opacity-100' : 'opacity-0']"
/>
<Check
class="absolute transition-opacity duration-150"
:class="[iconSizeClass, !indeterminate && model ? 'opacity-100' : 'opacity-0']"
:class="['size-3', !indeterminate && model ? 'opacity-100' : 'opacity-0']"
/>
</span>
<span v-if="$slots.default || (label && showLabel)" class="text-(--cp-text-primary)">
<slot>{{ label }}</slot>
</span>
</button>
</template>
+6 -2
View File
@@ -9,6 +9,7 @@ const props = defineProps<{
description?: string
icon?: Component
compact?: boolean
plain?: boolean
}>()
const resolvedTitle = computed(() => props.title ?? props.message ?? '暂无数据')
@@ -17,8 +18,11 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)
<template>
<div
class="grid justify-items-center rounded-[14px] bg-(--cp-bg-subtle) text-center"
:class="compact ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8'"
class="grid justify-items-center text-center"
:class="[
plain ? 'bg-transparent' : 'rounded-[14px] bg-(--cp-bg-subtle)',
compact ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
]"
>
<span
class="inline-flex items-center justify-center rounded-[10px] bg-(--cp-bg-muted) text-(--cp-text-muted)"
+2 -2
View File
@@ -5,7 +5,7 @@ const props = withDefaults(
defineProps<{
label?: string
title?: string
size?: 'default' | 'large' | 'sm' | 'md'
size?: 'default' | 'sm' | 'md'
variant?: 'default' | 'ghost'
active?: boolean
disabled?: boolean
@@ -21,7 +21,7 @@ const props = withDefaults(
)
const sizeClasses = computed(() => {
if (props.size === 'large' || props.size === 'md') return 'h-11 w-11 rounded-xl'
if (props.size === 'md') return 'h-11 w-11 rounded-xl'
if (props.size === 'sm') return 'h-8 w-8 rounded-(--cp-icon-button-radius)'
return 'h-9 w-9 rounded-xl'
})
+2 -14
View File
@@ -1,15 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
type InputSize = 'small' | 'default' | 'large'
const props = withDefaults(
defineProps<{
placeholder?: string
type?: string
disabled?: boolean
error?: string
size?: InputSize
autocomplete?: string
}>(),
{
@@ -17,23 +14,14 @@ const props = withDefaults(
type: 'text',
disabled: false,
error: undefined,
size: 'default',
autocomplete: undefined,
},
)
const model = defineModel<string>({ default: '' })
const sizeClasses = {
large: 'h-(--cp-input-height-large) px-4 gap-2.5 text-sm rounded-(--cp-input-radius-base)',
default:
'h-(--cp-input-height-default) px-3.5 gap-2.5 text-[13px] rounded-(--cp-input-radius-base)',
small: 'h-(--cp-input-height-compact) px-2.5 gap-1.5 text-xs rounded-(--cp-input-radius-small)',
}
const containerClasses = computed(() => [
'relative inline-flex w-full min-w-0 items-center overflow-visible border-0 text-(--cp-text-primary) shadow-(--cp-shadow-input) transition-[background-color,box-shadow,color] duration-[160ms]',
sizeClasses[props.size],
'relative inline-flex h-(--cp-input-height-default) w-full min-w-0 items-center gap-2.5 overflow-visible rounded-(--cp-input-radius-base) border-0 px-3.5 text-[13px] text-(--cp-text-primary) shadow-(--cp-shadow-input) transition-[background-color,box-shadow,color] duration-[160ms]',
props.disabled
? 'cursor-not-allowed bg-(--cp-disabled-bg) text-(--cp-disabled-text) shadow-none'
: props.error
@@ -66,7 +54,7 @@ const inputClasses = computed(() => [
</script>
<template>
<label class="grid box-content gap-2 overflow-visible p-[3px]">
<label class="grid box-content gap-2 overflow-visible p-0.75">
<span :class="containerClasses">
<span v-if="$slots.prefix" :class="iconClasses">
<slot name="prefix" />
+13 -46
View File
@@ -3,20 +3,14 @@ import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplate
const props = withDefaults(
defineProps<{
tag?: string
always?: boolean
minThumbSize?: number
wrapClass?: string
viewClass?: string
maxHeight?: string
forceVisible?: boolean
}>(),
{
tag: 'div',
always: false,
minThumbSize: 32,
wrapClass: '',
viewClass: '',
maxHeight: undefined,
forceVisible: false,
},
)
@@ -32,12 +26,11 @@ const visible = shallowRef(false)
const dragging = shallowRef(false)
let resizeObserver: ResizeObserver | undefined
let hideTimer: number | undefined
let dragStartY = 0
let dragStartScrollTop = 0
const canScrollY = computed(() => thumbHeight.value > 0)
const scrollbarVisible = computed(() => props.always || dragging.value || visible.value)
const scrollbarVisible = computed(() => props.forceVisible || dragging.value || visible.value)
const thumbStyle = computed(() => ({
height: `${thumbHeight.value}px`,
transform: `translateY(${thumbTop.value}px)`,
@@ -61,13 +54,11 @@ function maxThumbTop(wrap: HTMLElement) {
function showScrollbar() {
visible.value = true
if (hideTimer) {
window.clearTimeout(hideTimer)
}
if (!props.always && !dragging.value) {
hideTimer = window.setTimeout(() => {
visible.value = false
}, 900)
}
function hideScrollbar() {
if (!dragging.value) {
visible.value = false
}
}
@@ -86,10 +77,7 @@ function update() {
}
const ratio = wrap.clientHeight / wrap.scrollHeight
thumbHeight.value = Math.min(
availableTrackHeight,
Math.max(availableTrackHeight * ratio, props.minThumbSize),
)
thumbHeight.value = Math.min(availableTrackHeight, Math.max(availableTrackHeight * ratio, 32))
thumbTop.value = (wrap.scrollTop / scrollRange) * maxThumbTop(wrap)
}
@@ -163,22 +151,6 @@ function handleThumbPointerUp() {
showScrollbar()
}
function scrollTo(options: ScrollToOptions): void
function scrollTo(x: number, y?: number): void
function scrollTo(arg1: ScrollToOptions | number, arg2?: number) {
if (typeof arg1 === 'number') {
wrapRef.value?.scrollTo(arg1, arg2 ?? 0)
return
}
wrapRef.value?.scrollTo(arg1)
}
function setScrollTop(value: number) {
if (wrapRef.value) {
wrapRef.value.scrollTop = value
}
}
onMounted(async () => {
await nextTick()
update()
@@ -193,16 +165,11 @@ onMounted(async () => {
onBeforeUnmount(() => {
resizeObserver?.disconnect()
if (hideTimer) {
window.clearTimeout(hideTimer)
}
document.removeEventListener('pointermove', handleThumbPointerMove)
})
defineExpose({
update,
scrollTo,
setScrollTop,
wrapRef,
})
</script>
@@ -211,14 +178,14 @@ defineExpose({
<div :class="rootClasses" :style="{ maxHeight }">
<div
ref="wrap"
class="h-full min-h-0 overflow-auto [max-height:inherit] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar]:bg-transparent"
:class="wrapClass"
class="h-full min-h-0 overflow-auto max-h-[inherit] [-ms-overflow-style:none] scrollbar-none [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar]:bg-transparent"
@mouseenter="showScrollbar"
@mouseleave="hideScrollbar"
@scroll="handleScroll"
>
<component :is="tag" ref="view" :class="viewClass">
<div ref="view" :class="viewClass">
<slot />
</component>
</div>
</div>
<div
+4 -4
View File
@@ -12,15 +12,15 @@ const model = defineModel<string>({ required: true })
</script>
<template>
<div class="inline-flex h-9 gap-1 rounded-xl bg-(--cp-bg-muted) p-1" role="tablist">
<div class="inline-flex h-9 items-center gap-1 rounded-xl bg-(--cp-bg-muted) p-1" role="tablist">
<button
v-for="option in options"
:key="option.value"
class="min-w-18.5 rounded-[9px] border-0 bg-transparent px-3 text-xs font-[650] leading-none text-(--cp-text-secondary) transition-colors outline-none hover:text-(--cp-text-primary) focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
class="h-7 min-w-0 flex-1 rounded-[9px] border-0 px-3 text-xs leading-none font-[650] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
:class="
model === option.value
? 'bg-(--cp-bg-surface) text-(--cp-text-primary) shadow-(--cp-shadow-control)'
: ''
? 'bg-white text-(--cp-text-primary) shadow-(--cp-shadow-control)'
: 'bg-transparent text-(--cp-text-secondary) hover:text-(--cp-text-primary)'
"
type="button"
role="tab"
+3 -13
View File
@@ -9,7 +9,7 @@ interface SelectOption {
disabled?: boolean
}
type SelectSize = 'small' | 'default' | 'large' | 'pagination'
type SelectSize = 'default' | 'pagination'
defineOptions({
inheritAttrs: false,
@@ -50,22 +50,12 @@ const sizeConfig: Record<
icon: number
}
> = {
large: {
trigger: 'h-(--cp-input-height-large) px-4 pr-10 text-sm rounded-(--cp-input-radius-base)',
option: 'h-9 px-3 text-sm',
icon: 16,
},
default: {
trigger:
'h-(--cp-input-height-default) px-3.5 pr-9 text-[13px] rounded-(--cp-input-radius-base)',
option: 'h-8.5 px-3 text-[13px]',
icon: 16,
},
small: {
trigger: 'h-(--cp-input-height-compact) px-2.5 pr-8 text-xs rounded-(--cp-input-radius-small)',
option: 'h-7 px-2 text-xs',
icon: 14,
},
pagination: {
trigger: 'h-8 px-2.5 pr-7 text-xs rounded-lg',
option: 'h-8 px-2.5 text-xs',
@@ -277,7 +267,7 @@ onBeforeUnmount(() => {
<template>
<div
ref="rootRef"
class="relative inline-block box-content overflow-visible p-[3px] text-left"
class="relative inline-block box-content overflow-visible p-0.75 text-left"
v-bind="attrs"
>
<button
@@ -344,7 +334,7 @@ onBeforeUnmount(() => {
<Check
v-if="option.value === model"
class="shrink-0 text-(--cp-info)"
:size="size === 'small' || size === 'pagination' ? 13 : 15"
:size="size === 'pagination' ? 13 : 15"
/>
</button>
</template>
-17
View File
@@ -1,17 +0,0 @@
<script setup lang="ts">
import { LoaderCircle } from '@lucide/vue'
withDefaults(defineProps<{ label?: string }>(), {
label: '加载中',
})
</script>
<template>
<span
class="inline-flex items-center gap-2.5 text-[13px] font-semibold text-(--cp-text-secondary)"
role="status"
>
<LoaderCircle class="animate-spin text-(--cp-info)" :size="16" />
<span>{{ label }}</span>
</span>
</template>
+281 -64
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { computed } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef } from 'vue'
import BaseEmpty from './BaseEmpty.vue'
import BaseScrollbar from './BaseScrollbar.vue'
@@ -13,6 +13,7 @@ export interface BaseTableColumn {
minWidth?: number | string
maxWidth?: number | string
flex?: number
fixed?: 'left'
align?: 'left' | 'right' | 'center'
headerClass?: string
cellClass?: string
@@ -39,6 +40,7 @@ const props = withDefaults(
loading?: boolean
emptyText?: string
maxHeight?: string
minWidth?: number | string
pagination?: BaseTablePagination
expandedRowKeys?: Array<string | number>
}>(),
@@ -50,6 +52,7 @@ const props = withDefaults(
loading: false,
emptyText: '暂无数据',
maxHeight: undefined,
minWidth: undefined,
},
)
@@ -58,9 +61,8 @@ const emit = defineEmits<{
'page-size-change': [pageSize: number]
}>()
const scrollbarGutterWidth = '16px'
const headerRowClass = 'h-10 text-[11px] font-bold text-(--cp-text-muted)'
const bodyRowClass = 'h-13 transition-colors hover:bg-(--cp-default-bg-hover)'
const bodyRowClass = 'h-13'
const headerCellClass =
'min-w-0 bg-(--cp-bg-subtle) px-4 first:pl-3 shadow-[0_10px_16px_-18px_#0e172638]'
const bodyCellClass =
@@ -70,17 +72,24 @@ const computedColumns = computed(() => {
const fixedPercentTotal = props.columns.reduce((sum, column) => {
return column.width === undefined ? sum : sum + numericPercentWidth(column.width)
}, 0)
const fixedPixelTotal = props.columns.reduce((sum, column) => {
return column.width === undefined ? sum : sum + numericPixelWidth(column.width)
}, 0)
const minWidthPixels = props.minWidth === undefined ? 0 : numericPixelWidth(props.minWidth)
const flexibleColumns = props.columns.filter((column) => column.width === undefined)
const flexTotal = flexibleColumns.reduce((sum, column) => sum + (column.flex ?? 1), 0)
const available = Math.max(100 - fixedPercentTotal, 0)
const availablePixels = Math.max(minWidthPixels - fixedPixelTotal, 0)
return props.columns.map((column) => {
const flex = column.flex ?? 1
const width =
column.width === undefined
? flexTotal > 0
? `${(available * flex) / flexTotal}%`
: `${available / Math.max(flexibleColumns.length, 1)}%`
? minWidthPixels > 0 && fixedPercentTotal === 0
? `${availablePixels / Math.max(flexibleColumns.length, 1)}px`
: flexTotal > 0
? `${(available * flex) / flexTotal}%`
: `${available / Math.max(flexibleColumns.length, 1)}%`
: normalizeWidth(column.width)
return {
@@ -129,6 +138,25 @@ const pageSizeModel = computed({
},
})
const headerWrapRef = useTemplateRef<HTMLDivElement>('headerWrap')
const bodyScrollbarRef = useTemplateRef<InstanceType<typeof BaseScrollbar>>('bodyScrollbar')
const tableViewRef = useTemplateRef<HTMLTableElement>('tableView')
const horizontalThumbWidth = shallowRef(0)
const horizontalThumbLeft = shallowRef(0)
const horizontalHovering = shallowRef(false)
const horizontalDragging = shallowRef(false)
const horizontalScrolled = shallowRef(false)
let resizeObserver: ResizeObserver | undefined
let horizontalDragStartX = 0
let horizontalDragStartScrollLeft = 0
const canScrollX = computed(() => horizontalThumbWidth.value > 0)
const horizontalThumbStyle = computed(() => ({
width: `${horizontalThumbWidth.value}px`,
transform: `translateX(${horizontalThumbLeft.value}px)`,
}))
const pagerItems = computed<PagerItem[]>(() => {
const total = totalPages.value
const current = currentPage.value
@@ -178,6 +206,19 @@ function numericPercentWidth(width: number | string) {
return 0
}
function numericPixelWidth(width: number | string) {
if (typeof width === 'number') {
return width
}
if (width.endsWith('px')) {
const parsed = Number.parseFloat(width)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
}
function columnStyle(column: (typeof computedColumns.value)[number]) {
return {
width: column.resolvedWidth,
@@ -186,6 +227,41 @@ function columnStyle(column: (typeof computedColumns.value)[number]) {
}
}
function tableStyle() {
if (props.minWidth === undefined) {
return undefined
}
return { width: `max(100%, ${normalizeWidth(props.minWidth)})` }
}
function fixedHeaderClass(column: BaseTableColumn) {
if (column.fixed !== 'left') {
return undefined
}
return [
'sticky left-0 z-30 bg-(--cp-bg-subtle)',
horizontalScrolled.value ? 'shadow-[8px_0_14px_-14px_rgba(15,23,42,0.38)]' : undefined,
]
}
function fixedBodyClass(column: BaseTableColumn, row: TableRow, index: number) {
if (column.fixed !== 'left') {
return undefined
}
return [
'sticky left-0 z-20',
horizontalScrolled.value ? 'shadow-[8px_0_14px_-14px_rgba(15,23,42,0.38)]' : undefined,
isRowSelected(row, index)
? 'bg-(--cp-bg-tertiary)'
: props.stripe && index % 2 === 1
? 'bg-(--cp-bg-subtle)'
: 'bg-(--cp-bg-surface)',
]
}
function cellValue(row: TableRow, key: string) {
return row[key]
}
@@ -222,11 +298,133 @@ function alignClass(column: BaseTableColumn) {
function rowClass(row: TableRow, index: number) {
return [
bodyRowClass,
'hover:[&>td]:bg-(--cp-default-bg-hover)',
props.stripe && index % 2 === 1 ? 'bg-(--cp-bg-subtle)' : undefined,
isRowSelected(row, index) ? 'bg-(--cp-bg-tertiary)' : undefined,
]
}
function horizontalTrackWidth(wrap: HTMLElement) {
return Math.max(wrap.clientWidth - 8, 0)
}
function maxScrollLeft(wrap: HTMLElement) {
return Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
}
function maxHorizontalThumbLeft(wrap: HTMLElement) {
return Math.max(horizontalTrackWidth(wrap) - horizontalThumbWidth.value, 0)
}
function scrollWrap() {
return bodyScrollbarRef.value?.wrapRef ?? null
}
function updateHorizontalScrollbar() {
const wrap = scrollWrap()
if (!wrap) {
return
}
const scrollRange = maxScrollLeft(wrap)
const trackWidth = horizontalTrackWidth(wrap)
if (scrollRange <= 0 || trackWidth <= 0) {
horizontalThumbWidth.value = 0
horizontalThumbLeft.value = 0
horizontalScrolled.value = false
return
}
horizontalThumbWidth.value = Math.min(
trackWidth,
Math.max(trackWidth * (wrap.clientWidth / wrap.scrollWidth), 32),
)
horizontalThumbLeft.value = (wrap.scrollLeft / scrollRange) * maxHorizontalThumbLeft(wrap)
horizontalScrolled.value = wrap.scrollLeft > 0
}
function handleTableScroll() {
const wrap = scrollWrap()
if (wrap && headerWrapRef.value) {
headerWrapRef.value.scrollLeft = wrap.scrollLeft
}
updateHorizontalScrollbar()
}
function handleHorizontalTrackPointerDown(event: PointerEvent) {
if (event.target !== event.currentTarget) {
return
}
const wrap = scrollWrap()
if (!wrap) {
return
}
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const nextThumbLeft = event.clientX - rect.left - horizontalThumbWidth.value / 2
const scrollRange = maxScrollLeft(wrap)
const thumbRange = maxHorizontalThumbLeft(wrap)
wrap.scrollLeft =
thumbRange > 0
? (Math.max(0, Math.min(nextThumbLeft, thumbRange)) / thumbRange) * scrollRange
: 0
}
function handleHorizontalThumbPointerDown(event: PointerEvent) {
const wrap = scrollWrap()
if (!wrap) {
return
}
event.preventDefault()
horizontalDragging.value = true
horizontalDragStartX = event.clientX
horizontalDragStartScrollLeft = wrap.scrollLeft
document.addEventListener('pointermove', handleHorizontalThumbPointerMove)
document.addEventListener('pointerup', handleHorizontalThumbPointerUp, { once: true })
}
function handleHorizontalThumbPointerMove(event: PointerEvent) {
const wrap = scrollWrap()
if (!wrap) {
return
}
const thumbRange = maxHorizontalThumbLeft(wrap)
if (thumbRange <= 0) {
return
}
const scrollRange = maxScrollLeft(wrap)
wrap.scrollLeft =
horizontalDragStartScrollLeft +
((event.clientX - horizontalDragStartX) / thumbRange) * scrollRange
}
function handleHorizontalThumbPointerUp() {
horizontalDragging.value = false
document.removeEventListener('pointermove', handleHorizontalThumbPointerMove)
}
onMounted(async () => {
await nextTick()
updateHorizontalScrollbar()
resizeObserver = new ResizeObserver(updateHorizontalScrollbar)
const wrap = scrollWrap()
if (wrap) {
resizeObserver.observe(wrap)
}
if (tableViewRef.value) {
resizeObserver.observe(tableViewRef.value)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
document.removeEventListener('pointermove', handleHorizontalThumbPointerMove)
})
function isLastColumn(index: number) {
return index === computedColumns.value.length - 1
}
@@ -278,52 +476,65 @@ function paginationPageClass(page: number) {
</script>
<template>
<div class="flex h-full min-h-0 max-w-full flex-col overflow-hidden">
<div class="flex min-h-0 max-w-full flex-1 flex-col overflow-hidden pb-1">
<div class="flex min-h-0 min-w-full flex-1 flex-col">
<table
class="w-full shrink-0 table-fixed border-separate border-spacing-y-2 text-left"
role="table"
>
<colgroup>
<col v-for="column in computedColumns" :key="column.key" :style="columnStyle(column)" />
<col :style="{ width: scrollbarGutterWidth }" />
</colgroup>
<thead>
<tr :class="headerRowClass" role="row">
<th
v-for="(column, index) in computedColumns"
:key="column.key"
class="bg-(--cp-bg-subtle)"
:class="[
headerCellClass,
alignClass(column),
isLastColumn(index) ? 'rounded-r-lg pr-3' : undefined,
column.headerClass,
]"
role="columnheader"
scope="col"
>
<div :class="cellContentClass(column)">
<slot :name="`header-${column.key}`" :column="column">
{{ column.label }}
</slot>
</div>
</th>
<th class="bg-(--cp-bg-subtle) p-0" aria-hidden="true" scope="col" />
</tr>
</thead>
</table>
<BaseScrollbar
always
class="min-h-0 flex-1"
:max-height="maxHeight"
wrap-class="overflow-x-hidden overflow-y-auto"
>
<div
class="flex h-full min-h-0 w-full max-w-full flex-col overflow-hidden"
@mouseenter="horizontalHovering = true"
@mouseleave="horizontalHovering = false"
>
<div class="relative flex min-h-0 max-w-full flex-1 overflow-hidden pb-3">
<div class="flex min-h-0 max-w-full flex-1 flex-col overflow-hidden">
<div ref="headerWrap" class="max-w-full overflow-hidden">
<table
class="w-full shrink-0 table-fixed border-separate border-spacing-y-2 text-left"
:style="tableStyle()"
role="table"
>
<colgroup>
<col
v-for="column in computedColumns"
:key="column.key"
:style="columnStyle(column)"
/>
</colgroup>
<thead>
<tr :class="headerRowClass" role="row">
<th
v-for="(column, index) in computedColumns"
:key="column.key"
class="bg-(--cp-bg-subtle)"
:class="[
headerCellClass,
alignClass(column),
isLastColumn(index) ? 'rounded-r-lg pr-3' : undefined,
fixedHeaderClass(column),
column.headerClass,
]"
role="columnheader"
scope="col"
>
<div :class="cellContentClass(column)">
<slot :name="`header-${column.key}`" :column="column">
{{ column.label }}
</slot>
</div>
</th>
</tr>
</thead>
</table>
</div>
<BaseScrollbar
ref="bodyScrollbar"
class="min-h-0 flex-1"
:force-visible="horizontalHovering"
:max-height="maxHeight"
@scroll="handleTableScroll"
>
<table
ref="tableView"
class="w-full table-fixed border-separate border-spacing-y-2 text-left"
:style="tableStyle()"
role="table"
>
<colgroup>
@@ -332,27 +543,19 @@ function paginationPageClass(page: number) {
:key="column.key"
:style="columnStyle(column)"
/>
<col :style="{ width: scrollbarGutterWidth }" />
</colgroup>
<tbody>
<tr v-if="!loading && rows.length === 0" role="row">
<td
:colspan="computedColumns.length + 1"
class="h-72 rounded-lg bg-(--cp-bg-surface) p-0"
role="cell"
>
<div class="flex h-full min-h-72 items-start justify-center pt-12">
<BaseEmpty :message="emptyText" compact class="w-full max-w-68" />
<td :colspan="computedColumns.length" class="h-72 p-0" role="cell">
<div class="grid h-full min-h-72 place-items-center">
<BaseEmpty :message="emptyText" plain class="w-full max-w-80" />
</div>
</td>
</tr>
<template v-for="(row, index) in rows" :key="getRowKey(row, index)">
<tr
:class="rowClass(row, index)"
role="row"
>
<tr :class="rowClass(row, index)" role="row">
<td
v-for="(column, columnIndex) in computedColumns"
:key="column.key"
@@ -360,6 +563,7 @@ function paginationPageClass(page: number) {
bodyCellClass,
alignClass(column),
isLastColumn(columnIndex) ? 'rounded-r-lg pr-3' : undefined,
fixedBodyClass(column, row, index),
column.cellClass,
]"
role="cell"
@@ -375,11 +579,10 @@ function paginationPageClass(page: number) {
</slot>
</div>
</td>
<td class="p-0" aria-hidden="true" />
</tr>
<tr v-if="isRowExpanded(row, index)" role="row">
<td
:colspan="computedColumns.length + 1"
:colspan="computedColumns.length"
class="rounded-lg bg-(--cp-info-bg) p-0"
role="cell"
>
@@ -391,6 +594,20 @@ function paginationPageClass(page: number) {
</table>
</BaseScrollbar>
</div>
<div
v-show="canScrollX"
class="absolute right-1 bottom-1 left-1 z-30 h-1.5 rounded-full transition-opacity duration-200"
:class="horizontalHovering || horizontalDragging ? 'opacity-100' : 'opacity-0'"
@pointerdown="handleHorizontalTrackPointerDown"
>
<div
class="h-full rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
:style="horizontalThumbStyle"
@pointerdown="handleHorizontalThumbPointerDown"
/>
</div>
</div>
<footer
+60 -18
View File
@@ -3,18 +3,22 @@ import { gsap } from 'gsap'
import { nextTick, onMounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Cat,
KeyRound,
LayoutDashboard,
List,
LogOut,
PanelLeftClose,
PanelLeftOpen,
Settings,
SquareTerminal,
Users,
} from '@lucide/vue'
import { useAuthStore } from '@/stores/modules/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const navItems = [
{ label: '概览', icon: LayoutDashboard, path: '/' },
@@ -33,6 +37,11 @@ function navigate(path: string) {
router.push(path)
}
async function handleLogout() {
await authStore.logout()
router.push('/login')
}
const props = withDefaults(
defineProps<{
collapsed?: boolean
@@ -196,27 +205,27 @@ watch(
:class="collapsed ? 'w-22 basis-22 items-center' : 'w-64 basis-64'"
>
<div
class="mt-5 grid h-11.5 grid-cols-[46px_minmax(0,1fr)] gap-2.5"
:class="collapsed ? 'w-11.5' : 'w-full'"
class="mt-5 grid h-12 grid-cols-[44px_minmax(0,1fr)] items-center"
:class="collapsed ? 'w-12 justify-start' : 'w-full gap-3'"
>
<span
class="inline-flex size-11 items-center justify-center rounded-[13px] bg-gray-900 text-white shadow-[0_8px_18px_-16px_#0E172614]"
class="inline-flex size-11 items-center justify-center rounded-xl bg-slate-100 text-slate-700"
>
<SquareTerminal :size="22" />
<Cat :size="27" stroke-width="2.05" />
</span>
<span
v-show="brandLabelVisible"
ref="brandLabelEl"
class="grid min-w-33 content-center overflow-hidden"
>
<strong class="text-[17px] leading-[1.1] font-[720] text-gray-900">Codex</strong>
<strong class="text-base leading-[1.1] font-[760] text-gray-900">Codex</strong>
<span class="mt-1 text-xs leading-[1.1] font-semibold text-slate-500"
>Proxy RS · v0.1.0</span
>
</span>
</div>
<nav class="mt-6 grid gap-3" :class="collapsed ? '' : 'w-full'" aria-label="主导航">
<nav class="mt-7 grid gap-3" :class="collapsed ? '' : 'w-full'" aria-label="主导航">
<button
v-for="item in navItems"
:key="item.label"
@@ -239,16 +248,49 @@ watch(
</button>
</nav>
<button
class="mt-auto mb-8 inline-flex items-center justify-center rounded-xl border-0 text-slate-500 transition-colors duration-200"
:class="collapsed ? 'h-9 w-11 bg-transparent' : 'size-9 self-end bg-slate-50'"
type="button"
data-sidebar-toggle
:aria-label="collapsed ? '展开侧边栏' : '收缩侧边栏'"
@click="$emit('toggle')"
>
<PanelLeftOpen v-if="collapsed" :size="20" />
<PanelLeftClose v-else :size="20" />
</button>
<div class="mt-auto mb-8" :class="collapsed ? 'w-11' : 'w-full'">
<div
class="bg-slate-50/80"
:class="
collapsed
? 'grid gap-1 rounded-xl p-1'
: 'flex h-11 items-center justify-between rounded-2xl px-2'
"
>
<span
v-if="!collapsed"
class="inline-flex h-7 items-center gap-1.5 rounded-lg bg-(--cp-success-bg) px-2.5 text-xs leading-none font-[650] text-(--cp-success-text)"
>
<i class="size-1.5 rounded-full bg-(--cp-success)" />
在线
</span>
<div class="flex items-center" :class="collapsed ? 'grid gap-1' : 'gap-1'">
<button
class="inline-flex items-center justify-center rounded-xl border-0 bg-transparent text-slate-500 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-700"
:class="collapsed ? 'size-9' : 'size-8'"
type="button"
data-sidebar-toggle
:aria-label="collapsed ? '展开侧边栏' : '收缩侧边栏'"
:title="collapsed ? '展开侧边栏' : '收缩侧边栏'"
@click="$emit('toggle')"
>
<PanelLeftOpen v-if="collapsed" :size="19" />
<PanelLeftClose v-else :size="18" />
</button>
<button
class="inline-flex items-center justify-center rounded-xl border-0 bg-transparent text-slate-500 transition-colors duration-200 hover:bg-(--cp-danger-bg) hover:text-(--cp-danger)"
:class="collapsed ? 'size-9' : 'size-8'"
type="button"
aria-label="退出登录"
title="退出登录"
@click="handleLogout"
>
<LogOut :size="collapsed ? 19 : 18" />
</button>
</div>
</div>
</div>
</aside>
</template>
-237
View File
@@ -1,237 +0,0 @@
<script setup lang="ts">
import { gsap } from 'gsap'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import {
CalendarDays,
ChevronDown,
ChevronRight,
ChevronUp,
LogOut,
RefreshCw,
ScrollText,
ShieldCheck,
User,
} from '@lucide/vue'
import { useAuthStore } from '@/stores/modules/auth'
const router = useRouter()
const authStore = useAuthStore()
const accountMenuOpen = ref(false)
const props = withDefaults(
defineProps<{
refreshing?: boolean
}>(),
{
refreshing: false,
},
)
const emit = defineEmits<{
refresh: []
}>()
async function handleLogout() {
await authStore.logout()
router.push('/login')
}
function handleRefresh() {
if (props.refreshing) return
emit('refresh')
}
function prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
function animateAccountMenuEnter(el: Element, done: () => void) {
const target = el as HTMLElement
if (prefersReducedMotion()) {
gsap.set(target, { opacity: 1, y: 0, scale: 1 })
done()
return
}
gsap.fromTo(
target,
{ opacity: 0, y: 6, scale: 0.98 },
{
opacity: 1,
y: 0,
scale: 1,
duration: 0.22,
ease: 'power3.out',
onComplete: done,
},
)
}
function animateAccountMenuLeave(el: Element, done: () => void) {
const target = el as HTMLElement
if (prefersReducedMotion()) {
gsap.set(target, { opacity: 0 })
done()
return
}
gsap.to(target, {
opacity: 0,
y: 4,
scale: 0.985,
duration: 0.14,
ease: 'power2.in',
onComplete: done,
})
}
</script>
<template>
<div class="relative z-30 flex h-11 items-start gap-3.5">
<button
class="group inline-flex h-11 w-42.5 items-center gap-2.5 whitespace-nowrap rounded-xl border-0 bg-white px-3.5 text-sm leading-[1.15] font-[650] text-(--cp-text-primary) shadow-(--cp-shadow-control) transition-shadow duration-200"
type="button"
>
<CalendarDays class="text-(--cp-info)" :size="18" />
<span class="whitespace-nowrap">最近 24 小时</span>
<ChevronDown
class="text-(--cp-text-secondary) transition-transform duration-200 group-hover:rotate-180"
:size="16"
/>
</button>
<button
class="inline-flex size-11 items-center justify-center rounded-xl border-0 bg-white text-(--cp-normal) shadow-(--cp-shadow-control) transition-shadow duration-200 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
aria-label="刷新"
:aria-busy="refreshing"
:disabled="refreshing"
@click="handleRefresh"
>
<RefreshCw :size="19" :class="refreshing ? 'animate-spin' : ''" />
</button>
<div class="relative ml-0.5">
<button
class="group grid h-11 w-54 grid-cols-[28px_minmax(0,1fr)_16px] items-center rounded-xl border-0 bg-white px-3 text-(--cp-text-secondary) shadow-(--cp-shadow-control) transition-shadow duration-200"
type="button"
data-account-trigger
@click="accountMenuOpen = !accountMenuOpen"
>
<span
class="inline-flex size-7 items-center justify-center rounded-full bg-(--cp-normal) text-xs leading-[1.15] font-bold text-white"
>A</span
>
<span class="grid gap-1.25 pl-3 text-left">
<strong class="text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)"
>admin</strong
>
<span class="text-[11px] leading-[1.15] font-[650] text-(--cp-text-secondary)"
>管理员</span
>
</span>
<ChevronUp
v-if="accountMenuOpen"
class="text-(--cp-text-secondary) transition-transform duration-200 group-hover:rotate-180"
:size="16"
/>
<ChevronDown
v-else
class="text-(--cp-text-secondary) transition-transform duration-200 group-hover:rotate-180"
:size="16"
/>
</button>
<Transition :css="false" @enter="animateAccountMenuEnter" @leave="animateAccountMenuLeave">
<div
v-if="accountMenuOpen"
data-account-menu
class="absolute right-0 top-14 h-76 w-80 origin-top-right rounded-[18px] bg-white pt-4.5 shadow-(--cp-shadow-popover)"
>
<div class="mx-4.5 grid h-11 grid-cols-[44px_minmax(0,1fr)_64px] items-center gap-3">
<span
class="inline-flex size-11 items-center justify-center rounded-full bg-(--cp-normal) text-sm leading-[1.15] font-bold text-white"
>A</span
>
<span class="grid gap-1.25">
<strong class="text-[15px] leading-[1.15] font-bold text-(--cp-text-primary)"
>admin</strong
>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)"
>管理员</span
>
</span>
<span
class="inline-flex h-7 w-16 items-center gap-1.5 rounded-full bg-(--cp-success-bg) px-3"
>
<i class="size-1.5 rounded-full bg-(--cp-success)" />
<span class="text-xs leading-[1.15] font-[650] text-(--cp-success-text)">在线</span>
</span>
</div>
<div
class="mx-4 mt-3.5 grid h-11 w-72 grid-cols-2 rounded-xl bg-(--cp-bg-subtle) px-4 py-2"
>
<span class="grid gap-1.25">
<small class="text-[11px] leading-[1.15] font-semibold text-(--cp-text-secondary)"
>MFA</small
>
<strong class="text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)"
>已开启</strong
>
</span>
<span class="grid gap-1.25 pl-4.5">
<small class="text-[11px] leading-[1.15] font-semibold text-(--cp-text-secondary)"
>会话</small
>
<strong class="text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)"
>1 台</strong
>
</span>
</div>
<div class="mx-4 mt-3.5 grid w-72 gap-1.5">
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_16px] items-center gap-3 rounded-[11px] border-0 bg-(--cp-bg-subtle) px-3 text-left text-(--cp-text-primary) transition-colors duration-200"
type="button"
>
<User :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">个人设置</span>
<ChevronRight class="text-(--cp-text-muted)" :size="16" />
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_34px_16px] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-(--cp-text-primary) transition-colors duration-200 hover:bg-(--cp-bg-subtle)"
type="button"
>
<ShieldCheck :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">安全与会话</span>
<span class="text-xs leading-[1.15] font-[650] text-(--cp-text-secondary)">MFA</span>
<ChevronRight class="text-(--cp-text-muted)" :size="16" />
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_16px] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-(--cp-text-primary) transition-colors duration-200 hover:bg-(--cp-bg-subtle)"
type="button"
>
<ScrollText :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">操作记录</span>
<ChevronRight class="text-(--cp-text-muted)" :size="16" />
</button>
<button
class="grid h-9 grid-cols-[20px_minmax(0,1fr)] items-center gap-3 rounded-[11px] border-0 bg-transparent px-3 text-left text-(--cp-danger-text) transition-colors duration-200 hover:bg-(--cp-danger-bg)"
type="button"
@click="handleLogout"
>
<LogOut class="text-(--cp-danger)" :size="20" />
<span class="text-[13px] leading-[1.15] font-[650]">退出登录</span>
</button>
</div>
</div>
</Transition>
</div>
</div>
</template>
+6 -12
View File
@@ -10,7 +10,6 @@ import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseTable from '@/components/base/BaseTable.vue'
import AppTopbar from '@/layout/components/AppTopbar.vue'
import { withMinimumDuration } from '@/utils/async'
import type { Account } from '@/api'
@@ -297,8 +296,6 @@ onBeforeUnmount(() => {
管理 Codex 账号 · 共 {{ totalAccounts }} 个账号
</p>
</div>
<AppTopbar class="mt-0.5" :refreshing="refreshingList" @refresh="refreshAccounts" />
</header>
<div class="mt-6 flex shrink-0 items-center justify-between gap-4">
@@ -312,7 +309,6 @@ onBeforeUnmount(() => {
<BaseButton
v-if="selectedIds.size > 0"
variant="danger"
size="md"
:disabled="batchDeleting"
@click="showDeleteModal = true"
>
@@ -341,7 +337,7 @@ onBeforeUnmount(() => {
<RefreshCw class="size-4.5" />
</BaseIconButton>
<BaseButton variant="primary" size="md" @click="showCreateModal = true">
<BaseButton variant="primary" @click="showCreateModal = true">
<Plus class="size-4" />
添加账号
</BaseButton>
@@ -364,7 +360,6 @@ onBeforeUnmount(() => {
:model-value="allSelected"
:indeterminate="indeterminate"
label="选择当前页账号"
size="table"
@update:model-value="toggleAll"
/>
</template>
@@ -385,7 +380,6 @@ onBeforeUnmount(() => {
<BaseCheckbox
:model-value="selectedIds.has(row.id)"
label="选择账号"
size="table"
@update:model-value="toggleSelection(row.id)"
/>
</div>
@@ -512,31 +506,31 @@ onBeforeUnmount(() => {
<h3 class="m-0 mb-3 text-[14px] font-[760] text-(--cp-text-primary)">Token 结构</h3>
<div class="grid gap-2">
<div class="flex items-center justify-between rounded-lg bg-green-50 px-3 py-2">
<span class="text-[12px] font-[700] text-green-700">输入 Tokens</span>
<span class="text-[12px] font-bold text-green-700">输入 Tokens</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ row.usage.inputTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-amber-50 px-3 py-2">
<span class="text-[12px] font-[700] text-amber-700">输出 Tokens</span>
<span class="text-[12px] font-bold text-amber-700">输出 Tokens</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ row.usage.outputTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-cyan-50 px-3 py-2">
<span class="text-[12px] font-[700] text-cyan-700">缓存 Tokens</span>
<span class="text-[12px] font-bold text-cyan-700">缓存 Tokens</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ row.usage.cachedTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-sky-50 px-3 py-2">
<span class="text-[12px] font-[700] text-sky-700">创建</span>
<span class="text-[12px] font-bold text-sky-700">创建</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ row.usage.createdTokensDisplay }}
</strong>
</div>
<div class="flex items-center justify-between rounded-lg bg-sky-50 px-3 py-2">
<span class="text-[12px] font-[700] text-sky-700">读取</span>
<span class="text-[12px] font-bold text-sky-700">读取</span>
<strong class="font-mono text-[13px] text-(--cp-text-primary)">
{{ row.usage.readTokensDisplay }}
</strong>
+1 -7
View File
@@ -10,7 +10,6 @@ import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseTable from '@/components/base/BaseTable.vue'
import AppTopbar from '@/layout/components/AppTopbar.vue'
import { withMinimumDuration } from '@/utils/async'
import type { ClientApiKey } from '@/api'
@@ -287,8 +286,6 @@ watch(filteredKeys, () => {
管理客户端访问密钥 · 共 {{ apiKeys.length }} 个
</p>
</div>
<AppTopbar class="mt-0.5" :refreshing="refreshingList" @refresh="refreshApiKeys" />
</header>
<div class="mt-6 flex shrink-0 items-center justify-between gap-4">
@@ -302,7 +299,6 @@ watch(filteredKeys, () => {
<BaseButton
v-if="selectedIds.size > 0"
variant="danger"
size="md"
:disabled="batchDeleting"
@click="showDeleteModal = true"
>
@@ -331,7 +327,7 @@ watch(filteredKeys, () => {
<RefreshCw class="size-4.5" />
</BaseIconButton>
<BaseButton variant="primary" size="md" @click="showCreateModal = true">
<BaseButton variant="primary" @click="showCreateModal = true">
<Plus class="size-4" />
创建 API Key
</BaseButton>
@@ -353,7 +349,6 @@ watch(filteredKeys, () => {
:model-value="allSelected"
:indeterminate="indeterminate"
label="选择当前页密钥"
size="table"
@update:model-value="toggleAll"
/>
</template>
@@ -362,7 +357,6 @@ watch(filteredKeys, () => {
<BaseCheckbox
:model-value="selectedIds.has(row.id)"
label="选择密钥"
size="table"
@update:model-value="toggleSelection(row.id)"
/>
</template>
+15 -16
View File
@@ -1,12 +1,10 @@
<script setup lang="ts">
import { RefreshCw } from '@lucide/vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import AppTopbar from '@/layout/components/AppTopbar.vue'
import AccountOverviewCard from './components/AccountOverviewCard.vue'
import EventLogCard from './components/EventLogCard.vue'
import MetricCard from './components/MetricCard.vue'
import RequestHealthTimelineCard from './components/RequestHealthTimelineCard.vue'
import RequestTrendCard from './components/RequestTrendCard.vue'
import ServiceStatusCard from './components/ServiceStatusCard.vue'
import { useDashboard } from './composables/useDashboard'
@@ -17,6 +15,7 @@ const {
metrics,
trendPoints,
trendSummary,
healthTimeline,
accountUsage,
serviceStatuses,
eventLogs,
@@ -30,7 +29,7 @@ const {
<template>
<div class="w-full pb-6">
<header class="flex min-h-17 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<header class="flex min-h-17 items-start justify-between gap-4">
<div>
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
系统概览
@@ -40,18 +39,16 @@ const {
</p>
</div>
<div class="flex items-center gap-2 self-end lg:self-auto">
<BaseIconButton
variant="ghost"
size="md"
title="刷新数据"
:loading="loading"
@click="refresh"
>
<RefreshCw class="size-4.5" />
</BaseIconButton>
<AppTopbar class="mt-0.5" :refreshing="loading" @refresh="refresh" />
</div>
<button
class="mt-0.5 inline-flex size-11 shrink-0 items-center justify-center rounded-xl border-0 bg-white text-(--cp-normal) shadow-(--cp-shadow-control) disabled:cursor-not-allowed disabled:opacity-50"
type="button"
aria-label="刷新概览"
:aria-busy="loading"
:disabled="loading"
@click="refresh"
>
<RefreshCw :size="19" :class="loading ? 'animate-spin' : ''" />
</button>
</header>
<section
@@ -81,6 +78,8 @@ const {
class="mt-6"
/>
<RequestHealthTimelineCard :timeline="healthTimeline" class="mt-6" />
<EventLogCard :rows="eventLogs" class="mt-6" />
</div>
</template>
@@ -143,9 +143,11 @@ const quotaToneClasses: Record<SemanticTone, string> = {
<template>
<BaseCard as="article" :padded="false" class="w-full xl:h-112.5">
<div
class="grid gap-7 px-4 pt-5 pb-6 lg:px-7 lg:pt-6 xl:grid-cols-[minmax(360px,360fr)_minmax(612px,612fr)_minmax(500px,500fr)] xl:pb-0"
class="grid px-4 pt-5 pb-6 lg:px-7 lg:pt-6 xl:grid-cols-[minmax(360px,360fr)_minmax(612px,612fr)_minmax(500px,500fr)] xl:gap-7 xl:pb-0"
>
<section class="w-full xl:h-100.5">
<section
class="w-full pb-6 shadow-[inset_0_-1px_0_rgba(15,23,42,0.08)] xl:h-100.5 xl:pb-0 xl:shadow-none"
>
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">账号调度</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
容量、并发与分配策略
@@ -195,34 +197,36 @@ const quotaToneClasses: Record<SemanticTone, string> = {
</div>
</section>
<section class="w-full">
<section
class="w-full py-6 shadow-[inset_0_-1px_0_rgba(15,23,42,0.08)] xl:py-0 xl:shadow-none"
>
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">活跃账号用量</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-semibold text-(--cp-text-secondary)">
总请求排序
</p>
<div class="mt-6.75 grid h-82.5 w-full gap-1.5 overflow-hidden">
<div class="mt-5 grid w-full gap-2 overflow-hidden xl:mt-6.75 xl:h-82.5 xl:gap-1.5">
<BaseEmpty
v-if="accounts.length === 0"
compact
title="暂无账号用量"
description="账号产生请求后会显示 24h 排序和 Token 负载。"
class="h-full place-content-center"
class="min-h-40 place-content-center xl:h-full"
/>
<template v-else>
<article
v-for="(account, index) in accounts"
:key="account.name"
class="grid h-19.5 w-full grid-cols-[34px_10px_minmax(150px,1fr)_8px_76px_8px_84px_8px_84px] items-start rounded-[14px] px-3.5 transition-colors duration-200 hover:bg-(--cp-bg-subtle)"
class="grid w-full grid-cols-[34px_minmax(0,1fr)] gap-x-3 rounded-[14px] px-3.5 py-3.5 hover:bg-(--cp-bg-subtle) xl:h-19.5 xl:grid-cols-[34px_minmax(150px,1fr)_76px_84px_84px] xl:items-center xl:gap-x-3 xl:py-0"
:class="['bg-(--cp-bg-subtle)', 'bg-white'][index % 2]"
>
<span
class="mt-5.5 inline-flex size-8.5 items-center justify-center rounded-full"
class="inline-flex size-8.5 items-center justify-center rounded-full"
:class="rowToneClasses[account.tone]"
>
{{ account.name[0] }}
</span>
<span class="col-start-3 row-start-1 mt-4.25 grid min-w-0 gap-4.25">
<span class="grid min-w-0 gap-1.5 xl:gap-4.25">
<span class="flex min-w-0 items-start">
<strong class="text-sm leading-[1.15] font-[650] text-(--cp-text-primary)">{{
account.name
@@ -237,38 +241,58 @@ const quotaToneClasses: Record<SemanticTone, string> = {
>{{ account.email }}</span
>
</span>
<span class="col-start-5 row-start-1 mt-5 grid min-w-0 gap-4.25">
<strong
class="w-full font-mono text-sm leading-[1.15] font-bold tabular-nums text-(--cp-text-primary)"
>{{ account.requests }}</strong
>
<span
class="w-20 whitespace-nowrap text-xs leading-[1.15] font-semibold text-(--cp-text-secondary)"
>{{ account.lastUsed }}</span
>
</span>
<strong
class="col-start-7 row-start-1 mt-5 w-full font-mono text-sm leading-[1.15] font-bold tabular-nums text-(--cp-text-primary)"
>{{ account.tokens }}</strong
>
<span
class="col-start-9 row-start-1 mt-9 block h-1.5 w-full overflow-hidden rounded-full bg-(--cp-bg-muted)"
class="col-span-2 mt-3 grid grid-cols-3 gap-3 pl-11 xl:col-span-3 xl:mt-0 xl:grid-cols-[76px_84px_84px] xl:pl-0"
>
<i
class="block h-1.5 rounded-full"
:class="quotaToneClasses[account.quotaTone]"
:style="{
width: `${account.quotaPercent}%`,
minWidth: account.quotaPercent > 0 ? '7px' : '0',
}"
/>
<span class="grid min-w-0 gap-1.5">
<span
class="text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted) xl:hidden"
>请求</span
>
<strong
class="w-full font-mono text-sm leading-[1.15] font-bold tabular-nums text-(--cp-text-primary)"
>{{ account.requests }}</strong
>
<span
class="w-full truncate text-xs leading-[1.15] font-semibold text-(--cp-text-secondary) xl:w-20 xl:whitespace-nowrap"
>{{ account.lastUsed }}</span
>
</span>
<span class="grid min-w-0 gap-1.5">
<span
class="text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted) xl:hidden"
>Token</span
>
<strong
class="w-full font-mono text-sm leading-[1.15] font-bold tabular-nums text-(--cp-text-primary)"
>{{ account.tokens }}</strong
>
</span>
<span class="grid min-w-0 gap-2">
<span
class="text-[11px] leading-[1.15] font-[650] text-(--cp-text-muted) xl:hidden"
>额度</span
>
<span
class="mt-1 block h-1.5 w-full overflow-hidden rounded-full bg-(--cp-bg-muted)"
>
<i
class="block h-1.5 rounded-full"
:class="quotaToneClasses[account.quotaTone]"
:style="{
width: `${account.quotaPercent}%`,
minWidth: account.quotaPercent > 0 ? '7px' : '0',
}"
/>
</span>
</span>
</span>
</article>
</template>
</div>
</section>
<section class="w-full xl:h-100.5">
<section class="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-[760] text-(--cp-text-primary)">账号状态</h2>
@@ -311,7 +335,7 @@ const quotaToneClasses: Record<SemanticTone, string> = {
<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-[14px] bg-(--cp-bg-subtle) px-3.5 transition-colors duration-200"
class="grid h-14.5 grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-[14px] bg-(--cp-bg-subtle) px-3.5"
>
<span
class="inline-flex size-7 items-center justify-center rounded-[9px]"
@@ -2,6 +2,7 @@
import { computed, ref } from 'vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
import BaseSegmented from '../../../components/base/BaseSegmented.vue'
import BaseTable from '../../../components/base/BaseTable.vue'
import type { EventLogItem, SemanticTone } from '../types'
@@ -16,13 +17,13 @@ const filters = [
{ label: '错误', value: 'error' },
]
const eventLogColumns = [
{ key: 'time', label: '时间', width: '11.5%' },
{ key: 'level', label: '级别', width: '7.5%' },
{ key: 'requestId', label: '请求 ID', width: '17%' },
{ key: 'route', label: '路由', width: '18.5%' },
{ key: 'model', label: '模型', width: '13%' },
{ key: 'statusCode', label: '状态码', width: '8.5%' },
{ key: 'latency', label: '延迟', width: '24%' },
{ key: 'time', label: '时间', width: 86, fixed: 'left' as const },
{ key: 'level', label: '级别' },
{ key: 'requestId', label: '请求 ID' },
{ key: 'route', label: '路由' },
{ key: 'model', label: '模型' },
{ key: 'statusCode', label: '状态码' },
{ key: 'latency', label: '延迟' },
]
const levelToneClasses: Record<SemanticTone, string> = {
@@ -62,24 +63,7 @@ const filteredRows = computed(() => {
</p>
</div>
<div
class="grid h-9 w-51.5 grid-cols-[62px_58px_58px] gap-1 rounded-xl bg-(--cp-bg-muted) p-1"
>
<button
v-for="filter in filters"
:key="filter.value"
class="h-7 rounded-[9px] border-0 text-xs leading-[1.15] font-[650] cursor-pointer"
:class="
activeFilter === filter.value
? 'bg-white text-(--cp-text-primary) shadow-(--cp-shadow-control)'
: 'bg-transparent text-(--cp-text-secondary)'
"
type="button"
@click="activeFilter = filter.value"
>
{{ filter.label }}
</button>
</div>
<BaseSegmented v-model="activeFilter" :options="filters" class="w-51.5" />
</header>
<div class="mt-4.25 flex h-60 w-full justify-between overflow-hidden">
@@ -92,7 +76,13 @@ const filteredRows = computed(() => {
rows.length === 0 ? '请求经过代理后会在这里显示最近事件。' : '调整筛选条件后再查看。'
"
/>
<BaseTable v-else class="min-w-0 flex-1" :columns="eventLogColumns" :rows="filteredRows">
<BaseTable
v-else
class="min-w-0 flex-1"
:columns="eventLogColumns"
:rows="filteredRows"
min-width="760px"
>
<template #time="{ value }">
<span class="font-mono tabular-nums">{{ value }}</span>
</template>
@@ -1,8 +1,12 @@
<script setup lang="ts">
import type { EChartsOption } from 'echarts'
import { computed } from 'vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseChart from '../../../components/charts/BaseChart.vue'
import type { MetricCardItem, SemanticTone } from '../types'
defineProps<{
const props = defineProps<{
metric: MetricCardItem
}>()
@@ -29,10 +33,59 @@ const trendToneClasses: Record<SemanticTone, string> = {
warning: 'bg-(--cp-warning-text)',
danger: 'bg-(--cp-danger-text)',
}
const sparklineColors: Record<SemanticTone, string> = {
normal: '#94A3B8',
info: '#60A5FA',
success: '#5CCB8A',
warning: '#E3B658',
danger: '#E87972',
}
const sparklineOption = computed<EChartsOption | null>(() => {
const values = props.metric.sparkline?.values ?? []
if (values.length < 2) return null
const color = sparklineColors[props.metric.sparkline?.tone ?? 'normal']
return {
animation: false,
grid: { left: 0, right: 0, top: 4, bottom: 4 },
xAxis: { type: 'category', show: false, data: values.map((_, index) => index) },
yAxis: { type: 'value', show: false, min: 'dataMin', max: 'dataMax' },
series: [
{
type: 'line',
data: values,
smooth: true,
symbol: 'none',
lineStyle: { color, width: 1.75, opacity: 0.9 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${color}18` },
{ offset: 1, color: `${color}00` },
],
},
},
},
],
tooltip: { show: false },
}
})
</script>
<template>
<BaseCard as="article" :padded="false" radius-class="rounded-2xl" class="h-38.5 w-full px-6 pt-5">
<BaseCard
as="article"
:padded="false"
radius-class="rounded-2xl"
class="relative h-38.5 w-full px-6 pt-5"
>
<div class="flex items-start gap-3">
<span
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-[10px]"
@@ -63,6 +116,10 @@ const trendToneClasses: Record<SemanticTone, string> = {
/>
</div>
<div v-if="sparklineOption" class="pointer-events-none absolute top-7 right-6 h-16 w-[42%]">
<BaseChart :option="sparklineOption" :height="64" />
</div>
<div
class="mt-3 grid h-7.5 w-full grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center rounded-[10px] bg-(--cp-bg-subtle) px-3"
>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import type { DashboardHealthTimeline } from '@/api'
const props = defineProps<{
timeline: DashboardHealthTimeline
}>()
const points = computed(() => props.timeline.points.split(''))
function cellClass(point: string) {
if (point === '1') return 'bg-[#4FAD7A]'
if (point === '2') return 'bg-[#D9A13B]'
if (point === '3') return 'bg-[#D86A62]'
return 'bg-[#CBD2DC]'
}
</script>
<template>
<BaseCard as="article" :padded="false" class="w-full px-4 py-4.5 lg:px-5">
<header class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 class="m-0 text-xl leading-[1.15] font-[760] text-(--cp-text-primary)">
{{ timeline.title }}
</h2>
<p class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">
{{ timeline.description }}
</p>
</div>
<div class="grid gap-2 lg:justify-items-end">
<span class="font-mono text-xs leading-none font-[650] text-(--cp-text-secondary)">
{{ timeline.rangeDisplay }}
</span>
<strong class="font-mono text-sm leading-none font-[760] text-(--cp-success-text)">
{{ timeline.reliabilityDisplay }}
</strong>
<div
class="flex items-center gap-2 text-[11px] leading-none font-[650] text-(--cp-text-muted)"
>
<span>{{ timeline.oldestLabel }}</span>
<i class="size-2 rounded-[2px] bg-[#CBD2DC]" />
<i class="size-2 rounded-[2px] bg-[#D86A62]" />
<i class="size-2 rounded-[2px] bg-[#D9A13B]" />
<i class="size-2 rounded-[2px] bg-[#4FAD7A]" />
<span>{{ timeline.newestLabel }}</span>
</div>
</div>
</header>
<div class="mt-4">
<div class="flex flex-wrap gap-1">
<i
v-for="(point, index) in points"
:key="index"
class="size-2.5 rounded-[3px]"
:class="cellClass(point)"
/>
</div>
</div>
</BaseCard>
</template>
@@ -4,6 +4,7 @@ import type { EChartsOption } from 'echarts'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseChart from '../../../components/charts/BaseChart.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
import BaseSegmented from '../../../components/base/BaseSegmented.vue'
import type { TrendPoint, TrendSummaryItem } from '../types'
const props = defineProps<{
@@ -16,7 +17,11 @@ const emit = defineEmits<{
trendChange: [tab: string]
}>()
const tabs = ['用量', '延迟', '错误']
const tabs = [
{ label: '用量', value: '用量' },
{ label: '延迟', value: '延迟' },
{ label: '错误', value: '错误' },
]
const activeTab = ref('用量')
const hasSamples = computed(() =>
@@ -182,16 +187,6 @@ function lineSeries(name: string, data: number[], color: string, area = false, y
}
}
function selectTab(tab: string) {
if (activeTab.value === tab) return
activeTab.value = tab
emit('trendChange', tab)
}
const activeSummary = computed(() => {
return props.summary
})
function formatLatency(ms: number): string {
if (!ms) return '—'
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
@@ -209,24 +204,12 @@ function formatLatency(ms: number): string {
</p>
</div>
<div
class="grid h-9.5 w-full max-w-61.5 grid-cols-3 gap-1 rounded-xl bg-(--cp-bg-muted) p-1 sm:w-61.5"
>
<button
v-for="tab in tabs"
:key="tab"
class="h-7.5 rounded-[9px] border-0 text-xs leading-[1.15] font-[650] cursor-pointer transition-colors"
:class="
activeTab === tab
? 'bg-white text-(--cp-text-primary) shadow-[0_10px_24px_-18px_#0E172614]'
: 'bg-transparent text-(--cp-text-secondary)'
"
type="button"
@click="selectTab(tab)"
>
{{ tab }}
</button>
</div>
<BaseSegmented
v-model="activeTab"
:options="tabs"
class="w-full max-w-61.5 sm:w-61.5"
@update:model-value="emit('trendChange', $event)"
/>
</header>
<div
@@ -248,7 +231,7 @@ function formatLatency(ms: number): string {
<aside class="grid h-67 w-full grid-rows-3 rounded-2xl bg-(--cp-bg-subtle) px-5 py-4.5">
<div
v-for="item in activeSummary"
v-for="item in props.summary"
:key="item.label"
class="grid grid-cols-[minmax(0,1fr)_8px] items-center gap-x-3 py-2"
>
@@ -15,6 +15,7 @@ import {
import { getDashboardSummary, getDashboardTrend } from '@/api'
import type {
DashboardAccountUsage,
DashboardHealthTimeline,
DashboardServiceStatus,
DashboardSummary,
DashboardTrend,
@@ -39,6 +40,7 @@ export function useDashboard(): {
metrics: Ref<MetricCardItem[]>
trendPoints: Ref<TrendPoint[]>
trendSummary: Ref<TrendSummaryItem[]>
healthTimeline: Ref<DashboardHealthTimeline>
accountUsage: Ref<AccountUsageItem[]>
serviceStatuses: Ref<ServiceStatusItem[]>
eventLogs: Ref<EventLogItem[]>
@@ -51,6 +53,7 @@ export function useDashboard(): {
const metrics = ref<MetricCardItem[]>(metricCards(emptyDashboardSummary()))
const trendPoints = ref<TrendPoint[]>([])
const trendSummary = ref<TrendSummaryItem[]>([])
const healthTimeline = ref<DashboardHealthTimeline>(emptyDashboardSummary().healthTimeline)
const accountUsage = ref<AccountUsageItem[]>([])
const serviceStatuses = ref<ServiceStatusItem[]>([])
const eventLogs = ref<EventLogItem[]>([])
@@ -93,6 +96,7 @@ export function useDashboard(): {
function applySummary(summary: DashboardSummary) {
metrics.value = metricCards(summary)
applyTrend(summary.trend)
healthTimeline.value = summary.healthTimeline
accountUsage.value = summary.accountUsage.map(accountUsageItem)
serviceStatuses.value = summary.serviceStatuses.map(serviceStatusItem)
eventLogs.value = summary.eventLogs.map((log) => ({
@@ -147,6 +151,15 @@ export function useDashboard(): {
points: [],
summary: [],
},
healthTimeline: {
title: '请求健康时间线',
description: '最近 7 天请求可靠性',
rangeDisplay: '-',
reliabilityDisplay: '-',
oldestLabel: '最早',
newestLabel: '最新',
points: '',
},
accountUsage: [],
serviceStatuses: [],
eventLogs: [],
@@ -171,6 +184,7 @@ export function useDashboard(): {
function metricCards(summary: DashboardSummary): MetricCardItem[] {
const { accounts, traffic, tokens, cache } = summary.cards
const points = summary.trend.points
return [
{
title: '账号',
@@ -195,6 +209,10 @@ export function useDashboard(): {
value: formatNumber(traffic.todayRequests),
icon: Activity,
tone: 'info',
sparkline: sparkline(
points.map((point) => point.requests),
'info',
),
trend: trendState(traffic.todayRequests, traffic.yesterdayRequests, 'info'),
details: [
{ label: '总请求', value: formatNumber(traffic.totalRequests), tone: 'info' },
@@ -210,6 +228,10 @@ export function useDashboard(): {
value: formatTokens(tokens.todayTokens),
icon: FileText,
tone: 'success',
sparkline: sparkline(
points.map((point) => point.tokens),
'success',
),
trend: trendState(tokens.todayTokens, tokens.yesterdayTokens, 'success'),
details: [
{ label: '总 Token', value: formatTokens(tokens.totalTokens), tone: 'success' },
@@ -225,6 +247,10 @@ export function useDashboard(): {
value: formatRate(cache.todayHitRate),
icon: Timer,
tone: cache.todayHitRate && cache.todayHitRate > 0 ? 'warning' : 'normal',
sparkline: sparkline(
points.map((point) => point.cachedTokens),
'warning',
),
trend: trendState(cache.todayHitRate ?? 0, cache.yesterdayHitRate ?? 0, 'warning'),
details: [
{ label: '总缓存命中', value: formatRate(cache.totalHitRate), tone: 'warning' },
@@ -238,6 +264,10 @@ export function useDashboard(): {
]
}
function sparkline(values: number[], tone: SemanticTone): MetricCardItem['sparkline'] {
return values.some((value) => value > 0) ? { values, tone } : undefined
}
function applyTrend(trend: DashboardTrend) {
trendPoints.value = trend.points
trendSummary.value = trend.summary.map((item) => {
@@ -414,6 +444,7 @@ export function useDashboard(): {
metrics,
trendPoints,
trendSummary,
healthTimeline,
accountUsage,
serviceStatuses,
eventLogs,
+4
View File
@@ -14,6 +14,10 @@ export interface MetricCardItem {
icon: Component
tone: SemanticTone
details: MetricDetail[]
sparkline?: {
values: number[]
tone: SemanticTone
}
trend?: {
direction: 'up' | 'down' | 'flat'
tone: SemanticTone
+1 -9
View File
@@ -10,7 +10,6 @@ import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import BaseTable from '@/components/base/BaseTable.vue'
import AppTopbar from '@/layout/components/AppTopbar.vue'
import { withMinimumDuration } from '@/utils/async'
import type { EventLog } from '@/api'
@@ -184,8 +183,6 @@ onBeforeUnmount(() => {
查看系统运行日志 · 共 {{ totalLogs }} 条
</p>
</div>
<AppTopbar class="mt-0.5" :refreshing="refreshingList" @refresh="refreshLogs" />
</header>
<div class="mt-6 flex shrink-0 items-center justify-between gap-4">
@@ -213,12 +210,7 @@ onBeforeUnmount(() => {
<RefreshCw class="size-4.5" />
</BaseIconButton>
<BaseButton
variant="danger"
size="md"
:disabled="clearingLogs"
@click="showClearModal = true"
>
<BaseButton variant="danger" :disabled="clearingLogs" @click="showClearModal = true">
<Trash2 class="size-4" />
清空日志
</BaseButton>
+1 -5
View File
@@ -6,7 +6,6 @@ import BaseButton from '@/components/base/BaseButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import BaseSwitch from '@/components/base/BaseSwitch.vue'
import AppTopbar from '@/layout/components/AppTopbar.vue'
import { withMinimumDuration } from '@/utils/async'
import type { Settings } from '@/api'
@@ -110,8 +109,6 @@ onMounted(() => {
配置系统运行参数
</p>
</div>
<AppTopbar class="mt-0.5" :refreshing="refreshing" @refresh="refreshSettings" />
</header>
<section
v-loading="loading"
@@ -130,7 +127,6 @@ onMounted(() => {
<div class="flex shrink-0 items-center gap-3">
<BaseButton
variant="ghost"
size="md"
:loading="refreshing"
:disabled="loading"
@click="refreshSettings"
@@ -140,7 +136,7 @@ onMounted(() => {
</template>
重置
</BaseButton>
<BaseButton variant="primary" size="md" :disabled="saving" @click="handleSave">
<BaseButton variant="primary" :disabled="saving" @click="handleSave">
<Save class="size-4" />
{{ saving ? '保存中...' : '保存设置' }}
</BaseButton>