1312 lines
36 KiB
Vue
1312 lines
36 KiB
Vue
<template>
|
||
<div v-if="isInitialLoading" class="admin-loading">
|
||
<div class="spinner" />
|
||
<p>正在加载监控数据...</p>
|
||
</div>
|
||
<div v-else class="admin-page">
|
||
<div class="secondary-overlay" v-if="!secondaryVerified">
|
||
<div class="secondary-card">
|
||
<h3>请输入管理员二级密码</h3>
|
||
<p class="muted">为保护敏感监控数据,需二级校验后查看。</p>
|
||
<input
|
||
class="secondary-input"
|
||
type="password"
|
||
v-model="secondaryPassword"
|
||
:disabled="secondaryLoading"
|
||
placeholder="二级密码"
|
||
@keyup.enter="handleVerifySecondary"
|
||
/>
|
||
<div class="secondary-actions">
|
||
<button type="button" :disabled="secondaryLoading" @click="handleVerifySecondary">
|
||
{{ secondaryLoading ? '校验中...' : '确认进入' }}
|
||
</button>
|
||
<button type="button" class="ghost-btn" :disabled="secondaryLoading" @click="checkSecondary">重新检测</button>
|
||
</div>
|
||
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
|
||
</div>
|
||
</div>
|
||
<header class="admin-header">
|
||
<div>
|
||
<h1>管理员监控面板</h1>
|
||
<p>最近更新:{{ timeAgo(snapshot?.generated_at || overview.generated_at) }}</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<label>
|
||
<input type="checkbox" v-model="autoRefresh" /> 自动刷新
|
||
</label>
|
||
<button type="button" :disabled="refreshing" @click="handleManualRefresh">
|
||
{{ refreshing ? '刷新中...' : '立即刷新' }}
|
||
</button>
|
||
<a class="link-btn" href="/admin/policy" target="_blank" rel="noopener">策略配置</a>
|
||
<a class="link-btn" href="/admin/api" target="_blank" rel="noopener">API 管理</a>
|
||
</div>
|
||
</header>
|
||
|
||
<section v-if="bannerError" class="banner-error">
|
||
<strong>刷新失败:</strong>
|
||
<span>{{ bannerError }}</span>
|
||
</section>
|
||
|
||
<div class="admin-layout" v-if="snapshot">
|
||
<aside class="admin-sidebar">
|
||
<button
|
||
v-for="tab in sectionTabs"
|
||
:key="tab.id"
|
||
type="button"
|
||
:class="['sidebar-tab', { active: activeSection === tab.id }]"
|
||
@click="activeSection = tab.id"
|
||
>
|
||
{{ tab.label }}
|
||
</button>
|
||
</aside>
|
||
<main class="admin-main">
|
||
<transition name="fade" mode="out-in">
|
||
<section v-if="activeSection === 'overview'" key="overview" class="panel">
|
||
<div class="metrics-grid">
|
||
<div v-for="card in metricCards" :key="card.title" class="metric-card">
|
||
<h3>{{ card.title }}</h3>
|
||
<strong>{{ card.value }}</strong>
|
||
<span>{{ card.sub }}</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section v-else-if="activeSection === 'usage'" key="usage" class="panel">
|
||
<h2>模型配额与趋势</h2>
|
||
<div class="stats-row">
|
||
<span>快速模式:{{ usageTotals.fast || 0 }}</span>
|
||
<span>思考模式:{{ usageTotals.thinking || 0 }}</span>
|
||
<span>搜索:{{ usageTotals.search || 0 }}</span>
|
||
</div>
|
||
<div class="token-summary">
|
||
<div class="token-card">
|
||
<p>累计输入 Token</p>
|
||
<strong>{{ formatNumber(tokenTotals.input_tokens) }}</strong>
|
||
</div>
|
||
<div class="token-card">
|
||
<p>累计输出 Token</p>
|
||
<strong>{{ formatNumber(tokenTotals.output_tokens) }}</strong>
|
||
</div>
|
||
<div class="token-card">
|
||
<p>总计 Token</p>
|
||
<strong>{{ formatNumber(tokenTotals.total_tokens) }}</strong>
|
||
</div>
|
||
</div>
|
||
<div class="token-breakdown">
|
||
<div class="token-breakdown-header">
|
||
<h3>按用户累计 Token</h3>
|
||
<span>实时拉取 · 按总量降序</span>
|
||
</div>
|
||
<div class="token-table-wrapper" v-if="tokenBreakdown.length">
|
||
<table class="token-table">
|
||
<thead>
|
||
<tr>
|
||
<th>用户</th>
|
||
<th>角色</th>
|
||
<th>输入 Token</th>
|
||
<th>输出 Token</th>
|
||
<th>累计 Token</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="entry in tokenBreakdown" :key="entry.username">
|
||
<td>{{ entry.username }}</td>
|
||
<td>{{ entry.role }}</td>
|
||
<td>{{ formatNumber(entry.input) }}</td>
|
||
<td>{{ formatNumber(entry.output) }}</td>
|
||
<td>
|
||
<strong>{{ formatNumber(entry.total) }}</strong>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p class="token-empty" v-else>暂无 token 统计数据</p>
|
||
</div>
|
||
<div class="usage-leaders">
|
||
<div v-for="block in leaderBlocks" :key="block.key" class="leader-card">
|
||
<h3>{{ block.label }}</h3>
|
||
<ul>
|
||
<li v-if="!block.items.length" class="muted">暂无数据</li>
|
||
<li v-for="item in block.items" :key="item.username">
|
||
<strong>{{ item.username }}</strong>
|
||
<span>{{ item.count }} / {{ item.limit ?? '∞' }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section v-else-if="activeSection === 'users'" key="users" class="panel">
|
||
<h2>用户与配额</h2>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>用户</th>
|
||
<th>角色</th>
|
||
<th>状态</th>
|
||
<th>项目占用</th>
|
||
<th>Fast</th>
|
||
<th>Thinking</th>
|
||
<th>Search</th>
|
||
<th>项目体积</th>
|
||
<th>最近活跃</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-if="!users.length">
|
||
<td colspan="9">暂无用户</td>
|
||
</tr>
|
||
<tr v-for="user in users" :key="user.username">
|
||
<td>
|
||
<strong>{{ user.username }}</strong>
|
||
<div class="subtext">{{ user.email || '—' }}</div>
|
||
</td>
|
||
<td>{{ user.role || 'user' }}</td>
|
||
<td>
|
||
<span :class="['status-badge', user.status?.online ? 'online' : 'offline']">
|
||
{{ user.status?.online ? '在线' : '离线' }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span :class="['status-badge', usageBadge(user.storage?.status)]">
|
||
{{ formatPercent(user.storage?.usage_percent) }}
|
||
</span>
|
||
<div class="progress-bar">
|
||
<span :style="{ width: percentWidth(user.storage?.usage_percent) }" />
|
||
</div>
|
||
</td>
|
||
<td>{{ user.usage?.fast?.count || 0 }}</td>
|
||
<td>{{ user.usage?.thinking?.count || 0 }}</td>
|
||
<td>{{ user.usage?.search?.count || 0 }}</td>
|
||
<td>{{ formatBytes(user.storage?.project_bytes) }}</td>
|
||
<td>{{ timeAgo(user.status?.last_active) }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
<section v-else-if="activeSection === 'containers'" key="containers" class="panel">
|
||
<h2>容器运行状态</h2>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>用户</th>
|
||
<th>模式</th>
|
||
<th>容器名</th>
|
||
<th>CPU</th>
|
||
<th>内存%</th>
|
||
<th>内存使用</th>
|
||
<th>内存上限</th>
|
||
<th>网络 RX/TX</th>
|
||
<th>状态</th>
|
||
<th>最近活跃</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-if="!containers.length">
|
||
<td colspan="10">暂无容器运行</td>
|
||
</tr>
|
||
<tr v-for="item in containers" :key="item.username">
|
||
<td>{{ item.username }}</td>
|
||
<td>{{ item.mode || 'host' }}</td>
|
||
<td>{{ item.container_name || '—' }}</td>
|
||
<td>{{ formatPercentNumber(item.stats?.cpu_percent) }}</td>
|
||
<td>{{ formatPercentNumber(item.stats?.memory?.percent) }}</td>
|
||
<td>{{ formatBytes(item.stats?.memory?.used_bytes) }}</td>
|
||
<td>{{ formatBytes(item.stats?.memory?.limit_bytes) }}</td>
|
||
<td>{{ formatBytes(item.stats?.net_io?.rx_bytes) }} / {{ formatBytes(item.stats?.net_io?.tx_bytes) }}</td>
|
||
<td>
|
||
<span :class="['status-badge', containerStatusClass(item)]">
|
||
{{ item.state?.status || (item.state?.running ? '运行中' : '未知') }}
|
||
</span>
|
||
</td>
|
||
<td>{{ timeAgo(item.last_active) }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
<section v-else-if="activeSection === 'uploads'" key="uploads" class="panel">
|
||
<h2>上传安全审计</h2>
|
||
<div class="stats-row">
|
||
<span>最近24小时:{{ uploadStats.last_24h || 0 }}</span>
|
||
<span>阻断:{{ uploadStats.blocked_last_24h || 0 }}</span>
|
||
<span>跳过扫描:{{ uploadStats.skipped_scan_last_24h || 0 }}</span>
|
||
<span>隔离区占用:{{ formatBytes(uploadStats.quarantine_bytes) }}</span>
|
||
</div>
|
||
<div class="stats-row">
|
||
<span v-if="!uploadSources.length">暂无来源统计</span>
|
||
<span v-for="source in uploadSources" :key="source.source">
|
||
{{ source.source }}:{{ source.count }}
|
||
</span>
|
||
</div>
|
||
<ul class="upload-feed">
|
||
<li v-if="!recentUploads.length" class="upload-item">暂无近期上传</li>
|
||
<li v-for="upload in recentUploads" :key="upload.upload_id" class="upload-item">
|
||
<div>
|
||
<strong>{{ upload.original_name || '未命名文件' }}</strong>
|
||
<div class="upload-meta">
|
||
<span>用户:{{ upload.username }}</span>
|
||
<span>来源:{{ upload.source || 'unknown' }}</span>
|
||
<span>大小:{{ formatBytes(upload.size) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="upload-meta">
|
||
<span>{{ timeAgo(upload.timestamp) }}</span>
|
||
<span :class="['status-badge', upload.accepted ? 'online' : 'danger']">
|
||
{{ upload.accepted ? '通过' : '阻断' }}
|
||
</span>
|
||
<span v-if="upload.error?.message" class="status-badge danger">{{ upload.error.message }}</span>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</section>
|
||
<section v-else-if="activeSection === 'balance'" key="balance" class="panel">
|
||
<div class="balance-header">
|
||
<div>
|
||
<h2>余额查询</h2>
|
||
<p class="muted">手动刷新,查看 Kimi / DeepSeek / Qwen 余额</p>
|
||
</div>
|
||
<div class="balance-actions">
|
||
<span class="muted" v-if="balanceUpdatedAt">上次刷新:{{ timeAgo(balanceUpdatedAt) }}</span>
|
||
<button type="button" :disabled="balanceLoading" @click="fetchBalance">
|
||
{{ balanceLoading ? '查询中...' : '手动刷新' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<section v-if="balanceError" class="banner-error">
|
||
<strong>查询失败:</strong>
|
||
<span>{{ balanceError }}</span>
|
||
</section>
|
||
<div class="balance-grid">
|
||
<div v-for="card in balanceCards" :key="card.key" class="balance-card" :class="{ error: !card.success }">
|
||
<div class="balance-card-head">
|
||
<h3>{{ card.label }}</h3>
|
||
<span v-if="card.success" class="status-badge online">正常</span>
|
||
<span v-else class="status-badge danger">失败</span>
|
||
</div>
|
||
<div class="balance-amount">
|
||
<strong v-if="card.amount !== null">{{ card.amount }} {{ card.currency }}</strong>
|
||
<strong v-else>—</strong>
|
||
<span v-if="card.amountCny !== null" class="sub">≈ ¥{{ card.amountCny }}</span>
|
||
</div>
|
||
<ul class="balance-meta" v-if="card.success">
|
||
<li v-if="card.extras.voucher !== undefined">券:{{ card.extras.voucher }}</li>
|
||
<li v-if="card.extras.cash !== undefined">现金:{{ card.extras.cash }}</li>
|
||
<li v-if="card.extras.granted !== undefined">赠送:{{ card.extras.granted }}</li>
|
||
<li v-if="card.extras.topped_up !== undefined">充值:{{ card.extras.topped_up }}</li>
|
||
<li v-if="card.extras.rate">汇率 USD→CNY:{{ card.extras.rate }}</li>
|
||
</ul>
|
||
<p v-else class="muted small">{{ card.error || '未知错误' }}</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section v-else-if="activeSection === 'invites'" key="invites" class="panel">
|
||
<h2>邀请码</h2>
|
||
<div class="stats-row">
|
||
<span>总计:{{ inviteSummary.total || 0 }}</span>
|
||
<span>可用:{{ inviteSummary.active || 0 }}</span>
|
||
<span>已用:{{ inviteSummary.consumed || 0 }}</span>
|
||
<span>无限制:{{ inviteSummary.unlimited || 0 }}</span>
|
||
</div>
|
||
<div class="invite-grid">
|
||
<div v-if="!inviteCodes.length" class="invite-card">暂无邀请码数据</div>
|
||
<div v-for="code in inviteCodes" :key="code.code" class="invite-card">
|
||
<h4>{{ code.code }}</h4>
|
||
<span>{{ inviteStatus(code.remaining) }}</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</transition>
|
||
</main>
|
||
</div>
|
||
|
||
<div v-else class="admin-error">
|
||
<strong>加载失败</strong>
|
||
<p>{{ errorMessage || '无法加载监控数据' }}</p>
|
||
<button type="button" @click="handleRetry">重试</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||
import { useSecondaryPass } from './useSecondaryPass';
|
||
|
||
type Snapshot = Record<string, any> | null;
|
||
|
||
type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'balance' | 'invites';
|
||
|
||
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass();
|
||
const secondaryPassword = ref('');
|
||
|
||
const loading = ref(true);
|
||
const refreshing = ref(false);
|
||
const snapshot = ref<Snapshot>(null);
|
||
const errorMessage = ref<string | null>(null);
|
||
const bannerError = ref<string | null>(null);
|
||
const autoRefresh = ref(true);
|
||
const activeSection = ref<SectionId>('overview');
|
||
let timer: number | undefined;
|
||
const balanceLoading = ref(false);
|
||
const balanceError = ref<string | null>(null);
|
||
const balanceData = ref<Record<string, any> | null>(null);
|
||
const balanceUpdatedAt = ref<number | null>(null);
|
||
|
||
const sectionTabs: Array<{ id: SectionId; label: string }> = [
|
||
{ id: 'overview', label: '系统总览' },
|
||
{ id: 'usage', label: '配额趋势' },
|
||
{ id: 'users', label: '用户' },
|
||
{ id: 'containers', label: '容器' },
|
||
{ id: 'uploads', label: '上传审计' },
|
||
{ id: 'balance', label: '余额查询' },
|
||
{ id: 'invites', label: '邀请码' }
|
||
];
|
||
|
||
const isInitialLoading = computed(() => loading.value && !snapshot.value);
|
||
|
||
const fetchDashboard = async (background = false) => {
|
||
if (!secondaryVerified.value) {
|
||
loading.value = false;
|
||
return;
|
||
}
|
||
if (background) {
|
||
refreshing.value = true;
|
||
} else if (!snapshot.value) {
|
||
loading.value = true;
|
||
}
|
||
try {
|
||
const resp = await fetch('/api/admin/dashboard', { credentials: 'same-origin' });
|
||
if (!resp.ok) {
|
||
throw new Error(`请求失败:${resp.status}`);
|
||
}
|
||
const payload = await resp.json();
|
||
if (!payload.success) {
|
||
throw new Error(payload.error || '未知错误');
|
||
}
|
||
// 额外调试:若后台返回 debug 字段,打印到控制台便于问题定位(仅开发模式会看到)
|
||
if (import.meta.env.DEV && payload.data?.debug) {
|
||
console.info('[admin dashboard debug]', payload.data.debug);
|
||
}
|
||
snapshot.value = payload.data;
|
||
errorMessage.value = null;
|
||
bannerError.value = null;
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
if (!snapshot.value) {
|
||
errorMessage.value = message;
|
||
} else {
|
||
bannerError.value = message;
|
||
}
|
||
} finally {
|
||
if (background) {
|
||
refreshing.value = false;
|
||
}
|
||
if (!snapshot.value) {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const scheduleAutoRefresh = () => {
|
||
if (timer) {
|
||
clearInterval(timer);
|
||
timer = undefined;
|
||
}
|
||
if (autoRefresh.value && secondaryVerified.value) {
|
||
timer = window.setInterval(() => fetchDashboard(true), 30000);
|
||
}
|
||
};
|
||
|
||
const handleManualRefresh = () => fetchDashboard(true);
|
||
const handleRetry = () => fetchDashboard(false);
|
||
|
||
const fetchBalance = async () => {
|
||
balanceLoading.value = true;
|
||
balanceError.value = null;
|
||
try {
|
||
const resp = await fetch('/api/admin/balance', { credentials: 'same-origin' });
|
||
if (!resp.ok) throw new Error(`请求失败:${resp.status}`);
|
||
const payload = await resp.json();
|
||
if (!payload.success) throw new Error(payload.error || '未知错误');
|
||
balanceData.value = payload.data || {};
|
||
balanceUpdatedAt.value = Date.now();
|
||
} catch (error) {
|
||
balanceError.value = error instanceof Error ? error.message : String(error);
|
||
} finally {
|
||
balanceLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const handleVerifySecondary = async () => {
|
||
await verifySecondary(secondaryPassword.value);
|
||
if (secondaryVerified.value) {
|
||
secondaryPassword.value = '';
|
||
fetchDashboard(false);
|
||
scheduleAutoRefresh();
|
||
}
|
||
};
|
||
|
||
watch(autoRefresh, () => {
|
||
scheduleAutoRefresh();
|
||
});
|
||
|
||
watch(
|
||
() => activeSection.value,
|
||
(val) => {
|
||
if (val === 'balance' && !balanceData.value && !balanceLoading.value) {
|
||
fetchBalance();
|
||
}
|
||
}
|
||
);
|
||
|
||
onMounted(async () => {
|
||
await checkSecondary();
|
||
if (secondaryVerified.value) {
|
||
fetchDashboard(false);
|
||
scheduleAutoRefresh();
|
||
} else {
|
||
loading.value = false;
|
||
}
|
||
});
|
||
|
||
watch(secondaryVerified, (val) => {
|
||
if (val) {
|
||
fetchDashboard(false);
|
||
scheduleAutoRefresh();
|
||
} else if (timer) {
|
||
clearInterval(timer);
|
||
timer = undefined;
|
||
loading.value = false;
|
||
}
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
if (timer) {
|
||
clearInterval(timer);
|
||
}
|
||
});
|
||
|
||
const snapshotData = computed(() => snapshot.value || null);
|
||
const overview = computed(() => snapshotData.value?.overview ?? {});
|
||
const usageTotals = computed(() => overview.value.usage_totals ?? {});
|
||
const tokenTotals = computed(() => overview.value.token_totals ?? {});
|
||
const leaderBlocks = computed(() => {
|
||
const leaders = overview.value.usage_leaders ?? {};
|
||
return [
|
||
{ key: 'fast', label: '快速模式 TOP', items: leaders.fast || [] },
|
||
{ key: 'thinking', label: '思考模式 TOP', items: leaders.thinking || [] },
|
||
{ key: 'search', label: '搜索调用 TOP', items: leaders.search || [] }
|
||
];
|
||
});
|
||
const storage = computed(() => overview.value.storage ?? {});
|
||
const uploadStats = computed(() => snapshotData.value?.uploads?.stats ?? {});
|
||
const uploadSources = computed(() => snapshotData.value?.uploads?.sources ?? []);
|
||
const recentUploads = computed(() => snapshotData.value?.uploads?.recent_events ?? []);
|
||
const users = computed(() => snapshotData.value?.users ?? []);
|
||
const containers = computed(() => snapshotData.value?.containers ?? []);
|
||
const inviteSummary = computed(() => snapshotData.value?.invites?.summary ?? {});
|
||
const inviteCodes = computed(() => snapshotData.value?.invites?.codes ?? []);
|
||
const totals = computed(() => overview.value.totals ?? {});
|
||
const containerSummary = computed(() => overview.value.containers ?? {});
|
||
const invitesOverview = computed(() => overview.value.invites ?? {});
|
||
const tokenBreakdown = computed(() => {
|
||
const list = users.value.map((user: any) => ({
|
||
username: user.username,
|
||
role: user.role || 'user',
|
||
input: Number(user.tokens?.input_tokens || 0),
|
||
output: Number(user.tokens?.output_tokens || 0),
|
||
total: Number(user.tokens?.total_tokens || 0)
|
||
}));
|
||
return list.sort((a, b) => b.total - a.total);
|
||
});
|
||
|
||
const usdToCnyRate = computed(() => balanceData.value?.rate_usd_cny ?? null);
|
||
|
||
const balanceCards = computed(() => {
|
||
const makeCard = (key: string, label: string) => {
|
||
const data = (balanceData.value as any)?.[key] || {};
|
||
const success = data.success === true;
|
||
const currency = data.currency || '—';
|
||
const amount = data.available ?? data.total ?? null;
|
||
return {
|
||
key,
|
||
label,
|
||
success,
|
||
currency,
|
||
amount,
|
||
amountCny: data.available_cny ?? (currency === 'USD' && usdToCnyRate.value && amount != null
|
||
? Math.round(amount * usdToCnyRate.value * 100) / 100
|
||
: null),
|
||
extras: data,
|
||
error: success ? null : data.error
|
||
};
|
||
};
|
||
return [
|
||
makeCard('kimi', 'Kimi'),
|
||
makeCard('deepseek', 'DeepSeek'),
|
||
makeCard('qwen', 'Qwen / Aliyun')
|
||
];
|
||
});
|
||
|
||
const metricCards = computed(() => [
|
||
{
|
||
title: '注册用户',
|
||
value: totals.value.users || 0,
|
||
sub: `${totals.value.active_users || 0} 个活跃`
|
||
},
|
||
{
|
||
title: '容器使用',
|
||
value: `${containerSummary.value.active || 0}/${containerSummary.value.max_containers ?? '∞'}`,
|
||
sub:
|
||
containerSummary.value.available_slots === null || containerSummary.value.available_slots === undefined
|
||
? '无限制'
|
||
: `${containerSummary.value.available_slots} 个空闲`
|
||
},
|
||
{
|
||
title: '资源告警',
|
||
value: (storage.value.warning_users || []).length,
|
||
sub: '达到预警阈值的工作区'
|
||
},
|
||
{
|
||
title: '上传审计 (24h)',
|
||
value: uploadStats.value.last_24h || 0,
|
||
sub: `${uploadStats.value.blocked_last_24h || 0} 条被阻断`
|
||
},
|
||
{
|
||
title: '配额调用 (fast / thinking / search)',
|
||
value: `${usageTotals.value.fast || 0} / ${usageTotals.value.thinking || 0} / ${usageTotals.value.search || 0}`,
|
||
sub: '窗口内累计调用'
|
||
},
|
||
{
|
||
title: '邀请码',
|
||
value: invitesOverview.value.total || 0,
|
||
sub: `${invitesOverview.value.active || 0} 个可用 · ${invitesOverview.value.consumed || 0} 已用`
|
||
},
|
||
{
|
||
title: '存储占用',
|
||
value: formatBytes(storage.value.total_bytes || 0),
|
||
sub: storage.value.per_user_limit_bytes
|
||
? `单用户上限 ${formatBytes(storage.value.per_user_limit_bytes)}`
|
||
: '未设置上限'
|
||
}
|
||
]);
|
||
|
||
const formatBytes = (value?: number | null) => {
|
||
if (value === null || value === undefined || Number(value) <= 0) return '0 B';
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
let val = Number(value);
|
||
const index = Math.min(Math.floor(Math.log(val) / Math.log(1024)), units.length - 1);
|
||
val /= 1024 ** index;
|
||
const digits = val >= 100 ? 0 : 1;
|
||
return `${val.toFixed(digits)} ${units[index]}`;
|
||
};
|
||
|
||
const percentWidth = (value?: number | null) => {
|
||
if (value === null || value === undefined) return '0%';
|
||
return `${Math.min(100, Math.max(0, Number(value)))}%`;
|
||
};
|
||
|
||
const formatPercent = (value?: number | null) => {
|
||
if (value === null || value === undefined || isNaN(Number(value))) return '—';
|
||
return `${Number(value).toFixed(1)}%`;
|
||
};
|
||
|
||
const formatNumber = (value?: number | null) => {
|
||
if (value === null || value === undefined) return '0';
|
||
const parsed = Number(value);
|
||
if (Number.isNaN(parsed)) return '0';
|
||
return parsed.toLocaleString();
|
||
};
|
||
|
||
const formatPercentNumber = (value?: number | null) => {
|
||
if (value === null || value === undefined || isNaN(Number(value))) return '—';
|
||
return `${Number(value).toFixed(1)}%`;
|
||
};
|
||
|
||
const timeAgo = (input?: string | number | null) => {
|
||
if (!input) return '未知时间';
|
||
const date = new Date(input);
|
||
if (Number.isNaN(date.getTime())) return String(input);
|
||
const diff = Date.now() - date.getTime();
|
||
if (diff < 60_000) return '刚刚';
|
||
const minutes = Math.floor(diff / 60_000);
|
||
if (minutes < 60) return `${minutes} 分钟前`;
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 24) return `${hours} 小时前`;
|
||
const days = Math.floor(hours / 24);
|
||
return `${days} 天前`;
|
||
};
|
||
|
||
const usageBadge = (status?: string) => {
|
||
if (status === 'critical') return 'danger';
|
||
if (status === 'warning') return 'warning';
|
||
return 'online';
|
||
};
|
||
|
||
const containerStatusClass = (item: any) => {
|
||
if (item?.error) return 'danger';
|
||
if (item?.state && item.state.running === false) return 'warning';
|
||
return 'online';
|
||
};
|
||
|
||
const inviteStatus = (remaining: number | null | undefined) => {
|
||
if (remaining === null || remaining === undefined) return '无限制';
|
||
if (remaining > 0) return `剩余 ${remaining}`;
|
||
return '已用完';
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
:global(body) {
|
||
margin: 0;
|
||
background: #f7f3ea;
|
||
font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, "Times New Roman", serif;
|
||
color: #2a2013;
|
||
overflow: hidden;
|
||
}
|
||
|
||
:global(#admin-app) {
|
||
min-height: 100vh;
|
||
height: 100vh;
|
||
padding: 24px 32px;
|
||
background: #f7f3ea;
|
||
color: #2a2013;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.admin-page {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.admin-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.admin-header h1 {
|
||
margin: 0;
|
||
font-size: 28px;
|
||
}
|
||
|
||
.admin-header p {
|
||
margin: 4px 0 0;
|
||
color: #6b5b44;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.header-actions label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: #5b4b35;
|
||
}
|
||
|
||
.link-btn {
|
||
padding: 10px 14px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||
background: #fff;
|
||
color: #2a2013;
|
||
text-decoration: none;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.link-btn:hover {
|
||
background: rgba(0, 0, 0, 0.04);
|
||
}
|
||
|
||
button {
|
||
border: none;
|
||
border-radius: 999px;
|
||
padding: 10px 18px;
|
||
background: var(--claude-highlight);
|
||
color: #3b2f1d;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
box-shadow: inset 0 -2px 0 rgba(189, 93, 58, 0.25);
|
||
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease;
|
||
}
|
||
|
||
button:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
box-shadow: none;
|
||
}
|
||
|
||
button:not(:disabled):hover {
|
||
transform: translateY(-1px);
|
||
background: var(--claude-accent);
|
||
box-shadow: 0 8px 16px rgba(189, 93, 58, 0.25);
|
||
}
|
||
|
||
.banner-error {
|
||
padding: 12px 16px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(189, 93, 58, 0.5);
|
||
background: rgba(189, 93, 58, 0.15);
|
||
color: #7c3418;
|
||
}
|
||
|
||
.secondary-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(20, 12, 5, 0.35);
|
||
backdrop-filter: blur(6px);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 999;
|
||
}
|
||
|
||
.secondary-card {
|
||
width: 360px;
|
||
max-width: 90vw;
|
||
background: #f6ecda;
|
||
border: 1px solid rgba(118, 103, 84, 0.35);
|
||
border-radius: 18px;
|
||
padding: 20px;
|
||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16);
|
||
}
|
||
|
||
.secondary-card h3 {
|
||
margin: 0 0 8px;
|
||
}
|
||
|
||
.secondary-card p {
|
||
margin: 0 0 12px;
|
||
color: #5b4b35;
|
||
}
|
||
|
||
.secondary-input {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
padding: 12px 14px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(44, 32, 19, 0.2);
|
||
margin-bottom: 12px;
|
||
font-size: 15px;
|
||
}
|
||
|
||
.secondary-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.secondary-actions .ghost-btn {
|
||
background: transparent;
|
||
border: 1px dashed rgba(44, 32, 19, 0.35);
|
||
padding: 10px 14px;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.secondary-error {
|
||
color: #b5473d;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.admin-layout {
|
||
display: flex;
|
||
gap: 20px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.admin-sidebar {
|
||
width: 220px;
|
||
flex-shrink: 0;
|
||
background: rgba(255, 255, 255, 0.85);
|
||
border-radius: 24px;
|
||
border: 1px solid rgba(118, 103, 84, 0.35);
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.sidebar-tab {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
border-radius: 14px;
|
||
border: 1px solid transparent;
|
||
background: rgba(255, 255, 255, 0.7);
|
||
color: #3c2f1d;
|
||
text-align: left;
|
||
}
|
||
|
||
.sidebar-tab.active {
|
||
background: rgba(218, 119, 86, 0.2);
|
||
color: #3c2f1d;
|
||
border-color: rgba(189, 93, 58, 0.55);
|
||
box-shadow: 0 10px 22px rgba(189, 93, 58, 0.18);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.admin-main {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding-right: 8px;
|
||
}
|
||
|
||
.panel {
|
||
background: #f6ecda;
|
||
border-radius: 28px;
|
||
padding: 24px;
|
||
border: 1px solid rgba(118, 103, 84, 0.35);
|
||
box-shadow: var(--claude-shadow);
|
||
color: #23170b;
|
||
}
|
||
|
||
.metrics-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.metric-card {
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border-radius: 22px;
|
||
padding: 18px;
|
||
border: 1px solid rgba(118, 103, 84, 0.4);
|
||
}
|
||
|
||
.metric-card h3 {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
color: var(--claude-text-secondary);
|
||
letter-spacing: 0.08em;
|
||
}
|
||
|
||
.metric-card strong {
|
||
font-size: 28px;
|
||
display: block;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.metric-card span {
|
||
color: #574630;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.stats-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-bottom: 18px;
|
||
color: #5c4a32;
|
||
}
|
||
|
||
.stats-row span {
|
||
background: rgba(218, 119, 86, 0.2);
|
||
padding: 6px 12px;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.token-summary {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.token-card {
|
||
border-radius: 18px;
|
||
padding: 16px;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
border: 1px solid rgba(44, 32, 19, 0.1);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||
}
|
||
|
||
.token-card p {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--claude-text-secondary);
|
||
}
|
||
|
||
.token-card strong {
|
||
display: block;
|
||
margin-top: 10px;
|
||
font-size: 22px;
|
||
color: #1f160c;
|
||
}
|
||
|
||
.token-breakdown {
|
||
border: 1px solid rgba(118, 103, 84, 0.25);
|
||
border-radius: 20px;
|
||
padding: 18px;
|
||
background: rgba(255, 255, 255, 0.86);
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.token-breakdown-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.token-breakdown-header h3 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.token-breakdown-header span {
|
||
font-size: 12px;
|
||
color: var(--claude-text-secondary);
|
||
}
|
||
|
||
.token-table-wrapper {
|
||
max-height: 320px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.token-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.token-table th,
|
||
.token-table td {
|
||
padding: 10px 8px;
|
||
border-bottom: 1px solid rgba(118, 103, 84, 0.15);
|
||
text-align: left;
|
||
}
|
||
|
||
.token-table th {
|
||
font-size: 12px;
|
||
color: #4c3a24;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.token-table td strong {
|
||
font-size: 14px;
|
||
color: #2c1f12;
|
||
}
|
||
|
||
.token-empty {
|
||
margin: 0;
|
||
color: var(--claude-text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.usage-leaders {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
.leader-card {
|
||
border: 1px solid rgba(118, 103, 84, 0.18);
|
||
border-radius: 18px;
|
||
padding: 14px;
|
||
background: rgba(255, 255, 255, 0.75);
|
||
}
|
||
|
||
.leader-card h3 {
|
||
margin: 0 0 10px;
|
||
color: var(--claude-text-secondary);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.leader-card ul {
|
||
margin: 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.leader-card li {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.leader-card .muted {
|
||
color: var(--claude-muted);
|
||
}
|
||
|
||
.table-wrapper {
|
||
overflow-x: auto;
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
|
||
thead {
|
||
background: rgba(255, 248, 238, 0.85);
|
||
}
|
||
|
||
th,
|
||
td {
|
||
padding: 12px;
|
||
border-bottom: 1px solid rgba(118, 103, 84, 0.18);
|
||
text-align: left;
|
||
}
|
||
|
||
th {
|
||
color: #4f3f2a;
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.status-badge {
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.status-badge.online {
|
||
background: rgba(118, 176, 134, 0.18);
|
||
color: var(--claude-success);
|
||
}
|
||
|
||
.status-badge.offline {
|
||
background: rgba(121, 109, 94, 0.2);
|
||
color: #5b4d3b;
|
||
}
|
||
|
||
.status-badge.warning {
|
||
background: rgba(217, 152, 69, 0.2);
|
||
color: var(--claude-warning);
|
||
}
|
||
|
||
.status-badge.danger {
|
||
background: rgba(189, 93, 58, 0.18);
|
||
color: var(--claude-accent-strong);
|
||
}
|
||
|
||
.progress-bar {
|
||
height: 6px;
|
||
border-radius: 999px;
|
||
background: rgba(121, 109, 94, 0.12);
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.progress-bar span {
|
||
display: block;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, var(--claude-highlight), var(--claude-accent));
|
||
}
|
||
|
||
.upload-feed {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.upload-item {
|
||
padding: 12px 16px;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(118, 103, 84, 0.3);
|
||
background: rgba(255, 255, 255, 0.88);
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.upload-meta {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
color: var(--claude-text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.invite-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.invite-card {
|
||
border: 1px solid rgba(118, 103, 84, 0.3);
|
||
border-radius: 16px;
|
||
padding: 14px;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.invite-card h4 {
|
||
margin: 0 0 6px;
|
||
}
|
||
|
||
.invite-card span {
|
||
color: var(--claude-text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.balance-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.balance-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.balance-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||
gap: 12px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.balance-card {
|
||
border: 1px solid rgba(118, 103, 84, 0.2);
|
||
border-radius: 14px;
|
||
padding: 12px;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||
}
|
||
|
||
.balance-card.error {
|
||
border-color: rgba(189, 93, 58, 0.4);
|
||
background: rgba(189, 93, 58, 0.08);
|
||
}
|
||
|
||
.balance-card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.balance-amount {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.balance-amount .sub {
|
||
color: #6a5d4c;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.balance-meta {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
color: #6a5d4c;
|
||
}
|
||
|
||
.small {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.admin-loading {
|
||
min-height: 60vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
color: var(--claude-text-secondary);
|
||
}
|
||
|
||
.spinner {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 50%;
|
||
border: 4px solid rgba(0, 0, 0, 0.12);
|
||
border-top-color: #050505;
|
||
border-right-color: #050505;
|
||
border-bottom-color: #f4f4f4;
|
||
border-left-color: #f4f4f4;
|
||
background: transparent;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.admin-error {
|
||
background: rgba(189, 93, 58, 0.15);
|
||
border: 1px solid rgba(189, 93, 58, 0.45);
|
||
border-radius: 16px;
|
||
padding: 18px;
|
||
color: #7d341a;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: opacity 0.15s ease;
|
||
}
|
||
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
:global(#admin-app) {
|
||
padding: 16px;
|
||
}
|
||
|
||
.admin-layout {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.admin-sidebar {
|
||
width: 100%;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.sidebar-tab {
|
||
flex: 1;
|
||
text-align: center;
|
||
}
|
||
}
|
||
</style>
|