refactor(frontend): optimize view pages (admin, shared, user)

This commit is contained in:
fawney19
2025-12-13 22:26:47 +08:00
parent 9ca845f9d0
commit d564842c4d
14 changed files with 980 additions and 268 deletions

View File

@@ -2,12 +2,12 @@
<div class="flex flex-col"> <div class="flex flex-col">
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<!-- 搜索和过滤区域 --> <!-- 搜索和过滤区域 -->
<div class="px-6 py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<h3 class="text-base font-semibold"> <h3 class="text-sm sm:text-base font-semibold shrink-0">
别名管理 别名管理
</h3> </h3>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="relative"> <div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" /> <Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
@@ -15,11 +15,11 @@
id="alias-search" id="alias-search"
v-model="aliasesSearch" v-model="aliasesSearch"
placeholder="搜索别名或关联模型" placeholder="搜索别名或关联模型"
class="w-44 pl-8 pr-3 h-8 text-sm border-border/60 focus-visible:ring-1" class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm border-border/60 focus-visible:ring-1"
/> />
</div> </div>
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 提供商过滤器 --> <!-- 提供商过滤器 -->
<Select <Select
@@ -27,7 +27,7 @@
:model-value="aliasProviderFilter" :model-value="aliasProviderFilter"
@update:model-value="aliasProviderFilter = $event" @update:model-value="aliasProviderFilter = $event"
> >
<SelectTrigger class="w-40 h-8 text-xs border-border/60"> <SelectTrigger class="w-28 sm:w-40 h-8 text-xs border-border/60">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -47,7 +47,7 @@
</SelectContent> </SelectContent>
</Select> </Select>
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 操作按钮 --> <!-- 操作按钮 -->
<Button <Button
@@ -73,7 +73,7 @@
<Loader2 class="w-10 h-10 animate-spin text-primary" /> <Loader2 class="w-10 h-10 animate-spin text-primary" />
</div> </div>
<div v-else> <div v-else>
<Table class="text-sm"> <Table class="hidden xl:table text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="w-[200px]"> <TableHead class="w-[200px]">
@@ -185,6 +185,83 @@
</TableBody> </TableBody>
</Table> </Table>
<!-- 移动端卡片列表 -->
<div
v-if="filteredAliases.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="alias in paginatedAliases"
:key="alias.id"
class="p-4 space-y-2"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-mono font-medium truncate">{{ alias.alias }}</span>
<Badge
:variant="alias.is_active ? 'default' : 'secondary'"
class="text-xs shrink-0"
>
{{ alias.is_active ? '活跃' : '停用' }}
</Badge>
</div>
<div class="text-xs text-muted-foreground mt-1">
<span class="font-medium">{{ alias.global_model_display_name || alias.global_model_name }}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="openEditAliasDialog(alias)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="toggleAliasStatus(alias)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="confirmDeleteAlias(alias)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div class="flex items-center gap-2">
<Badge
variant="secondary"
class="text-xs"
>
{{ alias.mapping_type === 'mapping' ? '映射' : '别名' }}
</Badge>
<Badge
v-if="alias.provider_id"
variant="outline"
class="text-xs"
>
{{ alias.provider_name || 'Provider 特定' }}
</Badge>
<Badge
v-else
variant="default"
class="text-xs"
>
全局
</Badge>
</div>
</div>
</div>
<!-- 分页 --> <!-- 分页 -->
<Pagination <Pagination
v-if="!loadingAliases && filteredAliases.length > 0" v-if="!loadingAliases && filteredAliases.length > 0"

View File

@@ -14,10 +14,10 @@
</div> </div>
<div v-else> <div v-else>
<div class="px-6 py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div> <div class="shrink-0">
<h3 class="text-base font-semibold"> <h3 class="text-sm sm:text-base font-semibold">
独立余额 API Keys 独立余额 API Keys
</h3> </h3>
<p class="text-xs text-muted-foreground mt-0.5"> <p class="text-xs text-muted-foreground mt-0.5">
@@ -28,7 +28,7 @@
> · 即将到期 {{ expiringSoonCount }}</span> > · 即将到期 {{ expiringSoonCount }}</span>
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="relative"> <div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" /> <Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
@@ -36,19 +36,19 @@
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
placeholder="搜索..." placeholder="搜索..."
class="h-8 w-40 pl-8 pr-2 text-xs" class="h-8 w-28 sm:w-40 pl-8 pr-2 text-xs"
/> />
</div> </div>
<!-- 分隔线 --> <!-- 分隔线 -->
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 状态筛选 --> <!-- 状态筛选 -->
<Select <Select
v-model="filterStatus" v-model="filterStatus"
v-model:open="filterStatusOpen" v-model:open="filterStatusOpen"
> >
<SelectTrigger class="w-28 h-8 text-xs border-border/60"> <SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" /> <SelectValue placeholder="全部状态" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -67,7 +67,7 @@
v-model="filterBalance" v-model="filterBalance"
v-model:open="filterBalanceOpen" v-model:open="filterBalanceOpen"
> >
<SelectTrigger class="w-28 h-8 text-xs border-border/60"> <SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部类型" /> <SelectValue placeholder="全部类型" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -82,7 +82,7 @@
</Select> </Select>
<!-- 分隔线 --> <!-- 分隔线 -->
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 创建独立 Key 按钮 --> <!-- 创建独立 Key 按钮 -->
<Button <Button

View File

@@ -6,17 +6,17 @@
class="overflow-hidden" class="overflow-hidden"
> >
<!-- 标题和操作栏 --> <!-- 标题和操作栏 -->
<div class="px-6 py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div> <div class="shrink-0">
<h3 class="text-base font-semibold"> <h3 class="text-sm sm:text-base font-semibold">
审计日志 审计日志
</h3> </h3>
<p class="text-xs text-muted-foreground mt-0.5"> <p class="text-xs text-muted-foreground mt-0.5">
查看系统所有操作记录 查看系统所有操作记录
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="relative"> <div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" /> <Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
@@ -24,19 +24,19 @@
id="audit-logs-search" id="audit-logs-search"
v-model="searchQuery" v-model="searchQuery"
placeholder="搜索用户ID..." placeholder="搜索用户ID..."
class="w-64 h-8 text-sm pl-8" class="w-32 sm:w-64 h-8 text-sm pl-8"
@input="handleSearchChange" @input="handleSearchChange"
/> />
</div> </div>
<!-- 分隔线 --> <!-- 分隔线 -->
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 事件类型筛选 --> <!-- 事件类型筛选 -->
<Select <Select
v-model="filters.eventType" v-model="filters.eventType"
v-model:open="eventTypeSelectOpen" v-model:open="eventTypeSelectOpen"
@update:model-value="handleEventTypeChange" @update:model-value="handleEventTypeChange"
> >
<SelectTrigger class="w-40 h-8 border-border/60"> <SelectTrigger class="w-24 sm:w-40 h-8 border-border/60">
<SelectValue placeholder="全部类型" /> <SelectValue placeholder="全部类型" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -81,7 +81,7 @@
v-model:open="daysSelectOpen" v-model:open="daysSelectOpen"
@update:model-value="handleDaysChange" @update:model-value="handleDaysChange"
> >
<SelectTrigger class="w-28 h-8 border-border/60"> <SelectTrigger class="w-20 sm:w-28 h-8 border-border/60">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -110,7 +110,7 @@
> >
<FilterX class="w-3.5 h-3.5" /> <FilterX class="w-3.5 h-3.5" />
</Button> </Button>
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 导出按钮 --> <!-- 导出按钮 -->
<Button <Button
variant="ghost" variant="ghost"
@@ -145,7 +145,7 @@
</div> </div>
<div v-else> <div v-else>
<Table> <Table class="hidden xl:table">
<TableHeader> <TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent"> <TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold"> <TableHead class="h-12 font-semibold">
@@ -242,6 +242,60 @@
</TableBody> </TableBody>
</Table> </Table>
<!-- 移动端卡片列表 -->
<div
v-if="logs.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="logItem in logs"
:key="logItem.id"
class="p-4 space-y-2 hover:bg-muted/30 cursor-pointer transition-colors"
@click="showLogDetail(logItem)"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<Badge :variant="getEventTypeBadgeVariant(logItem.event_type)">
<component
:is="getEventTypeIcon(logItem.event_type)"
class="h-3 w-3 mr-1"
/>
{{ getEventTypeLabel(logItem.event_type) }}
</Badge>
<div class="text-xs text-muted-foreground mt-1.5">
{{ formatDateTime(logItem.created_at) }}
</div>
</div>
<Badge
v-if="logItem.status_code"
:variant="getStatusCodeVariant(logItem.status_code)"
class="shrink-0"
>
{{ logItem.status_code }}
</Badge>
</div>
<div
v-if="logItem.user_id"
class="text-sm"
>
{{ logItem.user_email || `用户 ${logItem.user_id}` }}
</div>
<div
class="text-xs text-muted-foreground truncate"
:title="logItem.description"
>
{{ logItem.description || '无描述' }}
</div>
<div
v-if="logItem.ip_address"
class="flex items-center text-xs text-muted-foreground"
>
<Globe class="h-3 w-3 mr-1" />
{{ logItem.ip_address }}
</div>
</div>
</div>
<!-- 分页控件 --> <!-- 分页控件 -->
<Pagination <Pagination
:current="currentPage" :current="currentPage"

View File

@@ -365,21 +365,19 @@ onBeforeUnmount(() => {
<!-- 缓存亲和性列表 --> <!-- 缓存亲和性列表 -->
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<div class="px-6 py-3 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div class="flex items-center gap-3"> <h3 class="text-sm sm:text-base font-semibold shrink-0">
<h3 class="text-base font-semibold"> 亲和性列表
亲和性列表 </h3>
</h3> <div class="flex flex-wrap items-center gap-2">
</div>
<div class="flex items-center gap-2">
<div class="relative"> <div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" /> <Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input <Input
id="cache-affinity-search" id="cache-affinity-search"
v-model="tableKeyword" v-model="tableKeyword"
placeholder="搜索用户或 Key" placeholder="搜索用户或 Key"
class="w-48 h-8 text-sm pl-8 pr-8" class="w-32 sm:w-48 h-8 text-sm pl-8 pr-8"
/> />
<button <button
v-if="tableKeyword" v-if="tableKeyword"
@@ -390,7 +388,7 @@ onBeforeUnmount(() => {
<X class="h-3.5 w-3.5" /> <X class="h-3.5 w-3.5" />
</button> </button>
</div> </div>
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -408,7 +406,7 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<Table> <Table class="hidden xl:table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="w-36"> <TableHead class="w-36">
@@ -537,6 +535,60 @@ onBeforeUnmount(() => {
</TableBody> </TableBody>
</Table> </Table>
<!-- 移动端卡片列表 -->
<div
v-if="!listLoading && affinityList.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="item in paginatedAffinityList"
:key="`m-${item.affinity_key}-${item.endpoint_id}-${item.key_id}`"
class="p-4 space-y-2"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<Badge
v-if="item.is_standalone"
variant="outline"
class="text-warning border-warning/30 text-[10px] px-1"
>
独立
</Badge>
<span class="text-sm font-medium truncate">{{ item.username || '未知' }}</span>
</div>
<div class="text-xs text-muted-foreground mt-0.5">
{{ item.user_api_key_name || '未命名' }} · {{ item.user_api_key_prefix || '---' }}
</div>
</div>
<Button
size="icon"
variant="ghost"
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive shrink-0"
:disabled="clearingRowAffinityKey === item.affinity_key"
@click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{{ item.provider_name || '未知' }}</span>
<span>·</span>
<span class="truncate max-w-[100px]">{{ item.model_display_name || '---' }}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">{{ item.endpoint_api_format || '---' }}</span>
<span>{{ getRemainingTime(item.expire_at) }} · {{ item.request_count }}</span>
</div>
</div>
</div>
<div
v-else-if="!listLoading && affinityList.length === 0"
class="xl:hidden text-center py-6 text-sm text-muted-foreground"
>
暂无缓存记录
</div>
<Pagination <Pagination
v-if="affinityList.length > 0" v-if="affinityList.length > 0"
:current="currentPage" :current="currentPage"
@@ -549,18 +601,18 @@ onBeforeUnmount(() => {
<!-- TTL 分析区域 --> <!-- TTL 分析区域 -->
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<div class="px-6 py-3 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 shrink-0">
<BarChart3 class="h-5 w-5 text-muted-foreground" /> <BarChart3 class="h-5 w-5 text-muted-foreground hidden sm:block" />
<h3 class="text-base font-semibold"> <h3 class="text-sm sm:text-base font-semibold">
TTL 分析 TTL 分析
</h3> </h3>
<span class="text-xs text-muted-foreground">分析用户请求间隔推荐合适的缓存 TTL</span> <span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔推荐合适的缓存 TTL</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Select v-model="analysisHours"> <Select v-model="analysisHours">
<SelectTrigger class="w-28 h-8"> <SelectTrigger class="w-24 sm:w-28 h-8">
<SelectValue placeholder="时间段" /> <SelectValue placeholder="时间段" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@@ -44,17 +44,17 @@
variant="default" variant="default"
class="overflow-hidden" class="overflow-hidden"
> >
<div class="px-6 py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div> <div class="shrink-0">
<h3 class="text-base font-semibold"> <h3 class="text-sm sm:text-base font-semibold">
IP 黑名单 IP 黑名单
</h3> </h3>
<p class="text-xs text-muted-foreground mt-0.5"> <p class="text-xs text-muted-foreground mt-0.5">
管理被禁止访问的 IP 地址 管理被禁止访问的 IP 地址
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -114,17 +114,17 @@
variant="default" variant="default"
class="overflow-hidden" class="overflow-hidden"
> >
<div class="px-6 py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div> <div class="shrink-0">
<h3 class="text-base font-semibold"> <h3 class="text-sm sm:text-base font-semibold">
IP 白名单 IP 白名单
</h3> </h3>
<p class="text-xs text-muted-foreground mt-0.5"> <p class="text-xs text-muted-foreground mt-0.5">
管理可信任的 IP 地址支持 CIDR 格式 管理可信任的 IP 地址支持 CIDR 格式
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -158,7 +158,7 @@
</div> </div>
<div v-else> <div v-else>
<Table> <Table class="hidden sm:table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>IP 地址 / CIDR</TableHead> <TableHead>IP 地址 / CIDR</TableHead>
@@ -189,6 +189,25 @@
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
<!-- 移动端卡片列表 -->
<div class="sm:hidden divide-y divide-border/40">
<div
v-for="ip in whitelistData.whitelist"
:key="ip"
class="p-4 flex items-center justify-between gap-3"
>
<span class="font-mono text-sm truncate">{{ ip }}</span>
<Button
variant="ghost"
size="sm"
class="h-8 px-3 shrink-0"
@click="handleRemoveFromWhitelist(ip)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
</div> </div>
</Card> </Card>

View File

@@ -5,15 +5,15 @@
<!-- 模型列表 --> <!-- 模型列表 -->
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<!-- 标题和操作栏 --> <!-- 标题和操作栏 -->
<div class="px-6 py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<!-- 左侧标题 --> <!-- 左侧标题 -->
<h3 class="text-base font-semibold"> <h3 class="text-sm sm:text-base font-semibold shrink-0">
模型管理 模型管理
</h3> </h3>
<!-- 右侧操作区 --> <!-- 右侧操作区 -->
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="relative"> <div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70 z-10 pointer-events-none" /> <Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70 z-10 pointer-events-none" />
@@ -22,11 +22,11 @@
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
placeholder="搜索模型名称..." placeholder="搜索模型名称..."
class="w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors" class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
/> />
</div> </div>
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 能力筛选 --> <!-- 能力筛选 -->
<div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden"> <div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden">
@@ -76,7 +76,7 @@
</button> </button>
</div> </div>
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 操作按钮 --> <!-- 操作按钮 -->
<Button <Button
@@ -96,7 +96,7 @@
</div> </div>
</div> </div>
<Table> <Table class="hidden xl:table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="w-[240px]"> <TableHead class="w-[240px]">
@@ -302,6 +302,109 @@
</TableBody> </TableBody>
</Table> </Table>
<!-- 移动端卡片列表 -->
<div
v-if="!loading && filteredGlobalModels.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="model in paginatedGlobalModels"
:key="model.id"
class="p-4 space-y-3 hover:bg-muted/50 cursor-pointer transition-colors"
@click="selectModel(model)"
>
<!-- 第一行名称 + 状态 + 操作 -->
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium truncate">{{ model.display_name }}</span>
<Badge
:variant="model.is_active ? 'default' : 'secondary'"
class="text-xs shrink-0"
>
{{ model.is_active ? '活跃' : '停用' }}
</Badge>
</div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<span class="font-mono truncate">{{ model.name }}</span>
<button
class="p-0.5 rounded hover:bg-muted transition-colors shrink-0"
@click.stop="copyToClipboard(model.name)"
>
<Copy class="w-3 h-3" />
</button>
</div>
</div>
<div
class="flex items-center gap-0.5 shrink-0"
@click.stop
>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="editModel(model)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="toggleModelStatus(model)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="deleteModel(model)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<!-- 第二行能力图标 -->
<div class="flex flex-wrap gap-1.5">
<Zap
v-if="model.default_supports_streaming"
class="w-4 h-4 text-muted-foreground"
/>
<Image
v-if="model.default_supports_image_generation"
class="w-4 h-4 text-muted-foreground"
/>
<Eye
v-if="model.default_supports_vision"
class="w-4 h-4 text-muted-foreground"
/>
<Wrench
v-if="model.default_supports_function_calling"
class="w-4 h-4 text-muted-foreground"
/>
<Brain
v-if="model.default_supports_extended_thinking"
class="w-4 h-4 text-muted-foreground"
/>
</div>
<!-- 第三行统计信息 -->
<div class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>提供商 {{ model.provider_count || 0 }}</span>
<span>别名 {{ model.alias_count || 0 }}</span>
<span>调用 {{ formatUsageCount(model.usage_count || 0) }}</span>
<span
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
class="font-mono"
>
${{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }}/${{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}
</span>
</div>
</div>
</div>
<!-- 分页 --> <!-- 分页 -->
<Pagination <Pagination
v-if="!loading && filteredGlobalModels.length > 0" v-if="!loading && filteredGlobalModels.length > 0"

View File

@@ -6,15 +6,15 @@
class="overflow-hidden" class="overflow-hidden"
> >
<!-- 标题和操作栏 --> <!-- 标题和操作栏 -->
<div class="px-6 py-3.5 border-b border-border/50"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/50">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<!-- 左侧标题 --> <!-- 左侧标题 -->
<h3 class="text-base font-semibold text-foreground"> <h3 class="text-sm sm:text-base font-semibold text-foreground shrink-0">
提供商管理 提供商管理
</h3> </h3>
<!-- 右侧操作区 --> <!-- 右侧操作区 -->
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="relative"> <div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70 z-10 pointer-events-none" /> <Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70 z-10 pointer-events-none" />
@@ -23,11 +23,11 @@
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
placeholder="搜索提供商..." placeholder="搜索提供商..."
class="w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors" class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
/> />
</div> </div>
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 调度策略 --> <!-- 调度策略 -->
<button <button
@@ -35,12 +35,12 @@
title="点击调整调度策略" title="点击调整调度策略"
@click="openPriorityDialog" @click="openPriorityDialog"
> >
<span class="text-muted-foreground/80">调度:</span> <span class="text-muted-foreground/80 hidden sm:inline">调度:</span>
<span class="font-medium text-foreground/90">{{ priorityModeConfig.label }}</span> <span class="font-medium text-foreground/90">{{ priorityModeConfig.label }}</span>
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" /> <ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
</button> </button>
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 操作按钮 --> <!-- 操作按钮 -->
<Button <Button
@@ -94,7 +94,7 @@
<!-- 桌面端表格 --> <!-- 桌面端表格 -->
<div <div
v-else v-else
class="overflow-x-auto" class="hidden xl:block overflow-x-auto"
> >
<Table> <Table>
<TableHeader> <TableHeader>
@@ -289,6 +289,118 @@
</Table> </Table>
</div> </div>
<!-- 移动端卡片列表 -->
<div
v-if="!loading && filteredProviders.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="provider in paginatedProviders"
:key="provider.id"
class="p-4 space-y-3 hover:bg-muted/20 transition-colors cursor-pointer"
@click="openProviderDrawer(provider.id)"
>
<!-- 第一行名称 + 状态 + 操作 -->
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground truncate">{{ provider.display_name }}</span>
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
class="text-xs shrink-0"
>
{{ provider.is_active ? '活跃' : '停用' }}
</Badge>
</div>
<span class="text-xs text-muted-foreground/70 font-mono">{{ provider.name }}</span>
</div>
<div
class="flex items-center gap-0.5 shrink-0"
@click.stop
>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="openEditProviderDialog(provider)"
>
<Edit class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="toggleProviderStatus(provider)"
>
<Power class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="handleDeleteProvider(provider)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- 第二行计费类型 + 资源统计 -->
<div class="flex flex-wrap items-center gap-3 text-xs">
<Badge
variant="outline"
class="text-xs font-normal border-border/50"
>
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
<span class="text-muted-foreground">
端点 {{ provider.active_endpoints }}/{{ provider.total_endpoints }}
</span>
<span class="text-muted-foreground">
密钥 {{ provider.active_keys }}/{{ provider.total_keys }}
</span>
<span class="text-muted-foreground">
模型 {{ provider.active_models }}/{{ provider.total_models }}
</span>
</div>
<!-- 第三行端点健康 -->
<div
v-if="provider.endpoint_health_details && provider.endpoint_health_details.length > 0"
class="flex flex-wrap gap-1.5"
>
<span
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
:key="endpoint.api_format"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md border text-[10px] font-medium tracking-wide uppercase leading-none"
:class="getEndpointTagClass(endpoint, provider)"
>
<span
class="w-1.5 h-1.5 rounded-full"
:class="getEndpointDotColor(endpoint, provider)"
/>
{{ endpoint.api_format }}
</span>
</div>
<!-- 第四行配额/限流 -->
<div
v-if="provider.billing_type === 'monthly_quota' || rpmUsage(provider)"
class="flex items-center gap-3 text-xs text-muted-foreground"
>
<span v-if="provider.billing_type === 'monthly_quota'">
配额: <span
class="font-semibold"
:class="getQuotaUsedColorClass(provider)"
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / ${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}
</span>
<span v-if="rpmUsage(provider)">
RPM: {{ rpmUsage(provider) }}
</span>
</div>
</div>
</div>
<!-- 分页 --> <!-- 分页 -->
<Pagination <Pagination
v-if="!loading && filteredProviders.length > 0" v-if="!loading && filteredProviders.length > 0"

View File

@@ -6,8 +6,86 @@
class="overflow-hidden" class="overflow-hidden"
> >
<!-- 标题和筛选器 --> <!-- 标题和筛选器 -->
<div class="px-6 py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <!-- 移动端标题行 + 筛选器行 -->
<div class="flex flex-col gap-3 sm:hidden">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">
用户管理
</h3>
<div class="flex items-center gap-2">
<!-- 新增用户按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="新增用户"
@click="openCreateDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton
:loading="usersStore.loading || loadingStats"
@click="refreshUsers"
/>
</div>
</div>
<!-- 筛选器 -->
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="users-search-mobile"
v-model="searchQuery"
type="text"
placeholder="搜索..."
class="w-full pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60"
/>
</div>
<Select
v-model="filterRole"
v-model:open="filterRoleOpenMobile"
>
<SelectTrigger class="w-24 h-8 text-xs border-border/60">
<SelectValue placeholder="角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部
</SelectItem>
<SelectItem value="admin">
管理员
</SelectItem>
<SelectItem value="user">
用户
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="filterStatus"
v-model:open="filterStatusOpenMobile"
>
<SelectTrigger class="w-20 h-8 text-xs border-border/60">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="inactive">
禁用
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<!-- 桌面端单行布局 -->
<div class="hidden sm:flex items-center justify-between gap-4">
<h3 class="text-base font-semibold"> <h3 class="text-base font-semibold">
用户管理 用户管理
</h3> </h3>
@@ -172,10 +250,6 @@
<span class="w-14">Tokens:</span> <span class="w-14">Tokens:</span>
<span class="font-medium text-foreground">{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}</span> <span class="font-medium text-foreground">{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}</span>
</div> </div>
<div class="flex items-center text-muted-foreground">
<span class="w-14">费用:</span>
<span class="font-medium text-foreground">${{ formatCurrency(userStats[user.id]?.total_cost) }}</span>
</div>
</div> </div>
<div <div
v-else v-else
@@ -338,14 +412,6 @@
{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }} {{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}
</div> </div>
</div> </div>
<div class="col-span-2">
<div class="text-muted-foreground mb-1">
消费金额
</div>
<div class="font-semibold text-sm text-foreground">
${{ formatCurrency(userStats[user.id]?.total_cost) }}
</div>
</div>
</div> </div>
<div class="p-2 sm:p-3 bg-muted/50 rounded-lg text-xs space-y-1"> <div class="p-2 sm:p-3 bg-muted/50 rounded-lg text-xs space-y-1">
@@ -707,6 +773,8 @@ const filterRole = ref('all')
const filterStatus = ref('all') const filterStatus = ref('all')
const filterRoleOpen = ref(false) const filterRoleOpen = ref(false)
const filterStatusOpen = ref(false) const filterStatusOpen = ref(false)
const filterRoleOpenMobile = ref(false)
const filterStatusOpenMobile = ref(false)
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(20) const pageSize = ref(20)

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="space-y-6"> <div class="space-y-6 px-4 sm:px-6 lg:px-0">
<!-- 页面头部统计卡片 + 公告 --> <!-- 页面头部统计卡片 + 公告 -->
<div class="flex gap-6 items-start"> <div class="flex flex-col sm:flex-row gap-6 sm:items-start">
<!-- 左侧统计区域 --> <!-- 左侧统计区域 -->
<div <div
ref="statsPanelRef" ref="statsPanelRef"
@@ -15,7 +15,7 @@
</Badge> </Badge>
<!-- 主要统计卡片 --> <!-- 主要统计卡片 -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-3 sm:gap-4 xl:grid-cols-4">
<template v-if="loading && stats.length === 0"> <template v-if="loading && stats.length === 0">
<Card <Card
v-for="i in 4" v-for="i in 4"
@@ -31,53 +31,55 @@
v-for="(stat, index) in stats" v-for="(stat, index) in stats"
v-else v-else
:key="stat.name" :key="stat.name"
class="relative overflow-hidden p-5" class="relative overflow-hidden p-3 sm:p-5"
:class="statCardBorders[index % statCardBorders.length]" :class="statCardBorders[index % statCardBorders.length]"
> >
<div <div
class="pointer-events-none absolute -right-4 -top-6 h-28 w-28 rounded-full blur-3xl opacity-40" class="pointer-events-none absolute -right-4 -top-6 h-28 w-28 rounded-full blur-3xl opacity-40"
:class="statCardGlows[index % statCardGlows.length]" :class="statCardGlows[index % statCardGlows.length]"
/> />
<div class="flex items-start justify-between relative"> <!-- 图标固定在右上角 -->
<div> <div
<p class="text-[11px] font-semibold uppercase tracking-[0.4em] text-muted-foreground"> class="absolute top-3 right-3 sm:top-5 sm:right-5 rounded-xl sm:rounded-2xl border border-border bg-card/50 p-2 sm:p-3 shadow-inner backdrop-blur-sm"
{{ stat.name }} :class="getStatIconColor(index)"
</p> >
<p class="mt-4 text-3xl font-semibold text-foreground"> <component
{{ stat.value }} :is="stat.icon"
</p> class="h-4 w-4 sm:h-5 sm:w-5"
<p />
v-if="stat.subValue" </div>
class="mt-1 text-sm text-muted-foreground" <!-- 内容区域 -->
> <div>
{{ stat.subValue }} <p class="text-[9px] sm:text-[11px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.4em] text-muted-foreground pr-10 sm:pr-14">
</p> {{ stat.name }}
<div </p>
v-if="stat.change || stat.extraBadge" <p class="mt-2 sm:mt-4 text-xl sm:text-3xl font-semibold text-foreground">
class="mt-2 flex items-center gap-1.5" {{ stat.value }}
> </p>
<Badge <p
v-if="stat.change" v-if="stat.subValue"
variant="secondary" class="mt-0.5 sm:mt-1 text-[10px] sm:text-sm text-muted-foreground"
>
{{ stat.change }}
</Badge>
<Badge
v-if="stat.extraBadge"
variant="secondary"
>
{{ stat.extraBadge }}
</Badge>
</div>
</div>
<div
class="rounded-2xl border border-border bg-card/50 p-3 shadow-inner backdrop-blur-sm"
:class="getStatIconColor(index)"
> >
<component {{ stat.subValue }}
:is="stat.icon" </p>
class="h-5 w-5" <div
/> v-if="stat.change || stat.extraBadge"
class="mt-1.5 sm:mt-2 flex items-center gap-1 sm:gap-1.5 flex-wrap"
>
<Badge
v-if="stat.change"
variant="secondary"
class="text-[9px] sm:text-xs"
>
{{ stat.change }}
</Badge>
<Badge
v-if="stat.extraBadge"
variant="secondary"
class="text-[9px] sm:text-xs"
>
{{ stat.extraBadge }}
</Badge>
</div> </div>
</div> </div>
</Card> </Card>
@@ -99,70 +101,62 @@
Monthly Monthly
</Badge> </Badge>
</div> </div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Card class="p-4 border-book-cloth/30"> <Card class="relative p-3 sm:p-4 border-book-cloth/30">
<div class="flex items-center justify-between"> <Clock class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-book-cloth" />
<div> <div class="pr-6">
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground"> <p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
平均响应 平均响应
</p> </p>
<p class="mt-2 text-xl font-semibold text-foreground"> <p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ systemHealth.avg_response_time }}s {{ systemHealth.avg_response_time }}s
</p> </p>
</div>
<Clock class="h-4 w-4 text-book-cloth" />
</div> </div>
</Card> </Card>
<Card class="p-4 border-kraft/30"> <Card class="relative p-3 sm:p-4 border-kraft/30">
<div class="flex items-center justify-between"> <AlertTriangle class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-kraft" />
<div> <div class="pr-6">
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground"> <p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
错误率 错误率
</p> </p>
<p <p
class="mt-2 text-xl font-semibold" class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold"
:class="systemHealth.error_rate > 5 ? 'text-destructive' : 'text-foreground'" :class="systemHealth.error_rate > 5 ? 'text-destructive' : 'text-foreground'"
> >
{{ systemHealth.error_rate }}% {{ systemHealth.error_rate }}%
</p> </p>
</div>
<AlertTriangle class="h-4 w-4 text-kraft" />
</div> </div>
</Card> </Card>
<Card class="p-4 border-book-cloth/25"> <Card class="relative p-3 sm:p-4 border-book-cloth/25">
<div class="flex items-center justify-between"> <Shuffle class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-kraft" />
<div> <div class="pr-6">
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground"> <p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
转移次数 转移次数
</p> </p>
<p class="mt-2 text-xl font-semibold text-foreground"> <p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ systemHealth.fallback_count }} {{ systemHealth.fallback_count }}
</p> </p>
</div>
<Shuffle class="h-4 w-4 text-kraft" />
</div> </div>
</Card> </Card>
<Card <Card
v-if="costStats" v-if="costStats"
class="p-4 border-manilla/40" class="relative p-3 sm:p-4 border-manilla/40"
> >
<div class="flex items-center justify-between"> <DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-book-cloth" />
<div> <div class="pr-6">
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground"> <p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
实际成本 实际成本
</p> </p>
<p class="mt-2 text-xl font-semibold text-foreground"> <p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ formatCurrency(costStats.total_actual_cost) }} {{ formatCurrency(costStats.total_actual_cost) }}
</p> </p>
<Badge <Badge
v-if="costStats.cost_savings > 0" v-if="costStats.cost_savings > 0"
variant="success" variant="success"
class="mt-1 text-[10px]" class="mt-1 text-[9px] sm:text-[10px]"
> >
节省 {{ formatCurrency(costStats.cost_savings) }} 节省 {{ formatCurrency(costStats.cost_savings) }}
</Badge> </Badge>
</div>
<DollarSign class="h-4 w-4 text-book-cloth" />
</div> </div>
</Card> </Card>
</div> </div>
@@ -184,63 +178,55 @@
Monthly Monthly
</Badge> </Badge>
</div> </div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Card class="p-4 border-book-cloth/30"> <Card class="relative p-3 sm:p-4 border-book-cloth/30">
<div class="flex items-center justify-between"> <Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-book-cloth" />
<div> <div class="pr-6">
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground"> <p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
缓存命中率 缓存命中率
</p> </p>
<p class="mt-2 text-xl font-semibold text-foreground"> <p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ cacheStats.cache_hit_rate || 0 }}% {{ cacheStats.cache_hit_rate || 0 }}%
</p> </p>
</div>
<Database class="h-4 w-4 text-book-cloth" />
</div> </div>
</Card> </Card>
<Card class="p-4 border-kraft/30"> <Card class="relative p-3 sm:p-4 border-kraft/30">
<div class="flex items-center justify-between"> <Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-kraft" />
<div> <div class="pr-6">
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground"> <p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
缓存读取 缓存读取
</p> </p>
<p class="mt-2 text-xl font-semibold text-foreground"> <p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ formatTokens(cacheStats.cache_read_tokens) }} {{ formatTokens(cacheStats.cache_read_tokens) }}
</p> </p>
</div>
<Hash class="h-4 w-4 text-kraft" />
</div> </div>
</Card> </Card>
<Card class="p-4 border-book-cloth/25"> <Card class="relative p-3 sm:p-4 border-book-cloth/25">
<div class="flex items-center justify-between"> <Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-kraft" />
<div> <div class="pr-6">
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground"> <p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
缓存创建 缓存创建
</p> </p>
<p class="mt-2 text-xl font-semibold text-foreground"> <p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ formatTokens(cacheStats.cache_creation_tokens) }} {{ formatTokens(cacheStats.cache_creation_tokens) }}
</p> </p>
</div>
<Database class="h-4 w-4 text-kraft" />
</div> </div>
</Card> </Card>
<Card <Card
v-if="tokenBreakdown" v-if="tokenBreakdown"
class="p-4 border-manilla/40" class="relative p-3 sm:p-4 border-manilla/40"
> >
<div class="flex items-center justify-between"> <Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-book-cloth" />
<div> <div class="pr-6">
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground"> <p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
总Token 总Token
</p> </p>
<p class="mt-2 text-xl font-semibold text-foreground"> <p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }} {{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }}
</p> </p>
<p class="mt-1 text-[10px] text-muted-foreground"> <p class="mt-0.5 sm:mt-1 text-[9px] sm:text-[10px] text-muted-foreground">
输入 {{ formatTokens(tokenBreakdown.input || 0) }} / 输出 {{ formatTokens(tokenBreakdown.output || 0) }} 输入 {{ formatTokens(tokenBreakdown.input || 0) }} / 输出 {{ formatTokens(tokenBreakdown.output || 0) }}
</p> </p>
</div>
<Hash class="h-4 w-4 text-book-cloth" />
</div> </div>
</Card> </Card>
</div> </div>
@@ -250,7 +236,7 @@
<!-- 右侧系统公告 --> <!-- 右侧系统公告 -->
<div <div
id="announcements-section" id="announcements-section"
class="w-[340px] flex-shrink-0 flex flex-col min-h-0" class="w-full sm:w-[260px] md:w-[300px] lg:w-[320px] flex-shrink-0 flex flex-col min-h-0"
:style="announcementsContainerStyle" :style="announcementsContainerStyle"
> >
<div class="mb-3 flex items-center justify-between flex-shrink-0"> <div class="mb-3 flex items-center justify-between flex-shrink-0">
@@ -265,7 +251,7 @@
</Badge> </Badge>
</div> </div>
<Card class="overflow-hidden p-4 flex flex-col flex-1 min-h-0 h-full"> <Card class="overflow-hidden p-4 flex flex-col flex-1 min-h-0 h-full max-h-[280px] sm:max-h-none">
<div <div
v-if="loadingAnnouncements" v-if="loadingAnnouncements"
class="py-8 text-center" class="py-8 text-center"
@@ -420,9 +406,70 @@
</Card> </Card>
</div> </div>
<!-- 每日统计表格 --> <!-- 每日统计 -->
<Card class="overflow-hidden mt-6"> <Card class="overflow-hidden mt-6">
<Table> <!-- 移动端卡片列表 -->
<div class="sm:hidden">
<div class="px-4 py-3 border-b border-border/60">
<h3 class="text-sm font-semibold">
每日统计
</h3>
</div>
<div
v-if="loadingDaily"
class="flex items-center justify-center py-8"
>
<Skeleton class="h-5 w-5 rounded-full" />
<span class="ml-2 text-muted-foreground text-xs">加载中...</span>
</div>
<div
v-else-if="dailyStats.length === 0"
class="py-8 text-center text-muted-foreground text-xs"
>
暂无数据
</div>
<div
v-else
class="divide-y divide-border/60"
>
<div
v-for="stat in dailyStats.slice().reverse()"
:key="stat.date"
class="p-4 space-y-2"
>
<div class="flex items-center justify-between">
<span class="font-medium text-sm">{{ formatDate(stat.date) }}</span>
<Badge
variant="success"
class="text-[10px]"
>
${{ stat.cost.toFixed(4) }}
</Badge>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="flex justify-between">
<span class="text-muted-foreground">请求</span>
<span>{{ stat.requests.toLocaleString() }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Tokens</span>
<span>{{ formatTokens(stat.tokens) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">响应</span>
<span>{{ formatResponseTime(stat.avg_response_time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">模型</span>
<span>{{ stat.unique_models }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 桌面端表格 -->
<Table class="hidden sm:table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="text-left"> <TableHead class="text-left">
@@ -657,13 +704,21 @@ const statsPanelRef = ref<HTMLElement | null>(null)
const announcementsHeight = ref<number | null>(null) const announcementsHeight = ref<number | null>(null)
const announcementsTimelineRef = ref<HTMLElement | null>(null) const announcementsTimelineRef = ref<HTMLElement | null>(null)
const timelineLineStyle = ref<{ top: string; bottom: string }>({ top: '0px', bottom: '0px' }) const timelineLineStyle = ref<{ top: string; bottom: string }>({ top: '0px', bottom: '0px' })
const isLargeScreen = ref(false)
const announcementsContainerStyle = computed(() => { const announcementsContainerStyle = computed(() => {
if (!announcementsHeight.value) return {} // 移动端不设置固定高度,让内容自然流动
// 设置固定高度,与左侧统计面板保持一致 if (!isLargeScreen.value || !announcementsHeight.value) return {}
// 桌面端设置固定高度,与左侧统计面板保持一致
return { height: `${announcementsHeight.value}px` } return { height: `${announcementsHeight.value}px` }
}) })
function checkScreenSize() {
if (typeof window !== 'undefined') {
isLargeScreen.value = window.innerWidth >= 640 // sm breakpoint
}
}
let statsPanelObserver: ResizeObserver | null = null let statsPanelObserver: ResizeObserver | null = null
let announcementsTimelineObserver: ResizeObserver | null = null let announcementsTimelineObserver: ResizeObserver | null = null
@@ -698,6 +753,7 @@ function updateTimelineLine() {
} }
function handleWindowResize() { function handleWindowResize() {
checkScreenSize()
updateAnnouncementsHeight() updateAnnouncementsHeight()
updateTimelineLine() updateTimelineLine()
} }
@@ -984,6 +1040,7 @@ const chartOptions = computed(() => ({
})) }))
onMounted(async () => { onMounted(async () => {
checkScreenSize()
setupResizeObserver() setupResizeObserver()
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('resize', handleWindowResize) window.addEventListener('resize', handleWindowResize)

View File

@@ -9,7 +9,7 @@
<IntervalTimelineCard <IntervalTimelineCard
:title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'" :title="isAdminPage ? '请求间隔时间线' : '我的请求间隔'"
:is-admin="isAdminPage" :is-admin="isAdminPage"
:hours="168" :hours="24"
/> />
</div> </div>

View File

@@ -6,17 +6,17 @@
class="overflow-hidden" class="overflow-hidden"
> >
<!-- 标题和操作栏 --> <!-- 标题和操作栏 -->
<div class="px-6 py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div> <div class="shrink-0">
<h3 class="text-base font-semibold"> <h3 class="text-sm sm:text-base font-semibold">
公告管理 公告管理
</h3> </h3>
<p class="text-xs text-muted-foreground mt-0.5"> <p class="text-xs text-muted-foreground mt-0.5">
{{ isAdmin ? '管理系统公告和通知' : '查看系统公告和通知' }} {{ isAdmin ? '管理系统公告和通知' : '查看系统公告和通知' }}
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Badge <Badge
v-if="unreadCount > 0" v-if="unreadCount > 0"
variant="default" variant="default"
@@ -24,7 +24,7 @@
> >
{{ unreadCount }} 条未读 {{ unreadCount }} 条未读
</Badge> </Badge>
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<Button <Button
v-if="isAdmin" v-if="isAdmin"
variant="ghost" variant="ghost"
@@ -68,7 +68,7 @@
v-else v-else
class="overflow-x-auto" class="overflow-x-auto"
> >
<Table> <Table class="hidden xl:table">
<TableHeader> <TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent"> <TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[80px] h-12 font-semibold text-center"> <TableHead class="w-[80px] h-12 font-semibold text-center">
@@ -217,6 +217,91 @@
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
<!-- 移动端卡片列表 -->
<div
v-if="announcements.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="announcement in announcements"
:key="announcement.id"
:class="[
'p-4 space-y-2 cursor-pointer transition-colors',
announcement.is_read ? 'hover:bg-muted/30' : 'bg-primary/5 hover:bg-primary/10'
]"
@click="viewAnnouncementDetail(announcement)"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-2">
<component
:is="getAnnouncementIcon(announcement.type)"
class="w-4 h-4 shrink-0"
:class="getIconColor(announcement.type)"
/>
<span class="font-medium text-sm">{{ announcement.title }}</span>
<Pin
v-if="announcement.is_pinned"
class="w-3.5 h-3.5 text-muted-foreground shrink-0"
/>
</div>
<Badge
:variant="announcement.is_read ? 'secondary' : 'default'"
class="text-xs shrink-0"
>
{{ announcement.is_read ? '已读' : '未读' }}
</Badge>
</div>
<p class="text-xs text-muted-foreground line-clamp-2">
{{ getPlainText(announcement.content) }}
</p>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{{ announcement.author.username }}</span>
<span>·</span>
<span>{{ formatDate(announcement.created_at) }}</span>
</div>
<div
v-if="isAdmin"
class="flex items-center gap-4 pt-2"
@click.stop
>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">置顶</span>
<Switch
:model-value="announcement.is_pinned"
class="data-[state=checked]:bg-emerald-500 scale-75"
@update:model-value="toggleAnnouncementPin(announcement, $event)"
/>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">启用</span>
<Switch
:model-value="announcement.is_active"
class="data-[state=checked]:bg-primary scale-75"
@update:model-value="toggleAnnouncementActive(announcement, $event)"
/>
</div>
<div class="flex items-center gap-1 ml-auto">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="openEditDialog(announcement)"
>
<SquarePen class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 hover:text-destructive"
@click="confirmDelete(announcement)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</div>
</div> </div>
<!-- 分页 --> <!-- 分页 -->

View File

@@ -3,15 +3,15 @@
<!-- 模型列表 --> <!-- 模型列表 -->
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<!-- 标题和操作栏 --> <!-- 标题和操作栏 -->
<div class="px-6 py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<!-- 左侧标题 --> <!-- 左侧标题 -->
<h3 class="text-base font-semibold"> <h3 class="text-sm sm:text-base font-semibold shrink-0">
可用模型 可用模型
</h3> </h3>
<!-- 右侧操作区 --> <!-- 右侧操作区 -->
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="relative"> <div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
@@ -20,11 +20,11 @@
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
placeholder="搜索模型名称..." placeholder="搜索模型名称..."
class="w-44 pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60 focus:border-primary/40 transition-colors" class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60 focus:border-primary/40 transition-colors"
/> />
</div> </div>
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 能力筛选 --> <!-- 能力筛选 -->
<div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden"> <div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden">
@@ -56,7 +56,7 @@
</button> </button>
</div> </div>
<div class="h-4 w-px bg-border" /> <div class="hidden sm:block h-4 w-px bg-border" />
<!-- 刷新按钮 --> <!-- 刷新按钮 -->
<RefreshButton <RefreshButton
@@ -68,7 +68,7 @@
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<Table class="table-fixed w-full"> <Table class="hidden xl:table table-fixed w-full">
<TableHeader> <TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent"> <TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[140px] h-12 font-semibold"> <TableHead class="w-[140px] h-12 font-semibold">
@@ -219,6 +219,91 @@
</template> </template>
</TableBody> </TableBody>
</Table> </Table>
<!-- 移动端卡片列表 -->
<div
v-if="!loading && filteredModels.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="model in paginatedModels"
:key="model.id"
class="p-4 space-y-3 hover:bg-muted/30 cursor-pointer transition-colors"
@click="selectedModel = model; drawerOpen = true"
>
<!-- 第一行名称 + 状态 -->
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<span class="font-medium truncate block">{{ model.display_name || model.name }}</span>
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<span class="truncate">{{ model.name }}</span>
<button
class="p-0.5 rounded hover:bg-muted transition-colors shrink-0"
@click.stop="copyToClipboard(model.name)"
>
<Copy class="w-3 h-3" />
</button>
</div>
</div>
<Badge :variant="model.is_active ? 'success' : 'secondary'">
{{ model.is_active ? '可用' : '停用' }}
</Badge>
</div>
<!-- 第二行能力图标 -->
<div class="flex gap-1.5">
<Eye
v-if="model.default_supports_vision"
class="w-4 h-4 text-muted-foreground"
/>
<Wrench
v-if="model.default_supports_function_calling"
class="w-4 h-4 text-muted-foreground"
/>
<Brain
v-if="model.default_supports_extended_thinking"
class="w-4 h-4 text-muted-foreground"
/>
</div>
<!-- 第三行价格 -->
<div
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
class="text-xs text-muted-foreground font-mono"
>
In: ${{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }} / Out: ${{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}
</div>
<!-- 第四行模型偏好按钮 -->
<div
v-if="getModelSupportedCapabilities(model).length > 0"
class="flex gap-1.5 flex-wrap"
@click.stop
>
<button
v-for="cap in getModelSupportedCapabilitiesDetails(model)"
:key="cap.name"
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all"
:class="[
isCapabilityEnabled(model.name, cap.name)
? 'bg-primary text-primary-foreground'
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50'
]"
@click="toggleCapability(model.name, cap.name)"
>
<Check
v-if="isCapabilityEnabled(model.name, cap.name)"
class="w-3 h-3"
/>
<Plus
v-else
class="w-3 h-3"
/>
{{ cap.short_name || cap.display_name }}
</button>
</div>
</div>
</div>
</div> </div>
<!-- 分页 --> <!-- 分页 -->

View File

@@ -6,9 +6,9 @@
class="overflow-hidden" class="overflow-hidden"
> >
<!-- 标题和操作栏 --> <!-- 标题和操作栏 -->
<div class="px-6 py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<h3 class="text-base font-semibold"> <h3 class="text-sm sm:text-base font-semibold shrink-0">
我的 API Keys 我的 API Keys
</h3> </h3>

View File

@@ -13,12 +13,12 @@
/> />
<!-- 抽屉内容 --> <!-- 抽屉内容 -->
<Card class="relative h-full w-[700px] rounded-none shadow-2xl overflow-y-auto"> <Card class="relative h-full w-full sm:w-[700px] sm:max-w-[90vw] rounded-none shadow-2xl overflow-y-auto">
<!-- 标题栏 --> <!-- 标题栏 -->
<div class="sticky top-0 z-10 bg-background border-b p-6"> <div class="sticky top-0 z-10 bg-background border-b p-4 sm:p-6">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-3 sm:gap-4">
<div class="space-y-1 flex-1 min-w-0"> <div class="space-y-1 flex-1 min-w-0">
<h3 class="text-xl font-bold truncate"> <h3 class="text-lg sm:text-xl font-bold truncate">
{{ model.display_name || model.name }} {{ model.display_name || model.name }}
</h3> </h3>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -55,13 +55,13 @@
</div> </div>
</div> </div>
<div class="p-6 space-y-6"> <div class="p-4 sm:p-6 space-y-6">
<!-- 模型能力 --> <!-- 模型能力 -->
<div class="space-y-3"> <div class="space-y-3">
<h4 class="font-semibold text-sm"> <h4 class="font-semibold text-sm">
模型能力 模型能力
</h4> </h4>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="flex items-center gap-2 p-3 rounded-lg border"> <div class="flex items-center gap-2 p-3 rounded-lg border">
<Zap class="w-5 h-5 text-muted-foreground" /> <Zap class="w-5 h-5 text-muted-foreground" />
<div class="flex-1"> <div class="flex-1">