- 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).
245 lines
11 KiB
Vue
245 lines
11 KiB
Vue
<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>
|