task-259(admin.html观感对齐): 生成记录页对齐(指定用户下拉+起止时间筛选/类型中文/≤3缩略图/空态)
This commit is contained in:
@@ -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 }">
|
||||
<div v-if="previewUrls(row as HistoryRecordItem).length" class="thumbs">
|
||||
<el-image
|
||||
v-for="(url, index) in ((row as HistoryRecordItem).resultUrls.slice(0, 4))"
|
||||
:key="index"
|
||||
v-for="(url, index) in previewUrls(row as HistoryRecordItem).slice(0, 3)"
|
||||
:key="url"
|
||||
:src="url"
|
||||
:preview-src-list="(row as HistoryRecordItem).resultUrls"
|
||||
:preview-src-list="previewUrls(row as HistoryRecordItem)"
|
||||
:initial-index="index"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
style="width: 56px; height: 56px; margin-right: 6px; border-radius: 4px"
|
||||
class="thumb"
|
||||
/>
|
||||
<span v-if="(row as HistoryRecordItem).resultUrls.length > 4" class="dim">+{{ (row as HistoryRecordItem).resultUrls.length - 4 }}</span>
|
||||
<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
|
||||
}
|
||||
@@ -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/, '适配层请求用户列表')
|
||||
})
|
||||
Reference in New Issue
Block a user