801 lines
22 KiB
Vue
801 lines
22 KiB
Vue
<template>
|
|
<div class="api-page">
|
|
<div class="secondary-overlay" v-if="!secondaryVerified">
|
|
<div class="secondary-card">
|
|
<h3>API 后台 · 二级验证</h3>
|
|
<p class="muted">请输入管理员二级密码后查看 API 调用与账户。</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="api-header">
|
|
<div>
|
|
<h1>API 管理面板</h1>
|
|
<p>监控 Token / 容器 / 上传审计,并管理 API 账户。</p>
|
|
<small class="muted">数据时间:{{ timeAgo(snapshot?.generated_at) }}</small>
|
|
</div>
|
|
<div class="header-actions">
|
|
<label><input type="checkbox" v-model="autoRefresh" /> 自动刷新</label>
|
|
<button type="button" :disabled="loading" @click="fetchAll">
|
|
{{ loading ? '刷新中...' : '手动刷新' }}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="panel">
|
|
<div class="metrics-grid">
|
|
<div class="metric-card">
|
|
<p>API 用户数</p>
|
|
<strong>{{ overview.totals?.users || 0 }}</strong>
|
|
</div>
|
|
<div class="metric-card">
|
|
<p>总请求</p>
|
|
<strong>{{ formatNumber(totalRequests) }}</strong>
|
|
</div>
|
|
<div class="metric-card">
|
|
<p>累计 Token</p>
|
|
<strong>{{ formatNumber(overview.token_totals?.total_tokens || 0) }}</strong>
|
|
<span class="muted">输入 {{ formatNumber(overview.token_totals?.input_tokens || 0) }} / 输出 {{ formatNumber(overview.token_totals?.output_tokens || 0) }}</span>
|
|
</div>
|
|
<div class="metric-card">
|
|
<p>活动容器</p>
|
|
<strong>{{ overview.totals?.containers_active || 0 }}</strong>
|
|
<span class="muted">可用槽位 {{ overview.totals?.available_container_slots ?? '—' }}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="section-head">
|
|
<h2>API 账户管理</h2>
|
|
<div class="create-form">
|
|
<input v-model="createForm.username" type="text" placeholder="新账号名 (小写英数/-/_)" />
|
|
<input v-model="createForm.note" type="text" placeholder="备注(可选)" />
|
|
<button type="button" :disabled="creating" @click="createUser">
|
|
{{ creating ? '创建中...' : '创建账号' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-wrapper">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>账号</th>
|
|
<th>备注</th>
|
|
<th>创建时间</th>
|
|
<th>请求</th>
|
|
<th>最近调用</th>
|
|
<th>Token 累计</th>
|
|
<th>存储</th>
|
|
<th>容器</th>
|
|
<th>密钥</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!users.length">
|
|
<td colspan="10">暂无 API 账号</td>
|
|
</tr>
|
|
<tr v-for="u in users" :key="u.username">
|
|
<td><strong>{{ u.username }}</strong></td>
|
|
<td>{{ u.note || '—' }}</td>
|
|
<td>{{ formatTime(u.created_at) }}</td>
|
|
<td>{{ formatNumber(u.usage?.total_requests || 0) }}</td>
|
|
<td>{{ timeAgo(u.usage?.last_request_at) }}</td>
|
|
<td>{{ formatNumber(u.tokens?.total_tokens || 0) }}</td>
|
|
<td>{{ formatBytes(u.storage?.total_bytes) }}</td>
|
|
<td>
|
|
<span v-if="u.container?.running" class="status-badge online">运行中</span>
|
|
<span v-else class="status-badge offline">未运行</span>
|
|
</td>
|
|
<td class="token-cell">
|
|
<span class="token-text">
|
|
{{ revealTokens[u.username]?.visible ? (revealTokens[u.username]?.token || '—') : '••••••••••' }}
|
|
</span>
|
|
<button class="icon-btn" @click="toggleReveal(u.username)" :disabled="revealLoading === u.username">
|
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
|
<path
|
|
d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12Zm11 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
<button class="icon-btn" @click="copyToken(u.username)" :disabled="revealLoading === u.username">
|
|
📋
|
|
</button>
|
|
</td>
|
|
<td>
|
|
<button class="link danger" @click="deleteUser(u.username)" :disabled="deleting === u.username">删除</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p v-if="createError" class="error-text">{{ createError }}</p>
|
|
</section>
|
|
|
|
<section class="panel grid">
|
|
<div>
|
|
<div class="section-head">
|
|
<h3>容器状态</h3>
|
|
<small class="muted">实时抓取</small>
|
|
</div>
|
|
<div class="table-wrapper small scrollable">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Key</th>
|
|
<th>CPU</th>
|
|
<th>内存%</th>
|
|
<th>内存</th>
|
|
<th>网络 RX/TX</th>
|
|
<th>状态</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!containerItems.length">
|
|
<td colspan="6">暂无容器</td>
|
|
</tr>
|
|
<tr v-for="c in containerItems" :key="c.key">
|
|
<td>{{ c.key }}</td>
|
|
<td>{{ formatPercentNumber(c.stats?.cpu_percent) }}</td>
|
|
<td>{{ formatPercentNumber(c.stats?.memory?.percent) }}</td>
|
|
<td>{{ formatBytes(c.stats?.memory?.used_bytes) }}</td>
|
|
<td>{{ formatBytes(c.stats?.net_io?.rx_bytes) }} / {{ formatBytes(c.stats?.net_io?.tx_bytes) }}</td>
|
|
<td>
|
|
<span :class="['status-badge', c.running ? 'online' : 'offline']">
|
|
{{ c.running ? '运行中' : '未运行' }}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="section-head">
|
|
<h3>上传审计</h3>
|
|
<small class="muted">来自隔离区日志</small>
|
|
</div>
|
|
<ul class="upload-feed scrollable">
|
|
<li v-if="!uploads.recent_events?.length" class="upload-item">暂无记录</li>
|
|
<li v-for="item in uploads.recent_events || []" :key="item.upload_id" class="upload-item">
|
|
<div>
|
|
<strong>{{ item.original_name || '未命名文件' }}</strong>
|
|
<div class="muted small">
|
|
用户 {{ item.username }} · {{ formatBytes(item.size) }} · {{ item.source || 'unknown' }}
|
|
</div>
|
|
</div>
|
|
<div class="muted small">
|
|
{{ timeAgo(item.timestamp) }}
|
|
<span :class="['status-badge', item.accepted ? 'online' : 'danger']">
|
|
{{ item.accepted ? '通过' : '阻断' }}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="balance-header">
|
|
<div>
|
|
<h3>余额查询</h3>
|
|
<p class="muted">Kimi / DeepSeek / Qwen</p>
|
|
</div>
|
|
<button type="button" :disabled="balanceLoading" @click="fetchBalance">
|
|
{{ balanceLoading ? '查询中...' : '刷新余额' }}
|
|
</button>
|
|
</div>
|
|
<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">
|
|
<h4>{{ card.label }}</h4>
|
|
<span :class="['status-badge', card.success ? 'online' : 'danger']">
|
|
{{ card.success ? '正常' : '失败' }}
|
|
</span>
|
|
</div>
|
|
<strong>{{ card.amount ?? '—' }} {{ card.currency }}</strong>
|
|
<span class="muted small" v-if="card.amountCny">≈ ¥{{ card.amountCny }}</span>
|
|
<p class="muted small" v-else-if="card.error">{{ card.error }}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import { useSecondaryPass } from './useSecondaryPass';
|
|
|
|
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass();
|
|
const secondaryPassword = ref('');
|
|
|
|
const loading = ref(false);
|
|
const autoRefresh = ref(true);
|
|
let timer: number | undefined;
|
|
|
|
const snapshot = ref<any>(null);
|
|
const users = ref<any[]>([]);
|
|
const revealTokens = ref<Record<string, { token: string; visible: boolean }>>({});
|
|
const revealLoading = ref('');
|
|
const deleting = ref('');
|
|
const creating = ref(false);
|
|
const createForm = ref({ username: '', note: '' });
|
|
const createError = ref('');
|
|
|
|
const balanceData = ref<Record<string, any> | null>(null);
|
|
const balanceLoading = ref(false);
|
|
|
|
const overview = computed(() => snapshot.value?.overview || {});
|
|
const uploads = computed(() => snapshot.value?.uploads || {});
|
|
const containerItems = computed(() => {
|
|
const items = snapshot.value?.containers || {};
|
|
return Object.keys(items).map((key) => ({ key, ...(items[key] || {}) }));
|
|
});
|
|
const totalRequests = computed(() =>
|
|
users.value.reduce((sum, u) => sum + ((u.usage && u.usage.total_requests) ? u.usage.total_requests : 0), 0)
|
|
);
|
|
|
|
const balanceCards = computed(() => {
|
|
const keys = ['kimi', 'deepseek', 'qwen'];
|
|
return keys.map((k) => {
|
|
const data = (balanceData.value as any)?.[k] || {};
|
|
return {
|
|
key: k,
|
|
label: k.toUpperCase(),
|
|
success: data.success === true,
|
|
amount: data.available ?? data.total ?? null,
|
|
amountCny: data.amount_cny ?? data.amountCny ?? null,
|
|
currency: data.currency || '',
|
|
error: data.error,
|
|
};
|
|
});
|
|
});
|
|
|
|
const formatNumber = (val: any) => {
|
|
const num = Number(val || 0);
|
|
return Number.isFinite(num) ? num.toLocaleString('zh-CN') : '0';
|
|
};
|
|
const formatBytes = (v: any) => {
|
|
const num = Number(v || 0);
|
|
if (!num) return '0B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let n = num;
|
|
let i = 0;
|
|
while (n >= 1024 && i < units.length - 1) {
|
|
n /= 1024;
|
|
i += 1;
|
|
}
|
|
return `${n.toFixed(1)}${units[i]}`;
|
|
};
|
|
const formatTime = (iso?: string) => {
|
|
if (!iso) return '—';
|
|
try {
|
|
return new Date(iso).toLocaleString();
|
|
} catch {
|
|
return iso;
|
|
}
|
|
};
|
|
const timeAgo = (iso?: string) => {
|
|
if (!iso) return '—';
|
|
const ts = new Date(iso).getTime();
|
|
if (!Number.isFinite(ts)) return '—';
|
|
const diff = Date.now() - ts;
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 1) return '刚刚';
|
|
if (mins < 60) return `${mins} 分钟前`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours} 小时前`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days} 天前`;
|
|
};
|
|
const formatPercentNumber = (v: any) => {
|
|
if (v === null || v === undefined) return '—';
|
|
const num = Number(v);
|
|
if (!Number.isFinite(num)) return '—';
|
|
return `${num.toFixed(1)}%`;
|
|
};
|
|
|
|
const fetchDashboard = async () => {
|
|
if (!secondaryVerified.value) return;
|
|
loading.value = true;
|
|
try {
|
|
const resp = await fetch('/api/admin/api-dashboard', { credentials: 'same-origin' });
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.error || '加载失败');
|
|
snapshot.value = data.data;
|
|
} catch (err: any) {
|
|
console.error(err);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const fetchUsers = async () => {
|
|
if (!secondaryVerified.value) return;
|
|
try {
|
|
const resp = await fetch('/api/admin/api-users', { credentials: 'same-origin' });
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.error || '加载失败');
|
|
const map: Record<string, any> = {};
|
|
users.value = (data.data || []).map((u: any) => ({
|
|
...u,
|
|
usage: u.usage || { total_requests: 0, endpoints: {}, last_request_at: null },
|
|
tokens: u.tokens || { total_tokens: 0, input_tokens: 0, output_tokens: 0 },
|
|
storage: u.storage || { total_bytes: 0 },
|
|
}));
|
|
users.value.forEach((u) => {
|
|
map[u.username] = revealTokens.value[u.username] || { token: '', visible: false };
|
|
});
|
|
revealTokens.value = map;
|
|
} catch (err: any) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const fetchAll = async () => {
|
|
await Promise.all([fetchDashboard(), fetchUsers()]);
|
|
};
|
|
|
|
const scheduleAutoRefresh = () => {
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
timer = undefined;
|
|
}
|
|
if (autoRefresh.value && secondaryVerified.value) {
|
|
timer = window.setInterval(fetchAll, 30000);
|
|
}
|
|
};
|
|
|
|
const createUser = async () => {
|
|
if (!secondaryVerified.value) return;
|
|
createError.value = '';
|
|
const username = createForm.value.username.trim().toLowerCase();
|
|
if (!username) {
|
|
createError.value = '请输入账号名';
|
|
return;
|
|
}
|
|
const pattern = /^[a-z0-9_-]{3,32}$/;
|
|
if (!pattern.test(username)) {
|
|
createError.value = '账号名需为 3-32 位小写字母/数字/_/-';
|
|
return;
|
|
}
|
|
creating.value = true;
|
|
try {
|
|
const resp = await fetch('/api/admin/api-users', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, note: createForm.value.note }),
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.error || '创建失败');
|
|
await fetchUsers();
|
|
revealTokens.value[username] = { token: data.data.token, visible: true };
|
|
createForm.value = { username: '', note: '' };
|
|
} catch (err: any) {
|
|
createError.value = err.message || '创建失败';
|
|
} finally {
|
|
creating.value = false;
|
|
}
|
|
};
|
|
|
|
const deleteUser = async (username: string) => {
|
|
if (!secondaryVerified.value) return;
|
|
if (!confirm(`确认删除 API 用户 ${username} 吗?`)) return;
|
|
deleting.value = username;
|
|
try {
|
|
const resp = await fetch(`/api/admin/api-users/${encodeURIComponent(username)}`, {
|
|
method: 'DELETE',
|
|
credentials: 'same-origin',
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.error || '删除失败');
|
|
await fetchUsers();
|
|
} catch (err: any) {
|
|
alert(err.message || '删除失败');
|
|
} finally {
|
|
deleting.value = '';
|
|
}
|
|
};
|
|
|
|
const toggleReveal = async (username: string) => {
|
|
const entry = revealTokens.value[username];
|
|
if (entry?.visible) {
|
|
revealTokens.value[username] = { ...(entry || {}), visible: false };
|
|
return;
|
|
}
|
|
revealLoading.value = username;
|
|
try {
|
|
const resp = await fetch(`/api/admin/api-users/${encodeURIComponent(username)}/token`, {
|
|
credentials: 'same-origin',
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.error || '获取失败');
|
|
revealTokens.value[username] = { token: data.data.token, visible: true };
|
|
} catch (err: any) {
|
|
alert(err.message || '获取 token 失败');
|
|
} finally {
|
|
revealLoading.value = '';
|
|
}
|
|
};
|
|
|
|
const copyToken = async (username: string) => {
|
|
const entry = revealTokens.value[username];
|
|
if (!entry?.token) {
|
|
await toggleReveal(username);
|
|
}
|
|
const token = revealTokens.value[username]?.token;
|
|
if (!token) return;
|
|
try {
|
|
await navigator.clipboard.writeText(token);
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const fetchBalance = async () => {
|
|
if (!secondaryVerified.value) return;
|
|
balanceLoading.value = true;
|
|
try {
|
|
const resp = await fetch('/api/admin/balance', { credentials: 'same-origin' });
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.error || '查询失败');
|
|
balanceData.value = data.data;
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
balanceLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const handleVerifySecondary = async () => {
|
|
await verifySecondary(secondaryPassword.value);
|
|
if (secondaryVerified.value) {
|
|
secondaryPassword.value = '';
|
|
await fetchAll();
|
|
scheduleAutoRefresh();
|
|
}
|
|
};
|
|
|
|
onMounted(async () => {
|
|
await checkSecondary();
|
|
if (secondaryVerified.value) {
|
|
await fetchAll();
|
|
scheduleAutoRefresh();
|
|
}
|
|
});
|
|
|
|
watch(autoRefresh, scheduleAutoRefresh);
|
|
watch(secondaryVerified, (val) => {
|
|
if (val) {
|
|
fetchAll();
|
|
scheduleAutoRefresh();
|
|
} else if (timer) {
|
|
clearInterval(timer);
|
|
timer = undefined;
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
if (timer) clearInterval(timer);
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
:global(body) {
|
|
margin: 0;
|
|
background: #f7f3ea;
|
|
font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, "Times New Roman", serif;
|
|
color: #2a2013;
|
|
}
|
|
|
|
:global(#admin-api-app) {
|
|
min-height: 100vh;
|
|
padding: 24px 32px 48px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.api-page {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.api-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
|
|
.panel {
|
|
background: #f6ecda;
|
|
border-radius: 20px;
|
|
padding: 18px;
|
|
border: 1px solid rgba(118, 103, 84, 0.35);
|
|
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.08);
|
|
}
|
|
|
|
.metrics-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.metric-card {
|
|
background: rgba(255,255,255,0.94);
|
|
border-radius: 16px;
|
|
padding: 14px;
|
|
border: 1px solid rgba(44,32,19,0.12);
|
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.6);
|
|
}
|
|
|
|
.metric-card p {
|
|
margin: 0;
|
|
color: #6b5b44;
|
|
font-size: 13px;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.metric-card strong {
|
|
font-size: 26px;
|
|
display: block;
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.muted {
|
|
color: #6b5b44;
|
|
}
|
|
|
|
.muted.small {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.section-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.create-form {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.create-form input {
|
|
padding: 10px 12px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(0,0,0,0.12);
|
|
}
|
|
|
|
button {
|
|
border: none;
|
|
border-radius: 12px;
|
|
padding: 10px 14px;
|
|
background: var(--claude-highlight, #f3d2a8);
|
|
color: #2a2013;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.ghost-btn {
|
|
background: transparent;
|
|
border: 1px dashed rgba(44, 32, 19, 0.35);
|
|
}
|
|
|
|
.table-wrapper {
|
|
width: 100%;
|
|
overflow: auto;
|
|
}
|
|
|
|
.table-wrapper.scrollable {
|
|
max-height: 320px;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
th, td {
|
|
padding: 10px;
|
|
border-bottom: 1px solid rgba(0,0,0,0.08);
|
|
text-align: left;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.token-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.icon-btn {
|
|
background: rgba(255,255,255,0.9);
|
|
border: 1px solid rgba(0,0,0,0.12);
|
|
border-radius: 8px;
|
|
padding: 6px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.link.danger {
|
|
background: transparent;
|
|
color: #b5473d;
|
|
border: 1px solid rgba(181, 71, 61, 0.4);
|
|
}
|
|
|
|
.status-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 4px 8px;
|
|
border-radius: 10px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.status-badge.online {
|
|
background: rgba(73, 160, 120, 0.15);
|
|
color: #2f6b4f;
|
|
}
|
|
|
|
.status-badge.offline {
|
|
background: rgba(0,0,0,0.06);
|
|
color: #5f5140;
|
|
}
|
|
|
|
.status-badge.danger {
|
|
background: rgba(189, 93, 58, 0.15);
|
|
color: #b5473d;
|
|
}
|
|
|
|
.upload-feed {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.upload-feed.scrollable {
|
|
max-height: 280px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.upload-item {
|
|
padding: 10px;
|
|
border-radius: 12px;
|
|
background: rgba(255,255,255,0.92);
|
|
border: 1px solid rgba(0,0,0,0.06);
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
gap: 14px;
|
|
}
|
|
|
|
.balance-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.balance-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.balance-card {
|
|
background: rgba(255,255,255,0.92);
|
|
border: 1px solid rgba(44,32,19,0.12);
|
|
border-radius: 14px;
|
|
padding: 12px;
|
|
}
|
|
|
|
.balance-card.error {
|
|
border-color: rgba(181, 71, 61, 0.35);
|
|
}
|
|
|
|
.balance-card-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.error-text {
|
|
color: #b5473d;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
</style>
|