feat(密钥管理): 代理配置后台明文展示 + 按次统计各密钥调用损耗

- 后台密钥管理「代理设置」列改为完整明文展示(含账号密码),便于运维核对;
  新增 full 字段仅对代理模块下发,密钥两列保持脱敏
- 新增 biz_user_secret_usage_daily(V117):用户 × 模块 × 天累计真实对外请求次数
- 计次口径:LLM 每次真实 HTTP 请求(含重试)计 1 次;代理每次成功提取计 1 次
- LLM 埋点走 SecretUsageContext 上下文(批次外设置、线程池内快照恢复)
- 新增内部上报接口 /api/internal/user-secret-usage(X-Internal-Token)
- 新增后台页「密钥用量统计」:日期范围 + 用户名 + 分组筛选,含范围内汇总
This commit is contained in:
2026-09-13 21:56:46 +08:00
parent 0c98c5bc15
commit a0f6582914
35 changed files with 1545 additions and 76 deletions
@@ -0,0 +1,395 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import OldPagination from '@/components/OldPagination.vue'
import {
fetchUserSecretUsage,
type AdminUserSecretUsageRow,
} from '@/api/user-secret-usage'
import type { GroupOption } from '@/api/user-secrets'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<AdminUserSecretUsageRow[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const keyword = ref('')
const startDate = ref('')
const endDate = ref('')
const groupFilter = ref<number | null>(null)
const groupOptions = ref<GroupOption[]>([])
/** 所选范围内三类总次数(后端按筛选条件统计,不分页)。 */
const summary = ref({ totalCalls: 0, similarAsinCalls: 0, appearancePatentCalls: 0, proxyCalls: 0 })
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
function formatCount(value: number | undefined) {
const num = Number(value || 0)
return num.toLocaleString('zh-CN')
}
async function load() {
loading.value = true
try {
const result = await fetchUserSecretUsage({
startDate: startDate.value || undefined,
endDate: endDate.value || undefined,
keyword: keyword.value.trim() || undefined,
groupId: groupFilter.value || undefined,
page: page.value,
pageSize: pageSize.value,
})
rows.value = result?.items || []
total.value = Number(result?.total || 0)
groupOptions.value = result?.groupOptions || []
summary.value = {
totalCalls: Number(result?.totalCalls || 0),
similarAsinCalls: Number(result?.similarAsinCalls || 0),
appearancePatentCalls: Number(result?.appearancePatentCalls || 0),
proxyCalls: Number(result?.proxyCalls || 0),
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '加载失败')
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
keyword.value = ''
startDate.value = ''
endDate.value = ''
groupFilter.value = null
page.value = 1
load()
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
onMounted(load)
</script>
<template>
<div class="secret-usage-view">
<section class="panel-box">
<div class="usage-head">
<h3>密钥用量统计</h3>
<span class="usage-note">口径真实对外请求次数LLM 每次请求代理每次成功提取</span>
</div>
<div class="summary-row">
<div class="summary-card">
<div class="summary-label">总调用次数</div>
<div class="summary-value">{{ formatCount(summary.totalCalls) }}</div>
</div>
<div class="summary-card">
<div class="summary-label">货源查询密钥</div>
<div class="summary-value">{{ formatCount(summary.similarAsinCalls) }}</div>
</div>
<div class="summary-card">
<div class="summary-label">外观专利密钥</div>
<div class="summary-value">{{ formatCount(summary.appearancePatentCalls) }}</div>
</div>
<div class="summary-card">
<div class="summary-label">代理提取</div>
<div class="summary-value">{{ formatCount(summary.proxyCalls) }}</div>
</div>
</div>
<div class="form-row usage-filter-row">
<div class="form-group" style="min-width: 160px">
<label>开始日期</label>
<input v-model="startDate" type="date" />
</div>
<div class="form-group" style="min-width: 160px">
<label>结束日期</label>
<input v-model="endDate" type="date" />
</div>
<div class="form-group" style="min-width: 200px">
<label>用户名</label>
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
</div>
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
<label>分组</label>
<select v-model="groupFilter">
<option :value="null">全部分组</option>
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<div class="filter-actions">
<button class="btn" type="button" @click="search">查询</button>
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
</div>
</div>
</div>
<div class="usage-table-scroll">
<table>
<thead>
<tr>
<th style="width: 58px">序号</th>
<th style="width: 220px">用户</th>
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
<th style="width: 170px">货源查询密钥</th>
<th style="width: 170px">外观专利密钥</th>
<th style="width: 150px">代理提取</th>
<th style="width: 130px">合计</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="(row, index) in rows" :key="row.userId">
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
<td>
<span class="user-name">{{ row.username || `UID ${row.userId}` }}</span>
</td>
<td v-if="isSuperAdmin">
<span class="group-name">{{ row.groups?.length ? row.groups.join('、') : '—' }}</span>
</td>
<td><span class="count-value">{{ formatCount(row.similarAsinCount) }}</span></td>
<td><span class="count-value">{{ formatCount(row.appearancePatentCount) }}</span></td>
<td><span class="count-value">{{ formatCount(row.proxyCount) }}</span></td>
<td><span class="count-value count-total">{{ formatCount(row.totalCount) }}</span></td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">
{{ keyword || startDate || endDate || groupFilter ? '暂无匹配记录' : '暂无用量记录' }}
</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
</div>
</template>
<style scoped>
/* 与密钥管理页保持同一视觉语言(像素复刻后台面板风格)。 */
.secret-usage-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.usage-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.usage-note {
color: #7d8fa2;
font-size: 12px;
}
.summary-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
flex: 1 1 160px;
min-width: 150px;
padding: 12px 16px;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #f7fafd;
}
.summary-label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.summary-value {
margin-top: 6px;
color: #2f5d8b;
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.usage-filter-row {
margin: 0 0 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.filter-actions {
display: flex;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
}
.btn-ghost {
background: #ffffff;
border-color: #c7d7e5;
color: #4f78a5;
box-shadow: none;
}
.btn-ghost:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.usage-table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.usage-table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
min-width: 1020px;
}
.usage-table-scroll th,
.usage-table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.usage-table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.usage-table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.user-name {
color: #24384d;
font-weight: 600;
white-space: normal;
overflow-wrap: anywhere;
}
.group-name {
color: #5b6f83;
font-size: 12.5px;
}
.count-value {
color: #2f5d8b;
font-variant-numeric: tabular-nums;
font-size: 13.5px;
}
.count-total {
font-weight: 700;
}
.empty-tip {
color: #8293a5;
text-align: center;
}
</style>