Files
crawler-plugin/admin-frontend-vue/src/pages/account/UserSecretUsagePage.vue
T
huangzd1997 a0f6582914 feat(密钥管理): 代理配置后台明文展示 + 按次统计各密钥调用损耗
- 后台密钥管理「代理设置」列改为完整明文展示(含账号密码),便于运维核对;
  新增 full 字段仅对代理模块下发,密钥两列保持脱敏
- 新增 biz_user_secret_usage_daily(V117):用户 × 模块 × 天累计真实对外请求次数
- 计次口径:LLM 每次真实 HTTP 请求(含重试)计 1 次;代理每次成功提取计 1 次
- LLM 埋点走 SecretUsageContext 上下文(批次外设置、线程池内快照恢复)
- 新增内部上报接口 /api/internal/user-secret-usage(X-Internal-Token)
- 新增后台页「密钥用量统计」:日期范围 + 用户名 + 分组筛选,含范围内汇总
2026-09-13 21:56:46 +08:00

396 lines
11 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.
<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>