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:
@@ -0,0 +1,47 @@
|
||||
import { http } from './http'
|
||||
import { unwrap } from './envelope'
|
||||
|
||||
/** 单用户一行:三类密钥调用次数 + 合计(口径=真实对外请求次数)。 */
|
||||
export interface AdminUserSecretUsageRow {
|
||||
userId: number
|
||||
username: string
|
||||
groups: string[]
|
||||
similarAsinCount: number
|
||||
appearancePatentCount: number
|
||||
proxyCount: number
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export interface AdminUserSecretUsagePage {
|
||||
items: AdminUserSecretUsageRow[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
/** 所选范围内(不分页)三类总次数。 */
|
||||
totalCalls: number
|
||||
similarAsinCalls: number
|
||||
appearancePatentCalls: number
|
||||
proxyCalls: number
|
||||
groupOptions?: Array<{ id: number; groupName: string }>
|
||||
}
|
||||
|
||||
export interface UserSecretUsageQuery {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
keyword?: string
|
||||
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
|
||||
groupId?: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 分页查询密钥用量:GET /api/admin/user-secret-usage(group_id 必须 snake_case)。 */
|
||||
export async function fetchUserSecretUsage(params: UserSecretUsageQuery): Promise<AdminUserSecretUsagePage> {
|
||||
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
|
||||
if (params.startDate) query.startDate = params.startDate
|
||||
if (params.endDate) query.endDate = params.endDate
|
||||
if (params.keyword) query.keyword = params.keyword
|
||||
if (params.groupId) query.group_id = params.groupId
|
||||
const { data } = await http.get('/api/admin/user-secret-usage', { params: query })
|
||||
return unwrap<AdminUserSecretUsagePage>(data)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ export interface AdminUserSecretModule {
|
||||
moduleKey: string
|
||||
moduleLabel: string
|
||||
masked: string
|
||||
/** 完整明文值:仅代理列有值(后端对密钥列不下发明文)。 */
|
||||
full: string
|
||||
exists: boolean
|
||||
checkStatus: string
|
||||
checkCode: string
|
||||
|
||||
@@ -78,6 +78,10 @@ export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
|
||||
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
|
||||
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
|
||||
},
|
||||
admin_user_secret_usage: {
|
||||
text: '按日期范围统计各用户的密钥调用次数(货源查询与外观专利按 LLM 请求次数、代理按提取次数),用于评估用户资源损耗。',
|
||||
steps: ['选择日期范围', '筛选用户或分组', '核对各模块次数'],
|
||||
},
|
||||
}
|
||||
|
||||
/** 全部有独立/兜底提示的后台业务菜单 key。 */
|
||||
|
||||
@@ -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> </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>
|
||||
@@ -77,10 +77,11 @@ function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 单格悬停详情:检测消息 + 检测时间 + 耗时。 */
|
||||
/** 单格悬停详情:完整值(代理为全量地址)+ 检测消息 + 检测时间 + 耗时。 */
|
||||
function moduleTooltip(module: AdminUserSecretModule | undefined) {
|
||||
if (!module || !module.exists) return '未配置'
|
||||
const parts: string[] = []
|
||||
if (module.full) parts.push(module.full)
|
||||
if (module.checkMessage) parts.push(module.checkMessage)
|
||||
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
|
||||
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
|
||||
@@ -230,7 +231,7 @@ onMounted(load)
|
||||
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
|
||||
<th style="width: 230px">货源查询密钥</th>
|
||||
<th style="width: 230px">外观专利密钥</th>
|
||||
<th style="width: 230px">代理设置</th>
|
||||
<th style="width: 380px">代理设置</th>
|
||||
<th style="width: 130px">状态</th>
|
||||
<th style="width: 170px">操作</th>
|
||||
</tr>
|
||||
@@ -264,8 +265,8 @@ onMounted(load)
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="module-cell">
|
||||
<span v-if="row.proxy?.exists" class="mono-mask" :title="moduleTooltip(row.proxy)">{{ row.proxy.masked }}</span>
|
||||
<div class="module-cell module-cell--stack">
|
||||
<span v-if="row.proxy?.exists" class="mono-mask mono-full" :title="moduleTooltip(row.proxy)">{{ row.proxy.full || row.proxy.masked }}</span>
|
||||
<span v-else class="empty-value">未配置</span>
|
||||
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
|
||||
{{ moduleStatusMeta(row.proxy).label }}
|
||||
@@ -486,11 +487,29 @@ h3 {
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 代理列:完整地址独占一行,状态药丸另起一行,避免长地址被药丸挤压折行 */
|
||||
.module-cell--stack {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
.module-cell .mono-mask {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* 代理列展示完整明文:允许折行显示,不截断、不省略 */
|
||||
.module-cell .mono-full {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.45;
|
||||
cursor: text;
|
||||
user-select: all;
|
||||
}
|
||||
.mono-mask {
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 12.5px;
|
||||
|
||||
@@ -18,6 +18,7 @@ export const adminPages: AdminPageDef[] = [
|
||||
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
|
||||
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
|
||||
{ path: 'account/user-secrets', menuKey: 'admin_user_secrets', title: '密钥管理', load: () => import('@/pages/account/UserSecretsPage.vue') },
|
||||
{ path: 'account/user-secret-usage', menuKey: 'admin_user_secret_usage', title: '密钥用量统计', load: () => import('@/pages/account/UserSecretUsagePage.vue') },
|
||||
{ path: 'shop-center/duplicate-check', menuKey: 'admin_shop_data_duplicate_check', title: '店铺撞款监控', load: () => import('@/pages/tasks/DuplicateCheckPage.vue') },
|
||||
{ path: 'shop-center/keys', menuKey: 'admin_shop_keys', title: '店铺密钥管理', load: () => import('@/pages/shop/ShopKeysPage.vue') },
|
||||
{ path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') },
|
||||
|
||||
Reference in New Issue
Block a user