Files
crawler-plugin/admin-frontend-vue/src/pages/account/UserSecretsPage.vue
T
huangzd1997 5b8105ec2b feat(后台管理): 实体管理列表统一展示创建时间/更新时间
用户管理、菜单管理、不符合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 页表头与真实时间值渲染
2026-09-19 15:53:01 +08:00

565 lines
17 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 { 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>&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="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>