mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: add vue dashboard web app
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
zyycn
co-authored by
Codex
parent
4ea7d86592
commit
ff580e57f8
+3
-1
@@ -14,4 +14,6 @@
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
.codegraph
|
||||
*.codegraph
|
||||
*/node_modules
|
||||
*/dist
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
||||
<title>Codex Proxy RS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "codex-proxy-rs-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.7.0",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.18.0",
|
||||
"axios": "^1.18.0",
|
||||
"echarts": "^6.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.38",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@types/node": "^25.9.3",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
Generated
+1691
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- axios@1.18.0
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="16" fill="#111827"/>
|
||||
<path d="M17 34h8l4-14 8 28 5-16h5" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './request'
|
||||
export type * from './types'
|
||||
@@ -0,0 +1,70 @@
|
||||
import axios, { type AxiosHeaders, type AxiosRequestConfig, type AxiosResponse, type AxiosResponseHeaders } from 'axios'
|
||||
|
||||
import type { ApiEnvelope } from './types'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly requestId?: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: '/',
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
type ResponseHeaders = AxiosResponseHeaders | Partial<AxiosHeaders>
|
||||
|
||||
function headerValue(headers: ResponseHeaders | undefined, name: string) {
|
||||
if (!headers) return undefined
|
||||
const value = headers[name] ?? headers[name.toLowerCase()]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function isApiEnvelope<T>(value: unknown): value is ApiEnvelope<T> {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& 'data' in value
|
||||
&& 'code' in value
|
||||
&& 'message' in value
|
||||
}
|
||||
|
||||
export async function requestJson<T, D = unknown>(
|
||||
url: string,
|
||||
config: Omit<AxiosRequestConfig<D>, 'url'> = {},
|
||||
): Promise<T> {
|
||||
try {
|
||||
const response = await http.request<ApiEnvelope<T> | T, AxiosResponse<ApiEnvelope<T> | T>, D>({
|
||||
url,
|
||||
...config,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...config.headers,
|
||||
},
|
||||
})
|
||||
|
||||
return isApiEnvelope<T>(response.data) ? response.data.data : response.data
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError<ApiEnvelope<unknown>>(error)) {
|
||||
const requestId = headerValue(error.response?.headers, 'x-request-id')
|
||||
?? error.response?.data?.requestId
|
||||
throw new ApiError(
|
||||
error.response?.data?.message ?? error.message,
|
||||
error.response?.status ?? 0,
|
||||
requestId,
|
||||
)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ApiEnvelope<T> {
|
||||
code: string
|
||||
message: string
|
||||
requestId: string
|
||||
data: T
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<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-[var(--cp-default-bg)] text-[var(--cp-text-secondary)]',
|
||||
normal: 'bg-[var(--cp-normal-bg)] text-[var(--cp-normal-text)]',
|
||||
success: 'bg-[var(--cp-success-bg)] text-[var(--cp-success-text)]',
|
||||
warning: 'bg-[var(--cp-warning-bg)] text-[var(--cp-warning-text)]',
|
||||
danger: 'bg-[var(--cp-danger-bg)] text-[var(--cp-danger-text)]',
|
||||
info: 'bg-[var(--cp-info-bg)] text-[var(--cp-info-text)]',
|
||||
}
|
||||
|
||||
const badgeClasses = computed(() => [
|
||||
'inline-flex min-h-[26px] items-center justify-center gap-[7px] whitespace-nowrap rounded-[var(--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>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircle } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type ButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'plain' | 'text'
|
||||
type ButtonSize = 'small' | 'default' | 'large'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}>(), {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
type: 'button',
|
||||
})
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
large: 'h-10 px-4 text-[13px]',
|
||||
default: 'h-8 px-3.5 text-[13px]',
|
||||
small: 'h-6 rounded-[var(--cp-button-radius-small)] px-2.5 text-xs',
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
default: 'bg-[var(--cp-default-bg)] text-[var(--cp-default-text)] hover:bg-[var(--cp-default-bg-hover)]',
|
||||
primary: 'bg-[var(--cp-info)] text-[var(--cp-info-on)] hover:bg-[var(--cp-info-hover)]',
|
||||
success: 'bg-[var(--cp-success-bg)] text-[var(--cp-success-text)] hover:bg-[var(--cp-success-bg-hover)]',
|
||||
warning: 'bg-[var(--cp-warning-bg)] text-[var(--cp-warning-text)] hover:bg-[var(--cp-warning-bg-hover)]',
|
||||
danger: 'bg-[var(--cp-danger-bg)] text-[var(--cp-danger-text)] hover:bg-[var(--cp-danger-bg-hover)]',
|
||||
plain: 'bg-[var(--cp-info-bg)] text-[var(--cp-info-text)] hover:bg-[var(--cp-info-bg-hover)]',
|
||||
text: 'h-auto bg-transparent p-0 text-[var(--cp-info-text)] hover:text-[var(--cp-info-hover)]',
|
||||
}
|
||||
|
||||
const buttonClasses = computed(() => [
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--cp-button-radius-base)] border-0 font-bold leading-none transition-colors disabled:cursor-not-allowed disabled:bg-[var(--cp-disabled-bg)] disabled:text-[var(--cp-disabled-text)]',
|
||||
sizeClasses[props.size],
|
||||
variantClasses[props.variant],
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :class="buttonClasses" :disabled="disabled || loading">
|
||||
<LoaderCircle v-if="loading" class="animate-spin" :size="14" />
|
||||
<span v-if="$slots.icon && !loading" class="inline-flex shrink-0">
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
<span>
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
padded?: boolean
|
||||
}>(), {
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
padded: true,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="overflow-hidden rounded-[var(--cp-card-radius)] bg-[var(--cp-bg-surface)] shadow-[var(--cp-shadow-card)]"
|
||||
:class="{ 'p-6 md:px-7': padded }"
|
||||
>
|
||||
<header v-if="title || description || $slots.actions" class="mb-6 flex items-start justify-between gap-5">
|
||||
<div class="min-w-0">
|
||||
<h2 v-if="title" class="m-0 text-xl font-[760] leading-[1.15] text-[var(--cp-text-primary)]">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="description" class="mt-[7px] mb-0 text-[13px] font-semibold leading-[1.15] text-[var(--cp-text-secondary)]">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="shrink-0">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid justify-items-center gap-2 rounded-[14px] bg-[var(--cp-bg-subtle)] p-5 text-center">
|
||||
<div class="h-1 w-7 rounded-full bg-[var(--cp-default-border-hover)]" />
|
||||
<p class="m-0 text-[13px] font-bold text-[var(--cp-text-primary)]">{{ title ?? '暂无数据' }}</p>
|
||||
<p v-if="description" class="m-0 text-xs font-semibold text-[var(--cp-text-secondary)]">{{ description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
label: string
|
||||
size?: 'default' | 'large'
|
||||
active?: boolean
|
||||
}>(), {
|
||||
size: 'default',
|
||||
active: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex items-center justify-center border-0 bg-[var(--cp-bg-surface)] text-[var(--cp-text-secondary)] shadow-[var(--cp-shadow-control)] transition-colors hover:bg-[var(--cp-default-bg-hover)] hover:text-[var(--cp-normal)]"
|
||||
:class="[
|
||||
size === 'large' ? 'h-11 w-11 rounded-xl' : 'h-9 w-9 rounded-xl',
|
||||
active ? 'bg-[var(--cp-default-bg-hover)] text-[var(--cp-normal)]' : '',
|
||||
]"
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
type="button"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
size?: 'small' | 'default' | 'large'
|
||||
}>(), {
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
error: undefined,
|
||||
size: 'default',
|
||||
})
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="grid gap-[7px]">
|
||||
<span
|
||||
class="inline-flex w-full items-center gap-2.5 rounded-[var(--cp-control-radius-base)] border border-transparent bg-[var(--cp-default-bg)] text-[var(--cp-text-primary)] transition focus-within:border-[var(--cp-info-border)] focus-within:bg-[var(--cp-bg-surface)] focus-within:shadow-[var(--cp-shadow-control)]"
|
||||
:class="[
|
||||
size === 'large' ? 'h-10 px-4' : size === 'small' ? 'h-6 rounded-[var(--cp-control-radius-small)] px-2' : 'h-8 px-3',
|
||||
error ? 'border-[var(--cp-danger-border)] bg-[var(--cp-danger-bg)]' : '',
|
||||
]"
|
||||
>
|
||||
<span v-if="$slots.prefix" class="inline-flex text-[var(--cp-text-muted)]">
|
||||
<slot name="prefix" />
|
||||
</span>
|
||||
<input
|
||||
v-model="model"
|
||||
class="min-w-0 flex-1 border-0 bg-transparent text-[13px] font-semibold text-[var(--cp-text-primary)] outline-0 placeholder:text-[var(--cp-text-muted)] disabled:text-[var(--cp-disabled-text)]"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="error" class="text-xs font-semibold text-[var(--cp-danger-text)]">{{ error }}</span>
|
||||
</label>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from '@lucide/vue'
|
||||
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import BaseIconButton from './BaseIconButton.vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="fixed inset-0 z-50 grid place-items-center p-6" role="presentation">
|
||||
<div class="absolute inset-0 bg-[var(--cp-overlay-scrim)]" @click="open = false" />
|
||||
<section
|
||||
class="relative w-[min(560px,100%)] rounded-[22px] bg-[var(--cp-bg-surface)] shadow-[var(--cp-shadow-popover)]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
>
|
||||
<header class="flex justify-between gap-4 p-6 pb-0">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-[760] text-[var(--cp-text-primary)]">{{ title }}</h2>
|
||||
<p v-if="description" class="mt-2 mb-0 text-[13px] font-semibold leading-tight text-[var(--cp-text-secondary)]">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<BaseIconButton label="关闭" @click="open = false">
|
||||
<X :size="16" />
|
||||
</BaseIconButton>
|
||||
</header>
|
||||
<div class="p-6">
|
||||
<slot />
|
||||
</div>
|
||||
<footer class="flex justify-end gap-3 px-6 pb-6">
|
||||
<slot name="footer">
|
||||
<BaseButton @click="open = false">取消</BaseButton>
|
||||
<BaseButton variant="primary" @click="open = false">确认</BaseButton>
|
||||
</slot>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
interface SegmentedOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
options: SegmentedOption[]
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex min-h-9 gap-1 rounded-xl bg-[var(--cp-bg-muted)] p-1" role="tablist">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="min-w-[74px] rounded-[9px] border-0 bg-transparent px-3 text-xs font-bold leading-none text-[var(--cp-text-secondary)] transition-colors"
|
||||
:class="model === option.value ? 'bg-[var(--cp-bg-surface)] text-[var(--cp-text-primary)] shadow-[var(--cp-shadow-control)]' : ''"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="model === option.value"
|
||||
@click="model = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
|
||||
interface SelectOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
options: SelectOption[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="relative inline-flex h-8 w-full rounded-[var(--cp-control-radius-base)] bg-[var(--cp-default-bg)]">
|
||||
<select
|
||||
v-model="model"
|
||||
class="h-full w-full appearance-none rounded-[inherit] border-0 bg-transparent pr-8 pl-3 text-[13px] font-semibold text-[var(--cp-text-primary)] outline-0"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<ChevronDown class="pointer-events-none absolute top-2 right-3 text-[var(--cp-text-secondary)]" :size="16" />
|
||||
</label>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<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-[var(--cp-text-secondary)]" role="status">
|
||||
<LoaderCircle class="animate-spin text-[var(--cp-info)]" :size="16" />
|
||||
<span>{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
interface TableColumn {
|
||||
key: string
|
||||
label: string
|
||||
width?: string
|
||||
align?: 'left' | 'right' | 'center'
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
columns: TableColumn[]
|
||||
rows: object[]
|
||||
}>()
|
||||
|
||||
function cellValue(row: object, key: string) {
|
||||
return (row as Record<string, unknown>)[key]
|
||||
}
|
||||
|
||||
function rowKey(row: object, index: number) {
|
||||
const id = cellValue(row, 'id')
|
||||
return typeof id === 'string' || typeof id === 'number' ? id : index
|
||||
}
|
||||
|
||||
function gridColumns(columns: TableColumn[]) {
|
||||
return columns.map((column) => column.width ?? '1fr').join(' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-full overflow-x-auto pb-1">
|
||||
<div class="grid min-w-[620px] gap-2">
|
||||
<div
|
||||
class="grid min-h-10 items-center rounded-xl bg-[var(--cp-bg-subtle)] text-[11px] font-bold text-[var(--cp-text-muted)]"
|
||||
:style="{ gridTemplateColumns: gridColumns(columns) }"
|
||||
>
|
||||
<div
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="min-w-0 overflow-hidden px-3 text-ellipsis whitespace-nowrap"
|
||||
:class="column.align === 'center' ? 'text-center' : column.align === 'right' ? 'text-right' : 'text-left'"
|
||||
>
|
||||
{{ column.label }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, index) in rows"
|
||||
:key="rowKey(row, index)"
|
||||
class="grid min-h-[52px] items-center rounded-[10px] bg-[var(--cp-bg-surface)] even:bg-[var(--cp-bg-subtle)]"
|
||||
:style="{ gridTemplateColumns: gridColumns(columns) }"
|
||||
>
|
||||
<div
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="min-w-0 overflow-hidden px-3 text-ellipsis whitespace-nowrap text-xs font-semibold text-[var(--cp-text-primary)]"
|
||||
:class="column.align === 'center' ? 'text-center' : column.align === 'right' ? 'text-right' : 'text-left'"
|
||||
>
|
||||
<slot :name="column.key" :row="row" :value="cellValue(row, column.key)">
|
||||
{{ cellValue(row, column.key) }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type ToastVariant = 'success' | 'warning' | 'danger' | 'info'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant?: ToastVariant
|
||||
title: string
|
||||
message?: string
|
||||
}>(), {
|
||||
variant: 'info',
|
||||
message: undefined,
|
||||
})
|
||||
|
||||
const icon = computed(() => {
|
||||
switch (props.variant) {
|
||||
case 'success':
|
||||
return CheckCircle2
|
||||
case 'warning':
|
||||
return TriangleAlert
|
||||
case 'danger':
|
||||
return AlertCircle
|
||||
default:
|
||||
return Info
|
||||
}
|
||||
})
|
||||
|
||||
const iconClasses: Record<ToastVariant, string> = {
|
||||
success: 'bg-[var(--cp-success-bg)] text-[var(--cp-success)]',
|
||||
warning: 'bg-[var(--cp-warning-bg)] text-[var(--cp-warning)]',
|
||||
danger: 'bg-[var(--cp-danger-bg)] text-[var(--cp-danger)]',
|
||||
info: 'bg-[var(--cp-info-bg)] text-[var(--cp-info)]',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex min-h-16 min-w-80 items-center gap-3 rounded-[18px] bg-[var(--cp-bg-surface)] px-4 shadow-[var(--cp-shadow-popover)]">
|
||||
<span class="inline-flex h-[38px] w-[38px] items-center justify-center rounded-xl" :class="iconClasses[variant]">
|
||||
<component :is="icon" :size="18" />
|
||||
</span>
|
||||
<span class="grid gap-[5px]">
|
||||
<strong class="text-[13px] font-[760] leading-none text-[var(--cp-text-primary)]">{{ title }}</strong>
|
||||
<span v-if="message" class="text-xs font-semibold leading-none text-[var(--cp-text-secondary)]">{{ message }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { useECharts } from '@/composables/useECharts'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
option: EChartsOption
|
||||
height?: number
|
||||
}>(), {
|
||||
height: 240,
|
||||
})
|
||||
|
||||
const chartElement = useTemplateRef<HTMLElement>('chart')
|
||||
const chartOption = shallowRef<EChartsOption>(props.option)
|
||||
|
||||
const style = computed(() => ({
|
||||
height: `${props.height}px`,
|
||||
}))
|
||||
|
||||
watch(
|
||||
() => props.option,
|
||||
(option) => {
|
||||
chartOption.value = option
|
||||
},
|
||||
)
|
||||
|
||||
useECharts(chartElement, chartOption)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="chart" class="w-full min-w-0" :style="style" />
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { init, type EChartsType } from 'echarts/core'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { onBeforeUnmount, shallowRef, watch, type ShallowRef } from 'vue'
|
||||
|
||||
import '@/plugins/echarts'
|
||||
|
||||
export function useECharts(
|
||||
target: Readonly<ShallowRef<HTMLElement | null>>,
|
||||
option: Readonly<ShallowRef<EChartsOption>>,
|
||||
) {
|
||||
const chart = shallowRef<EChartsType>()
|
||||
const observer = shallowRef<ResizeObserver>()
|
||||
|
||||
function resize() {
|
||||
chart.value?.resize()
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
observer.value?.disconnect()
|
||||
chart.value?.dispose()
|
||||
observer.value = undefined
|
||||
chart.value = undefined
|
||||
}
|
||||
|
||||
watch(
|
||||
target,
|
||||
(element) => {
|
||||
dispose()
|
||||
if (!element) return
|
||||
chart.value = init(element, undefined, { renderer: 'canvas' })
|
||||
chart.value.setOption(option.value)
|
||||
observer.value = new ResizeObserver(resize)
|
||||
observer.value.observe(element)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
option,
|
||||
(nextOption) => {
|
||||
chart.value?.setOption(nextOption, true)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(dispose)
|
||||
|
||||
return {
|
||||
chart,
|
||||
resize,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Box,
|
||||
ChartColumn,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
PanelLeftClose,
|
||||
Radar,
|
||||
ScrollText,
|
||||
Settings,
|
||||
SquareTerminal,
|
||||
Users,
|
||||
} from '@lucide/vue'
|
||||
|
||||
const navItems = [
|
||||
{ label: '概览', icon: LayoutDashboard, active: true, topClass: 'top-[112px]' },
|
||||
{ label: '账号池', icon: Users, active: false, topClass: 'top-[170px]' },
|
||||
{ label: 'API Keys', icon: KeyRound, active: false, topClass: 'top-[228px]' },
|
||||
{ label: '日志', icon: ScrollText, active: false, topClass: 'top-[286px]' },
|
||||
{ label: '用量', icon: ChartColumn, active: false, topClass: 'top-[344px]' },
|
||||
{ label: '模型', icon: Box, active: false, topClass: 'top-[402px]' },
|
||||
{ label: '设置', icon: Settings, active: false, topClass: 'top-[460px]' },
|
||||
{ label: '诊断', icon: Radar, active: false, topClass: 'top-[518px]' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="sticky top-0 z-20 hidden h-screen min-h-screen w-[280px] shrink-0 basis-[280px] bg-white shadow-[var(--cp-shadow-sidebar)] min-[961px]:block"
|
||||
>
|
||||
<div class="absolute left-[28px] top-[31px] inline-flex size-[46px] items-center justify-center rounded-[13px] bg-[#111827] text-white shadow-[0_8px_18px_-16px_#0E172614]">
|
||||
<SquareTerminal :size="24" />
|
||||
</div>
|
||||
|
||||
<div class="absolute left-[84px] top-[34px] text-[17px] font-[720] leading-[1.1] text-[#111827]">
|
||||
Codex
|
||||
</div>
|
||||
<div class="absolute left-[84px] top-[57px] flex h-4 w-[132px] items-center text-xs font-semibold leading-[1.1] text-[#64748B]">
|
||||
Proxy RS · v0.1.0
|
||||
</div>
|
||||
|
||||
<nav aria-label="主导航">
|
||||
<a
|
||||
v-for="item in navItems"
|
||||
:key="item.label"
|
||||
class="absolute left-6 h-[46px] w-[232px] rounded-xl no-underline transition-colors"
|
||||
:class="[
|
||||
item.topClass,
|
||||
item.active
|
||||
? 'bg-[#E9EEF5] text-[#111827]'
|
||||
: 'bg-transparent text-[#64748B] hover:bg-[#F8FAFC]',
|
||||
]"
|
||||
href="#"
|
||||
>
|
||||
<component :is="item.icon" class="absolute left-[22px] top-[13px]" :size="20" />
|
||||
<span
|
||||
class="absolute left-[54px] top-[15px] text-sm leading-[1.15]"
|
||||
:class="item.active ? 'font-bold' : 'font-semibold'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
class="absolute bottom-8 left-[220px] inline-flex size-9 items-center justify-center rounded-xl border-0 bg-[#F8FAFC] text-[#64748B]"
|
||||
type="button"
|
||||
aria-label="收缩侧边栏"
|
||||
>
|
||||
<PanelLeftClose :size="20" />
|
||||
</button>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
LogOut,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
ShieldCheck,
|
||||
User,
|
||||
} from '@lucide/vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative z-30 flex h-11 items-start gap-3.5">
|
||||
<button
|
||||
class="inline-flex h-11 w-[170px] items-center gap-2.5 rounded-xl border-0 bg-white px-3.5 text-[14px] leading-[1.15] font-[650] text-[var(--cp-text-primary)] shadow-[var(--cp-shadow-control)]"
|
||||
type="button"
|
||||
>
|
||||
<CalendarDays class="text-[var(--cp-info)]" :size="18" />
|
||||
<span>最近 24 小时</span>
|
||||
<ChevronDown class="text-[var(--cp-text-secondary)]" :size="16" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="inline-flex size-11 items-center justify-center rounded-xl border-0 bg-white text-[var(--cp-normal)] shadow-[var(--cp-shadow-control)]"
|
||||
type="button"
|
||||
aria-label="刷新"
|
||||
>
|
||||
<RefreshCw :size="19" />
|
||||
</button>
|
||||
|
||||
<div class="relative ml-0.5">
|
||||
<button
|
||||
class="grid h-11 w-[216px] grid-cols-[28px_minmax(0,1fr)_16px] items-center rounded-xl border-0 bg-white px-3 text-[var(--cp-text-secondary)] shadow-[var(--cp-shadow-control)]"
|
||||
type="button"
|
||||
>
|
||||
<span class="inline-flex size-7 items-center justify-center rounded-full bg-[var(--cp-normal)] text-[12px] leading-[1.15] font-bold text-white">A</span>
|
||||
<span class="grid gap-[5px] pl-3 text-left">
|
||||
<strong class="text-[13px] leading-[1.15] font-bold text-[var(--cp-text-primary)]">admin</strong>
|
||||
<span class="text-[11px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">管理员</span>
|
||||
</span>
|
||||
<ChevronUp class="text-[var(--cp-text-secondary)]" :size="16" />
|
||||
</button>
|
||||
|
||||
<div class="absolute right-0 top-14 h-[304px] w-80 rounded-[18px] bg-white pt-[18px] shadow-[var(--cp-shadow-popover)]">
|
||||
<div class="mx-[18px] 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-[var(--cp-normal)] text-[14px] leading-[1.15] font-bold text-white">A</span>
|
||||
<span class="grid gap-[5px]">
|
||||
<strong class="text-[15px] leading-[1.15] font-bold text-[var(--cp-text-primary)]">admin</strong>
|
||||
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">管理员</span>
|
||||
</span>
|
||||
<span class="inline-flex h-7 w-16 items-center gap-1.5 rounded-full bg-[var(--cp-success-bg)] px-3">
|
||||
<i class="size-1.5 rounded-full bg-[var(--cp-success)]" />
|
||||
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-success-text)]">在线</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-4 mt-3.5 grid h-11 w-[288px] grid-cols-2 rounded-xl bg-[var(--cp-bg-subtle)] px-4 py-2">
|
||||
<span class="grid gap-[5px]">
|
||||
<small class="text-[11px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">MFA</small>
|
||||
<strong class="text-[13px] leading-[1.15] font-bold text-[var(--cp-text-primary)]">已开启</strong>
|
||||
</span>
|
||||
<span class="grid gap-[5px] pl-[18px]">
|
||||
<small class="text-[11px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">会话</small>
|
||||
<strong class="text-[13px] leading-[1.15] font-bold text-[var(--cp-text-primary)]">1 台</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-4 mt-3.5 grid w-[288px] gap-1.5">
|
||||
<button
|
||||
class="grid h-9 grid-cols-[20px_minmax(0,1fr)_16px] items-center gap-3 rounded-[11px] border-0 bg-[var(--cp-bg-subtle)] px-3 text-left text-[var(--cp-text-primary)]"
|
||||
type="button"
|
||||
>
|
||||
<User :size="20" />
|
||||
<span class="text-[13px] leading-[1.15] font-[650]">个人设置</span>
|
||||
<ChevronRight class="text-[var(--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-[var(--cp-text-primary)]"
|
||||
type="button"
|
||||
>
|
||||
<ShieldCheck :size="20" />
|
||||
<span class="text-[13px] leading-[1.15] font-[650]">安全与会话</span>
|
||||
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">MFA</span>
|
||||
<ChevronRight class="text-[var(--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-[var(--cp-text-primary)]"
|
||||
type="button"
|
||||
>
|
||||
<ScrollText :size="20" />
|
||||
<span class="text-[13px] leading-[1.15] font-[650]">操作记录</span>
|
||||
<ChevronRight class="text-[var(--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-[var(--cp-danger-text)]"
|
||||
type="button"
|
||||
>
|
||||
<LogOut class="text-[var(--cp-danger)]" :size="20" />
|
||||
<span class="text-[13px] leading-[1.15] font-[650]">退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen bg-[var(--cp-bg-page)]">
|
||||
<AppSidebar />
|
||||
<main class="min-w-0 flex-1 overflow-x-auto">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import { pinia } from './stores'
|
||||
|
||||
import './styles/index.css'
|
||||
|
||||
createApp(App).use(pinia).use(router).mount('#app')
|
||||
@@ -0,0 +1,11 @@
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
use([
|
||||
LineChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
CanvasRenderer,
|
||||
])
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import { routes } from './routes'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory('/admin/'),
|
||||
routes,
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import AdminLayout from '@/layout/index.vue'
|
||||
import DashboardView from '@/views/dashboard/DashboardView.vue'
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: DashboardView,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export const pinia = createPinia()
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const sidebarCollapsed = shallowRef(false)
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface SessionUser {
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export const useSessionStore = defineStore('session', () => {
|
||||
const user: SessionUser = {
|
||||
name: 'admin',
|
||||
role: '管理员',
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 360px;
|
||||
background: var(--cp-bg-page);
|
||||
color: var(--cp-text-primary);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--cp-bg-page);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@import './tokens.css';
|
||||
@import './base.css';
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--color-cp-page: var(--cp-bg-page);
|
||||
--color-cp-surface: var(--cp-bg-surface);
|
||||
--color-cp-subtle: var(--cp-bg-subtle);
|
||||
--color-cp-muted: var(--cp-bg-muted);
|
||||
--color-cp-primary: var(--cp-text-primary);
|
||||
--color-cp-secondary: var(--cp-text-secondary);
|
||||
--color-cp-info: var(--cp-info);
|
||||
--color-cp-normal: var(--cp-normal);
|
||||
--color-cp-success: var(--cp-success);
|
||||
--color-cp-warning: var(--cp-warning);
|
||||
--color-cp-danger: var(--cp-danger);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--cp-bg-page: #f6f8fb;
|
||||
--cp-bg-surface: #ffffff;
|
||||
--cp-bg-subtle: #f8fafc;
|
||||
--cp-bg-muted: #f1f5f9;
|
||||
--cp-bg-nav-active: #e9eef5;
|
||||
--cp-bg-dark: #111827;
|
||||
--cp-white: #ffffff;
|
||||
--cp-transparent: #ffffff00;
|
||||
--cp-overlay-scrim: #0e17264d;
|
||||
|
||||
--cp-text-primary: #0e1726;
|
||||
--cp-text-strong: #111827;
|
||||
--cp-text-secondary: #64748b;
|
||||
--cp-text-muted: #94a3b8;
|
||||
|
||||
--cp-default-bg: #f8fafc;
|
||||
--cp-default-bg-hover: #f1f5f9;
|
||||
--cp-default-bg-active: #e9eef5;
|
||||
--cp-default-border: #e2e8f0;
|
||||
--cp-default-border-hover: #cbd5e1;
|
||||
--cp-default-text: #0e1726;
|
||||
--cp-default-text-hover: #111827;
|
||||
--cp-default-text-active: #0b1220;
|
||||
|
||||
--cp-disabled-bg: #f1f5f9;
|
||||
--cp-disabled-border: #e2e8f0;
|
||||
--cp-disabled-text: #94a3b8;
|
||||
--cp-disabled-icon: #cbd5e1;
|
||||
|
||||
--cp-info: #2563eb;
|
||||
--cp-info-hover: #1d4ed8;
|
||||
--cp-info-pressed: #1e40af;
|
||||
--cp-info-bg: #eef6ff;
|
||||
--cp-info-bg-hover: #dbeafe;
|
||||
--cp-info-bg-active: #bfdbfe;
|
||||
--cp-info-border: #93c5fd;
|
||||
--cp-info-text: #2563eb;
|
||||
--cp-info-on: #ffffff;
|
||||
|
||||
--cp-normal: #0f9f9a;
|
||||
--cp-normal-hover: #0d8d88;
|
||||
--cp-normal-pressed: #0b7370;
|
||||
--cp-normal-bg: #ecfdfd;
|
||||
--cp-normal-bg-hover: #ddf7f6;
|
||||
--cp-normal-bg-active: #c7efee;
|
||||
--cp-normal-border: #a7dfdd;
|
||||
--cp-normal-text: #0b7370;
|
||||
--cp-normal-on: #0b1220;
|
||||
|
||||
--cp-success: #12b981;
|
||||
--cp-success-hover: #0ea371;
|
||||
--cp-success-pressed: #0b865f;
|
||||
--cp-success-bg: #ecfdf5;
|
||||
--cp-success-bg-hover: #ddfbea;
|
||||
--cp-success-bg-active: #c8f5dc;
|
||||
--cp-success-border: #b7ead0;
|
||||
--cp-success-text: #047857;
|
||||
--cp-success-on: #0b1220;
|
||||
|
||||
--cp-warning: #f59e0b;
|
||||
--cp-warning-hover: #d97706;
|
||||
--cp-warning-pressed: #b45309;
|
||||
--cp-warning-bg: #fffbeb;
|
||||
--cp-warning-bg-hover: #fef3c7;
|
||||
--cp-warning-bg-active: #fde68a;
|
||||
--cp-warning-border: #fcd34d;
|
||||
--cp-warning-text: #b45309;
|
||||
--cp-warning-on: #0b1220;
|
||||
|
||||
--cp-danger: #ef4444;
|
||||
--cp-danger-hover: #dc2626;
|
||||
--cp-danger-pressed: #b91c1c;
|
||||
--cp-danger-bg: #fef2f2;
|
||||
--cp-danger-bg-hover: #fee2e2;
|
||||
--cp-danger-bg-active: #fecaca;
|
||||
--cp-danger-border: #fca5a5;
|
||||
--cp-danger-text: #b91c1c;
|
||||
--cp-danger-on: #0b1220;
|
||||
|
||||
--cp-control-height-small: 24px;
|
||||
--cp-control-height-default: 32px;
|
||||
--cp-control-height-large: 40px;
|
||||
--cp-control-padding-x: 12px;
|
||||
--cp-control-radius-small: 2px;
|
||||
--cp-control-radius-base: 4px;
|
||||
--cp-button-radius-small: 4px;
|
||||
--cp-button-radius-base: 6px;
|
||||
--cp-icon-button-radius: 8px;
|
||||
--cp-card-radius: 18px;
|
||||
--cp-panel-radius: 16px;
|
||||
--cp-tag-radius: 7px;
|
||||
--cp-radius-circle: 999px;
|
||||
|
||||
--cp-shadow-card: 0 10px 22px -18px #0e172614;
|
||||
--cp-shadow-control: 0 8px 18px -16px #0e172614;
|
||||
--cp-shadow-popover: 0 16px 34px -18px #0e172622;
|
||||
--cp-shadow-sidebar: 2px 0 12px -12px #0e172607;
|
||||
|
||||
--cp-space-page-x: 28px;
|
||||
--cp-space-page-y: 32px;
|
||||
--cp-gap-card: 24px;
|
||||
--cp-sidebar-width: 280px;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
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 RequestTrendCard from './components/RequestTrendCard.vue'
|
||||
import ServiceStatusCard from './components/ServiceStatusCard.vue'
|
||||
import { useDashboard } from './composables/useDashboard'
|
||||
|
||||
const {
|
||||
metrics,
|
||||
trendPoints,
|
||||
trendSummary,
|
||||
accountUsage,
|
||||
serviceStatuses,
|
||||
eventLogs,
|
||||
} = useDashboard()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ml-7 w-[1584px] pt-[34px] pb-[60px]">
|
||||
<header class="flex h-[68px] items-start justify-between">
|
||||
<div>
|
||||
<h1 class="mt-0 text-[34px] leading-[1.15] font-[800] mb-0 text-[var(--cp-text-primary)]">
|
||||
系统概览
|
||||
</h1>
|
||||
<p class="mt-2.5 text-[15px] leading-[1.15] font-semibold mb-0 text-[var(--cp-text-secondary)]">
|
||||
生产环境 · 14:32 更新 · 自动刷新 30s
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AppTopbar class="mt-0.5" />
|
||||
</header>
|
||||
|
||||
<section class="mt-6 grid grid-cols-4 gap-6" aria-label="核心指标">
|
||||
<MetricCard v-for="metric in metrics" :key="metric.title" :metric="metric" />
|
||||
</section>
|
||||
|
||||
<section class="mt-6 grid grid-cols-[948px_608px] gap-7">
|
||||
<RequestTrendCard :points="trendPoints" :summary="trendSummary" />
|
||||
<ServiceStatusCard :items="serviceStatuses" />
|
||||
</section>
|
||||
|
||||
<AccountOverviewCard :accounts="accountUsage" class="mt-6" />
|
||||
|
||||
<EventLogCard :rows="eventLogs" class="mt-6" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
|
||||
|
||||
import type { AccountUsageItem, SemanticTone } from '../types'
|
||||
|
||||
defineProps<{
|
||||
accounts: AccountUsageItem[]
|
||||
}>()
|
||||
|
||||
const scheduleStats = [
|
||||
{ label: '单账号并发', value: '3' },
|
||||
{ label: '可用槽位', value: '81' },
|
||||
{ label: '剩余槽位', value: '54' },
|
||||
]
|
||||
|
||||
const statusRows = [
|
||||
{
|
||||
label: '活跃账号',
|
||||
description: '可直接参与调度',
|
||||
value: '32',
|
||||
tone: 'success',
|
||||
icon: CircleCheck,
|
||||
},
|
||||
{
|
||||
label: '刷新中',
|
||||
description: '令牌自动刷新中',
|
||||
value: '2',
|
||||
tone: 'normal',
|
||||
icon: RefreshCw,
|
||||
},
|
||||
{
|
||||
label: '额度受限',
|
||||
description: '已触发或接近额度阈值',
|
||||
value: '6',
|
||||
tone: 'warning',
|
||||
icon: TriangleAlert,
|
||||
},
|
||||
{
|
||||
label: '不可用',
|
||||
description: '过期 4 · 禁用 3 · 封禁 0',
|
||||
value: '7',
|
||||
tone: 'danger',
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
] satisfies Array<{
|
||||
label: string
|
||||
description: string
|
||||
value: string
|
||||
tone: SemanticTone
|
||||
icon: typeof CircleCheck
|
||||
}>
|
||||
|
||||
const rowToneClasses: Record<SemanticTone, string> = {
|
||||
normal: 'bg-[var(--cp-normal-bg)] text-[var(--cp-normal)]',
|
||||
info: 'bg-[var(--cp-info-bg)] text-[var(--cp-info)]',
|
||||
success: 'bg-[var(--cp-success-bg)] text-[var(--cp-success)]',
|
||||
warning: 'bg-[var(--cp-warning-bg)] text-[var(--cp-warning)]',
|
||||
danger: 'bg-[var(--cp-danger-bg)] text-[var(--cp-danger)]',
|
||||
}
|
||||
|
||||
const valueToneClasses: Record<SemanticTone, string> = {
|
||||
normal: 'text-[var(--cp-normal-text)]',
|
||||
info: 'text-[var(--cp-info-text)]',
|
||||
success: 'text-[var(--cp-success-text)]',
|
||||
warning: 'text-[var(--cp-warning-text)]',
|
||||
danger: 'text-[var(--cp-danger-text)]',
|
||||
}
|
||||
|
||||
const loadToneClasses: Record<SemanticTone, string> = {
|
||||
normal: 'bg-[var(--cp-normal)]',
|
||||
info: 'bg-[var(--cp-info)]',
|
||||
success: 'bg-[var(--cp-success)]',
|
||||
warning: 'bg-[var(--cp-warning)]',
|
||||
danger: 'bg-[var(--cp-danger)]',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="h-[450px] w-[1584px] rounded-[18px] bg-white shadow-[var(--cp-shadow-card)]">
|
||||
<div class="grid grid-cols-[360px_556px_556px] gap-7 px-7 pt-6">
|
||||
<section class="h-[402px] w-[360px]">
|
||||
<h2 class="m-0 text-[20px] leading-[1.15] font-[760] text-[var(--cp-text-primary)]">账号调度</h2>
|
||||
<p class="mt-[7px] mb-0 text-[13px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">容量、并发与分配策略</p>
|
||||
|
||||
<div class="mt-[31px] h-[122px] rounded-[14px] bg-[var(--cp-bg-subtle)] px-4 pt-[18px]">
|
||||
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">可调度容量</span>
|
||||
<div class="mt-[7px] flex items-end justify-between">
|
||||
<strong class="font-mono text-[32px] leading-[1.05] font-[760] tabular-nums text-[var(--cp-text-primary)]">81 / 135</strong>
|
||||
<span class="pb-[3px] text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">60% 已分配</span>
|
||||
</div>
|
||||
<div class="mt-[17px] h-2.5 w-[328px] overflow-hidden rounded-full bg-[#E2E8F0]">
|
||||
<i class="block h-2.5 w-[197px] rounded-full bg-[var(--cp-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid h-[90px] grid-cols-3 rounded-[14px] bg-[var(--cp-bg-subtle)] px-4 pt-5">
|
||||
<div v-for="stat in scheduleStats" :key="stat.label">
|
||||
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">{{ stat.label }}</span>
|
||||
<strong class="mt-[13px] block font-mono text-[21px] leading-[1.1] font-[760] tabular-nums text-[var(--cp-text-primary)]">{{ stat.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 h-[82px] rounded-[14px] bg-[var(--cp-bg-subtle)] px-4 pt-[17px]">
|
||||
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">分配策略</span>
|
||||
<strong class="mt-[10px] block text-[17px] leading-[1.15] font-[650] text-[var(--cp-text-primary)]">最少使用优先</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="w-[556px]">
|
||||
<h2 class="m-0 text-[20px] leading-[1.15] font-[760] text-[var(--cp-text-primary)]">活跃账号用量</h2>
|
||||
<p class="mt-[7px] mb-0 text-[13px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">24h 请求排序</p>
|
||||
|
||||
<div class="mt-[51px] grid h-[330px] w-[556px] gap-1.5 overflow-hidden">
|
||||
<article
|
||||
v-for="account in accounts"
|
||||
:key="account.name"
|
||||
class="grid h-[78px] w-[528px] grid-cols-[34px_10px_120px_6px_70px_30px_64px_2px_70px_4px_84px] items-center rounded-[14px] px-3.5"
|
||||
:class="account.name === 'Amy Ops' || account.name === 'Build Bot' ? 'bg-[var(--cp-bg-subtle)]' : 'bg-white'"
|
||||
>
|
||||
<span class="inline-flex size-[34px] items-center justify-center rounded-full" :class="rowToneClasses[account.tone]">
|
||||
{{ account.name[0] }}
|
||||
</span>
|
||||
<span class="col-start-3 grid">
|
||||
<span>
|
||||
<strong class="text-[14px] leading-[1.15] font-[650] text-[var(--cp-text-primary)]">{{ account.name }}</strong>
|
||||
<small class="ml-1.5 text-[11px] leading-[1.15] font-[650] text-[var(--cp-text-muted)]">{{ account.plan }}</small>
|
||||
</span>
|
||||
<span class="mt-[12px] text-[12px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">{{ account.email }}</span>
|
||||
</span>
|
||||
<strong class="col-start-7 font-mono text-[14px] leading-[1.15] font-bold tabular-nums text-[var(--cp-text-primary)]">{{ account.requests }}</strong>
|
||||
<strong class="col-start-9 font-mono text-[14px] leading-[1.15] font-bold tabular-nums text-[var(--cp-text-primary)]">{{ account.tokens }}</strong>
|
||||
<span class="col-start-7 row-start-1 mt-11 text-[12px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">{{ account.lastUsed }}</span>
|
||||
<span class="col-start-11 row-start-1 mt-[19px] block h-1.5 w-[84px] overflow-hidden rounded-full bg-[var(--cp-bg-muted)]">
|
||||
<i class="block h-1.5 rounded-full" :class="loadToneClasses[account.tone]" :style="{ width: `${account.loadWidth}px` }" />
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="h-[402px] w-[556px]">
|
||||
<header class="flex h-[50px] items-start justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-[20px] leading-[1.15] font-[760] text-[var(--cp-text-primary)]">账号状态</h2>
|
||||
<p class="mt-1 mb-0 text-[13px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">账号池健康结构</p>
|
||||
</div>
|
||||
<div class="grid justify-items-end">
|
||||
<strong class="font-mono text-[24px] leading-[1.05] font-[760] tabular-nums text-[var(--cp-success-text)]">71.1%</strong>
|
||||
<span class="mt-0.5 text-[12px] leading-[1.15] font-bold text-[var(--cp-text-secondary)]">可用率</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-[22px] h-[42px] w-[532px]">
|
||||
<div class="flex h-4 items-center justify-between">
|
||||
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">状态分布</span>
|
||||
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-danger-text)]">不可用 7</span>
|
||||
</div>
|
||||
<div class="mt-2.5 flex h-3 w-[532px] overflow-hidden rounded-full bg-[var(--cp-bg-muted)]">
|
||||
<i class="h-3 w-[372px] bg-[var(--cp-success)]" />
|
||||
<i class="h-3 w-11 bg-[var(--cp-normal)]" />
|
||||
<i class="h-3 w-14 bg-[var(--cp-warning)]" />
|
||||
<i class="h-3 w-[60px] bg-[var(--cp-danger)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-[26px] grid h-[262px] w-[532px] gap-2.5">
|
||||
<div
|
||||
v-for="row in statusRows"
|
||||
:key="row.label"
|
||||
class="grid h-[58px] grid-cols-[28px_14px_minmax(0,1fr)_76px] items-center rounded-[14px] bg-[var(--cp-bg-subtle)] px-3.5"
|
||||
>
|
||||
<span class="inline-flex size-7 items-center justify-center rounded-[9px]" :class="rowToneClasses[row.tone]">
|
||||
<component :is="row.icon" :size="16" />
|
||||
</span>
|
||||
<span class="col-start-3 grid gap-1">
|
||||
<strong class="text-[14px] leading-[1.15] font-[650] text-[var(--cp-text-primary)]">{{ row.label }}</strong>
|
||||
<span class="text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">{{ row.description }}</span>
|
||||
</span>
|
||||
<strong class="col-start-4 text-right font-mono text-[17px] leading-[1.15] font-[760] tabular-nums" :class="valueToneClasses[row.tone]">{{ row.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import type { EventLogItem, SemanticTone } from '../types'
|
||||
|
||||
defineProps<{
|
||||
rows: EventLogItem[]
|
||||
}>()
|
||||
|
||||
const filters = ['全部', '警告', '错误']
|
||||
const columns = [
|
||||
{ label: '时间', className: '' },
|
||||
{ label: '级别', className: 'col-start-3' },
|
||||
{ label: '请求 ID', className: 'col-start-5' },
|
||||
{ label: '路由', className: 'col-start-7' },
|
||||
{ label: '模型', className: 'col-start-9' },
|
||||
{ label: '状态码', className: 'col-start-11' },
|
||||
{ label: '延迟', className: '[grid-column-start:13]' },
|
||||
]
|
||||
|
||||
const levelToneClasses: Record<SemanticTone, string> = {
|
||||
normal: 'bg-[var(--cp-normal-bg)] text-[var(--cp-normal-text)]',
|
||||
info: 'bg-[var(--cp-info-bg)] text-[var(--cp-info-text)]',
|
||||
success: 'bg-[var(--cp-success-bg)] text-[var(--cp-success-text)]',
|
||||
warning: 'bg-[var(--cp-warning-bg)] text-[var(--cp-warning-text)]',
|
||||
danger: 'bg-[var(--cp-danger-bg)] text-[var(--cp-danger-text)]',
|
||||
}
|
||||
|
||||
const statusToneClasses: Record<SemanticTone, string> = {
|
||||
normal: 'text-[var(--cp-normal-text)]',
|
||||
info: 'text-[var(--cp-success-text)]',
|
||||
success: 'text-[var(--cp-success-text)]',
|
||||
warning: 'text-[var(--cp-warning-text)]',
|
||||
danger: 'text-[var(--cp-danger-text)]',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="h-[350px] w-[1584px] rounded-[18px] bg-white px-7 pt-6 shadow-[var(--cp-shadow-card)]">
|
||||
<header class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-[20px] leading-[1.15] font-[760] text-[var(--cp-text-primary)]">事件日志</h2>
|
||||
<p class="mt-[7px] mb-0 text-[13px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">最近 50 条事件</p>
|
||||
</div>
|
||||
|
||||
<div class="grid h-9 w-[206px] grid-cols-[62px_58px_58px] gap-1 rounded-xl bg-[var(--cp-bg-muted)] p-1">
|
||||
<button
|
||||
v-for="filter in filters"
|
||||
:key="filter"
|
||||
class="h-7 rounded-[9px] border-0 text-[12px] leading-[1.15] font-[650]"
|
||||
:class="filter === '全部' ? 'bg-white text-[var(--cp-text-primary)] shadow-[var(--cp-shadow-control)]' : 'bg-transparent text-[var(--cp-text-secondary)]'"
|
||||
type="button"
|
||||
>
|
||||
{{ filter }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-[26px] h-10 w-[1528px] grid grid-cols-[150px_40px_90px_20px_230px_22px_250px_24px_170px_24px_96px_32px_100px] items-center rounded-xl bg-[var(--cp-bg-subtle)] px-5">
|
||||
<span
|
||||
v-for="column in columns"
|
||||
:key="column.label"
|
||||
class="text-[12px] leading-[1.15] font-bold text-[var(--cp-text-secondary)]"
|
||||
:class="column.className"
|
||||
>
|
||||
{{ column.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex h-[184px] w-[1528px] justify-between overflow-hidden">
|
||||
<div class="grid w-[1476px] gap-2">
|
||||
<div
|
||||
v-for="(row, index) in rows"
|
||||
:key="row.id"
|
||||
class="grid h-14 w-[1476px] grid-cols-[150px_40px_90px_20px_230px_22px_250px_24px_170px_24px_96px_32px_100px] items-center rounded-[10px] px-5"
|
||||
:class="index % 2 === 0 ? 'bg-white' : 'bg-[var(--cp-bg-subtle)]'"
|
||||
>
|
||||
<span class="font-mono text-[12px] leading-[1.15] font-[650] tabular-nums text-[var(--cp-text-primary)]">{{ row.time }}</span>
|
||||
<span class="col-start-3 inline-flex h-6 w-[58px] items-center justify-center rounded-full text-[12px] leading-[1.15] font-bold" :class="levelToneClasses[row.tone]">{{ row.level }}</span>
|
||||
<span class="col-start-5 font-mono text-[12px] leading-[1.15] font-[650] tabular-nums text-[var(--cp-text-primary)]">{{ row.requestId }}</span>
|
||||
<span class="col-start-7 text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-primary)]">{{ row.route }}</span>
|
||||
<span class="col-start-9 text-[12px] leading-[1.15] font-[650] text-[var(--cp-text-primary)]">{{ row.model }}</span>
|
||||
<span class="col-start-11 font-mono text-[12px] leading-[1.15] font-bold tabular-nums" :class="statusToneClasses[row.tone]">{{ row.statusCode }}</span>
|
||||
<span class="[grid-column-start:13] font-mono text-[12px] leading-[1.15] font-[650] tabular-nums text-[var(--cp-text-primary)]">{{ row.latency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-1.5 h-[172px] w-[3px] overflow-hidden rounded-full bg-[var(--cp-bg-muted)]">
|
||||
<i class="mt-2 block h-16 w-[3px] rounded-full bg-[var(--cp-text-muted)]" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { MetricCardItem, SemanticTone } from '../types'
|
||||
|
||||
defineProps<{
|
||||
metric: MetricCardItem
|
||||
}>()
|
||||
|
||||
const iconToneClasses: Record<SemanticTone, string> = {
|
||||
normal: 'bg-[var(--cp-normal-bg)] text-[var(--cp-normal)]',
|
||||
info: 'bg-[var(--cp-info-bg)] text-[var(--cp-info)]',
|
||||
success: 'bg-[var(--cp-success-bg)] text-[var(--cp-success)]',
|
||||
warning: 'bg-[var(--cp-warning-bg)] text-[var(--cp-warning)]',
|
||||
danger: 'bg-[var(--cp-danger-bg)] text-[var(--cp-danger)]',
|
||||
}
|
||||
|
||||
const detailToneClasses: Record<SemanticTone, string> = {
|
||||
normal: 'text-[var(--cp-normal-text)]',
|
||||
info: 'text-[var(--cp-info-text)]',
|
||||
success: 'text-[var(--cp-success-text)]',
|
||||
warning: 'text-[var(--cp-warning-text)]',
|
||||
danger: 'text-[var(--cp-danger-text)]',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="h-[154px] w-[378px] rounded-2xl bg-white px-6 pt-5 shadow-[var(--cp-shadow-card)]">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex size-[34px] shrink-0 items-center justify-center rounded-[10px]" :class="iconToneClasses[metric.tone]">
|
||||
<component :is="metric.icon" :size="18" />
|
||||
</span>
|
||||
<span class="mt-1 text-[13px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">{{ metric.title }}</span>
|
||||
</div>
|
||||
|
||||
<strong class="mt-2.5 block w-[330px] font-mono text-[28px] leading-[1.05] font-[780] tabular-nums text-[var(--cp-text-primary)]">
|
||||
{{ metric.value }}
|
||||
</strong>
|
||||
|
||||
<div class="mt-[16.6px] grid h-[30px] w-[330px] grid-cols-[40px_82px_48px_94px] items-center gap-0 rounded-[10px] bg-[var(--cp-bg-subtle)] px-3">
|
||||
<span class="text-[11px] leading-[1.15] font-[650] text-[var(--cp-text-muted)]">{{ metric.details[0]?.label }}</span>
|
||||
<b class="font-mono text-[12px] leading-[1.15] font-bold tabular-nums" :class="metric.details[0]?.tone ? detailToneClasses[metric.details[0].tone] : undefined">
|
||||
{{ metric.details[0]?.value }}
|
||||
</b>
|
||||
<span class="text-[11px] leading-[1.15] font-[650] text-[var(--cp-text-muted)]">{{ metric.details[1]?.label }}</span>
|
||||
<b class="text-right font-mono text-[12px] leading-[1.15] font-bold tabular-nums" :class="metric.details[1]?.tone ? detailToneClasses[metric.details[1].tone] : undefined">
|
||||
{{ metric.details[1]?.value }}
|
||||
</b>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import type { SemanticTone, TrendPoint, TrendSummaryItem } from '../types'
|
||||
|
||||
defineProps<{
|
||||
points: TrendPoint[]
|
||||
summary: TrendSummaryItem[]
|
||||
}>()
|
||||
|
||||
const tabs = ['请求', '错误', '延迟']
|
||||
const axisLabels = ['00', '04', '08', '12', '16', '20', '24']
|
||||
|
||||
const dotToneClasses: Record<SemanticTone, string> = {
|
||||
normal: 'bg-[var(--cp-normal)]',
|
||||
info: 'bg-[var(--cp-info)]',
|
||||
success: 'bg-[var(--cp-success)]',
|
||||
warning: 'bg-[var(--cp-warning)]',
|
||||
danger: 'bg-[var(--cp-danger)]',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="h-[380px] w-[948px] rounded-[18px] bg-white px-7 pt-[22px] shadow-[var(--cp-shadow-card)]">
|
||||
<header class="flex items-start justify-between">
|
||||
<div class="pt-0.5">
|
||||
<h2 class="m-0 text-[20px] leading-[1.15] font-[760] text-[var(--cp-text-primary)]">请求趋势</h2>
|
||||
<p class="mt-[7px] mb-0 text-[13px] leading-[1.15] font-[650] text-[var(--cp-text-secondary)]">最近 24 小时 · 14:00 峰值 15.2K</p>
|
||||
</div>
|
||||
|
||||
<div class="grid h-[38px] w-[246px] grid-cols-3 gap-1 rounded-xl bg-[var(--cp-bg-muted)] p-1">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab"
|
||||
class="h-[30px] rounded-[9px] border-0 text-[12px] leading-[1.15] font-[650]"
|
||||
:class="tab === '请求' ? 'bg-white text-[var(--cp-text-primary)] shadow-[0_10px_24px_-18px_#0E172614]' : 'bg-transparent text-[var(--cp-text-secondary)]'"
|
||||
type="button"
|
||||
>
|
||||
{{ tab }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-7 grid grid-cols-[682px_180px] gap-[30px]">
|
||||
<div class="h-[268px] w-[682px] overflow-hidden rounded-[10px] bg-white">
|
||||
<svg class="block h-[268px] w-[682px]" viewBox="0 0 682 268" role="img" aria-label="最近 24 小时请求趋势">
|
||||
<g transform="translate(0 0)">
|
||||
<line v-for="y in [18, 62, 106, 150, 194, 238]" :key="y" x1="0" x2="650" :y1="y" :y2="y" stroke="#F1F5F9" />
|
||||
<path
|
||||
d="M0 150c70-14 96-20 136-38 48-24 74-20 108-58 42-42 78-22 116 6 44 32 82 18 124-10 44-24 82-16 120 30 18 22 34 12 46-6"
|
||||
fill="none"
|
||||
stroke="#2563EB"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3"
|
||||
transform="translate(0 34) scale(1 0.838983)"
|
||||
/>
|
||||
<path
|
||||
d="M0 70c80-2 122-8 170-6 56 2 116-14 166-8 62 6 94 18 142 2 48-16 108-2 172-10"
|
||||
fill="none"
|
||||
stroke="#EF4444"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
transform="translate(0 126) scale(1 0.813559)"
|
||||
/>
|
||||
<path
|
||||
d="M0 44c72 4 116-8 174-4 58 2 110-6 166-2 66 8 118-8 174-4 60 6 100-4 136-8"
|
||||
fill="none"
|
||||
stroke="#0F9F9A"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
transform="translate(0 140) scale(1 0.791667)"
|
||||
/>
|
||||
</g>
|
||||
<g font-family="JetBrains Mono, SFMono-Regular, Consolas, monospace" font-size="11" font-weight="700" fill="#94A3B8">
|
||||
<text v-for="(label, index) in axisLabels" :key="label" :x="[0, 108, 216, 324, 432, 540, 632][index]" y="261">{{ label }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<aside class="h-[268px] w-[180px] rounded-2xl bg-[var(--cp-bg-subtle)] px-5 py-[18px]">
|
||||
<div
|
||||
v-for="(item, index) in summary"
|
||||
:key="item.label"
|
||||
class="grid grid-cols-[100px_8px] items-start gap-3"
|
||||
:class="index === 0 ? '' : 'mt-[38px]'"
|
||||
>
|
||||
<span class="col-span-2 text-[12px] leading-[1.15] font-bold text-[var(--cp-text-secondary)]">{{ item.label }}</span>
|
||||
<strong class="mt-[7px] font-mono text-[24px] leading-[1.15] font-[760] tabular-nums text-[var(--cp-text-primary)]">{{ item.value }}</strong>
|
||||
<i class="mt-[14px] size-2 rounded-full" :class="dotToneClasses[item.tone]" />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { SemanticTone, ServiceStatusItem } from '../types'
|
||||
|
||||
defineProps<{
|
||||
items: ServiceStatusItem[]
|
||||
}>()
|
||||
|
||||
const iconToneClasses: Record<SemanticTone, string> = {
|
||||
normal: 'bg-[var(--cp-normal-bg)] text-[var(--cp-normal)]',
|
||||
info: 'bg-[var(--cp-info-bg)] text-[var(--cp-info)]',
|
||||
success: 'bg-[var(--cp-success-bg)] text-[var(--cp-success)]',
|
||||
warning: 'bg-[var(--cp-warning-bg)] text-[var(--cp-warning)]',
|
||||
danger: 'bg-[var(--cp-danger-bg)] text-[var(--cp-danger)]',
|
||||
}
|
||||
|
||||
const valueToneClasses: Record<SemanticTone, string> = {
|
||||
normal: 'text-[var(--cp-normal-text)]',
|
||||
info: 'text-[var(--cp-info-text)]',
|
||||
success: 'text-[var(--cp-success-text)]',
|
||||
warning: 'text-[var(--cp-warning-text)]',
|
||||
danger: 'text-[var(--cp-danger-text)]',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="h-[380px] w-[608px] rounded-[18px] bg-white px-7 pt-6 shadow-[var(--cp-shadow-card)]">
|
||||
<h2 class="m-0 text-[20px] leading-[1.15] font-[760] text-[var(--cp-text-primary)]">服务状态</h2>
|
||||
|
||||
<div class="mt-[23px] grid gap-2">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
class="grid h-[50px] w-[552px] grid-cols-[24px_16px_224px_16px_86px_34px_100px] items-center rounded-[14px] bg-[var(--cp-bg-subtle)] px-3.5"
|
||||
>
|
||||
<span class="inline-flex size-6 items-center justify-center rounded-lg" :class="iconToneClasses[item.tone]">
|
||||
<component :is="item.icon" :size="14" />
|
||||
</span>
|
||||
<strong class="col-start-3 w-40 text-[13px] leading-[1.15] font-[650] text-[var(--cp-text-primary)]">{{ item.label }}</strong>
|
||||
<span class="col-start-5 w-[86px] text-[13px] leading-[1.15] font-bold" :class="valueToneClasses[item.tone]">{{ item.value }}</span>
|
||||
<span class="col-start-7 w-[100px] font-mono text-[12px] leading-[1.15] font-semibold text-[var(--cp-text-secondary)]">{{ item.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
Activity,
|
||||
Boxes,
|
||||
CloudCheck,
|
||||
FileText,
|
||||
MonitorCheck,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
Timer,
|
||||
Users,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import type {
|
||||
AccountUsageItem,
|
||||
EventLogItem,
|
||||
MetricCardItem,
|
||||
ServiceStatusItem,
|
||||
TrendPoint,
|
||||
TrendSummaryItem,
|
||||
} from '../types'
|
||||
|
||||
export function useDashboard() {
|
||||
const metrics: MetricCardItem[] = [
|
||||
{
|
||||
title: '账号',
|
||||
value: '33',
|
||||
icon: Users,
|
||||
tone: 'normal',
|
||||
details: [
|
||||
{ label: '启用', value: '1', tone: 'normal' },
|
||||
{ label: '错误', value: '31', tone: 'danger' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '今日请求',
|
||||
value: '0',
|
||||
icon: Activity,
|
||||
tone: 'info',
|
||||
details: [
|
||||
{ label: '今日', value: '0', tone: 'info' },
|
||||
{ label: '总计', value: '3,363', tone: 'info' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '总 Token',
|
||||
value: '401.29M',
|
||||
icon: FileText,
|
||||
tone: 'success',
|
||||
details: [
|
||||
{ label: '今日', value: '0', tone: 'success' },
|
||||
{ label: '总计', value: '401.29M', tone: 'success' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '平均响应',
|
||||
value: '21.71s',
|
||||
icon: Timer,
|
||||
tone: 'warning',
|
||||
details: [
|
||||
{ label: '今日', value: '21.71s', tone: 'warning' },
|
||||
{ label: '目标', value: '< 3s', tone: 'warning' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const trendPoints: TrendPoint[] = [
|
||||
{ time: '00', requests: 0, tokens: 0, errors: 0 },
|
||||
{ time: '04', requests: 0, tokens: 0, errors: 0 },
|
||||
{ time: '08', requests: 0, tokens: 0, errors: 0 },
|
||||
{ time: '12', requests: 0, tokens: 0, errors: 0 },
|
||||
{ time: '16', requests: 0, tokens: 0, errors: 0 },
|
||||
{ time: '20', requests: 0, tokens: 0, errors: 0 },
|
||||
{ time: '24', requests: 0, tokens: 0, errors: 0 },
|
||||
]
|
||||
|
||||
const trendSummary: TrendSummaryItem[] = [
|
||||
{ label: '成功率', value: '99.88%', tone: 'success' },
|
||||
{ label: '峰值', value: '15.2K', tone: 'info' },
|
||||
{ label: '慢请求', value: '42', tone: 'warning' },
|
||||
]
|
||||
|
||||
const accountUsage: AccountUsageItem[] = [
|
||||
{
|
||||
name: 'Amy Ops',
|
||||
email: 'amy.ops@example.com',
|
||||
plan: 'plus',
|
||||
requests: '12.8K',
|
||||
tokens: '382K',
|
||||
lastUsed: '2分钟前',
|
||||
tone: 'info',
|
||||
loadWidth: 84,
|
||||
},
|
||||
{
|
||||
name: 'Team Codex',
|
||||
email: 'team-codex@example.com',
|
||||
plan: 'pro',
|
||||
requests: '9.4K',
|
||||
tokens: '241K',
|
||||
lastUsed: '8分钟前',
|
||||
tone: 'normal',
|
||||
loadWidth: 70,
|
||||
},
|
||||
{
|
||||
name: 'Build Bot',
|
||||
email: 'build@proxy.local',
|
||||
plan: 'team',
|
||||
requests: '7.1K',
|
||||
tokens: '198K',
|
||||
lastUsed: '16分钟前',
|
||||
tone: 'success',
|
||||
loadWidth: 58,
|
||||
},
|
||||
{
|
||||
name: 'Reviewer',
|
||||
email: 'reviewer@example.com',
|
||||
plan: 'plus',
|
||||
requests: '6.8K',
|
||||
tokens: '176K',
|
||||
lastUsed: '24分钟前',
|
||||
tone: 'warning',
|
||||
loadWidth: 48,
|
||||
},
|
||||
]
|
||||
|
||||
const serviceStatuses: ServiceStatusItem[] = [
|
||||
{ label: '上游连接', value: '正常', detail: '23ms', tone: 'success', icon: CloudCheck },
|
||||
{ label: '模型目录', value: '已同步', detail: '58 个模型', tone: 'info', icon: Boxes },
|
||||
{ label: '自动刷新', value: '开启', detail: '2 并发', tone: 'success', icon: RefreshCw },
|
||||
{ label: '事件记录', value: '关闭', detail: '已存 1.8K', tone: 'warning', icon: ScrollText },
|
||||
{ label: '客户端版本', value: '可用', detail: '26.519', tone: 'normal', icon: MonitorCheck },
|
||||
]
|
||||
|
||||
const eventLogs: EventLogItem[] = [
|
||||
{
|
||||
id: 'evt_01',
|
||||
time: '14:30:25',
|
||||
level: '信息',
|
||||
requestId: 'req_01HW9K7QY2',
|
||||
route: '/v1/responses',
|
||||
model: 'gpt-5.5',
|
||||
statusCode: '200',
|
||||
latency: '1.25s',
|
||||
tone: 'info',
|
||||
},
|
||||
{
|
||||
id: 'evt_02',
|
||||
time: '14:30:15',
|
||||
level: '警告',
|
||||
requestId: 'req_01HW9K6N4F',
|
||||
route: '/v1/chat/completions',
|
||||
model: 'gpt-5.5',
|
||||
statusCode: '429',
|
||||
latency: '2.15s',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
id: 'evt_03',
|
||||
time: '14:30:10',
|
||||
level: '错误',
|
||||
requestId: 'req_01HW9K5Z7A',
|
||||
route: '/v1/responses',
|
||||
model: 'gpt-5.5',
|
||||
statusCode: '502',
|
||||
latency: '5.32s',
|
||||
tone: 'danger',
|
||||
},
|
||||
{
|
||||
id: 'evt_04',
|
||||
time: '14:29:56',
|
||||
level: '信息',
|
||||
requestId: 'req_01HW9K4C3Q',
|
||||
route: '/v1/models',
|
||||
model: 'catalog',
|
||||
statusCode: '200',
|
||||
latency: '0.84s',
|
||||
tone: 'info',
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
metrics,
|
||||
trendPoints,
|
||||
trendSummary,
|
||||
accountUsage,
|
||||
serviceStatuses,
|
||||
eventLogs,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export type SemanticTone = 'normal' | 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
export interface MetricDetail {
|
||||
label: string
|
||||
value: string
|
||||
tone?: SemanticTone
|
||||
}
|
||||
|
||||
export interface MetricCardItem {
|
||||
title: string
|
||||
value: string
|
||||
icon: Component
|
||||
tone: SemanticTone
|
||||
details: MetricDetail[]
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
time: string
|
||||
requests: number
|
||||
tokens: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export interface TrendSummaryItem {
|
||||
label: string
|
||||
value: string
|
||||
tone: SemanticTone
|
||||
}
|
||||
|
||||
export interface AccountUsageItem {
|
||||
name: string
|
||||
email: string
|
||||
plan: string
|
||||
requests: string
|
||||
tokens: string
|
||||
lastUsed: string
|
||||
tone: SemanticTone
|
||||
loadWidth: number
|
||||
}
|
||||
|
||||
export interface ServiceStatusItem {
|
||||
label: string
|
||||
value: string
|
||||
detail: string
|
||||
tone: SemanticTone
|
||||
icon: Component
|
||||
}
|
||||
|
||||
export interface EventLogItem {
|
||||
id: string
|
||||
time: string
|
||||
level: string
|
||||
requestId: string
|
||||
route: string
|
||||
model: string
|
||||
statusCode: string
|
||||
latency: string
|
||||
tone: SemanticTone
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
async function source(path) {
|
||||
return readFile(resolve(root, path), 'utf8')
|
||||
}
|
||||
|
||||
function assertTokens(sourceText, tokens) {
|
||||
for (const token of tokens) {
|
||||
assert.match(
|
||||
sourceText,
|
||||
new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
`Expected token: ${token}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoAbsoluteLayout(sourceText, label) {
|
||||
assert.doesNotMatch(
|
||||
sourceText,
|
||||
/\babsolute\b/,
|
||||
`${label} should use grid/flex/margin/padding flow layout, not absolute positioning`,
|
||||
)
|
||||
}
|
||||
|
||||
test('dashboard page recreates the Pencil canvas with flow layout spacing', async () => {
|
||||
const view = await source('src/views/dashboard/DashboardView.vue')
|
||||
|
||||
assertNoAbsoluteLayout(view, 'DashboardView')
|
||||
assertTokens(view, [
|
||||
'ml-7 w-[1584px] pt-[34px] pb-[60px]',
|
||||
'flex h-[68px] items-start justify-between',
|
||||
'mt-0 text-[34px] leading-[1.15] font-[800]',
|
||||
'mt-2.5 text-[15px] leading-[1.15] font-semibold',
|
||||
'mt-6 grid grid-cols-4 gap-6',
|
||||
'mt-6 grid grid-cols-[948px_608px] gap-7',
|
||||
'mt-6',
|
||||
'MetricCard',
|
||||
'RequestTrendCard',
|
||||
'ServiceStatusCard',
|
||||
'AccountOverviewCard',
|
||||
'EventLogCard',
|
||||
])
|
||||
})
|
||||
|
||||
test('topbar controls are normal flex items and only the popover floats', async () => {
|
||||
const topbar = await source('src/layout/components/AppTopbar.vue')
|
||||
|
||||
assert.doesNotMatch(topbar, /absolute left-\[/)
|
||||
assertTokens(topbar, [
|
||||
'flex h-11 items-start gap-3.5',
|
||||
'h-11 w-[170px]',
|
||||
'size-11',
|
||||
'h-11 w-[216px]',
|
||||
'relative',
|
||||
'absolute right-0 top-14',
|
||||
'h-[304px] w-80',
|
||||
'CalendarDays',
|
||||
'RefreshCw',
|
||||
'ChevronUp',
|
||||
'User',
|
||||
'ShieldCheck',
|
||||
'ScrollText',
|
||||
'LogOut',
|
||||
'MFA',
|
||||
'已开启',
|
||||
'会话',
|
||||
'1 台',
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboard cards use grid and flex primitives with exact Pencil dimensions', async () => {
|
||||
const metric = await source('src/views/dashboard/components/MetricCard.vue')
|
||||
const trend = await source('src/views/dashboard/components/RequestTrendCard.vue')
|
||||
const service = await source('src/views/dashboard/components/ServiceStatusCard.vue')
|
||||
const account = await source('src/views/dashboard/components/AccountOverviewCard.vue')
|
||||
const events = await source('src/views/dashboard/components/EventLogCard.vue')
|
||||
|
||||
for (const [label, file] of [
|
||||
['MetricCard', metric],
|
||||
['RequestTrendCard', trend],
|
||||
['ServiceStatusCard', service],
|
||||
['AccountOverviewCard', account],
|
||||
['EventLogCard', events],
|
||||
]) {
|
||||
assertNoAbsoluteLayout(file, label)
|
||||
}
|
||||
|
||||
assertTokens(metric, [
|
||||
'h-[154px] w-[378px]',
|
||||
'px-6 pt-5',
|
||||
'flex items-start gap-3',
|
||||
'mt-2.5',
|
||||
'mt-[16.6px]',
|
||||
'h-[30px] w-[330px]',
|
||||
])
|
||||
|
||||
assertTokens(trend, [
|
||||
'h-[380px] w-[948px]',
|
||||
'px-7 pt-[22px]',
|
||||
'flex items-start justify-between',
|
||||
'h-[38px] w-[246px]',
|
||||
'mt-7 grid grid-cols-[682px_180px] gap-[30px]',
|
||||
'h-[268px] w-[682px]',
|
||||
'M0 150c70-14 96-20 136-38 48-24 74-20 108-58 42-42 78-22 116 6 44 32 82 18 124-10 44-24 82-16 120 30 18 22 34 12 46-6',
|
||||
'M0 70c80-2 122-8 170-6 56 2 116-14 166-8 62 6 94 18 142 2 48-16 108-2 172-10',
|
||||
'M0 44c72 4 116-8 174-4 58 2 110-6 166-2 66 8 118-8 174-4 60 6 100-4 136-8',
|
||||
])
|
||||
|
||||
assertTokens(service, [
|
||||
'h-[380px] w-[608px]',
|
||||
'px-7 pt-6',
|
||||
'mt-[23px] grid gap-2',
|
||||
'h-[50px] w-[552px]',
|
||||
])
|
||||
|
||||
assertTokens(account, [
|
||||
'h-[450px] w-[1584px]',
|
||||
'grid grid-cols-[360px_556px_556px] gap-7 px-7 pt-6',
|
||||
'h-[402px] w-[360px]',
|
||||
'h-[330px] w-[556px]',
|
||||
'h-[402px] w-[556px]',
|
||||
'81 / 135',
|
||||
'60% 已分配',
|
||||
'最少使用优先',
|
||||
'71.1%',
|
||||
])
|
||||
|
||||
assertTokens(events, [
|
||||
'h-[350px] w-[1584px]',
|
||||
'px-7 pt-6',
|
||||
'flex items-start justify-between',
|
||||
'h-9 w-[206px]',
|
||||
'mt-[26px] h-10 w-[1528px]',
|
||||
'mt-4 flex h-[184px] w-[1528px] justify-between overflow-hidden',
|
||||
'请求 ID',
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboard data mirrors Pencil text values', async () => {
|
||||
const data = await source('src/views/dashboard/composables/useDashboard.ts')
|
||||
|
||||
assertTokens(data, [
|
||||
'启用',
|
||||
'错误',
|
||||
'3,363',
|
||||
'< 3s',
|
||||
'成功率',
|
||||
'慢请求',
|
||||
'Amy Ops',
|
||||
'Team Codex',
|
||||
'Build Bot',
|
||||
'Reviewer',
|
||||
'team-codex@example.com',
|
||||
'24分钟前',
|
||||
'上游连接',
|
||||
'模型目录',
|
||||
'自动刷新',
|
||||
'事件记录',
|
||||
'客户端版本',
|
||||
'CloudCheck',
|
||||
'Boxes',
|
||||
'MonitorCheck',
|
||||
'req_01HW9K7QY2',
|
||||
'req_01HW9K6N4F',
|
||||
'req_01HW9K5Z7A',
|
||||
'req_01HW9K4C3Q',
|
||||
'/v1/chat/completions',
|
||||
'catalog',
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { test } from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const sidebarPath = resolve(root, 'src/layout/components/AppSidebar.vue')
|
||||
|
||||
test('sidebar uses Pencil design icons and brand copy', async () => {
|
||||
const source = await readFile(sidebarPath, 'utf8')
|
||||
|
||||
for (const token of [
|
||||
'SquareTerminal',
|
||||
'LayoutDashboard',
|
||||
'Users',
|
||||
'KeyRound',
|
||||
'ScrollText',
|
||||
'ChartColumn',
|
||||
'Box',
|
||||
'Settings',
|
||||
'Radar',
|
||||
'PanelLeftClose',
|
||||
'Proxy RS · v0.1.0',
|
||||
]) {
|
||||
assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||
}
|
||||
})
|
||||
|
||||
test('sidebar locks core Pencil dimensions and coordinates', async () => {
|
||||
const source = await readFile(sidebarPath, 'utf8')
|
||||
|
||||
for (const token of [
|
||||
'w-[280px]',
|
||||
'basis-[280px]',
|
||||
'left-[28px]',
|
||||
'top-[31px]',
|
||||
'size-[46px]',
|
||||
'left-[84px]',
|
||||
'top-[34px]',
|
||||
'top-[57px]',
|
||||
'left-6',
|
||||
'w-[232px]',
|
||||
'h-[46px]',
|
||||
'left-[22px]',
|
||||
'top-[13px]',
|
||||
'left-[54px]',
|
||||
'top-[15px]',
|
||||
'top-[112px]',
|
||||
'top-[170px]',
|
||||
'top-[228px]',
|
||||
'top-[286px]',
|
||||
'top-[344px]',
|
||||
'top-[402px]',
|
||||
'top-[460px]',
|
||||
'top-[518px]',
|
||||
'left-[220px]',
|
||||
'bottom-8',
|
||||
'size-9',
|
||||
]) {
|
||||
assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"include": ["vite.config.ts"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2024",
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8080',
|
||||
'/auth': 'http://127.0.0.1:8080',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
{
|
||||
name: 'echarts',
|
||||
test: /node_modules\/echarts|node_modules\/zrender/,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user