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:
@@ -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, {
|
return json(res, {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
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,
|
total: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (url === '/api/admin/permission-menus') {
|
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') {
|
if (url === '/api/admin/shop-manage-groups') {
|
||||||
return json(res, { success: true, data: { items: [] } })
|
return json(res, { success: true, data: { items: [] } })
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface AdminUserSecretRow {
|
|||||||
proxy: AdminUserSecretModule
|
proxy: AdminUserSecretModule
|
||||||
status: string
|
status: string
|
||||||
statusMessage: string
|
statusMessage: string
|
||||||
|
/** 首次配置时间(三模块中最早);从未配置为 null。 */
|
||||||
|
createdAt: string | null
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export function toAdminUserItem(raw: unknown): AdminUser | null {
|
|||||||
if (createdById !== null) item.createdById = createdById
|
if (createdById !== null) item.createdById = createdById
|
||||||
const createdAt = text(r.created_at)
|
const createdAt = text(r.created_at)
|
||||||
if (createdAt) item.createdAt = createdAt
|
if (createdAt) item.createdAt = createdAt
|
||||||
|
const updatedAt = text(r.updated_at)
|
||||||
|
if (updatedAt) item.updatedAt = updatedAt
|
||||||
const creator = text(r.creator_username)
|
const creator = text(r.creator_username)
|
||||||
if (creator) item.creatorUsername = creator
|
if (creator) item.creatorUsername = creator
|
||||||
const abbr = text(r.pinyin_abbr)
|
const abbr = text(r.pinyin_abbr)
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ onMounted(loadMenus)
|
|||||||
<th style="width: 100px">菜单类型</th>
|
<th style="width: 100px">菜单类型</th>
|
||||||
<th style="width: 120px">上级菜单</th>
|
<th style="width: 120px">上级菜单</th>
|
||||||
<th style="width: 170px">创建时间</th>
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
<th style="width: 170px">操作</th>
|
<th style="width: 170px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -271,6 +272,7 @@ onMounted(loadMenus)
|
|||||||
<td><span class="menu-type-text">{{ menuTypeLabel(row.menuType) }}</span></td>
|
<td><span class="menu-type-text">{{ menuTypeLabel(row.menuType) }}</span></td>
|
||||||
<td>{{ parentNameOf(row) }}</td>
|
<td>{{ parentNameOf(row) }}</td>
|
||||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||||
<button
|
<button
|
||||||
@@ -286,10 +288,10 @@ onMounted(loadMenus)
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="7" class="empty-tip">加载中...</td>
|
<td colspan="8" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="7" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
|
<td colspan="8" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -233,6 +233,8 @@ onMounted(load)
|
|||||||
<th style="width: 230px">外观专利密钥</th>
|
<th style="width: 230px">外观专利密钥</th>
|
||||||
<th style="width: 380px">代理设置</th>
|
<th style="width: 380px">代理设置</th>
|
||||||
<th style="width: 130px">状态</th>
|
<th style="width: 130px">状态</th>
|
||||||
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
<th style="width: 170px">操作</th>
|
<th style="width: 170px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -278,6 +280,8 @@ onMounted(load)
|
|||||||
{{ rowStatusMeta(row.status).label }}
|
{{ rowStatusMeta(row.status).label }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm"
|
class="btn btn-sm"
|
||||||
@@ -292,10 +296,10 @@ onMounted(load)
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<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>
|
||||||
<tr v-else>
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ onMounted(loadUsers)
|
|||||||
<th style="width: 140px">角色</th>
|
<th style="width: 140px">角色</th>
|
||||||
<th style="width: 160px">所属管理员</th>
|
<th style="width: 160px">所属管理员</th>
|
||||||
<th style="width: 180px">创建时间</th>
|
<th style="width: 180px">创建时间</th>
|
||||||
|
<th style="width: 180px">更新时间</th>
|
||||||
<th style="width: 160px">操作</th>
|
<th style="width: 160px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -180,6 +181,7 @@ onMounted(loadUsers)
|
|||||||
<td>{{ roleLabel(row.role) }}</td>
|
<td>{{ roleLabel(row.role) }}</td>
|
||||||
<td>{{ row.creatorUsername || '-' }}</td>
|
<td>{{ row.creatorUsername || '-' }}</td>
|
||||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm"
|
class="btn btn-sm"
|
||||||
@@ -203,10 +205,10 @@ onMounted(loadUsers)
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="6" class="empty-tip">加载中...</td>
|
<td colspan="7" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="6" class="empty-tip">{{ userListEmptyHint() }}</td>
|
<td colspan="7" class="empty-tip">{{ userListEmptyHint() }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface MenuManageNode {
|
|||||||
routePath: string
|
routePath: string
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
children?: MenuManageNode[]
|
children?: MenuManageNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +91,8 @@ export function parseMenuManageItem(raw: unknown): MenuManageNode | null {
|
|||||||
if (rootColumnKey) node.rootColumnKey = rootColumnKey
|
if (rootColumnKey) node.rootColumnKey = rootColumnKey
|
||||||
const createdAt = text(record.created_at)
|
const createdAt = text(record.created_at)
|
||||||
if (createdAt) node.createdAt = createdAt
|
if (createdAt) node.createdAt = createdAt
|
||||||
|
const updatedAt = text(record.updated_at)
|
||||||
|
if (updatedAt) node.updatedAt = updatedAt
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ onMounted(() => {
|
|||||||
<th v-if="isSuperAdmin" style="width: 140px">分组</th>
|
<th v-if="isSuperAdmin" style="width: 140px">分组</th>
|
||||||
<th style="width: 110px">来源</th>
|
<th style="width: 110px">来源</th>
|
||||||
<th style="width: 170px">创建时间</th>
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
<th style="width: 150px">操作</th>
|
<th style="width: 150px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -218,6 +219,7 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||||
@@ -225,10 +227,10 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<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>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无数据</td>
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -375,6 +375,7 @@ onMounted(() => {
|
|||||||
<th>用户名</th>
|
<th>用户名</th>
|
||||||
<th v-if="isSuperAdmin">分组</th>
|
<th v-if="isSuperAdmin">分组</th>
|
||||||
<th>创建时间</th>
|
<th>创建时间</th>
|
||||||
|
<th>更新时间</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -387,6 +388,7 @@ onMounted(() => {
|
|||||||
<td>{{ row.username || '-' }}</td>
|
<td>{{ row.username || '-' }}</td>
|
||||||
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
|
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
|
||||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||||
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row)">删除</button>
|
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row)">删除</button>
|
||||||
@@ -394,10 +396,10 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<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>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无总数据</td>
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无总数据</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from './product-category-model.ts'
|
} from './product-category-model.ts'
|
||||||
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
|
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
|
||||||
import { buildProductCategoryExportUrl } from './product-category-export.ts'
|
import { buildProductCategoryExportUrl } from './product-category-export.ts'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
import OldPagination from '@/components/OldPagination.vue'
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -330,6 +331,8 @@ onMounted(loadTree)
|
|||||||
<th style="width: 90px">排序</th>
|
<th style="width: 90px">排序</th>
|
||||||
<th style="width: 120px">来源</th>
|
<th style="width: 120px">来源</th>
|
||||||
<th>备注</th>
|
<th>备注</th>
|
||||||
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
<th style="width: 150px">操作</th>
|
<th style="width: 150px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -337,7 +340,7 @@ onMounted(loadTree)
|
|||||||
<template v-if="treeRows.length">
|
<template v-if="treeRows.length">
|
||||||
<template v-for="row in treeRows" :key="String(row.node.id)">
|
<template v-for="row in treeRows" :key="String(row.node.id)">
|
||||||
<tr v-if="isCategoryLoadMoreNode(row.node)" class="load-more-row">
|
<tr v-if="isCategoryLoadMoreNode(row.node)" class="load-more-row">
|
||||||
<td colspan="6">
|
<td colspan="8">
|
||||||
<div class="load-more-cell">
|
<div class="load-more-cell">
|
||||||
<span class="tree-indent" :style="{ width: `${(row.depth + 1) * 24}px` }"></span>
|
<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)">
|
<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-if="row.node.description" class="node-desc" :title="row.node.description">{{ row.node.description }}</span>
|
||||||
<span v-else class="dim">—</span>
|
<span v-else class="dim">—</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ formatDateTime(row.node.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.node.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button class="btn btn-sm" type="button" @click="openEdit(row.node)">编辑</button>
|
<button class="btn btn-sm" type="button" @click="openEdit(row.node)">编辑</button>
|
||||||
<button
|
<button
|
||||||
@@ -392,10 +397,10 @@ onMounted(loadTree)
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="6" class="empty-tip">加载中...</td>
|
<td colspan="8" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="6" class="empty-tip">暂无商品类目</td>
|
<td colspan="8" class="empty-tip">暂无商品类目</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tbody v-else>
|
<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-if="row.description" class="node-desc" :title="row.description">{{ row.description }}</span>
|
||||||
<span v-else class="dim">—</span>
|
<span v-else class="dim">—</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||||
<button
|
<button
|
||||||
@@ -426,10 +433,10 @@ onMounted(loadTree)
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="searchLoading">
|
<tr v-else-if="searchLoading">
|
||||||
<td colspan="6" class="empty-tip">搜索中...</td>
|
<td colspan="8" class="empty-tip">搜索中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="6" class="empty-tip">暂无搜索结果</td>
|
<td colspan="8" class="empty-tip">暂无搜索结果</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import CopyText from '@/components/CopyText.vue'
|
import CopyText from '@/components/CopyText.vue'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
|
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
|
||||||
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts'
|
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts'
|
||||||
import { asinCountryLabel } from './asin-country.ts'
|
import { asinCountryLabel } from './asin-country.ts'
|
||||||
@@ -394,6 +395,8 @@ onMounted(() => {
|
|||||||
<th style="width: 15%">店铺名</th>
|
<th style="width: 15%">店铺名</th>
|
||||||
<th style="width: 24%">ASIN</th>
|
<th style="width: 24%">ASIN</th>
|
||||||
<th style="width: 12%">国家</th>
|
<th style="width: 12%">国家</th>
|
||||||
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
<th style="width: 9%">操作</th>
|
<th style="width: 9%">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -413,6 +416,8 @@ onMounted(() => {
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||||
<template v-if="row.isFirst">
|
<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">
|
<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" 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>
|
<button class="btn btn-sm btn-danger" type="button" @click="removeShopAsin(row.item as QueryAsinItem)">删除</button>
|
||||||
@@ -421,10 +426,10 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<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>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td :colspan="isSuperAdmin ? 6 : 5" class="empty-tip">暂无数据</td>
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import CopyText from '@/components/CopyText.vue'
|
import CopyText from '@/components/CopyText.vue'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
|
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
|
||||||
import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts'
|
import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts'
|
||||||
import { asinCountryLabel } from './asin-country.ts'
|
import { asinCountryLabel } from './asin-country.ts'
|
||||||
@@ -447,6 +448,8 @@ onMounted(() => {
|
|||||||
<th style="width: 24%">ASIN</th>
|
<th style="width: 24%">ASIN</th>
|
||||||
<th style="width: 12%">国家</th>
|
<th style="width: 12%">国家</th>
|
||||||
<th style="width: 10%">最低价</th>
|
<th style="width: 10%">最低价</th>
|
||||||
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
<th style="width: 9%">操作</th>
|
<th style="width: 9%">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -467,6 +470,8 @@ onMounted(() => {
|
|||||||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||||
<td class="price-cell">{{ row.minimumPrice !== '' ? row.minimumPrice : '-' }}</td>
|
<td class="price-cell">{{ row.minimumPrice !== '' ? row.minimumPrice : '-' }}</td>
|
||||||
<template v-if="row.isFirst">
|
<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">
|
<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" 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>
|
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row.item as SkipPriceItem)">删除</button>
|
||||||
@@ -475,10 +480,10 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<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>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无数据</td>
|
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">暂无数据</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface DedupeTotalItem {
|
|||||||
uploaderUserId: number | null
|
uploaderUserId: number | null
|
||||||
username: string
|
username: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DedupeTotalPageResult {
|
export interface DedupeTotalPageResult {
|
||||||
@@ -51,6 +52,8 @@ export function toDedupeTotalItem(raw: unknown): DedupeTotalItem | null {
|
|||||||
}
|
}
|
||||||
const createdAt = text(record.createdAt ?? record.created_at)
|
const createdAt = text(record.createdAt ?? record.created_at)
|
||||||
if (createdAt) item.createdAt = createdAt
|
if (createdAt) item.createdAt = createdAt
|
||||||
|
const updatedAt = text(record.updatedAt ?? record.updated_at)
|
||||||
|
if (updatedAt) item.updatedAt = updatedAt
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface ProductCategoryNode {
|
|||||||
childCount: number
|
childCount: number
|
||||||
level: number | null
|
level: number | null
|
||||||
path: string
|
path: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
children: ProductCategoryNode[]
|
children: ProductCategoryNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,10 @@ export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null
|
|||||||
path: text(record.path),
|
path: text(record.path),
|
||||||
children: [],
|
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)) {
|
if (Array.isArray(record.children)) {
|
||||||
node.children = record.children
|
node.children = record.children
|
||||||
.map((child) => toProductCategoryNode(child))
|
.map((child) => toProductCategoryNode(child))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface InvalidAsinItem {
|
|||||||
groupName: string
|
groupName: string
|
||||||
recordSource: string
|
recordSource: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvalidAsinPageResult {
|
export interface InvalidAsinPageResult {
|
||||||
@@ -74,6 +75,8 @@ export function toInvalidAsinItem(raw: unknown): InvalidAsinItem | null {
|
|||||||
}
|
}
|
||||||
const createdAt = text(record.createdAt ?? record.created_at)
|
const createdAt = text(record.createdAt ?? record.created_at)
|
||||||
if (createdAt) item.createdAt = createdAt
|
if (createdAt) item.createdAt = createdAt
|
||||||
|
const updatedAt = text(record.updatedAt ?? record.updated_at)
|
||||||
|
if (updatedAt) item.updatedAt = updatedAt
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface AdminUser {
|
|||||||
createdById?: number | null
|
createdById?: number | null
|
||||||
creatorUsername?: string
|
creatorUsername?: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
pinyinAbbr?: string
|
pinyinAbbr?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ test('align_query_asin_page_layout_wiring', () => {
|
|||||||
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
|
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
|
||||||
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
|
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
|
||||||
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
|
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
|
||||||
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
|
// 需求变更:后台管理列表统一展示「创建时间 + 更新时间」,覆盖早期「不加更新时间」的像素对齐。
|
||||||
|
assert.match(page, /创建时间/, '表格含创建时间列')
|
||||||
|
assert.match(page, /更新时间/, '表格含更新时间列')
|
||||||
assert.match(page, /queryAsinDisplayRows/, '行展开走纯模型')
|
assert.match(page, /queryAsinDisplayRows/, '行展开走纯模型')
|
||||||
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
|
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
|
||||||
assert.match(page, /请先选择分组/, '店铺下拉未选分组时占位对齐')
|
assert.match(page, /请先选择分组/, '店铺下拉未选分组时占位对齐')
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ test('align_skip_price_page_layout_wiring', () => {
|
|||||||
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
|
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
|
||||||
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
|
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
|
||||||
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
|
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
|
||||||
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
|
// 需求变更:后台管理列表统一展示「创建时间 + 更新时间」,覆盖早期「不加更新时间」的像素对齐。
|
||||||
|
assert.match(page, /创建时间/, '表格含创建时间列')
|
||||||
|
assert.match(page, /更新时间/, '表格含更新时间列')
|
||||||
assert.match(page, /skipPriceDisplayRows/, '行展开走纯模型')
|
assert.match(page, /skipPriceDisplayRows/, '行展开走纯模型')
|
||||||
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
|
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
|
||||||
assert.match(page, /最低价格式不正确/, '最低价格式校验对齐')
|
assert.match(page, /最低价格式不正确/, '最低价格式校验对齐')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.nanri.aiimage.common.model.entity;
|
package com.nanri.aiimage.common.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
@@ -24,5 +25,13 @@ public class AdminUserEntity {
|
|||||||
private Long createdById;
|
private Long createdById;
|
||||||
@TableField("created_at")
|
@TableField("created_at")
|
||||||
private LocalDateTime createdAt;
|
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;
|
private String machine;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -16,6 +16,8 @@ public class AdminUserItemVo {
|
|||||||
private String creatorUsername;
|
private String creatorUsername;
|
||||||
@JsonProperty("created_at")
|
@JsonProperty("created_at")
|
||||||
private String createdAt;
|
private String createdAt;
|
||||||
|
@JsonProperty("updated_at")
|
||||||
|
private String updatedAt;
|
||||||
@JsonProperty("pinyin_abbr")
|
@JsonProperty("pinyin_abbr")
|
||||||
private String pinyinAbbr;
|
private String pinyinAbbr;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -338,6 +338,8 @@ public class AdminUserService {
|
|||||||
vo.setCreatorUsername(creatorId == null ? "" : creatorMap.getOrDefault(creatorId, ""));
|
vo.setCreatorUsername(creatorId == null ? "" : creatorMap.getOrDefault(creatorId, ""));
|
||||||
LocalDateTime createdAt = entity.getCreatedAt();
|
LocalDateTime createdAt = entity.getCreatedAt();
|
||||||
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
|
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()));
|
vo.setPinyinAbbr(PinyinAbbrUtil.abbr(entity.getUsername()));
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -1,6 +1,8 @@
|
|||||||
package com.nanri.aiimage.modules.dedupe.model.entity;
|
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.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -19,5 +21,12 @@ public class DedupeTotalDataEntity {
|
|||||||
private Long uploaderUserId;
|
private Long uploaderUserId;
|
||||||
private String uploaderUsername;
|
private String uploaderUsername;
|
||||||
private LocalDateTime createdAt;
|
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;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -32,4 +32,7 @@ public class DedupeTotalDataItemVo {
|
|||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -1398,6 +1398,7 @@ public class DedupeTotalDataService {
|
|||||||
vo.setUploaderUserId(entity.getUploaderUserId());
|
vo.setUploaderUserId(entity.getUploaderUserId());
|
||||||
vo.setUsername(entity.getUploaderUsername());
|
vo.setUsername(entity.getUploaderUsername());
|
||||||
vo.setCreatedAt(entity.getCreatedAt());
|
vo.setCreatedAt(entity.getCreatedAt());
|
||||||
|
vo.setUpdatedAt(entity.getUpdatedAt());
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
@@ -1,6 +1,8 @@
|
|||||||
package com.nanri.aiimage.modules.invalidasin.model.entity;
|
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.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -18,5 +20,12 @@ public class InvalidAsinDataEntity {
|
|||||||
private Long groupId;
|
private Long groupId;
|
||||||
private String recordSource;
|
private String recordSource;
|
||||||
private LocalDateTime createdAt;
|
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;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -29,4 +29,7 @@ public class InvalidAsinDataItemVo {
|
|||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -245,6 +245,7 @@ public class InvalidAsinDataService {
|
|||||||
vo.setGroupName(groupName == null ? "" : groupName);
|
vo.setGroupName(groupName == null ? "" : groupName);
|
||||||
vo.setRecordSource(isManualRecord(entity) ? RECORD_SOURCE_MANUAL : RECORD_SOURCE_AUTO);
|
vo.setRecordSource(isManualRecord(entity) ? RECORD_SOURCE_MANUAL : RECORD_SOURCE_AUTO);
|
||||||
vo.setCreatedAt(entity.getCreatedAt());
|
vo.setCreatedAt(entity.getCreatedAt());
|
||||||
|
vo.setUpdatedAt(entity.getUpdatedAt());
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -1,5 +1,6 @@
|
|||||||
package com.nanri.aiimage.modules.permission.model.entity;
|
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.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
@@ -22,4 +23,12 @@ public class PermissionMenuEntity {
|
|||||||
private String routePath;
|
private String routePath;
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
private LocalDateTime createdAt;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -24,6 +24,8 @@ public class PermissionMenuItemVo {
|
|||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
@JsonProperty("created_at")
|
@JsonProperty("created_at")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
@JsonProperty("updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前操作者能否把该菜单授予他人。仅授权树接口(list)填充;菜单 CRUD 回显等
|
* 当前操作者能否把该菜单授予他人。仅授权树接口(list)填充;菜单 CRUD 回显等
|
||||||
|
|||||||
+1
@@ -1160,6 +1160,7 @@ public class PermissionMenuService {
|
|||||||
vo.setRoutePath(entity.getRoutePath());
|
vo.setRoutePath(entity.getRoutePath());
|
||||||
vo.setSortOrder(entity.getSortOrder());
|
vo.setSortOrder(entity.getSortOrder());
|
||||||
vo.setCreatedAt(entity.getCreatedAt());
|
vo.setCreatedAt(entity.getCreatedAt());
|
||||||
|
vo.setUpdatedAt(entity.getUpdatedAt());
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -1,5 +1,6 @@
|
|||||||
package com.nanri.aiimage.modules.shopkey.model.entity;
|
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.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
@@ -39,6 +40,12 @@ public class QueryAsinEntity {
|
|||||||
@TableField("created_at")
|
@TableField("created_at")
|
||||||
private LocalDateTime createdAt;
|
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;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -56,6 +56,12 @@ public class SkipPriceAsinEntity {
|
|||||||
@TableField("created_at")
|
@TableField("created_at")
|
||||||
private LocalDateTime createdAt;
|
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;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -37,6 +37,9 @@ public class AdminUserSecretRowVo {
|
|||||||
@Schema(description = "行级状态说明")
|
@Schema(description = "行级状态说明")
|
||||||
private String statusMessage;
|
private String statusMessage;
|
||||||
|
|
||||||
|
@Schema(description = "首次配置时间(三模块中最早)")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@Schema(description = "最近更新时间(三模块中最晚)")
|
@Schema(description = "最近更新时间(三模块中最晚)")
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -41,6 +41,7 @@ import java.time.LocalDate;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -607,6 +608,7 @@ public class UserApiSecretService implements UserSecretCleanupPort {
|
|||||||
vo.setStatus(summarizeRowStatus(modules));
|
vo.setStatus(summarizeRowStatus(modules));
|
||||||
vo.setStatusMessage(summarizeRowMessage(modules, vo.getStatus()));
|
vo.setStatusMessage(summarizeRowMessage(modules, vo.getStatus()));
|
||||||
vo.setUpdatedAt(latestUpdatedAt(modules));
|
vo.setUpdatedAt(latestUpdatedAt(modules));
|
||||||
|
vo.setCreatedAt(earliestCreatedAt(moduleRows.values()));
|
||||||
AdminUserEntity user = adminUserMapper.selectById(userId);
|
AdminUserEntity user = adminUserMapper.selectById(userId);
|
||||||
vo.setUsername(user == null ? "" : user.getUsername());
|
vo.setUsername(user == null ? "" : user.getUsername());
|
||||||
vo.setGroups(List.of());
|
vo.setGroups(List.of());
|
||||||
@@ -673,6 +675,18 @@ public class UserApiSecretService implements UserSecretCleanupPort {
|
|||||||
return latest;
|
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) {
|
private void upsert(Long userId, String moduleKey, String plainValue, String source) {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
UserApiSecretEntity existing = selectOne(userId, moduleKey);
|
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;
|
||||||
Reference in New Issue
Block a user