update log page

This commit is contained in:
2026-04-03 11:18:23 +08:00
parent 2f5af0538e
commit 16df59ca9d
6 changed files with 178 additions and 27 deletions
@@ -83,6 +83,26 @@ const mockUsers = {
per_page: 200, per_page: 200,
} }
const mockRoles = {
items: [
{ id: 3, name: 'developer' },
{ id: 4, name: 'administrator' },
],
total: 2,
page: 1,
per_page: 200,
}
const mockRouteGroups = {
items: [
{ id: 10, name: 'default' },
{ id: 11, name: 'api-management' },
],
total: 2,
page: 1,
per_page: 200,
}
// ─── Store Tests ─────────────────────────────────────── // ─── Store Tests ───────────────────────────────────────
describe('useOperationLogStore', () => { describe('useOperationLogStore', () => {
@@ -192,27 +212,59 @@ describe('useOperationLogStore', () => {
}) })
}) })
describe('userMap', () => { describe('lookup maps', () => {
it('S6: builds userMap from loaded users', async () => { it('S6: builds all maps from loaded lookups', async () => {
const { api } = await import('@/utils/request') const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValueOnce(mockUsers) vi.mocked(api.get)
.mockResolvedValueOnce(mockUsers)
.mockResolvedValueOnce(mockRoles)
.mockResolvedValueOnce(mockRouteGroups)
const store = useOperationLogStore() const store = useOperationLogStore()
await store.loadLookups() await store.loadLookups()
expect(store.userMap.get(1)).toBe('admin') expect(store.userMap.get(1)).toBe('admin')
expect(store.userMap.get(2)).toBe('editor') expect(store.userMap.get(2)).toBe('editor')
expect(store.roleMap.get(3)).toBe('developer')
expect(store.roleMap.get(4)).toBe('administrator')
expect(store.routeGroupMap.get(10)).toBe('default')
expect(store.routeGroupMap.get(11)).toBe('api-management')
}) })
it('S7: handles loadLookups failure gracefully', async () => { it('S6a: userMap handles string id from API via Number() defense', () => {
const store = useOperationLogStore()
store.users = [{ id: '1' as any, username: 'admin' }] as any
expect(store.userMap.get(1)).toBe('admin')
})
it('S7: handles all lookups failure gracefully', async () => {
const { api } = await import('@/utils/request') const { api } = await import('@/utils/request')
vi.mocked(api.get).mockRejectedValueOnce(new Error('加载失败')) vi.mocked(api.get)
.mockRejectedValueOnce(new Error('用户加载失败'))
.mockRejectedValueOnce(new Error('角色加载失败'))
.mockRejectedValueOnce(new Error('路由组加载失败'))
const store = useOperationLogStore() const store = useOperationLogStore()
await store.loadLookups() await store.loadLookups()
expect(store.users).toEqual([])
expect(store.userMap.size).toBe(0) expect(store.userMap.size).toBe(0)
expect(store.roleMap.size).toBe(0)
expect(store.routeGroupMap.size).toBe(0)
})
it('S7a: partial lookup failure does not affect other maps', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get)
.mockResolvedValueOnce(mockUsers)
.mockRejectedValueOnce(new Error('角色加载失败'))
.mockResolvedValueOnce(mockRouteGroups)
const store = useOperationLogStore()
await store.loadLookups()
expect(store.userMap.get(1)).toBe('admin')
expect(store.roleMap.size).toBe(0)
expect(store.routeGroupMap.get(10)).toBe('default')
}) })
}) })
}) })
@@ -237,6 +289,8 @@ describe('OperationLogPage', () => {
const { api } = await import('@/utils/request') const { api } = await import('@/utils/request')
vi.mocked(api.get).mockImplementation((url: string) => { vi.mocked(api.get).mockImplementation((url: string) => {
if (url === '/api/v1/users') return Promise.resolve(mockUsers) if (url === '/api/v1/users') return Promise.resolve(mockUsers)
if (url === '/api/v1/roles') return Promise.resolve(mockRoles)
if (url === '/api/v1/route-groups') return Promise.resolve(mockRouteGroups)
if (url === '/api/v1/logs/operations') { if (url === '/api/v1/logs/operations') {
return Promise.resolve({ return Promise.resolve({
items: mockLogs, items: mockLogs,
@@ -293,11 +347,14 @@ describe('OperationLogPage', () => {
expect(tagTexts).toContain('order.delete') expect(tagTexts).toContain('order.delete')
}) })
it('P5: displays target as type + id', async () => { it('P5: displays target with name for small datasets, #id for large', async () => {
await mountPage() await mountPage()
const html = wrapper.html() const html = wrapper.html()
// user id=5 not in mockUsers, so fallback to #id
expect(html).toContain('用户 #5') expect(html).toContain('用户 #5')
expect(html).toContain('角色 #3') // role id=3 is in mockRoles as 'developer'
expect(html).toContain('角色 developer')
// order is large dataset, always #id
expect(html).toContain('订单 #100') expect(html).toContain('订单 #100')
}) })
@@ -358,6 +415,35 @@ describe('OperationLogPage', () => {
expect(api.get).toHaveBeenCalled() expect(api.get).toHaveBeenCalled()
}) })
it('P9a: renders operator username instead of #id', async () => {
await mountPage()
const html = wrapper.html()
expect(html).toContain('admin')
expect(html).not.toMatch(/#1(?![\d])/)
})
it('P9b: renders role name in target column', async () => {
await mountPage()
const html = wrapper.html()
// mockLogs[1] has target_type='role', target_id=3, which maps to 'developer'
expect(html).toContain('角色 developer')
expect(html).not.toContain('角色 #3')
})
it('P9c: preserves #id for large dataset targets', async () => {
await mountPage()
const html = wrapper.html()
// mockLogs[2] has target_type='order', target_id=100 — large dataset, keep #id
expect(html).toContain('订单 #100')
})
it('P9d: calls all three lookup APIs on mount', async () => {
const { api } = await mountPage()
expect(api.get).toHaveBeenCalledWith('/api/v1/users', { per_page: 200 })
expect(api.get).toHaveBeenCalledWith('/api/v1/roles', { per_page: 200 })
expect(api.get).toHaveBeenCalledWith('/api/v1/route-groups', { per_page: 200 })
})
it('P9: view button opens drawer and loads detail', async () => { it('P9: view button opens drawer and loads detail', async () => {
const { api } = await mountPage() const { api } = await mountPage()
@@ -214,6 +214,19 @@ describe('useRequestLogStore', () => {
expect(store.userMap.get(2)).toBe('editor') expect(store.userMap.get(2)).toBe('editor')
}) })
it('S7a: userMap handles string id from API via Number() defense', async () => {
const store = useRequestLogStore()
// Simulate API returning string-typed ids (the actual bug scenario)
store.users = [
{ id: '1' as any, username: 'admin' },
{ id: '2' as any, username: 'editor' },
] as any
// Number() defense in store ensures Map keys are numbers
expect(store.userMap.get(1)).toBe('admin')
expect(store.userMap.get(2)).toBe('editor')
})
it('S7: handles loadLookups failure gracefully', async () => { it('S7: handles loadLookups failure gracefully', async () => {
const { api } = await import('@/utils/request') const { api } = await import('@/utils/request')
vi.mocked(api.get).mockRejectedValueOnce(new Error('加载失败')) vi.mocked(api.get).mockRejectedValueOnce(new Error('加载失败'))
@@ -369,6 +382,25 @@ describe('RequestLogPage', () => {
expect(api.get).toHaveBeenCalled() expect(api.get).toHaveBeenCalled()
}) })
it('P9a: renders username instead of #id in user column', async () => {
await mountPage()
const html = wrapper.html()
// user_id=1 should render as 'admin', not '#1'
expect(html).toContain('admin')
expect(html).not.toMatch(/#1(?![\d])/)
// user_id=2 should render as 'editor', not '#2'
expect(html).toContain('editor')
})
it('P9b: renders dash for null user_id', async () => {
await mountPage()
// mockLogs[1] has user_id: null, should render '-'
// The table should contain '-' for the null user_id row
const cells = wrapper.findAll('td')
const userCells = cells.filter((c) => c.text() === '-')
expect(userCells.length).toBeGreaterThan(0)
})
it('P9: view button opens drawer and loads detail', async () => { it('P9: view button opens drawer and loads detail', async () => {
const { api } = await mountPage() const { api } = await mountPage()
+17 -5
View File
@@ -63,8 +63,8 @@ const createdAtRange = computed({
}, },
}) })
onMounted(() => { onMounted(async () => {
store.loadLookups() await store.loadLookups()
store.fetchLogs() store.fetchLogs()
}) })
@@ -89,8 +89,20 @@ function formatTime(time: string | null) {
return time.replace('T', ' ').substring(0, 16) return time.replace('T', ' ').substring(0, 16)
} }
function formatTarget(type: string, id: number) { function formatTarget(type: string, id: number): string {
const label = targetTypeLabels[type] || type const label = targetTypeLabels[type] || type
if (type === 'user') {
const name = store.userMap.get(Number(id))
return name ? `${label} ${name}` : `${label} #${id}`
}
if (type === 'role') {
const name = store.roleMap.get(Number(id))
return name ? `${label} ${name}` : `${label} #${id}`
}
if (type === 'route_group') {
const name = store.routeGroupMap.get(Number(id))
return name ? `${label} ${name}` : `${label} #${id}`
}
return `${label} #${id}` return `${label} #${id}`
} }
@@ -198,7 +210,7 @@ async function handleViewDetail(record: { id: number }) {
<template v-else-if="column.key === 'user_id'"> <template v-else-if="column.key === 'user_id'">
{{ {{
record.user_id record.user_id
? (store.userMap.get(record.user_id) || `#${record.user_id}`) ? (store.userMap.get(Number(record.user_id)) || `#${record.user_id}`)
: '-' : '-'
}} }}
</template> </template>
@@ -255,7 +267,7 @@ async function handleViewDetail(record: { id: number }) {
<a-descriptions-item label="操作人"> <a-descriptions-item label="操作人">
{{ {{
detail.user_id detail.user_id
? (store.userMap.get(detail.user_id) || `#${detail.user_id}`) ? (store.userMap.get(Number(detail.user_id)) || `#${detail.user_id}`)
: '-' : '-'
}} }}
</a-descriptions-item> </a-descriptions-item>
+4 -4
View File
@@ -72,8 +72,8 @@ const createdAtRange = computed({
}, },
}) })
onMounted(() => { onMounted(async () => {
store.loadLookups() await store.loadLookups()
store.fetchLogs() store.fetchLogs()
}) })
@@ -214,7 +214,7 @@ async function handleViewDetail(record: { id: number }) {
</a-tag> </a-tag>
</template> </template>
<template v-else-if="column.key === 'user_id'"> <template v-else-if="column.key === 'user_id'">
{{ record.user_id ? (store.userMap.get(record.user_id) || `#${record.user_id}`) : '-' }} {{ record.user_id ? (store.userMap.get(Number(record.user_id)) || `#${record.user_id}`) : '-' }}
</template> </template>
<template v-else-if="column.key === 'duration_ms'"> <template v-else-if="column.key === 'duration_ms'">
<span :class="durationClass(record.duration_ms)"> <span :class="durationClass(record.duration_ms)">
@@ -274,7 +274,7 @@ async function handleViewDetail(record: { id: number }) {
<a-descriptions-item label="用户"> <a-descriptions-item label="用户">
{{ {{
detail.user_id detail.user_id
? (store.userMap.get(detail.user_id) || `#${detail.user_id}`) ? (store.userMap.get(Number(detail.user_id)) || `#${detail.user_id}`)
: '-' : '-'
}} }}
</a-descriptions-item> </a-descriptions-item>
+30 -9
View File
@@ -12,6 +12,16 @@ interface UserLookup {
username: string username: string
} }
interface RoleLookup {
id: number
name: string
}
interface RouteGroupLookup {
id: number
name: string
}
export const useOperationLogStore = defineStore('operationLog', () => { export const useOperationLogStore = defineStore('operationLog', () => {
const logs = ref<OperationLogRecord[]>([]) const logs = ref<OperationLogRecord[]>([])
const loading = ref(false) const loading = ref(false)
@@ -28,19 +38,28 @@ export const useOperationLogStore = defineStore('operationLog', () => {
}) })
const users = ref<UserLookup[]>([]) const users = ref<UserLookup[]>([])
const roles = ref<RoleLookup[]>([])
const routeGroups = ref<RouteGroupLookup[]>([])
const userMap = computed( const userMap = computed(
() => new Map(users.value.map((u) => [u.id, u.username])), () => new Map(users.value.map((u) => [Number(u.id), u.username])),
)
const roleMap = computed(
() => new Map(roles.value.map((r) => [Number(r.id), r.name])),
)
const routeGroupMap = computed(
() => new Map(routeGroups.value.map((rg) => [Number(rg.id), rg.name])),
) )
async function loadLookups() { async function loadLookups() {
try { const [userData, roleData, rgData] = await Promise.allSettled([
const data = await api.get<PaginatedData<UserLookup>>('/api/v1/users', { api.get<PaginatedData<UserLookup>>('/api/v1/users', { per_page: 200 }),
per_page: 200, api.get<PaginatedData<RoleLookup>>('/api/v1/roles', { per_page: 200 }),
}) api.get<PaginatedData<RouteGroupLookup>>('/api/v1/route-groups', { per_page: 200 }),
users.value = data.items ])
} catch (err: unknown) { if (userData.status === 'fulfilled') users.value = userData.value.items
console.warn('加载用户列表失败', err) if (roleData.status === 'fulfilled') roles.value = roleData.value.items
} if (rgData.status === 'fulfilled') routeGroups.value = rgData.value.items
} }
async function fetchLogs() { async function fetchLogs() {
@@ -86,6 +105,8 @@ export const useOperationLogStore = defineStore('operationLog', () => {
filters, filters,
users, users,
userMap, userMap,
roleMap,
routeGroupMap,
loadLookups, loadLookups,
fetchLogs, fetchLogs,
resetFilters, resetFilters,
+1 -1
View File
@@ -30,7 +30,7 @@ export const useRequestLogStore = defineStore('requestLog', () => {
const users = ref<UserLookup[]>([]) const users = ref<UserLookup[]>([])
const userMap = computed( const userMap = computed(
() => new Map(users.value.map((u) => [u.id, u.username])), () => new Map(users.value.map((u) => [Number(u.id), u.username])),
) )
async function loadLookups() { async function loadLookups() {