Files
opencatd-open/frontend/src/views/dashboard/Overview.vue
T
Sakurasan 094230a293 feat: API key management improvements
- Rename routes: /dashboard/tokens → /dashboard/apikeys, /dashboard/manager/keys → /dashboard/manager/channels
- Add KeyPlain field to store plaintext API keys for re-viewing
- API key list shows masked key (sk-ot-123456****abcd) with eye toggle to reveal
- Copy button with 2s feedback on key list
- TokenNew shows full key + copy after creation
- Increase key prefix display to 12 characters
- Fix SQLite driver: replace gorm.io/driver/sqlite with ncruces/go-sqlite3/gormlite
- Fix user.status === 'active' checks across frontend views
- Add channel store for new channels API
- Update Makefile frontend build to work reliably

BREAKING CHANGE: Existing API keys created before this change will not show their plaintext value (only prefix visible).
2026-08-30 15:21:49 +08:00

245 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="space-y-6">
<BreadcrumbHeader />
<!-- Welcome hero(随时间段切换配色) -->
<section
class="relative overflow-hidden rounded-2xl bg-gradient-to-br text-white shadow-sm"
:class="getTimeTheme()">
<div class="flex flex-col items-center gap-4 p-6 text-center sm:flex-row sm:p-8 sm:text-left">
<div class="placeholder avatar">
<div
class="flex w-16 items-center justify-center rounded-full bg-white/15 ring-2 ring-white/25 sm:w-20">
<span v-if="!user?.avatar_url" class="text-2xl font-bold sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
<img v-else :src="user?.avatar_url" :alt="user?.name || user?.username || 'Avatar'"
class="h-full w-full object-cover" />
</div>
</div>
<div>
<h2 class="text-xl font-bold tracking-tight text-balance sm:text-2xl">
<span class="mr-1.5" aria-hidden="true">👋</span>{{ getTimeOfDay() }},{{ user?.name || user?.username }}
</h2>
<p class="mt-1 text-sm text-white/80">欢迎回到您的个人仪表盘</p>
</div>
</div>
</section>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- 基本信息 -->
<article class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body p-5">
<h3 class="card-title pb-1 text-base font-semibold">基本信息</h3>
<dl class="divide-y divide-base-300/40 text-sm">
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">用户名</dt>
<dd class="truncate font-medium">{{ user?.username }}</dd>
</div>
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">显示名称</dt>
<dd class="truncate font-medium">{{ user?.name || '—' }}</dd>
</div>
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">邮箱</dt>
<dd class="truncate font-medium">{{ user?.email || '—' }}</dd>
</div>
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">角色</dt>
<dd>
<span class="badge badge-sm"
:class="(user?.role || 0) > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">{{
getRoleName(user?.role || 0) }}</span>
</dd>
</div>
</dl>
</div>
</article>
<!-- 账户状态 -->
<article class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body p-5">
<h3 class="card-title pb-1 text-base font-semibold">账户状态</h3>
<dl class="divide-y divide-base-300/40 text-sm">
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">状态</dt>
<dd>
<span class="badge badge-sm"
:class="user?.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
{{ user?.status === 'active' ? 'Active' : 'Inactive' }}
</span>
</dd>
</div>
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">时区</dt>
<dd class="font-medium">{{ user?.timezone || 'UTC' }}</dd>
</div>
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">语言</dt>
<dd class="font-medium uppercase">{{ user?.language || 'en' }}</dd>
</div>
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">最后活动</dt>
<dd class="font-medium tabular-nums">{{ getLastActive() }}</dd>
</div>
</dl>
</div>
</article>
<!-- 配额信息 -->
<article class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body p-5">
<h3 class="card-title pb-1 text-base font-semibold">配额信息</h3>
<div class="flex-1 space-y-4 pt-1">
<div v-if="user?.unlimited_quota" class="flex items-center justify-between text-sm">
<span class="text-base-content/60">使用量</span>
<span class="badge badge-success badge-soft">无限制</span>
</div>
<div v-else class="space-y-2 text-sm">
<div class="flex items-center justify-between gap-3">
<span class="text-base-content/60">使用量</span>
<span class="font-medium tabular-nums" :class="getQuotaColor">
{{ formatQuota(user?.used_quota || 0, user?.quota || 0) }}
</span>
</div>
<progress class="progress w-full" :class="getQuotaColorClass" :value="quotaPercentage"
max="100" :aria-label="`Quota used ${quotaPercentage.toFixed(1)}%`"></progress>
</div>
<div class="grid grid-cols-2 gap-3 border-t border-base-300/40 pt-3">
<div>
<p class="text-xs text-base-content/50">创建时间</p>
<p class="mt-0.5 text-sm font-medium tabular-nums">{{ formatDateTime(user?.created_at) }}</p>
</div>
<div>
<p class="text-xs text-base-content/50">更新时间</p>
<p class="mt-0.5 text-sm font-medium tabular-nums">{{ formatDateTime(user?.updated_at) }}</p>
</div>
</div>
</div>
</div>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
const router = useRouter();
const authStore = useAuthStore();
const user = computed(() => authStore.user);
onMounted(async () => {
if (!authStore.isLoggedIn) {
router.push('/login');
};
await authStore.refreshProfile();
});
const getTimeOfDay = (): string => {
const hour = new Date().getHours();
if (hour < 6) return '夜深了';
if (hour < 12) return '早上好';
if (hour < 18) return '下午好';
return '晚上好';
};
// 横幅随时间段切换配色:白色文字位于渐变左侧深色端,保证对比度
const getTimeTheme = (): string => {
const hour = new Date().getHours();
if (hour < 6) return 'from-slate-900 via-slate-800 to-indigo-950'; // 深夜
if (hour < 12) return 'from-sky-600 via-sky-500 to-amber-400'; // 早晨
if (hour < 18) return 'from-blue-600 via-sky-500 to-cyan-400'; // 白天
return 'from-slate-900 via-indigo-950 to-slate-800'; // 夜晚
};
const formatQuota = (used: number, total: number): string => {
if (total === 0) return '无限制';
// 格式化金额
const formatCurrency = (amount: number): string => {
if (amount === 0) return '$0';
return `$${amount.toFixed(2)}`;
};
const percentage = (used / total) * 100;
return `${formatCurrency(used)} / ${formatCurrency(total)} (${percentage.toFixed(1)}%)`;
};
const getRoleName = (role: number): string => {
switch (role) {
case 20: return 'Root';
case 10: return 'Admin';
default: return 'User';
}
};
// 格式化日期时间
function formatDateTime(unixTimestamp?: number): string {
// 如果时间戳不存在或为0,返回'未知'
if (!unixTimestamp) return '未知';
// 将Unix时间戳转换为毫秒
const date = new Date(unixTimestamp * 1000);
// 获取日期和时间的各个部分
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
// 返回格式化的日期时间字符串
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
// 计算配额百分比
const quotaPercentage = computed(() => {
if (!user.value || user.value.unlimited_quota || !user.value.quota) return 0;
return (user.value.used_quota ?? 0) / user.value.quota * 100;
});
// 获取配额颜色
const getQuotaColor = computed(() => {
const percentage = quotaPercentage.value;
if (percentage < 50) return 'text-success';
if (percentage < 80) return 'text-warning';
return 'text-error';
});
// 获取配额颜色类
const getQuotaColorClass = computed(() => {
const percentage = quotaPercentage.value;
if (percentage < 50) return 'progress-success';
if (percentage < 80) return 'progress-warning';
return 'progress-error';
});
// 用户最后活动时间
const getLastActive = (): string => {
if (!user.value || !user.value?.updated_at) return '未知';
const lastActive = new Date(user.value.updated_at * 1000);
const now = new Date();
const diff = now.getTime() - lastActive.getTime();
// 转换为天/小时/分钟
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days > 0) return `${days}天前`;
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours > 0) return `${hours}小时前`;
const minutes = Math.floor(diff / (1000 * 60));
if (minutes > 0) return `${minutes}分钟前`;
return '刚刚';
};
</script>