a0f6582914
- 后台密钥管理「代理设置」列改为完整明文展示(含账号密码),便于运维核对; 新增 full 字段仅对代理模块下发,密钥两列保持脱敏 - 新增 biz_user_secret_usage_daily(V117):用户 × 模块 × 天累计真实对外请求次数 - 计次口径:LLM 每次真实 HTTP 请求(含重试)计 1 次;代理每次成功提取计 1 次 - LLM 埋点走 SecretUsageContext 上下文(批次外设置、线程池内快照恢复) - 新增内部上报接口 /api/internal/user-secret-usage(X-Internal-Token) - 新增后台页「密钥用量统计」:日期范围 + 用户名 + 分组筛选,含范围内汇总
396 lines
11 KiB
Vue
396 lines
11 KiB
Vue
<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> </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>
|