feat: add personal usage stats

This commit is contained in:
JOJO 2026-03-09 12:03:47 +08:00
parent 77b96623ac
commit d71525a3c6
3 changed files with 301 additions and 23 deletions

View File

@ -331,6 +331,55 @@
</div> </div>
</div> </div>
</section> </section>
<section v-else-if="activeTab === 'usage'" key="usage" class="personal-page usage-summary-page">
<div class="usage-summary-card">
<div class="usage-summary-header">
<div>
<p class="usage-summary-eyebrow">对话用量</p>
<h3>用量统计</h3>
<p class="usage-summary-desc">累计统计所有对话的输入/输出 Token对话数量用户消息与工具调用</p>
<p class="usage-summary-note">宿主机模式同样统计 data/conversations 内的记录</p>
</div>
</div>
<div class="usage-summary-grid usage-summary-grid--tokens">
<div class="usage-summary-item">
<div class="label">累计输入</div>
<div class="value value--success">{{ formatTokenCount(usageSummary.total_input_tokens) }}</div>
</div>
<div class="usage-summary-item">
<div class="label">累计输出</div>
<div class="value value--warning">{{ formatTokenCount(usageSummary.total_output_tokens) }}</div>
</div>
</div>
<div class="usage-summary-grid usage-summary-grid--counts">
<div class="usage-summary-item">
<div class="label">总对话数</div>
<div class="value">{{ formatTokenCount(usageSummary.total_conversations) }}</div>
</div>
<div class="usage-summary-item">
<div class="label">用户消息数</div>
<div class="value">{{ formatTokenCount(usageSummary.total_user_messages) }}</div>
</div>
<div class="usage-summary-item">
<div class="label">工具调用次数</div>
<div class="value">{{ formatTokenCount(usageSummary.total_tools) }}</div>
</div>
</div>
<div class="usage-summary-meta">
<span v-if="usageError" class="usage-summary-error">{{ usageError }}</span>
<span v-else-if="usageLoading">正在同步最新统计...</span>
<span v-else>最近更新{{ usageUpdatedText }}</span>
<button
type="button"
class="usage-summary-refresh"
@click="fetchUsageSummary"
:disabled="usageLoading"
>
{{ usageLoading ? '刷新中...' : '刷新数据' }}
</button>
</div>
</div>
</section>
<section v-else-if="activeTab === 'behavior'" key="behavior" class="personal-page behavior-page"> <section v-else-if="activeTab === 'behavior'" key="behavior" class="personal-page behavior-page">
<div class="behavior-section"> <div class="behavior-section">
<div class="behavior-field"> <div class="behavior-field">
@ -672,12 +721,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { usePersonalizationStore } from '@/stores/personalization'; import { usePersonalizationStore } from '@/stores/personalization';
import { useResourceStore } from '@/stores/resource'; import { useResourceStore } from '@/stores/resource';
import { useUiStore } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
import { usePolicyStore } from '@/stores/policy'; import { usePolicyStore } from '@/stores/policy';
import { formatTokenCount } from '@/utils/formatters';
import { useTheme } from '@/utils/theme'; import { useTheme } from '@/utils/theme';
import type { ThemeKey } from '@/utils/theme'; import type { ThemeKey } from '@/utils/theme';
@ -707,6 +757,7 @@ const {
const baseTabs = [ const baseTabs = [
{ id: 'preferences', label: '个性化设置', description: '称呼、语气与注意事项' }, { id: 'preferences', label: '个性化设置', description: '称呼、语气与注意事项' },
{ id: 'model', label: '模型偏好', description: '默认模型选择' }, { id: 'model', label: '模型偏好', description: '默认模型选择' },
{ id: 'usage', label: '用量统计', description: '对话累计数据' },
{ id: 'behavior', label: '模型行为', description: '工具提示与界面表现' }, { id: 'behavior', label: '模型行为', description: '工具提示与界面表现' },
{ id: 'skills', label: 'Skills', description: '可用技能开关' }, { id: 'skills', label: 'Skills', description: '可用技能开关' },
{ id: 'image', label: '图片压缩', description: '发送图片的尺寸策略' }, { id: 'image', label: '图片压缩', description: '发送图片的尺寸策略' },
@ -714,7 +765,16 @@ const baseTabs = [
{ id: 'experiments', label: '实验功能', description: 'Liquid Glass' } { id: 'experiments', label: '实验功能', description: 'Liquid Glass' }
] as const; ] as const;
type PersonalTab = 'preferences' | 'model' | 'behavior' | 'skills' | 'image' | 'theme' | 'experiments' | 'admin-monitor'; type PersonalTab =
| 'preferences'
| 'model'
| 'usage'
| 'behavior'
| 'skills'
| 'image'
| 'theme'
| 'experiments'
| 'admin-monitor';
const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin'); const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin');
@ -767,6 +827,71 @@ const imageCompressionOptions = [
{ id: '540p', label: '540p', desc: '最长边不超过 540p 等比缩放' } { id: '540p', label: '540p', desc: '最长边不超过 540p 等比缩放' }
] as const; ] as const;
const usageSummary = ref({
total_input_tokens: 0,
total_output_tokens: 0,
total_conversations: 0,
total_user_messages: 0,
total_tools: 0
});
const usageLoading = ref(false);
const usageError = ref('');
const usageUpdatedAt = ref<string | null>(null);
const usageUpdatedText = computed(() => {
if (!usageUpdatedAt.value) {
return '尚未刷新';
}
const date = new Date(usageUpdatedAt.value);
if (Number.isNaN(date.getTime())) {
return '时间未知';
}
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
});
const fetchUsageSummary = async () => {
if (usageLoading.value) {
return;
}
usageLoading.value = true;
usageError.value = '';
try {
const response = await fetch('/api/conversations/statistics');
const payload = await response.json();
if (!response.ok || !payload.success) {
throw new Error(payload.error || payload.message || '获取用量统计失败');
}
const data = payload.data || {};
const tokenStats = data.token_statistics || {};
usageSummary.value = {
total_input_tokens: Number(tokenStats.total_input_tokens || 0),
total_output_tokens: Number(tokenStats.total_output_tokens || 0),
total_conversations: Number(data.total_conversations || 0),
total_user_messages: Number(data.total_user_messages || 0),
total_tools: Number(data.total_tools || 0)
};
usageUpdatedAt.value = new Date().toISOString();
} catch (error: any) {
usageError.value = error?.message || '获取用量统计失败';
} finally {
usageLoading.value = false;
}
};
watch(
() => [activeTab.value, visible.value],
([tab, isVisible]) => {
if (isVisible && tab === 'usage') {
fetchUsageSummary();
}
}
);
const setActiveTab = (tab: PersonalTab) => { const setActiveTab = (tab: PersonalTab) => {
activeTab.value = tab; activeTab.value = tab;
}; };
@ -912,9 +1037,6 @@ const openApiAdmin = () => {
}; };
// ===== ===== // ===== =====
import { useTheme } from '@/utils/theme';
import type { ThemeKey } from '@/utils/theme';
const { setTheme, loadTheme } = useTheme(); const { setTheme, loadTheme } = useTheme();
const themeOptions: Array<{ id: ThemeKey; label: string; desc: string; swatches: string[] }> = [ const themeOptions: Array<{ id: ThemeKey; label: string; desc: string; swatches: string[] }> = [
{ {
@ -946,6 +1068,146 @@ const applyThemeOption = (theme: ThemeKey) => {
</script> </script>
<style scoped> <style scoped>
.usage-summary-page {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 12px 0 24px;
height: 100%;
}
.usage-summary-card {
width: 100%;
max-width: 720px;
padding: 0;
min-height: 320px;
display: flex;
flex-direction: column;
gap: 18px;
flex: 1 1 auto;
min-height: 0;
}
.usage-summary-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.usage-summary-header h3 {
margin: 4px 0 6px;
font-size: 24px;
}
.usage-summary-eyebrow {
margin: 0;
font-size: 12px;
color: var(--claude-accent-strong);
letter-spacing: 0.18em;
text-transform: uppercase;
}
.usage-summary-desc {
margin: 0;
color: var(--claude-text-secondary);
}
.usage-summary-note {
margin: 6px 0 0;
font-size: 12px;
color: var(--claude-text-tertiary);
}
.usage-summary-refresh {
border: 1px solid var(--claude-border);
border-radius: 12px;
padding: 6px 14px;
background: var(--claude-panel);
color: var(--claude-text);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
box-shadow: 0 6px 14px rgba(61, 57, 41, 0.12);
display: inline-flex;
align-items: center;
justify-content: center;
}
.usage-summary-refresh:disabled {
opacity: 0.7;
cursor: not-allowed;
box-shadow: none;
}
.usage-summary-refresh:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(61, 57, 41, 0.22);
}
.usage-summary-grid {
display: grid;
gap: 14px;
}
.usage-summary-grid--tokens {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.usage-summary-grid--counts {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.usage-summary-item {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--claude-border);
background: transparent;
display: flex;
flex-direction: column;
gap: 6px;
}
.usage-summary-item .label {
font-size: 12px;
color: var(--claude-text-secondary);
}
.usage-summary-item .value {
font-size: 22px;
font-weight: 600;
color: var(--claude-text);
}
.usage-summary-item .value--success {
color: var(--claude-success);
}
.usage-summary-item .value--warning {
color: var(--claude-warning);
}
.usage-summary-item .value--accent {
color: var(--claude-text);
}
.usage-summary-meta {
font-size: 12px;
color: var(--claude-text-secondary);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: auto;
}
.usage-summary-error {
color: var(--claude-warning);
}
.admin-monitor-page { .admin-monitor-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1083,6 +1345,16 @@ const applyThemeOption = (theme: ThemeKey) => {
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.usage-summary-refresh {
width: 100%;
justify-content: center;
}
.usage-summary-grid--tokens,
.usage-summary-grid--counts {
grid-template-columns: 1fr;
}
.admin-monitor-panel { .admin-monitor-panel {
padding: 24px; padding: 24px;
} }

View File

@ -545,8 +545,8 @@
} }
.behavior-section { .behavior-section {
padding: 16px; padding: 0;
border-radius: 12px; border-radius: 0;
overflow: visible; overflow: visible;
} }
@ -802,10 +802,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
background: var(--theme-surface-muted); background: transparent;
border-radius: 20px; border-radius: 0;
padding: 24px; padding: 0;
border: 1px solid var(--theme-control-border); border: none;
} }
.behavior-field { .behavior-field {
@ -1291,11 +1291,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 18px;
background: var(--theme-surface-soft); background: transparent;
border-radius: 20px; border-radius: 0;
border: 1px solid var(--theme-control-border); border: none;
padding: 22px 24px 26px; padding: 0;
box-shadow: var(--theme-shadow-mid); box-shadow: none;
} }
.theme-section { .theme-section {
@ -3318,4 +3318,3 @@ body[data-theme='dark'] {
background: #1a1a1a; background: #1a1a1a;
} }
} }

View File

@ -1011,17 +1011,24 @@ class ConversationManager:
total_input_tokens = 0 total_input_tokens = 0
total_output_tokens = 0 total_output_tokens = 0
token_stats_count = 0 token_stats_count = 0
total_user_messages = 0
for conv_id in index.keys(): for conv_id in index.keys():
token_stats = self.get_token_statistics(conv_id) conversation_data = self.load_conversation(conv_id)
if token_stats: if not conversation_data:
total_input_tokens += token_stats.get("total_input_tokens", 0) continue
total_output_tokens += token_stats.get("total_output_tokens", 0) validated = self._validate_token_statistics(conversation_data)
token_stats_count += 1 token_stats = validated.get("token_statistics", {}) or {}
total_input_tokens += token_stats.get("total_input_tokens", 0)
total_output_tokens += token_stats.get("total_output_tokens", 0)
token_stats_count += 1
messages = conversation_data.get("messages") or []
total_user_messages += sum(1 for msg in messages if msg.get("role") == "user")
return { return {
"total_conversations": total_conversations, "total_conversations": total_conversations,
"total_messages": total_messages, "total_messages": total_messages,
"total_user_messages": total_user_messages,
"total_tools": total_tools, "total_tools": total_tools,
"status_distribution": status_count, "status_distribution": status_count,
"thinking_mode_distribution": thinking_mode_count, "thinking_mode_distribution": thinking_mode_count,