align(生成记录·像素复刻样办): RecordsHistoryPage 重写为自绘复刻 admin panel-history(旧版 form-box/panel-box/原生控件/table-scroll/thumb 缩略图/旧式分页/大图 modal CSS);脚本沿用现有 API,测试断言适配自绘结构

This commit is contained in:
2026-09-06 22:08:32 +08:00
parent 7adef149d8
commit 4276a90236
5 changed files with 509 additions and 143 deletions
@@ -1,5 +1,6 @@
<script setup lang="ts">
/** 历史生成记录页module 13 task 259 对齐 admin panel-history):指定用户下拉 + 起止时间筛选、类型中文、≤3 缩略图、大图预览、空态。 */
/** 生成记录页 · 像素复刻旧版 admin.html panel-history(自绘:form-box 筛选 + panel-box 表格 + 旧式分页 + thumb 缩略图)。
* script 逻辑沿用现有 Vue 实现(查询/重置/翻页/预览/清理数据)。 */
import { formatDateTime } from '@/utils/datetime'
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchHistoryList, fetchHistoryUserOptions, clearHistory } from './history-api.ts'
@@ -21,9 +22,18 @@ const filter = reactive({ userId: '', timeStart: '', timeEnd: '' })
const previewVisible = ref(false)
const previewItem = ref<HistoryRecordItem | null>(null)
const jumpPage = ref('')
const hasFilter = computed(() => Boolean(filter.userId || filter.timeStart || filter.timeEnd))
const emptyText = computed(() => historyEmptyText({ hasFilter: hasFilter.value, total: total.value }))
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
/** datetime-local 原生值(YYYY-MM-DDTHH:mm) 转后端兼容格式。 */
function toApiTime(value: string): string | undefined {
const t = (value || '').trim()
if (!t) return undefined
return `${t.replace('T', ' ')}:00`
}
/** 预览图顺序:长图在前 + 结果图去重(对齐 admin 缩略图 [long,...result])。 */
function previewUrls(item: HistoryRecordItem): string[] {
@@ -48,8 +58,8 @@ async function load() {
page: page.value,
pageSize,
userId: filter.userId ? Number(filter.userId) : undefined,
timeStart: filter.timeStart || undefined,
timeEnd: filter.timeEnd || undefined,
timeStart: toApiTime(filter.timeStart),
timeEnd: toApiTime(filter.timeEnd),
})
rows.value = result.items
total.value = result.total
@@ -90,10 +100,20 @@ function retry() {
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
load()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(n)
}
function openPreview(item: HistoryRecordItem) {
previewItem.value = item
previewVisible.value = true
@@ -101,7 +121,7 @@ function openPreview(item: HistoryRecordItem) {
const clearing = ref(false)
/** 清空全部生成记录:二次确认后调后端 DELETE /api/admin/history。 */
/** 清空全部生成记录:二次确认后调后端 DELETE /api/admin/history(新版增强,保留)。 */
async function clearAll() {
try {
await ElMessageBox.confirm('确定清空全部生成记录吗?该操作会删除所有历史数据,无法恢复。', '清理生成记录', {
@@ -132,143 +152,485 @@ onMounted(() => {
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>生成记录</h2>
<p>查看各用户在后台生成的历史记录与结果图</p>
</div>
</div>
<el-card shadow="never" class="filter-card">
<div class="filter-grid">
<div class="f-item">
<div class="history-view">
<section class="form-box">
<h3>筛选条件</h3>
<div class="form-row">
<div class="form-group" style="min-width: 150px">
<label>指定用户</label>
<el-select v-model="filter.userId" clearable filterable placeholder="全部用户" style="width: 100%" :loading="userLoading">
<el-option label="全部用户" value="" />
<el-option v-for="u in userOptions" :key="u.id" :label="u.username" :value="String(u.id)" />
</el-select>
<select v-model="filter.userId" :disabled="userLoading">
<option value="">全部用户</option>
<option v-for="u in userOptions" :key="u.id" :value="String(u.id)">{{ u.username }}</option>
</select>
</div>
<div class="f-item">
<div class="form-group" style="min-width: 176px">
<label>开始时间</label>
<el-date-picker
v-model="filter.timeStart"
type="datetime"
placeholder="开始时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
<input v-model="filter.timeStart" type="datetime-local" />
</div>
<div class="f-item">
<div class="form-group" style="min-width: 176px">
<label>结束时间</label>
<el-date-picker
v-model="filter.timeEnd"
type="datetime"
placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
<input v-model="filter.timeEnd" type="datetime-local" />
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
<div class="form-actions">
<button class="btn" type="button" @click="apply">查询</button>
<button class="btn btn-secondary" type="button" @click="reset">重置</button>
</div>
</div>
</el-card>
</section>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="danger" plain :loading="clearing" @click="clearAll">清理数据</el-button>
<section class="panel-box">
<div class="panel-head">
<h3>生成记录</h3>
<button class="btn btn-danger" type="button" :disabled="clearing" :aria-busy="clearing" @click="clearAll">
{{ clearing ? '清理中...' : '清理数据' }}
</button>
</div>
<el-alert
v-if="errorText"
:title="errorText"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
>
<template #default>
<el-button link type="primary" @click="retry">重试</el-button>
</template>
</el-alert>
<el-table v-loading="loading" :data="rows" stripe :empty-text="emptyText">
<el-table-column prop="id" label="ID" min-width="90" />
<el-table-column prop="username" label="用户" min-width="140" />
<el-table-column label="类型" min-width="150">
<template #default="{ row }">
{{ historyPanelTypeLabel((row as HistoryRecordItem).panelType) }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime((row as HistoryRecordItem).createdAt) }}</template>
</el-table-column>
<el-table-column label="结果预览" min-width="220">
<template #default="{ row }">
<div v-if="previewUrls(row as HistoryRecordItem).length" class="thumbs">
<el-image
v-for="(url, index) in previewUrls(row as HistoryRecordItem).slice(0, 3)"
<div v-if="errorText" class="msg err">
<span>加载失败: {{ errorText }}</span>
<button class="btn btn-sm btn-secondary" type="button" @click="retry">重试</button>
</div>
<div class="table-scroll history-table-scroll">
<table>
<thead>
<tr>
<th style="width: 70px">ID</th>
<th style="width: 130px">用户</th>
<th style="width: 160px">类型</th>
<th style="width: 170px">创建时间</th>
<th>结果预览</th>
<th style="width: 96px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="h in rows" :key="h.id">
<td>{{ h.id }}</td>
<td>{{ h.username || '-' }}</td>
<td>{{ historyPanelTypeLabel(h.panelType) }}</td>
<td>{{ formatDateTime(h.createdAt) }}</td>
<td>
<div class="thumb-wrap">
<img
v-for="url in previewUrls(h).slice(0, 3)"
:key="url"
:src="url"
:preview-src-list="previewUrls(row as HistoryRecordItem)"
:initial-index="index"
preview-teleported
fit="cover"
class="thumb"
:src="url"
alt=""
title="点击查看大图"
@click="openPreview(h)"
/>
<span v-if="previewUrls(row as HistoryRecordItem).length > 3" class="dim">+{{ previewUrls(row as HistoryRecordItem).length - 3 }}</span>
<span v-if="!previewUrls(h).length" class="empty-mini">暂无图片</span>
</div>
<span v-else class="dim"></span>
</td>
<td>
<button class="btn btn-sm" type="button" @click="openPreview(h)">查看大图</button>
</td>
</tr>
</template>
</el-table-column>
<el-table-column label="操作" min-width="110" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="openPreview(row as HistoryRecordItem)">查看大图</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> <b>{{ total.toLocaleString() }}</b> </span>
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" />
<tr v-else-if="loading">
<td colspan="6" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">{{ emptyText }}</td>
</tr>
</tbody>
</table>
</div>
</el-card>
<el-dialog v-model="previewVisible" :title="previewItem ? `记录 #${previewItem.id} 结果图` : '结果图'" width="820px" top="6vh">
<div class="pagination">
<span class="page-total"> {{ total }} </span>
<button type="button" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button>
<span class="page-cur"> {{ page }} / {{ totalPages }} </span>
<button type="button" :disabled="page >= totalPages" @click="changePage(page + 1)">下一页</button>
<span class="page-jump">
跳至
<input v-model="jumpPage" type="text" inputmode="numeric" @keyup.enter="goJump" />
<button type="button" @click="goJump">跳转</button>
</span>
</div>
</section>
<div v-if="previewVisible" class="modal-mask" @click.self="previewVisible = false">
<div class="modal">
<div class="modal-head">
<h3 v-if="previewItem">记录 #{{ previewItem.id }} 结果图</h3>
<button class="modal-close" type="button" aria-label="关闭" @click="previewVisible = false">×</button>
</div>
<div v-if="previewItem" class="preview-meta">
<el-tag size="small">{{ previewItem.username }}</el-tag>
<span>{{ previewItem.username || '-' }}</span>
<span>·</span>
<span>{{ historyPanelTypeLabel(previewItem.panelType) }}</span>
<span>·</span>
<span>{{ formatDateTime(previewItem.createdAt) }}</span>
</div>
<div v-if="previewItem && previewUrls(previewItem).length" class="preview-grid">
<el-image
v-for="(url, index) in previewUrls(previewItem)"
<a
v-for="url in previewUrls(previewItem)"
:key="url"
:src="url"
:preview-src-list="previewUrls(previewItem)"
:initial-index="index"
preview-teleported
fit="contain"
class="preview-img"
/>
:href="url"
target="_blank"
rel="noopener"
title="新标签打开原图"
>
<img :src="url" alt="" />
</a>
</div>
<div v-else class="modal-empty">无结果图片</div>
<div class="modal-foot">
<button class="btn btn-secondary" type="button" @click="previewVisible = false">关闭</button>
</div>
</div>
</div>
<el-empty v-else description="无结果图片" />
</el-dialog>
</div>
</template>
<style scoped>
.filter-card { margin-bottom: 14px; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.thumbs { display: flex; align-items: center; gap: 6px; }
.thumb { width: 56px; height: 56px; border-radius: 4px; border: 1px solid var(--el-border-color-lighter); flex: none; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.table-footer b { color: var(--el-text-color-primary); }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.preview-meta { display: flex; align-items: center; gap: 12px; color: var(--el-text-color-secondary); font-size: 12px; margin-bottom: 12px; }
.preview-grid { display: flex; flex-direction: column; gap: 12px; }
.preview-img { width: 100%; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; }
/* 像素复刻旧版 admin.html 蓝白末层样式:form-box/panel-box 双卡 + table-scroll 表格 + 旧式分页 + thumb 缩略图。 */
.history-view {
font-family: inherit;
color: var(--c-text, #24384d);
display: flex;
flex-direction: column;
gap: 18px;
}
.form-box,
.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 0 16px;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-head h3 {
margin-bottom: 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
}
.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);
}
.form-actions {
display: inline-flex;
align-items: center;
gap: 8px;
padding-bottom: 1px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
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.6;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.msg {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding: 10px 12px;
border: 1px solid;
border-radius: 9px;
font-size: 13px;
}
.msg.err {
color: #91474f;
background: #f8ebeb;
border-color: #e4c2c5;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.history-table-scroll > table {
min-width: 900px;
}
.table-scroll th,
.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;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
white-space: nowrap;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.thumb-wrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.thumb {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #d8e3ee;
cursor: zoom-in;
}
.empty-mini {
color: #8293a5;
font-size: 12.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.modal-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
background: rgba(45, 66, 86, 0.36);
backdrop-filter: blur(6px);
}
.modal {
display: flex;
flex-direction: column;
width: min(880px, 100%);
max-height: 84vh;
padding: 20px 22px;
border: 1px solid #c7d7e5;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-head h3 {
margin: 0;
}
.modal-close {
width: 30px;
height: 30px;
border: 1px solid transparent;
border-radius: 9px;
background: none;
color: #5b6f83;
font-size: 20px;
line-height: 1;
cursor: pointer;
}
.modal-close:hover {
border-color: #cbd9e6;
background: #eef4fa;
color: #24384d;
}
.preview-meta {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0 10px;
color: #5b6f83;
font-size: 12.5px;
}
.preview-grid {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
border: 1px solid #dbe5ee;
border-radius: 10px;
padding: 12px;
background: #f8fbfd;
}
.preview-grid a {
display: block;
}
.preview-grid img {
display: block;
width: 100%;
border-radius: 6px;
border: 1px solid #d8e3ee;
background: #fff;
}
.modal-empty {
padding: 48px 20px;
text-align: center;
color: #8293a5;
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
</style>
@@ -16,7 +16,6 @@ const PAGES = [
'src/pages/asin/QueryAsinPage.vue',
'src/pages/asin/SkipPricePage.vue',
'src/pages/records/RecordsDigitalHumanVersionPage.vue',
'src/pages/records/RecordsHistoryPage.vue',
'src/pages/records/RecordsSoftwareVersionPage.vue',
'src/pages/shop/ShopKeysPage.vue',
'src/pages/shop/ShopManagePage.vue',
@@ -51,6 +50,10 @@ test('align_column_width_boundary_empty_input', () => {
const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue')
assert.equal(/el-table-column/.test(dup), false, '撞款台账已用 reference 原生表格,不再有 el-table-column')
assert.match(dup, /class="ledger"/, '撞款台账仍为表格实现')
const hist = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.equal(/el-table-column/.test(hist), false, '生成记录已像素复刻 admin panel-history 原生表格,不再有 el-table-column')
assert.match(hist, /class="table-scroll history-table-scroll"/, '生成记录用原生 .table-scroll 表格')
assert.match(hist, /<th[^>]*>结果预览<\/th>/, '原生表含结果预览列')
})
test('align_column_width_dependency_failure_returns_actionable_message', () => {
@@ -7,8 +7,8 @@ import { historyEmptyText } from '../src/pages/records/history-feedback.ts'
test('align_history_column_labels', () => {
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.match(page, /label="类型"/, '列名对齐参考「类型」')
assert.match(page, /label="创建时间"/, '列名对齐参考「创建时间」')
assert.match(page, /<th[^>]*>类型<\/th>/, '列名对齐参考「类型」(自绘 th)')
assert.match(page, /<th[^>]*>创建时间<\/th>/, '列名对齐参考「创建时间」(自绘 th)')
assert.doesNotMatch(page, /记录类型/, '去掉“记录类型”列名')
assert.doesNotMatch(page, /label="生成时间"/, '去掉“生成时间”列名')
})
@@ -23,9 +23,10 @@ test('align_shop_keys_row_buttons_solid', () => {
assertRowButtonSolid(s, 'remove')
})
test('align_records_history_preview_solid', () => {
test('align_records_history_preview_old_btn', () => {
const s = readSource('src/pages/records/RecordsHistoryPage.vue')
assertRowButtonSolid(s, 'openPreview')
assert.match(s, /class="btn btn-sm"[^>]*@click="openPreview/, '查看大图用旧版 btn-sm 小按钮')
assert.doesNotMatch(s, /el-button[^>]*openPreview/, '生成记录已自绘,无 EP 按钮')
})
test('align_digital_human_row_buttons_solid', () => {
+1 -1
View File
@@ -58,7 +58,7 @@ test('test_task_259_history_boundary_single_item', () => {
test('test_task_259_history_boundary_limit_or_missing_field', () => {
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.match(page, /指定用户/, '筛选需有“指定用户”标签(admin 语义)')
assert.match(page, /el-date-picker/, '需用日期时间选择器')
assert.match(page, /type="datetime-local"/, '自绘复刻用原生日历控件(对齐 admin datetime-local')
assert.match(page, /historyPanelTypeLabel|panelTypeLabel/, '类型列需走中文映射')
assert.match(page, /formatDateTime/, '时间统一格式化')
})