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

1960 lines
54 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-manage">
<input
v-model.trim="newInviteCode"
class="invite-input"
type="text"
placeholder="新邀请码"
:disabled="inviteSubmitting"
/>
<input
v-model="newInviteRemaining"
class="invite-input short"
type="number"
min="0"
placeholder="剩余次数"
:disabled="inviteSubmitting || newInviteUnlimited"
/>
<label class="invite-check">
<input type="checkbox" v-model="newInviteUnlimited" :disabled="inviteSubmitting" />
不限次数
</label>
<button type="button" :disabled="inviteSubmitting" @click="createInvite">
{{ inviteSubmitting ? '处理中...' : '新增邀请码' }}
</button>
</div>
<p v-if="inviteError" class="secondary-error">{{ inviteError }}</p>
<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 class="invite-actions">
<button
type="button"
class="mini-btn"
:disabled="inviteSubmitting"
@click="editInvite(code)"
>
调整次数
</button>
<button
type="button"
class="mini-btn danger"
:disabled="inviteSubmitting"
@click="removeInvite(code.code)"
>
删除
</button>
</div>
</div>
</div>
</section>
<section v-else-if="activeSection === 'passwords'" key="passwords" class="panel">
<h2>密码管理</h2>
<p class="muted">管理员可重置任意用户的登录密码(包括自己)。</p>
<div class="password-form">
<div class="password-field">
<label>选择用户</label>
<div class="user-search-wrapper" ref="userSearchRef">
<div
class="user-search-trigger"
:class="{ open: passwordDropdownOpen, filled: passwordTargetUser }"
@click="togglePasswordDropdown"
>
<span v-if="passwordTargetUser" class="user-search-selected">
<strong>{{ passwordTargetUser }}</strong>
<span class="user-search-email">{{ selectedUserEmail }}</span>
</span>
<span v-else class="user-search-placeholder">搜索用户名或邮箱...</span>
<span class="user-search-arrow">▾</span>
</div>
<div v-if="passwordDropdownOpen" class="user-search-dropdown">
<input
ref="passwordSearchInputRef"
class="user-search-input"
type="text"
v-model="passwordSearchQuery"
placeholder="输入关键词筛选..."
@keydown.escape="passwordDropdownOpen = false"
/>
<div class="user-search-list">
<div
v-for="user in filteredUsers"
:key="user.username"
class="user-search-item"
:class="{ active: passwordTargetUser === user.username }"
@click="selectPasswordUser(user)"
>
<span class="user-search-name">{{ user.username }}</span>
<span class="user-search-email">{{ user.email || '—' }}</span>
<span v-if="user.role === 'admin'" class="user-search-role">管理员</span>
</div>
<div v-if="!filteredUsers.length" class="user-search-empty">
无匹配用户
</div>
</div>
</div>
</div>
</div>
<div class="password-field">
<label>新密码(至少 8 位)</label>
<input
class="password-input"
type="password"
v-model="passwordNewValue"
:disabled="passwordSubmitting"
placeholder="输入新密码"
@keyup.enter="handleResetPassword"
/>
</div>
<div class="password-actions">
<button
type="button"
:disabled="!passwordTargetUser || !passwordNewValue || passwordSubmitting"
@click="handleResetPassword"
>
{{ passwordSubmitting ? '重置中...' : '重置密码' }}
</button>
<button
type="button"
class="ghost-btn"
:disabled="passwordSubmitting"
@click="passwordTargetUser = ''; passwordNewValue = ''; passwordResult = ''; passwordError = ''"
>
清空
</button>
</div>
<p v-if="passwordResult" class="password-success">{{ passwordResult }}</p>
<p v-if="passwordError" class="password-error-msg">{{ passwordError }}</p>
</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' | 'passwords';
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 inviteSubmitting = ref(false);
const inviteError = ref<string | null>(null);
const newInviteCode = ref('');
const newInviteRemaining = ref('1');
const newInviteUnlimited = ref(false);
const passwordTargetUser = ref('');
const passwordNewValue = ref('');
const passwordSubmitting = ref(false);
const passwordResult = ref('');
const passwordError = ref('');
const passwordSearchQuery = ref('');
const passwordDropdownOpen = ref(false);
const userSearchRef = ref<HTMLElement | null>(null);
const passwordSearchInputRef = ref<HTMLInputElement | 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: '邀请码' },
{ id: 'passwords', 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 fetchCsrfToken = async (): Promise<string> => {
const resp = await fetch('/api/csrf-token', { credentials: 'same-origin' });
if (!resp.ok) {
throw new Error(`获取 CSRF Token 失败:${resp.status}`);
}
const payload = await resp.json();
const token = payload?.token;
if (!token || typeof token !== 'string') {
throw new Error('获取 CSRF Token 失败:响应无 token');
}
return token;
};
const parseInviteRemaining = (raw: unknown): number | null => {
const text = String(raw ?? '')
.trim()
.toLowerCase();
if (
!text ||
text === 'unlimited' ||
text === 'null' ||
text === 'none' ||
text === '不限' ||
text === '无限制'
) {
return null;
}
const parsed = Number(text);
if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) {
throw new Error('剩余次数必须是大于等于 0 的整数,或留空表示不限次数');
}
return parsed;
};
const createInvite = async () => {
const code = newInviteCode.value.trim();
if (!code) {
inviteError.value = '请输入邀请码';
return;
}
inviteSubmitting.value = true;
inviteError.value = null;
try {
const remaining = newInviteUnlimited.value
? null
: parseInviteRemaining(newInviteRemaining.value);
const csrfToken = await fetchCsrfToken();
const resp = await fetch('/api/admin/invites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ code, remaining })
});
const payload = await resp.json();
if (!resp.ok || !payload.success) {
throw new Error(payload.error || `请求失败:${resp.status}`);
}
newInviteCode.value = '';
newInviteRemaining.value = '1';
newInviteUnlimited.value = false;
await fetchDashboard(true);
} catch (error) {
inviteError.value = error instanceof Error ? error.message : String(error);
} finally {
inviteSubmitting.value = false;
}
};
const handleResetPassword = async () => {
const username = passwordTargetUser.value.trim();
const password = passwordNewValue.value.trim();
if (!username || !password) return;
if (password.length < 8) {
passwordError.value = '密码长度至少 8 位';
passwordResult.value = '';
return;
}
passwordSubmitting.value = true;
passwordResult.value = '';
passwordError.value = '';
try {
const csrfToken = await fetchCsrfToken();
const resp = await fetch(`/api/admin/users/${encodeURIComponent(username)}/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ password })
});
const payload = await resp.json();
if (!resp.ok || !payload.success) {
throw new Error(payload.error || `请求失败:${resp.status}`);
}
passwordResult.value = `用户 ${username} 的密码已成功重置`;
passwordNewValue.value = '';
} catch (error) {
passwordError.value = error instanceof Error ? error.message : String(error);
} finally {
passwordSubmitting.value = false;
}
};
const filteredUsers = computed(() => {
const query = passwordSearchQuery.value.trim().toLowerCase();
if (!query) return users.value;
return users.value.filter(
(u: any) =>
u.username.toLowerCase().includes(query) ||
(u.email || '').toLowerCase().includes(query)
);
});
const selectedUserEmail = computed(() => {
const user = users.value.find((u: any) => u.username === passwordTargetUser.value);
return user?.email || '';
});
const togglePasswordDropdown = () => {
passwordDropdownOpen.value = !passwordDropdownOpen.value;
if (passwordDropdownOpen.value) {
passwordSearchQuery.value = '';
// 聚焦搜索框
setTimeout(() => {
passwordSearchInputRef.value?.focus();
}, 50);
}
};
const selectPasswordUser = (user: any) => {
passwordTargetUser.value = user.username;
passwordSearchQuery.value = '';
passwordDropdownOpen.value = false;
};
const handleClickOutside = (event: MouseEvent) => {
if (userSearchRef.value && !userSearchRef.value.contains(event.target as Node)) {
passwordDropdownOpen.value = false;
}
};
const editInvite = async (item: any) => {
const current = item?.remaining;
const seed = current === null || typeof current === 'undefined' ? 'unlimited' : String(current);
const value = window.prompt('请输入新的剩余次数(留空或输入 unlimited 表示不限次数)', seed);
if (value === null) return;
inviteSubmitting.value = true;
inviteError.value = null;
try {
const remaining = parseInviteRemaining(value);
const csrfToken = await fetchCsrfToken();
const resp = await fetch('/api/admin/invites', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ code: item?.code, remaining })
});
const payload = await resp.json();
if (!resp.ok || !payload.success) {
throw new Error(payload.error || `请求失败:${resp.status}`);
}
await fetchDashboard(true);
} catch (error) {
inviteError.value = error instanceof Error ? error.message : String(error);
} finally {
inviteSubmitting.value = false;
}
};
const removeInvite = async (code: string) => {
if (!code) return;
if (!window.confirm(`确认删除邀请码 ${code}`)) return;
inviteSubmitting.value = true;
inviteError.value = null;
try {
const csrfToken = await fetchCsrfToken();
const resp = await fetch('/api/admin/invites', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ code })
});
const payload = await resp.json();
if (!resp.ok || !payload.success) {
throw new Error(payload.error || `请求失败:${resp.status}`);
}
await fetchDashboard(true);
} catch (error) {
inviteError.value = error instanceof Error ? error.message : String(error);
} finally {
inviteSubmitting.value = 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 () => {
document.addEventListener('click', handleClickOutside);
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(() => {
document.removeEventListener('click', handleClickOutside);
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-manage {
margin: 10px 0 14px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.invite-input {
min-width: 180px;
height: 38px;
border-radius: 10px;
border: 1px solid rgba(118, 103, 84, 0.35);
background: rgba(255, 255, 255, 0.92);
padding: 0 10px;
box-sizing: border-box;
}
.invite-input.short {
min-width: 120px;
width: 120px;
}
.invite-check {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--claude-text-secondary);
font-size: 13px;
}
.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;
}
.invite-actions {
margin-top: 10px;
display: flex;
gap: 8px;
}
.mini-btn {
border-radius: 10px;
padding: 6px 10px;
font-size: 12px;
}
.mini-btn.danger {
background: rgba(189, 93, 58, 0.2);
color: #7c3418;
box-shadow: none;
}
.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;
}
}
.password-form {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 480px;
margin-top: 12px;
}
.password-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.password-field label {
font-size: 13px;
color: var(--claude-text-secondary);
font-weight: 600;
}
.password-select,
.password-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
font-size: 14px;
background: rgba(255, 255, 255, 0.92);
}
.password-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
font-size: 14px;
background: rgba(255, 255, 255, 0.92);
}
/* 自定义用户搜索下拉 */
.user-search-wrapper {
position: relative;
}
.user-search-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
background: rgba(255, 255, 255, 0.92);
cursor: pointer;
min-height: 44px;
box-sizing: border-box;
transition: border-color 0.15s ease;
}
.user-search-trigger:hover {
border-color: rgba(44, 32, 19, 0.4);
}
.user-search-trigger.open {
border-color: rgba(189, 93, 58, 0.5);
border-radius: 12px 12px 0 0;
}
.user-search-trigger.filled {
border-color: rgba(44, 32, 19, 0.35);
}
.user-search-selected {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.user-search-selected strong {
font-size: 14px;
flex-shrink: 0;
}
.user-search-selected .user-search-email {
font-size: 12px;
color: var(--claude-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-search-placeholder {
color: #9a8b7a;
font-size: 14px;
}
.user-search-arrow {
font-size: 12px;
color: var(--claude-text-secondary);
transition: transform 0.15s ease;
flex-shrink: 0;
}
.user-search-trigger.open .user-search-arrow {
transform: rotate(180deg);
}
.user-search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
border: 1px solid rgba(189, 93, 58, 0.5);
border-top: none;
border-radius: 0 0 12px 12px;
z-index: 100;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
overflow: hidden;
}
.user-search-input {
width: 100%;
box-sizing: border-box;
padding: 10px 14px;
border: none;
border-bottom: 1px solid rgba(118, 103, 84, 0.15);
font-size: 13px;
outline: none;
background: rgba(255, 255, 255, 0.95);
}
.user-search-input::placeholder {
color: #b0a392;
}
.user-search-list {
max-height: 220px;
overflow-y: auto;
}
.user-search-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
cursor: pointer;
transition: background 0.1s ease;
}
.user-search-item:hover {
background: rgba(218, 119, 86, 0.08);
}
.user-search-item.active {
background: rgba(218, 119, 86, 0.14);
}
.user-search-name {
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.user-search-item .user-search-email {
font-size: 12px;
color: var(--claude-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-search-role {
font-size: 11px;
padding: 2px 6px;
border-radius: 6px;
background: rgba(189, 93, 58, 0.15);
color: #7c3418;
flex-shrink: 0;
margin-left: auto;
}
.user-search-empty {
padding: 16px;
text-align: center;
color: var(--claude-text-secondary);
font-size: 13px;
}
.password-actions {
display: flex;
gap: 10px;
align-items: center;
}
.password-actions .ghost-btn {
background: transparent;
border: 1px dashed rgba(44, 32, 19, 0.35);
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
box-shadow: none;
color: var(--claude-text-secondary);
}
.password-actions .ghost-btn:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.04);
transform: none;
}
.password-success {
color: var(--claude-success);
margin: 0;
font-weight: 600;
}
.password-error-msg {
color: #b5473d;
margin: 0;
}
</style>