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 页表头与真实时间值渲染
This commit is contained in:
2026-09-19 15:53:01 +08:00
parent 5fcb449946
commit 5b8105ec2b
36 changed files with 418 additions and 25 deletions
@@ -0,0 +1,44 @@
import { expect, test, type Page } from '@playwright/test'
// 后台管理列表「创建时间 + 更新时间」两列验收(2026-09-19 需求):
// 8 个实体管理列表统一展示创建/更新时间,数据取自各列表 VO。
// mock 夹具统一给 createdAt=2026-03-01 09:15:00、updatedAt=2026-09-18 16:42:30
// 页面统一经 formatDateTime 归一为「YYYY-MM-DD HH:mm:ss」。
const CREATED = '2026-03-01 09:15:00'
const UPDATED = '2026-09-18 16:42:30'
const PAGES: Array<{ title: string; path: string; heading: string }> = [
{ title: '用户管理', path: '/admin-vue/account/users', heading: '用户列表' },
{ title: '菜单管理', path: '/admin-vue/account/menus', heading: '菜单' },
{ title: '密钥管理', path: '/admin-vue/account/user-secrets', heading: '用户密钥列表' },
{ title: '不符合ASIN数据', path: '/admin-vue/asin-center/invalid', heading: '品牌数据列表' },
{ title: '数据去重总数据', path: '/admin-vue/asin-center/registry', heading: '数据去重总数据' },
{ title: '查询ASIN', path: '/admin-vue/asin-center/query', heading: '查询' },
{ title: '最低价ASIN', path: '/admin-vue/asin-center/skip-price', heading: '最低价' },
{ title: '商品类目', path: '/admin-vue/asin-center/categories', heading: '商品类目' },
]
async function openPage(page: Page, path: string) {
await page.goto(path)
await expect(page.locator('.panel-box table tbody tr').first()).toBeVisible({ timeout: 15000 })
}
for (const spec of PAGES) {
test(`${spec.title}:表格展示创建时间与更新时间`, async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await openPage(page, spec.path)
const table = page.locator('.panel-box table').first()
// 表头出现两列
await expect(table.locator('thead')).toContainText('创建时间')
await expect(table.locator('thead')).toContainText('更新时间')
// 数据行渲染出真实时间值(非空、非占位符)
await expect(table.locator('tbody')).toContainText(CREATED)
await expect(table.locator('tbody')).toContainText(UPDATED)
expect(errors).toEqual([])
})
}
@@ -220,13 +220,183 @@ const server = createServer((req, res) => {
return json(res, {
success: true,
data: {
items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', createdAt: '2026-01-01 00:00:00' }],
items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', created_at: '2026-03-01 09:15:00', updated_at: '2026-09-18 16:42:30' }],
total: 1,
},
})
}
if (url === '/api/admin/permission-menus') {
return json(res, { success: true, data: { items: [] } })
// 菜单管理列表:一条根节点,带创建/更新时间(验收时间列)。
return json(res, {
success: true,
data: [
{
id: 1,
name: '用户管理',
column_key: 'admin_users',
parent_id: null,
menu_type: 'admin',
route_path: 'account/users',
sort_order: 10,
created_at: '2026-03-01T09:15:00',
updated_at: '2026-09-18T16:42:30',
},
],
})
}
if (url === '/api/admin/invalid-asin-data') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
dataValue: 'B0INVALID1',
brand: '测试品牌',
groupId: 1,
groupName: '测试分组',
recordSource: 'MANUAL',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/dedupe-total-data') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
dataValue: 'B0DEDUPE01',
country: 'DE',
groupId: 1,
groupName: '测试分组',
uploaderUserId: 1,
username: 'admin',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/query-asins') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
groupId: 1,
groupName: '测试分组',
shopName: '测试店铺',
asinDe: 'B0QUERY001',
asinUk: '',
asinFr: '',
asinIt: '',
asinEs: '',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/skip-price-asins') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
groupId: 1,
groupName: '测试分组',
shopName: '测试店铺',
asinDe: 'B0SKIP0001',
minimumPriceDe: 9.99,
asinUk: '',
minimumPriceUk: null,
asinFr: '',
minimumPriceFr: null,
asinIt: '',
minimumPriceIt: null,
asinEs: '',
minimumPriceEs: null,
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/product-categories/children') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
parentId: null,
name: '护肤品',
categoryKey: 'skincare',
sortOrder: 10,
description: '测试备注',
isBuiltin: true,
childCount: 0,
level: 0,
path: '护肤品',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
hasMore: false,
},
})
}
if (url === '/api/admin/user-secrets') {
const emptyModule = { moduleKey: '', moduleLabel: '', masked: '', full: '', exists: false, checkStatus: 'unknown', checkCode: '', checkMessage: '', checkLatencyMs: null, checkedAt: null, updatedAt: null }
return json(res, {
success: true,
data: {
items: [
{
userId: 1,
username: 'admin',
groups: [],
leaderUsername: '',
similarAsin: { ...emptyModule, moduleKey: 'similar-asin', moduleLabel: '货源查询密钥' },
appearancePatent: { ...emptyModule, moduleKey: 'appearance-patent', moduleLabel: '外观专利密钥' },
proxy: { ...emptyModule, moduleKey: 'proxy', moduleLabel: '代理设置' },
status: 'unknown',
statusMessage: '',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 15,
groupOptions: [],
},
})
}
if (url === '/api/admin/shop-manage-groups') {
return json(res, { success: true, data: { items: [] } })
@@ -30,6 +30,8 @@ export interface AdminUserSecretRow {
proxy: AdminUserSecretModule
status: string
statusMessage: string
/** 首次配置时间(三模块中最早);从未配置为 null。 */
createdAt: string | null
updatedAt: string | null
}
@@ -23,6 +23,8 @@ export function toAdminUserItem(raw: unknown): AdminUser | null {
if (createdById !== null) item.createdById = createdById
const createdAt = text(r.created_at)
if (createdAt) item.createdAt = createdAt
const updatedAt = text(r.updated_at)
if (updatedAt) item.updatedAt = updatedAt
const creator = text(r.creator_username)
if (creator) item.creatorUsername = creator
const abbr = text(r.pinyin_abbr)
@@ -241,6 +241,7 @@ onMounted(loadMenus)
<th style="width: 100px">菜单类型</th>
<th style="width: 120px">上级菜单</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 170px">操作</th>
</tr>
</thead>
@@ -271,6 +272,7 @@ onMounted(loadMenus)
<td><span class="menu-type-text">{{ menuTypeLabel(row.menuType) }}</span></td>
<td>{{ parentNameOf(row) }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button
@@ -286,10 +288,10 @@ onMounted(loadMenus)
</tr>
</template>
<tr v-else-if="loading">
<td colspan="7" class="empty-tip">加载中...</td>
<td colspan="8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="7" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
<td colspan="8" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
</tr>
</tbody>
</table>
@@ -233,6 +233,8 @@ onMounted(load)
<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>
@@ -278,6 +280,8 @@ onMounted(load)
{{ 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"
@@ -292,10 +296,10 @@ onMounted(load)
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">{{ keyword || statusFilter || groupFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">{{ keyword || statusFilter || groupFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
</tr>
</tbody>
</table>
@@ -169,6 +169,7 @@ onMounted(loadUsers)
<th style="width: 140px">角色</th>
<th style="width: 160px">所属管理员</th>
<th style="width: 180px">创建时间</th>
<th style="width: 180px">更新时间</th>
<th style="width: 160px">操作</th>
</tr>
</thead>
@@ -180,6 +181,7 @@ onMounted(loadUsers)
<td>{{ roleLabel(row.role) }}</td>
<td>{{ row.creatorUsername || '-' }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button
class="btn btn-sm"
@@ -203,10 +205,10 @@ onMounted(loadUsers)
</tr>
</template>
<tr v-else-if="loading">
<td colspan="6" class="empty-tip">加载中...</td>
<td colspan="7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">{{ userListEmptyHint() }}</td>
<td colspan="7" class="empty-tip">{{ userListEmptyHint() }}</td>
</tr>
</tbody>
</table>
@@ -15,6 +15,7 @@ export interface MenuManageNode {
routePath: string
sortOrder: number
createdAt?: string
updatedAt?: string
children?: MenuManageNode[]
}
@@ -90,6 +91,8 @@ export function parseMenuManageItem(raw: unknown): MenuManageNode | null {
if (rootColumnKey) node.rootColumnKey = rootColumnKey
const createdAt = text(record.created_at)
if (createdAt) node.createdAt = createdAt
const updatedAt = text(record.updated_at)
if (updatedAt) node.updatedAt = updatedAt
return node
}
@@ -202,6 +202,7 @@ onMounted(() => {
<th v-if="isSuperAdmin" style="width: 140px">分组</th>
<th style="width: 110px">来源</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 150px">操作</th>
</tr>
</thead>
@@ -218,6 +219,7 @@ onMounted(() => {
</span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
@@ -225,10 +227,10 @@ onMounted(() => {
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无数据</td>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
@@ -375,6 +375,7 @@ onMounted(() => {
<th>用户名</th>
<th v-if="isSuperAdmin">分组</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
@@ -387,6 +388,7 @@ onMounted(() => {
<td>{{ row.username || '-' }}</td>
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row)">删除</button>
@@ -394,10 +396,10 @@ onMounted(() => {
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无总数据</td>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无总数据</td>
</tr>
</tbody>
</table>
@@ -15,6 +15,7 @@ import {
} from './product-category-model.ts'
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
import { buildProductCategoryExportUrl } from './product-category-export.ts'
import { formatDateTime } from '@/utils/datetime'
import OldPagination from '@/components/OldPagination.vue'
const loading = ref(false)
@@ -330,6 +331,8 @@ onMounted(loadTree)
<th style="width: 90px">排序</th>
<th style="width: 120px">来源</th>
<th>备注</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 150px">操作</th>
</tr>
</thead>
@@ -337,7 +340,7 @@ onMounted(loadTree)
<template v-if="treeRows.length">
<template v-for="row in treeRows" :key="String(row.node.id)">
<tr v-if="isCategoryLoadMoreNode(row.node)" class="load-more-row">
<td colspan="6">
<td colspan="8">
<div class="load-more-cell">
<span class="tree-indent" :style="{ width: `${(row.depth + 1) * 24}px` }"></span>
<button class="btn btn-sm btn-secondary" type="button" :disabled="moreLoading(row.node)" @click="loadMoreChildren(row.node)">
@@ -376,6 +379,8 @@ onMounted(loadTree)
<span v-if="row.node.description" class="node-desc" :title="row.node.description">{{ row.node.description }}</span>
<span v-else class="dim"></span>
</td>
<td>{{ formatDateTime(row.node.createdAt) }}</td>
<td>{{ formatDateTime(row.node.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row.node)">编辑</button>
<button
@@ -392,10 +397,10 @@ onMounted(loadTree)
</template>
</template>
<tr v-else-if="loading">
<td colspan="6" class="empty-tip">加载中...</td>
<td colspan="8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">暂无商品类目</td>
<td colspan="8" class="empty-tip">暂无商品类目</td>
</tr>
</tbody>
<tbody v-else>
@@ -411,6 +416,8 @@ onMounted(loadTree)
<span v-if="row.description" class="node-desc" :title="row.description">{{ row.description }}</span>
<span v-else class="dim"></span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button
@@ -426,10 +433,10 @@ onMounted(loadTree)
</tr>
</template>
<tr v-else-if="searchLoading">
<td colspan="6" class="empty-tip">搜索中...</td>
<td colspan="8" class="empty-tip">搜索中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">暂无搜索结果</td>
<td colspan="8" class="empty-tip">暂无搜索结果</td>
</tr>
</tbody>
</table>
@@ -4,6 +4,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import CopyText from '@/components/CopyText.vue'
import { formatDateTime } from '@/utils/datetime'
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts'
import { asinCountryLabel } from './asin-country.ts'
@@ -394,6 +395,8 @@ onMounted(() => {
<th style="width: 15%">店铺名</th>
<th style="width: 24%">ASIN</th>
<th style="width: 12%">国家</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 9%">操作</th>
</tr>
</thead>
@@ -413,6 +416,8 @@ onMounted(() => {
</td>
<td>{{ asinCountryLabel(row.country || '') }}</td>
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.createdAt) }}</td>
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
<td :rowspan="row.rowspan" class="asin-col-actions">
<button class="btn btn-sm" type="button" @click="openConfig(row.item as QueryAsinItem)">配置</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeShopAsin(row.item as QueryAsinItem)">删除</button>
@@ -421,10 +426,10 @@ onMounted(() => {
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 6 : 5" class="empty-tip">加载中...</td>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 6 : 5" class="empty-tip">暂无数据</td>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
@@ -4,6 +4,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import CopyText from '@/components/CopyText.vue'
import { formatDateTime } from '@/utils/datetime'
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts'
import { asinCountryLabel } from './asin-country.ts'
@@ -447,6 +448,8 @@ onMounted(() => {
<th style="width: 24%">ASIN</th>
<th style="width: 12%">国家</th>
<th style="width: 10%">最低价</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 9%">操作</th>
</tr>
</thead>
@@ -467,6 +470,8 @@ onMounted(() => {
<td>{{ asinCountryLabel(row.country || '') }}</td>
<td class="price-cell">{{ row.minimumPrice !== '' ? row.minimumPrice : '-' }}</td>
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.createdAt) }}</td>
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
<td :rowspan="row.rowspan" class="asin-col-actions">
<button class="btn btn-sm" type="button" @click="openConfig(row.item as SkipPriceItem)">配置</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row.item as SkipPriceItem)">删除</button>
@@ -475,10 +480,10 @@ onMounted(() => {
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无数据</td>
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
@@ -11,6 +11,7 @@ export interface DedupeTotalItem {
uploaderUserId: number | null
username: string
createdAt?: string
updatedAt?: string
}
export interface DedupeTotalPageResult {
@@ -51,6 +52,8 @@ export function toDedupeTotalItem(raw: unknown): DedupeTotalItem | null {
}
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) item.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) item.updatedAt = updatedAt
return item
}
@@ -14,6 +14,8 @@ export interface ProductCategoryNode {
childCount: number
level: number | null
path: string
createdAt?: string
updatedAt?: string
children: ProductCategoryNode[]
}
@@ -72,6 +74,10 @@ export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null
path: text(record.path),
children: [],
}
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) node.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) node.updatedAt = updatedAt
if (Array.isArray(record.children)) {
node.children = record.children
.map((child) => toProductCategoryNode(child))
@@ -12,6 +12,7 @@ export interface InvalidAsinItem {
groupName: string
recordSource: string
createdAt?: string
updatedAt?: string
}
export interface InvalidAsinPageResult {
@@ -74,6 +75,8 @@ export function toInvalidAsinItem(raw: unknown): InvalidAsinItem | null {
}
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) item.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) item.updatedAt = updatedAt
return item
}
+1
View File
@@ -12,6 +12,7 @@ export interface AdminUser {
createdById?: number | null
creatorUsername?: string
createdAt?: string
updatedAt?: string
pinyinAbbr?: string
}
@@ -82,7 +82,9 @@ test('align_query_asin_page_layout_wiring', () => {
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
// 需求变更:后台管理列表统一展示「创建时间 + 更新时间」,覆盖早期「不加更新时间」的像素对齐。
assert.match(page, /创建时间/, '表格含创建时间列')
assert.match(page, /更新时间/, '表格含更新时间列')
assert.match(page, /queryAsinDisplayRows/, '行展开走纯模型')
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
assert.match(page, /请先选择分组/, '店铺下拉未选分组时占位对齐')
@@ -82,7 +82,9 @@ test('align_skip_price_page_layout_wiring', () => {
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
// 需求变更:后台管理列表统一展示「创建时间 + 更新时间」,覆盖早期「不加更新时间」的像素对齐。
assert.match(page, /创建时间/, '表格含创建时间列')
assert.match(page, /更新时间/, '表格含更新时间列')
assert.match(page, /skipPriceDisplayRows/, '行展开走纯模型')
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
assert.match(page, /最低价格式不正确/, '最低价格式校验对齐')
@@ -1,5 +1,6 @@
package com.nanri.aiimage.common.model.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -24,5 +25,13 @@ public class AdminUserEntity {
private Long createdById;
@TableField("created_at")
private LocalDateTime createdAt;
/**
* 更新时间(V131 新增):由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP)。
*
* <p>禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时不会触发自动更新,
* 而本表写回多为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结更新时间。
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
private String machine;
}
@@ -16,6 +16,8 @@ public class AdminUserItemVo {
private String creatorUsername;
@JsonProperty("created_at")
private String createdAt;
@JsonProperty("updated_at")
private String updatedAt;
@JsonProperty("pinyin_abbr")
private String pinyinAbbr;
}
@@ -338,6 +338,8 @@ public class AdminUserService {
vo.setCreatorUsername(creatorId == null ? "" : creatorMap.getOrDefault(creatorId, ""));
LocalDateTime createdAt = entity.getCreatedAt();
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
LocalDateTime updatedAt = entity.getUpdatedAt();
vo.setUpdatedAt(updatedAt == null ? "" : updatedAt.format(CREATED_AT_FORMATTER));
vo.setPinyinAbbr(PinyinAbbrUtil.abbr(entity.getUsername()));
return vo;
}
@@ -1,6 +1,8 @@
package com.nanri.aiimage.modules.dedupe.model.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@@ -19,5 +21,12 @@ public class DedupeTotalDataEntity {
private Long uploaderUserId;
private String uploaderUsername;
private LocalDateTime createdAt;
/**
* 更新时间:由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP)。
*
* <p>禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时不会触发自动更新,
* 而本表写回为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结更新时间。
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
}
@@ -32,4 +32,7 @@ public class DedupeTotalDataItemVo {
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
}
@@ -1398,6 +1398,7 @@ public class DedupeTotalDataService {
vo.setUploaderUserId(entity.getUploaderUserId());
vo.setUsername(entity.getUploaderUsername());
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
@@ -1,6 +1,8 @@
package com.nanri.aiimage.modules.invalidasin.model.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@@ -18,5 +20,12 @@ public class InvalidAsinDataEntity {
private Long groupId;
private String recordSource;
private LocalDateTime createdAt;
/**
* 更新时间:由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP)。
*
* <p>禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时不会触发自动更新,
* 而本表写回为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结更新时间。
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
}
@@ -29,4 +29,7 @@ public class InvalidAsinDataItemVo {
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
}
@@ -245,6 +245,7 @@ public class InvalidAsinDataService {
vo.setGroupName(groupName == null ? "" : groupName);
vo.setRecordSource(isManualRecord(entity) ? RECORD_SOURCE_MANUAL : RECORD_SOURCE_AUTO);
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
}
@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.permission.model.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -22,4 +23,12 @@ public class PermissionMenuEntity {
private String routePath;
private Integer sortOrder;
private LocalDateTime createdAt;
/**
* 更新时间V131 新增由数据库维护DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP
*
* <p>禁止应用显式写MySQL UPDATE 语句显式给该列赋值时不会触发自动更新
* 而本表写回多为 selectById 改字段 updateById实体带着旧值一旦写回就会冻结更新时间
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
}
@@ -24,6 +24,8 @@ public class PermissionMenuItemVo {
private Integer sortOrder;
@JsonProperty("created_at")
private LocalDateTime createdAt;
@JsonProperty("updated_at")
private LocalDateTime updatedAt;
/**
* 当前操作者能否把该菜单授予他人仅授权树接口list填充菜单 CRUD 回显等
@@ -1160,6 +1160,7 @@ public class PermissionMenuService {
vo.setRoutePath(entity.getRoutePath());
vo.setSortOrder(entity.getSortOrder());
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.shopkey.model.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -39,6 +40,12 @@ public class QueryAsinEntity {
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
/**
* 更新时间由数据库维护DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP
*
* <p>禁止应用显式写本表更新走 selectById 改字段 updateById实体带着旧值
* 显式写回旧值会触发 MySQL 显式赋值不自动更新规则把更新时间冻结在首次写入时刻
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
}
@@ -56,6 +56,12 @@ public class SkipPriceAsinEntity {
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
/**
* 更新时间由数据库维护DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP
*
* <p>禁止应用显式写本表更新走 selectById 改字段 updateById实体带着旧值
* 显式写回旧值会触发 MySQL 显式赋值不自动更新规则把更新时间冻结在首次写入时刻
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
}
@@ -37,6 +37,9 @@ public class AdminUserSecretRowVo {
@Schema(description = "行级状态说明")
private String statusMessage;
@Schema(description = "首次配置时间(三模块中最早)")
private LocalDateTime createdAt;
@Schema(description = "最近更新时间(三模块中最晚)")
private LocalDateTime updatedAt;
}
@@ -41,6 +41,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -607,6 +608,7 @@ public class UserApiSecretService implements UserSecretCleanupPort {
vo.setStatus(summarizeRowStatus(modules));
vo.setStatusMessage(summarizeRowMessage(modules, vo.getStatus()));
vo.setUpdatedAt(latestUpdatedAt(modules));
vo.setCreatedAt(earliestCreatedAt(moduleRows.values()));
AdminUserEntity user = adminUserMapper.selectById(userId);
vo.setUsername(user == null ? "" : user.getUsername());
vo.setGroups(List.of());
@@ -673,6 +675,18 @@ public class UserApiSecretService implements UserSecretCleanupPort {
return latest;
}
/** 行级创建时间:该用户已配置模块中最早的一条 created_at;一条都没有则为 null。 */
private LocalDateTime earliestCreatedAt(Collection<UserApiSecretEntity> rows) {
LocalDateTime earliest = null;
for (UserApiSecretEntity row : rows) {
LocalDateTime value = row.getCreatedAt();
if (value != null && (earliest == null || value.isBefore(earliest))) {
earliest = value;
}
}
return earliest;
}
private void upsert(Long userId, String moduleKey, String plainValue, String source) {
LocalDateTime now = LocalDateTime.now();
UserApiSecretEntity existing = selectOne(userId, moduleKey);
@@ -0,0 +1,45 @@
-- V131: users(用户)、columns(后台菜单) 两表增加 updated_at 列
--
-- 背景:后台「用户管理」「菜单管理」列表要展示「创建时间 + 更新时间」,但这两张历史表
-- 建表时只落了一个 created_at,更新时间无从取值。补一列由数据库维护的 updated_at
-- DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP),应用侧不写它。
--
-- 与 V125 同样的取舍:列交给数据库维护,Java 实体上标注
-- insertStrategy=NEVER / updateStrategy=NEVER,保证 MyBatis-Plus 永不显式写。
-- 这一步是必要的——本项目的写回模式多为 selectById → 改字段 → updateById(实体带着旧值),
-- 而 MySQL 的规则是「UPDATE 语句显式给某列赋值时不触发该列的自动更新」,
-- 不禁写就会把读出来的旧值写回去,更新时间会一直停在第一次写入的值。
--
-- 存量行:ADD COLUMN 的 DEFAULT CURRENT_TIMESTAMP 会把已有行填成迁移执行时刻,
-- 不是真实的历史变更时间(历史上也没有记录,无法还原),属已知取舍。
--
-- 风险:两表均小(users 百余行、columns 数十行),ADD COLUMN 秒级完成。
-- 回滚:ALTER TABLE `users` DROP COLUMN updated_at; ALTER TABLE `columns` DROP COLUMN updated_at;
SET @db_name = DATABASE();
-- users(用户表)
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'updated_at'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `users` ADD COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间'' AFTER `created_at`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- columns(后台菜单表)
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'columns' AND COLUMN_NAME = 'updated_at'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `columns` ADD COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间'' AFTER `created_at`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;