5b8105ec2b
用户管理、菜单管理、不符合ASIN、数据去重总数据、查询ASIN、最低价ASIN、 商品类目、密钥管理共 8 个实体管理列表补齐两列。分组管理/店铺密钥/店铺管理 此前已带创建+修改时间,任务列表与统计报表(撞款监控、密钥用量、日志、 记录与版本)不含实体更新语义,均未改动。 关键点——时间列必须由数据库维护,否则新列是假的: 这些表的更新走 selectById → 改字段 → updateById,实体带着读出的旧 updated_at 一起写回。MySQL 规则是「UPDATE 显式给某列赋值时不触发该列的 ON UPDATE 自动更新」,不禁写就会把旧值写回去,更新时间永远冻结在首次写入 时刻。按 V125(biz_file_result)既有样板,给 7 个实体标注 @TableField(insertStrategy=NEVER, updateStrategy=NEVER)。 - V131:users / columns 补 updated_at(幂等 ADD COLUMN,仿 V125 写法)。 存量行被回填为迁移执行时刻,非真实历史变更时间(历史上无记录,无法还原) - 实体/VO:AdminUserEntity、PermissionMenuEntity、InvalidAsinDataEntity、 DedupeTotalDataEntity、ProductCategoryEntity、QueryAsinEntity、 SkipPriceAsinEntity 加/改写 updatedAt;AdminUserItemVo、PermissionMenuItemVo、 InvalidAsinDataItemVo、DedupeTotalDataItemVo 补 updatedAt; AdminUserSecretRowVo 补 createdAt(行级首次配置时间 = 三模块最早) - 查询ASIN/最低价ASIN 后端 VO 与前端 model 本就有两字段,仅补渲染 - 前端 8 页表格加列,同步修正空态/加载行的 colspan(手写表格,不同步会错位) - 测试:align-query-asin / align-skip-price 原断言「不允许有更新时间列」 (像素复刻旧版),按新需求改为断言两列存在;新增 e2e list-time-columns 覆盖 8 页表头与真实时间值渲染
565 lines
17 KiB
Vue
565 lines
17 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { formatDateTime } from '@/utils/datetime'
|
||
import OldPagination from '@/components/OldPagination.vue'
|
||
import {
|
||
checkUserSecret,
|
||
deleteUserSecret,
|
||
fetchUserSecretList,
|
||
type AdminUserSecretModule,
|
||
type AdminUserSecretRow,
|
||
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<AdminUserSecretRow[]>([])
|
||
const total = ref(0)
|
||
const page = ref(1)
|
||
const pageSize = ref(15)
|
||
const keyword = ref('')
|
||
const statusFilter = ref('')
|
||
const groupFilter = ref<number | null>(null)
|
||
/** 分组下拉(超管=全部;主管=自己带的分组,由列表接口带回)。 */
|
||
const groupOptions = ref<GroupOption[]>([])
|
||
/** 正在检测的行 userId,用于按钮 loading 态。 */
|
||
const checkingId = ref<number | null>(null)
|
||
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||
|
||
const STATUS_OPTIONS = [
|
||
{ value: '', label: '全部状态' },
|
||
{ value: 'passed', label: '检测通过' },
|
||
{ value: 'failed', label: '检测失败' },
|
||
{ value: 'incomplete', label: '未配齐' },
|
||
{ value: 'error', label: '无法判定' },
|
||
{ value: 'unknown', label: '未检测' },
|
||
]
|
||
|
||
/** 行级状态药丸:三类都检测通过才显示「检测通过」。 */
|
||
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:
|
||
return { label: '未检测', tone: 'is-unknown' }
|
||
}
|
||
}
|
||
|
||
/** 单格状态药丸:未配置时统一灰色;欠费单独标识。 */
|
||
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':
|
||
if (module.checkCode === 'insufficient_balance') {
|
||
return { label: '欠费', tone: 'is-warn' }
|
||
}
|
||
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 (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`)
|
||
return parts.join(';') || '暂无检测记录'
|
||
}
|
||
|
||
/** 分组列文案:优先分组名;未建分组的用户回退展示所属主管,便于判断归属。 */
|
||
function groupTextOf(row: AdminUserSecretRow) {
|
||
if (row.groups?.length) return row.groups.join('、')
|
||
if (row.leaderUsername) return `${row.leaderUsername}(未建分组)`
|
||
return '—'
|
||
}
|
||
|
||
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(';') || '暂无检测记录'
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const result = await fetchUserSecretList({
|
||
keyword: keyword.value.trim() || undefined,
|
||
checkStatus: statusFilter.value || 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 || []
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function search() {
|
||
page.value = 1
|
||
load()
|
||
}
|
||
|
||
function reset() {
|
||
keyword.value = ''
|
||
statusFilter.value = ''
|
||
groupFilter.value = null
|
||
page.value = 1
|
||
load()
|
||
}
|
||
|
||
/** 立即检测:真实请求该用户全部已配置项并把结果落库。 */
|
||
async function check(row: AdminUserSecretRow) {
|
||
checkingId.value = row.userId
|
||
try {
|
||
const results = await checkUserSecret(row.userId)
|
||
if (!results || !results.length) {
|
||
ElMessage.warning('该用户尚未配置任何密钥或代理')
|
||
} else {
|
||
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) {
|
||
ElMessage.error(error instanceof Error ? error.message : '检测失败')
|
||
} finally {
|
||
checkingId.value = null
|
||
}
|
||
}
|
||
|
||
async function remove(row: AdminUserSecretRow) {
|
||
const who = row.username || `UID ${row.userId}`
|
||
if (!window.confirm(`确定清空「${who}」的全部密钥与代理配置吗?清空后该用户需要重新配置。`)) return
|
||
try {
|
||
await deleteUserSecret(row.userId)
|
||
ElMessage.success('已清空')
|
||
load()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '清空失败')
|
||
}
|
||
}
|
||
|
||
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="user-secrets-view">
|
||
<section class="panel-box">
|
||
<div class="secrets-head">
|
||
<h3>用户密钥列表</h3>
|
||
</div>
|
||
|
||
<div class="form-row secrets-filter-row">
|
||
<div class="form-group" style="min-width: 220px">
|
||
<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="statusFilter">
|
||
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||
</select>
|
||
</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="secrets-table-scroll">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 58px">序号</th>
|
||
<th style="width: 260px">用户</th>
|
||
<th v-if="isSuperAdmin" style="width: 130px">分组</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>
|
||
<th style="width: 170px">更新时间</th>
|
||
<th style="width: 170px">操作</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 || '—' }}</span>
|
||
</td>
|
||
<td v-if="isSuperAdmin">
|
||
<span class="creator-name">{{ groupTextOf(row) }}</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>
|
||
<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>
|
||
<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 }}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span class="wh-pill" :class="rowStatusMeta(row.status).tone" :title="rowStatusTooltip(row)">
|
||
{{ rowStatusMeta(row.status).label }}
|
||
</span>
|
||
</td>
|
||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||
<td class="ops-cell">
|
||
<button
|
||
class="btn btn-sm"
|
||
type="button"
|
||
:disabled="checkingId === row.userId"
|
||
@click="check(row)"
|
||
>
|
||
{{ 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="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
|
||
</tr>
|
||
<tr v-else>
|
||
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">{{ keyword || statusFilter || groupFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 像素复刻旧版 admin.html 面板风格(与店铺密钥页同一视觉语言)。 */
|
||
.user-secrets-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;
|
||
}
|
||
.secrets-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.secrets-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);
|
||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||
}
|
||
.btn:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.55;
|
||
}
|
||
.btn-danger {
|
||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||
border-color: #b35f6a;
|
||
}
|
||
.btn-danger:hover:not(:disabled) {
|
||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||
}
|
||
.btn-ghost {
|
||
background: #ffffff;
|
||
border-color: #c7d7e5;
|
||
color: #4f78a5;
|
||
box-shadow: none;
|
||
}
|
||
.btn-ghost:hover:not(:disabled) {
|
||
background: #edf5fb;
|
||
border-color: #95b1cb;
|
||
color: #2f5d8b;
|
||
}
|
||
.btn-sm {
|
||
min-height: 36px;
|
||
padding: 7px 12px;
|
||
}
|
||
.secrets-table-scroll {
|
||
width: 100%;
|
||
min-width: 0;
|
||
overflow-x: auto;
|
||
border: 1px solid #dbe5ee;
|
||
border-radius: 10px;
|
||
background: #ffffff;
|
||
}
|
||
.secrets-table-scroll table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
table-layout: fixed;
|
||
min-width: 1440px;
|
||
}
|
||
.secrets-table-scroll th,
|
||
.secrets-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;
|
||
}
|
||
.secrets-table-scroll th {
|
||
background: #edf4fa;
|
||
color: #4e6479;
|
||
border-bottom-color: #d5e1eb;
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.4px;
|
||
}
|
||
.secrets-table-scroll tbody tr:hover td {
|
||
background: #f1f7fb;
|
||
}
|
||
.secrets-table-scroll tbody tr:last-child td {
|
||
border-bottom: 0;
|
||
}
|
||
.user-name {
|
||
color: #24384d;
|
||
font-weight: 600;
|
||
/* 用户名(含中文)可完整换行展示,不截断 */
|
||
white-space: normal;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.creator-name {
|
||
color: #5b6f83;
|
||
font-size: 12.5px;
|
||
}
|
||
.module-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
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;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.empty-value {
|
||
color: #a7b4c1;
|
||
}
|
||
.ops-cell {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.empty-tip {
|
||
color: #8293a5;
|
||
text-align: center;
|
||
}
|
||
.wh-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 2px 10px;
|
||
border-radius: 999px;
|
||
border: 1px solid;
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
cursor: help;
|
||
flex-shrink: 0;
|
||
}
|
||
.wh-pill.is-allowed {
|
||
background: #e8f6ee;
|
||
border-color: #b9e0c9;
|
||
color: #2f7d52;
|
||
}
|
||
.wh-pill.is-blocked {
|
||
background: #fdecef;
|
||
border-color: #f2c4cd;
|
||
color: #b04a5a;
|
||
}
|
||
.wh-pill.is-warn {
|
||
background: #fdf6e3;
|
||
border-color: #f0dfae;
|
||
color: #8f6d1e;
|
||
}
|
||
.wh-pill.is-unknown {
|
||
background: #f0f3f6;
|
||
border-color: #d6dee6;
|
||
color: #6b7d8f;
|
||
}
|
||
</style>
|