feat(密钥管理): 列表按用户聚合三字段列 + 代理配置服务端上报
- Java:后台列表一行一用户(货源查询密钥/外观专利密钥/代理设置),行级状态三项全通过才算通过;检测/清空改为按用户;搜索仅按用户名(去 UID);新增代理检测(经用户代理请求自家域名)与代理掩码(隐去账密) - 后台前端:三字段列改版 + 行级状态筛选 + 用户名筛选 - 桌面前端:登录加载密钥时补报本地代理(只填空缺不覆盖)、保存/清空代理实时上报
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
import { http } from './http'
|
||||
import { unwrap } from './envelope'
|
||||
|
||||
export interface AdminUserSecretItem {
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
/** 单模块状态(脱敏值 + 连通性)。 */
|
||||
export interface AdminUserSecretModule {
|
||||
moduleKey: string
|
||||
moduleLabel: string
|
||||
masked: string
|
||||
@@ -14,12 +12,23 @@ export interface AdminUserSecretItem {
|
||||
checkMessage: string
|
||||
checkLatencyMs: number | null
|
||||
checkedAt: string | null
|
||||
source: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
/** 一行一用户:三个字段列 + 行级状态(三类都检测通过才算 passed)。 */
|
||||
export interface AdminUserSecretRow {
|
||||
userId: number
|
||||
username: string
|
||||
similarAsin: AdminUserSecretModule
|
||||
appearancePatent: AdminUserSecretModule
|
||||
proxy: AdminUserSecretModule
|
||||
status: string
|
||||
statusMessage: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminUserSecretPage {
|
||||
items: AdminUserSecretItem[]
|
||||
items: AdminUserSecretRow[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
@@ -27,34 +36,35 @@ export interface AdminUserSecretPage {
|
||||
|
||||
export interface UserSecretQuery {
|
||||
keyword?: string
|
||||
moduleKey?: string
|
||||
checkStatus?: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 分页查询用户密钥(脱敏):GET /api/admin/user-secrets */
|
||||
/** 分页查询用户密钥(一行一用户):GET /api/admin/user-secrets */
|
||||
export async function fetchUserSecretList(params: UserSecretQuery): Promise<AdminUserSecretPage> {
|
||||
const { data } = await http.get('/api/admin/user-secrets', { params })
|
||||
return unwrap<AdminUserSecretPage>(data)
|
||||
}
|
||||
|
||||
/** 立即检测指定密钥:POST /api/admin/user-secrets/{id}/check */
|
||||
export async function checkUserSecret(id: number) {
|
||||
const { data } = await http.post(`/api/admin/user-secrets/${id}/check`)
|
||||
return unwrap<{
|
||||
moduleKey: string
|
||||
checkStatus: string
|
||||
checkCode: string
|
||||
checkMessage: string
|
||||
checkLatencyMs: number | null
|
||||
checkedAt: string | null
|
||||
viaProxy: boolean
|
||||
}>(data)
|
||||
/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check */
|
||||
export async function checkUserSecret(userId: number) {
|
||||
const { data } = await http.post(`/api/admin/user-secrets/${userId}/check`)
|
||||
return unwrap<
|
||||
Array<{
|
||||
moduleKey: string
|
||||
checkStatus: string
|
||||
checkCode: string
|
||||
checkMessage: string
|
||||
checkLatencyMs: number | null
|
||||
checkedAt: string | null
|
||||
viaProxy: boolean
|
||||
}>
|
||||
>(data)
|
||||
}
|
||||
|
||||
/** 清空指定用户密钥:DELETE /api/admin/user-secrets/{id} */
|
||||
export async function deleteUserSecret(id: number): Promise<void> {
|
||||
const { data } = await http.delete(`/api/admin/user-secrets/${id}`)
|
||||
/** 清空该用户全部密钥与代理配置:DELETE /api/admin/user-secrets/{userId} */
|
||||
export async function deleteUserSecret(userId: number): Promise<void> {
|
||||
const { data } = await http.delete(`/api/admin/user-secrets/${userId}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
@@ -7,43 +7,40 @@ import {
|
||||
checkUserSecret,
|
||||
deleteUserSecret,
|
||||
fetchUserSecretList,
|
||||
type AdminUserSecretItem,
|
||||
type AdminUserSecretModule,
|
||||
type AdminUserSecretRow,
|
||||
} from '@/api/user-secrets'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<AdminUserSecretItem[]>([])
|
||||
const rows = ref<AdminUserSecretRow[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const keyword = ref('')
|
||||
const moduleFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
/** 正在检测的行 id,用于按钮 loading 态。 */
|
||||
/** 正在检测的行 userId,用于按钮 loading 态。 */
|
||||
const checkingId = ref<number | null>(null)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
const MODULE_OPTIONS = [
|
||||
{ value: '', label: '全部类型' },
|
||||
{ value: 'appearance-patent', label: '外观专利密钥' },
|
||||
{ value: 'similar-asin', label: '货源查询密钥' },
|
||||
]
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'passed', label: '检测通过' },
|
||||
{ value: 'failed', label: '检测失败' },
|
||||
{ value: 'incomplete', label: '未配齐' },
|
||||
{ value: 'error', label: '无法判定' },
|
||||
{ value: 'unknown', label: '未检测' },
|
||||
]
|
||||
|
||||
/** 状态药丸:与店铺密钥页 whitelistStatusMeta 同一视觉语言。 */
|
||||
function statusMeta(status: string) {
|
||||
/** 行级状态药丸:三类都检测通过才显示「检测通过」。 */
|
||||
function rowStatusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return { label: '检测通过', tone: 'is-allowed' }
|
||||
case 'failed':
|
||||
return { label: '检测失败', tone: 'is-blocked' }
|
||||
case 'incomplete':
|
||||
return { label: '未配齐', tone: 'is-warn' }
|
||||
case 'error':
|
||||
return { label: '无法判定', tone: 'is-warn' }
|
||||
default:
|
||||
@@ -51,13 +48,37 @@ function statusMeta(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态药丸悬停详情:检测时间 + 检测消息 + 耗时。 */
|
||||
function statusTooltip(row: AdminUserSecretItem) {
|
||||
/** 单格状态药丸:未配置时统一灰色。 */
|
||||
function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
||||
if (!module || !module.exists) {
|
||||
return { label: '未配置', tone: 'is-unknown' }
|
||||
}
|
||||
switch (module.checkStatus) {
|
||||
case 'passed':
|
||||
return { label: '通过', tone: 'is-allowed' }
|
||||
case 'failed':
|
||||
return { label: '失败', tone: 'is-blocked' }
|
||||
case 'error':
|
||||
return { label: '无法判定', tone: 'is-warn' }
|
||||
default:
|
||||
return { label: '未检测', tone: 'is-unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 单格悬停详情:检测消息 + 检测时间 + 耗时。 */
|
||||
function moduleTooltip(module: AdminUserSecretModule | undefined) {
|
||||
if (!module || !module.exists) return '未配置'
|
||||
const parts: string[] = []
|
||||
if (row.checkMessage) parts.push(row.checkMessage)
|
||||
if (row.checkedAt) parts.push(`检测时间:${formatDateTime(row.checkedAt)}`)
|
||||
if (row.checkLatencyMs != null) parts.push(`耗时:${row.checkLatencyMs}ms`)
|
||||
if (row.source) parts.push(`来源:${row.source}`)
|
||||
if (module.checkMessage) parts.push(module.checkMessage)
|
||||
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
|
||||
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
|
||||
return parts.join(';') || '暂无检测记录'
|
||||
}
|
||||
|
||||
function rowStatusTooltip(row: AdminUserSecretRow) {
|
||||
const parts: string[] = []
|
||||
if (row.statusMessage) parts.push(row.statusMessage)
|
||||
if (row.updatedAt) parts.push(`最近更新:${formatDateTime(row.updatedAt)}`)
|
||||
return parts.join(';') || '暂无检测记录'
|
||||
}
|
||||
|
||||
@@ -66,7 +87,6 @@ async function load() {
|
||||
try {
|
||||
const result = await fetchUserSecretList({
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
moduleKey: moduleFilter.value || undefined,
|
||||
checkStatus: statusFilter.value || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
@@ -87,23 +107,28 @@ function search() {
|
||||
|
||||
function reset() {
|
||||
keyword.value = ''
|
||||
moduleFilter.value = ''
|
||||
statusFilter.value = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
/** 立即检测:真实请求一次 LLM 接口并把结果落库。 */
|
||||
async function check(row: AdminUserSecretItem) {
|
||||
checkingId.value = row.id
|
||||
/** 立即检测:真实请求该用户全部已配置项并把结果落库。 */
|
||||
async function check(row: AdminUserSecretRow) {
|
||||
checkingId.value = row.userId
|
||||
try {
|
||||
const result = await checkUserSecret(row.id)
|
||||
if (result?.checkStatus === 'passed') {
|
||||
ElMessage.success('检测通过')
|
||||
} else if (result?.checkStatus === 'failed') {
|
||||
ElMessage.warning(result.checkMessage || '检测未通过')
|
||||
const results = await checkUserSecret(row.userId)
|
||||
if (!results || !results.length) {
|
||||
ElMessage.warning('该用户尚未配置任何密钥或代理')
|
||||
} else {
|
||||
ElMessage.warning(result?.checkMessage || '本次无法判定')
|
||||
const failed = results.filter((item) => item.checkStatus === 'failed')
|
||||
const errors = results.filter((item) => item.checkStatus === 'error')
|
||||
if (failed.length) {
|
||||
ElMessage.warning(failed[0].checkMessage || '存在检测失败项')
|
||||
} else if (errors.length) {
|
||||
ElMessage.warning('部分配置本次无法判定')
|
||||
} else {
|
||||
ElMessage.success('检测通过')
|
||||
}
|
||||
}
|
||||
load()
|
||||
} catch (error) {
|
||||
@@ -113,11 +138,11 @@ async function check(row: AdminUserSecretItem) {
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: AdminUserSecretItem) {
|
||||
async function remove(row: AdminUserSecretRow) {
|
||||
const who = row.username || `UID ${row.userId}`
|
||||
if (!window.confirm(`确定清空「${who}」的${row.moduleLabel || row.moduleKey}吗?清空后该用户需要重新配置。`)) return
|
||||
if (!window.confirm(`确定清空「${who}」的全部密钥与代理配置吗?清空后该用户需要重新配置。`)) return
|
||||
try {
|
||||
await deleteUserSecret(row.id)
|
||||
await deleteUserSecret(row.userId)
|
||||
ElMessage.success('已清空')
|
||||
load()
|
||||
} catch (error) {
|
||||
@@ -149,17 +174,11 @@ onMounted(load)
|
||||
|
||||
<div class="form-row secrets-filter-row">
|
||||
<div class="form-group" style="min-width: 220px">
|
||||
<label>用户名 / UID</label>
|
||||
<input v-model="keyword" type="text" placeholder="模糊搜索用户名或输入用户ID" @keyup.enter="search" />
|
||||
<label>用户名</label>
|
||||
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 170px">
|
||||
<label>密钥类型</label>
|
||||
<select v-model="moduleFilter">
|
||||
<option v-for="option in MODULE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 150px">
|
||||
<label>连通性状态</label>
|
||||
<label>状态(三项都通过才算通过)</label>
|
||||
<select v-model="statusFilter">
|
||||
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
@@ -178,59 +197,71 @@ onMounted(load)
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 58px">序号</th>
|
||||
<th style="width: 200px">用户</th>
|
||||
<th style="width: 150px">密钥类型</th>
|
||||
<th style="width: 220px">密钥(脱敏)</th>
|
||||
<th style="width: 150px">连通性</th>
|
||||
<th style="width: 130px">来源</th>
|
||||
<th style="width: 170px">更新时间</th>
|
||||
<th style="width: 190px">用户</th>
|
||||
<th style="width: 230px">货源查询密钥</th>
|
||||
<th style="width: 230px">外观专利密钥</th>
|
||||
<th style="width: 230px">代理设置</th>
|
||||
<th style="width: 130px">状态</th>
|
||||
<th style="width: 170px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="(row, index) in rows" :key="row.id">
|
||||
<tr v-for="(row, index) in rows" :key="row.userId">
|
||||
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||
<td>
|
||||
<div class="user-cell">
|
||||
<span class="user-name">{{ row.username || '—' }}</span>
|
||||
<span class="user-uid">UID {{ row.userId }}</span>
|
||||
<span class="user-name">{{ row.username || '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="module-cell">
|
||||
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
|
||||
<span v-else class="empty-value">未配置</span>
|
||||
<span class="wh-pill" :class="moduleStatusMeta(row.similarAsin).tone" :title="moduleTooltip(row.similarAsin)">
|
||||
{{ moduleStatusMeta(row.similarAsin).label }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ row.moduleLabel || row.moduleKey }}</td>
|
||||
<td>
|
||||
<span v-if="row.exists" class="mono-mask">{{ row.masked || '已配置' }}</span>
|
||||
<span v-else class="empty-value">未配置</span>
|
||||
<div class="module-cell">
|
||||
<span v-if="row.appearancePatent?.exists" class="mono-mask" :title="moduleTooltip(row.appearancePatent)">{{ row.appearancePatent.masked }}</span>
|
||||
<span v-else class="empty-value">未配置</span>
|
||||
<span class="wh-pill" :class="moduleStatusMeta(row.appearancePatent).tone" :title="moduleTooltip(row.appearancePatent)">
|
||||
{{ moduleStatusMeta(row.appearancePatent).label }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="wh-pill"
|
||||
:class="statusMeta(row.checkStatus).tone"
|
||||
:title="statusTooltip(row)"
|
||||
>
|
||||
{{ statusMeta(row.checkStatus).label }}
|
||||
<div class="module-cell">
|
||||
<span v-if="row.proxy?.exists" class="mono-mask" :title="moduleTooltip(row.proxy)">{{ 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 }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="wh-pill" :class="rowStatusMeta(row.status).tone" :title="rowStatusTooltip(row)">
|
||||
{{ rowStatusMeta(row.status).label }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ row.source || '—' }}</td>
|
||||
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
type="button"
|
||||
:disabled="checkingId === row.id || !row.exists"
|
||||
:disabled="checkingId === row.userId"
|
||||
@click="check(row)"
|
||||
>
|
||||
{{ checkingId === row.id ? '检测中…' : '立即检测' }}
|
||||
{{ checkingId === row.userId ? '检测中…' : '立即检测' }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">清空</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="8" class="empty-tip">加载中...</td>
|
||||
<td colspan="7" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="8" class="empty-tip">{{ keyword || moduleFilter || statusFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
||||
<td colspan="7" class="empty-tip">{{ keyword || statusFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -377,7 +408,7 @@ h3 {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
min-width: 1200px;
|
||||
min-width: 1240px;
|
||||
}
|
||||
.secrets-table-scroll th,
|
||||
.secrets-table-scroll td {
|
||||
@@ -405,19 +436,20 @@ h3 {
|
||||
.secrets-table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.user-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.user-name {
|
||||
color: #24384d;
|
||||
font-weight: 600;
|
||||
}
|
||||
.user-uid {
|
||||
color: #8293a5;
|
||||
font-size: 11.5px;
|
||||
.module-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.module-cell .mono-mask {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mono-mask {
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
@@ -444,6 +476,7 @@ h3 {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wh-pill.is-allowed {
|
||||
background: #e8f6ee;
|
||||
|
||||
Reference in New Issue
Block a user