mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
refactor: polish admin dashboard ui
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user