task-259(admin.html观感对齐): 生成记录页对齐(指定用户下拉+起止时间筛选/类型中文/≤3缩略图/空态)

This commit is contained in:
2026-09-05 21:55:14 +08:00
parent eebdb7af2d
commit 8120ba48f6
5 changed files with 271 additions and 45 deletions
@@ -1,49 +1,76 @@
<script setup lang="ts">
/** 历史生成记录页(module 13 task 259 对齐 admin panel-history):指定用户下拉 + 起止时间筛选、类型中文、≤3 缩略图、大图预览、空态。 */
import { formatDateTime } from '@/utils/datetime'
/** 历史生成记录页:分页 + 时间/用户筛选 + 结果大图预览。 */
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { fetchHistoryList } from './history-api.ts'
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchHistoryList, fetchHistoryUserOptions } from './history-api.ts'
import type { HistoryUserOption } from './history-user-option.ts'
import type { HistoryRecordItem } from './history-dto.ts'
import { historyEmptyText, historyErrorText } from './history-feedback.ts'
import { historyPanelTypeLabel } from './history-type.ts'
const loading = ref(false)
const userLoading = ref(false)
const rows = ref<HistoryRecordItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const filter = reactive({ userId: '', dateRange: [] as string[] })
const userOptions = ref<HistoryUserOption[]>([])
const errorText = ref('')
const filter = reactive({ userId: '', timeStart: '', timeEnd: '' })
const previewVisible = ref(false)
const previewItem = ref<HistoryRecordItem | null>(null)
function imageUrls(item: HistoryRecordItem): string[] {
const urls: string[] = []
for (const url of item.resultUrls || []) urls.push(url)
if (item.longImageUrl) urls.push(item.longImageUrl)
return urls
const hasFilter = computed(() => Boolean(filter.userId || filter.timeStart || filter.timeEnd))
const emptyText = computed(() => historyEmptyText({ hasFilter: hasFilter.value, total: total.value }))
/** 预览图顺序:长图在前 + 结果图去重(对齐 admin 缩略图 [long,...result])。 */
function previewUrls(item: HistoryRecordItem): string[] {
const out: string[] = []
const seen = new Set<string>()
const push = (url: string) => {
const clean = typeof url === 'string' ? url.trim() : ''
if (!clean || seen.has(clean)) return
seen.add(clean)
out.push(clean)
}
if (item.longImageUrl) push(item.longImageUrl)
for (const url of item.resultUrls || []) push(url)
return out
}
async function load() {
loading.value = true
errorText.value = ''
try {
const userId = filter.userId.trim()
const result = await fetchHistoryList({
page: page.value,
pageSize,
userId: userId ? Number(userId) : undefined,
timeStart: filter.dateRange?.[0] || '',
timeEnd: filter.dateRange?.[1] || '',
userId: filter.userId ? Number(filter.userId) : undefined,
timeStart: filter.timeStart || undefined,
timeEnd: filter.timeEnd || undefined,
})
rows.value = result.items
total.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '历史记录加载失败')
errorText.value = historyErrorText(error)
} finally {
loading.value = false
}
}
async function loadUserOptions() {
userLoading.value = true
try {
userOptions.value = await fetchHistoryUserOptions()
} catch {
userOptions.value = []
} finally {
userLoading.value = false
}
}
function apply() {
page.value = 1
load()
@@ -51,17 +78,30 @@ function apply() {
function reset() {
filter.userId = ''
filter.dateRange = []
filter.timeStart = ''
filter.timeEnd = ''
page.value = 1
load()
}
function retry() {
load()
}
function changePage(next: number) {
page.value = next
load()
}
function openPreview(item: HistoryRecordItem) {
previewItem.value = item
previewVisible.value = true
}
onMounted(load)
onMounted(() => {
load()
loadUserOptions()
})
</script>
<template>
@@ -73,15 +113,34 @@ onMounted(load)
</div>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
<el-card shadow="never" class="filter-card">
<div class="filter-grid">
<div class="f-item">
<label>用户ID</label>
<el-input v-model="filter.userId" placeholder="按用户ID筛选" clearable @keyup.enter="apply" />
<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>
</div>
<div class="f-item wide">
<label>生成时间</label>
<el-date-picker v-model="filter.dateRange" type="daterange" range-separator="至" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DD" unlink-panels />
<div class="f-item">
<label>开始时间</label>
<el-date-picker
v-model="filter.timeStart"
type="datetime"
placeholder="开始时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</div>
<div class="f-item">
<label>结束时间</label>
<el-date-picker
v-model="filter.timeEnd"
type="datetime"
placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
@@ -91,28 +150,45 @@ onMounted(load)
</el-card>
<el-card shadow="never">
<el-table v-loading="loading" :data="rows" stripe border>
<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" width="90" />
<el-table-column prop="username" label="用户" width="140" />
<el-table-column prop="panelType" label="记录类型" min-width="150" />
<el-table-column label="结果数" width="90" align="center">
<template #default="{ row }">{{ (row as HistoryRecordItem).resultUrls.length }}</template>
<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.createdAt) }}</template>
<template #default="{ row }">{{ formatDateTime((row as HistoryRecordItem).createdAt) }}</template>
</el-table-column>
<el-table-column label="缩略图" min-width="200">
<el-table-column label="结果预览" min-width="220">
<template #default="{ row }">
<el-image
v-for="(url, index) in ((row as HistoryRecordItem).resultUrls.slice(0, 4))"
:key="index"
:src="url"
:preview-src-list="(row as HistoryRecordItem).resultUrls"
preview-teleported
fit="cover"
style="width: 56px; height: 56px; margin-right: 6px; border-radius: 4px"
/>
<span v-if="(row as HistoryRecordItem).resultUrls.length > 4" class="dim">+{{ (row as HistoryRecordItem).resultUrls.length - 4 }}</span>
<div v-if="previewUrls(row as HistoryRecordItem).length" class="thumbs">
<el-image
v-for="(url, index) in previewUrls(row as HistoryRecordItem).slice(0, 3)"
:key="url"
:src="url"
:preview-src-list="previewUrls(row as HistoryRecordItem)"
:initial-index="index"
preview-teleported
fit="cover"
class="thumb"
/>
<span v-if="previewUrls(row as HistoryRecordItem).length > 3" class="dim">+{{ previewUrls(row as HistoryRecordItem).length - 3 }}</span>
</div>
<span v-else class="dim"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
@@ -123,18 +199,27 @@ onMounted(load)
</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="(p: number) => { page = p; load() }" />
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" />
</div>
</el-card>
<el-dialog v-model="previewVisible" :title="previewItem ? `记录 #${previewItem.id} 结果图` : '结果图'" width="820px" top="6vh">
<div v-if="previewItem" class="preview-meta">
<el-tag size="small">{{ previewItem.username }}</el-tag>
<span>{{ previewItem.panelType }}</span>
<span>{{ historyPanelTypeLabel(previewItem.panelType) }}</span>
<span>{{ formatDateTime(previewItem.createdAt) }}</span>
</div>
<div v-if="previewItem && imageUrls(previewItem).length" class="preview-grid">
<el-image v-for="(url, index) in imageUrls(previewItem)" :key="index" :src="url" :preview-src-list="imageUrls(previewItem)" :initial-index="index" preview-teleported fit="contain" style="max-width: 100%" class="preview-img" />
<div v-if="previewItem && previewUrls(previewItem).length" class="preview-grid">
<el-image
v-for="(url, index) in previewUrls(previewItem)"
:key="url"
:src="url"
:preview-src-list="previewUrls(previewItem)"
:initial-index="index"
preview-teleported
fit="contain"
class="preview-img"
/>
</div>
<el-empty v-else description="无结果图片" />
</el-dialog>
@@ -142,11 +227,13 @@ onMounted(load)
</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: 180px; }
.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.wide { width: 320px; }
.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); }
@@ -2,6 +2,7 @@
import { http } from '@/api/http'
import { parseHistoryPage } from './history-model.ts'
import { normalizeHistoryParams, toHistoryListQuery, type HistoryListParams, type HistoryPageResult } from './history-dto.ts'
import { parseHistoryUserOptions, type HistoryUserOption } from './history-user-option.ts'
export const HISTORY_ENDPOINT = '/api/admin/history'
@@ -10,3 +11,9 @@ export async function fetchHistoryList(params: Partial<HistoryListParams> = {}):
const { data } = await http.get<unknown>(HISTORY_ENDPOINT, { params: toHistoryListQuery(normalized) })
return parseHistoryPage(data)
}
/** “指定用户”下拉选项:全量用户(对齐 admin /api/admin/users?page_size=999)。 */
export async function fetchHistoryUserOptions(): Promise<HistoryUserOption[]> {
const { data } = await http.get<unknown>('/api/admin/users', { params: { page: 1, page_size: 999 } })
return parseHistoryUserOptions(data)
}
@@ -0,0 +1,21 @@
/** 历史记录类型中文映射(module 13 task 259 对齐 admin.js panelTypeLabel)。纯逻辑。 */
const HISTORY_PANEL_TYPE_LABELS: Record<string, string> = {
textToImage: '反推词',
productMainImage: '产品主图',
buyerShow: '买家秀',
productPoster: '产品海报',
clonePoster: '克隆海报',
randomPoster: '随机海报',
clothingDetail: '服装详情',
productDetail: '产品详情',
extremeDetail: '极致详情',
cloneDetail: '克隆详情',
imageEdit: '图片编辑',
}
/** 记录类型展示文案;未知类型回原文,空值回占位。 */
export function historyPanelTypeLabel(value: string | null | undefined): string {
const raw = String(value ?? '').trim()
if (!raw) return '—'
return HISTORY_PANEL_TYPE_LABELS[raw] ?? raw
}
@@ -0,0 +1,31 @@
/** 历史页“指定用户”下拉选项解析(module 13 task 259):/api/admin/users?page_size=999 的用户精简选项。纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
export interface HistoryUserOption {
id: number
username: string
}
function idOf(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) && value >= 1 ? Math.floor(value) : null
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
export function parseHistoryUserOptions(payload: unknown): HistoryUserOption[] {
const core = unwrap<unknown>(payload)
if (!core || typeof core !== 'object') return []
const items = (core as Record<string, unknown>).items
if (!Array.isArray(items)) return []
const options: HistoryUserOption[] = []
for (const raw of items) {
if (!raw || typeof raw !== 'object') continue
const row = raw as Record<string, unknown>
const id = idOf(row.id)
if (id === null) continue
options.push({ id, username: text(row.username) || `#${id}` })
}
return options
}
+80
View File
@@ -0,0 +1,80 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { historyPanelTypeLabel } from '../src/pages/records/history-type.ts'
import { parseHistoryUserOptions } from '../src/pages/records/history-user-option.ts'
import { buildPreviewDescriptor } from '../src/pages/records/history-preview.ts'
import type { HistoryRecordItem } from '../src/pages/records/history-dto.ts'
// module 13 task 259:生成记录页对齐 admin panel-history(指定用户下拉 + 起止时间、类型中文、缩略图预览、空态)。
test('test_task_259_history_normal_primary_path', () => {
// 正常主路径:类型映射覆盖常见 panel_type。
assert.equal(historyPanelTypeLabel('textToImage'), '反推词')
assert.equal(historyPanelTypeLabel('productMainImage'), '产品主图')
assert.equal(historyPanelTypeLabel('imageEdit'), '图片编辑')
})
test('test_task_259_history_normal_variant_input', () => {
// 正常变体:未知/空类型回退原文或占位。
assert.equal(historyPanelTypeLabel('someNew'), 'someNew')
assert.equal(historyPanelTypeLabel(''), '—')
assert.equal(historyPanelTypeLabel(null), '—')
})
test('test_task_259_history_normal_repeated_operation_is_idempotent', () => {
assert.equal(historyPanelTypeLabel('buyerShow'), '买家秀')
assert.equal(historyPanelTypeLabel('buyerShow'), '买家秀')
})
test('test_task_259_history_boundary_empty_input', () => {
// 边界:用户下拉解析过滤无 id/非法项。
const parsed = parseHistoryUserOptions({
success: true,
data: { items: [{ id: 1, username: 'admin' }, { id: 2, username: 'bob' }, { username: 'no-id' }, null] },
})
assert.deepEqual(parsed, [
{ id: 1, username: 'admin' },
{ id: 2, username: 'bob' },
])
assert.deepEqual(parseHistoryUserOptions({ success: true, data: { items: [] } }), [])
})
test('test_task_259_history_boundary_single_item', () => {
// 边界:预览描述单条去重与限数。
const item: HistoryRecordItem = {
id: 1,
userId: 1,
username: 'u',
createdAt: '',
panelType: 'x',
originalUrls: [],
resultUrls: ['http://a/1.png', 'http://a/1.png', 'http://a/2.png'],
longImageUrl: 'http://a/long.png',
}
assert.deepEqual(buildPreviewDescriptor(item, 2).images, ['http://a/1.png', 'http://a/2.png'])
})
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, /historyPanelTypeLabel|panelTypeLabel/, '类型列需走中文映射')
assert.match(page, /formatDateTime/, '时间统一格式化')
})
test('test_task_259_history_invalid_input_rejected', () => {
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.match(page, /historyEmptyText/, '空态文案来自 history-feedback')
const feedback = readSource('src/pages/records/history-feedback.ts')
assert.match(feedback, /暂无/, 'history-feedback 提供“暂无”空态文案')
assert.match(page, /buildPreviewDescriptor|preview/, '需有结果预览能力')
})
test('test_task_259_history_dependency_failure_returns_actionable_message', () => {
// 依赖失败:用户下拉由 API 适配加载(不内联裸 fetch 拼 /api/admin/users 在页面)。
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.match(page, /fetchHistoryUserOptions|fetchUserList|loadUserOptions/, '用户选项应经适配函数加载')
const api = readSource('src/pages/records/history-api.ts')
assert.match(api, /\/api\/admin\/users/, '适配层请求用户列表')
})