agent-Specialization/static/src/admin/AdminDashboardApp.vue

1312 lines
36 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 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>