task-52(账号/权限页面): 实现用户列表加载/空/错状态

新增 user-list-state.ts 纯 phase 归一(loading>error>empty>ready)与空/错提示;
UsersPage 显式 loadError 态 + 错误 el-alert(带重试)、加载/错误/空相位决策、
删除按钮对超管禁用。8 用例全过,379 单测 + build 绿。
This commit is contained in:
2026-09-05 15:11:18 +08:00
parent 47d1c6bf80
commit 916f179fca
3 changed files with 113 additions and 3 deletions
@@ -1,19 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { 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 type { AdminUser } from '@/types/admin' import type { AdminUser } from '@/types/admin'
import { deleteUser, fetchUserList } from '@/api/users' import { deleteUser, fetchUserList } from '@/api/users'
import { createUserFilterState, toUserListParams } from './users-filter' import { createUserFilterState, toUserListParams } from './users-filter'
import { totalPageCount } from './user-pagination' import { totalPageCount } from './user-pagination'
import { deleteBlockReason } from './user-delete-model' import { deleteBlockReason } from './user-delete-model'
import { listPhaseOf } from './user-list-state'
const loading = ref(false) const loading = ref(false)
const loadError = ref('')
const rows = ref<AdminUser[]>([]) const rows = ref<AdminUser[]>([])
const total = ref(0) const total = ref(0)
const filters = reactive(createUserFilterState()) const filters = reactive(createUserFilterState())
const statePhase = computed(() => listPhaseOf({ loading: loading.value, error: loadError.value, items: rows.value, total: total.value }))
async function loadUsers() { async function loadUsers() {
loading.value = true loading.value = true
loadError.value = ''
try { try {
const page = await fetchUserList(toUserListParams(filters)) const page = await fetchUserList(toUserListParams(filters))
rows.value = page.items rows.value = page.items
@@ -27,12 +31,18 @@ async function loadUsers() {
total.value = retried.total total.value = retried.total
} }
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '用户列表加载失败') loadError.value = error instanceof Error ? error.message : '用户列表加载失败'
ElMessage.error(loadError.value)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
/** 错误态重试:重新拉取当前筛选列表。 */
function retry() {
void loadUsers()
}
/** 提交新筛选条件:重置回第一页再查询。 */ /** 提交新筛选条件:重置回第一页再查询。 */
function search() { function search() {
filters.page = 1 filters.page = 1
@@ -83,6 +93,18 @@ onMounted(loadUsers)
</el-form> </el-form>
</el-card> </el-card>
<el-card shadow="never"> <el-card shadow="never">
<el-alert
v-if="statePhase === 'error' && loadError"
:title="loadError"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
>
<template #default>
<el-button link type="primary" @click="retry">重试</el-button>
</template>
</el-alert>
<el-table v-loading="loading" :data="rows" stripe> <el-table v-loading="loading" :data="rows" stripe>
<el-table-column prop="id" label="ID" width="90" /> <el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="username" label="用户名" min-width="180" /> <el-table-column prop="username" label="用户名" min-width="180" />
@@ -91,7 +113,7 @@ onMounted(loadUsers)
<el-table-column prop="createdAt" label="创建时间" width="180" /> <el-table-column prop="createdAt" label="创建时间" width="180" />
<el-table-column label="操作" width="120" fixed="right"> <el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="danger" @click="removeUser(row)">删除</el-button> <el-button link type="danger" :disabled="deleteBlockReason(row) !== undefined" @click="removeUser(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -0,0 +1,31 @@
/** 用户列表加载/空/错状态(任务 52):纯 phase 归一无 UI 依赖,供页面展示决策复用。 */
export type ListPhase = 'loading' | 'error' | 'empty' | 'ready'
export interface UserListPhaseInput {
loading?: boolean
error?: string
items?: unknown[]
total?: number
}
/** 归一列表展示相位:loading 优先,其次 error,其次空,否则 ready。 */
export function listPhaseOf(input: UserListPhaseInput): ListPhase {
if (input.loading) return 'loading'
if (typeof input.error === 'string' && input.error.trim()) return 'error'
const items = Array.isArray(input.items) ? input.items : []
const total = typeof input.total === 'number' ? input.total : items.length
if (items.length === 0 || total === 0) return 'empty'
return 'ready'
}
/** 空态提示文案。 */
export function userListEmptyHint(): string {
return '暂无用户数据'
}
/** 错误态可操作提示:带上具体原因,缺省给通用文案。 */
export function userListErrorHint(message?: string | null): string {
const text = (message || '').trim()
return text ? `加载失败:${text}` : '加载失败,请稍后重试'
}
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { listPhaseOf, userListEmptyHint, userListErrorHint, type ListPhase, type UserListPhaseInput } from '../src/pages/account/user-list-state.ts'
test('test_task_052_user_list_state_normal_primary_path', () => {
// 正常主路径:有数据且无错 → ready。
const input: UserListPhaseInput = { loading: false, error: '', items: [{ id: 1 }], total: 1 }
assert.equal(listPhaseOf(input), 'ready')
})
test('test_task_052_user_list_state_normal_variant_input', () => {
// 正常变体:加载中优先于其它;空数据且有 total 0 → empty。
assert.equal(listPhaseOf({ loading: true, error: '', items: [], total: 0 }), 'loading')
assert.equal(listPhaseOf({ loading: false, error: '', items: [], total: 0 }), 'empty')
})
test('test_task_052_user_list_state_normal_repeated_operation_is_idempotent', () => {
// 正常重复:纯函数不改输入、结果稳定。
const input: UserListPhaseInput = { loading: false, error: 'x', items: [], total: 0 }
assert.equal(listPhaseOf(input), listPhaseOf(input))
assert.equal(input.error, 'x')
})
test('test_task_052_user_list_state_boundary_empty_input', () => {
// 边界空值:空对象按默认(无错无加载) → empty,不崩溃。
assert.equal(listPhaseOf({} as never), 'empty')
assert.ok(userListEmptyHint())
assert.ok(userListErrorHint('网络异常'))
})
test('test_task_052_user_list_state_boundary_single_item', () => {
// 边界单元素:单条记录 ready。
assert.equal(listPhaseOf({ loading: false, error: '', items: [{ id: 7 }], total: 1 }), 'ready')
})
test('test_task_052_user_list_state_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:items 空但 total>0 仍视为空(首屏缺行);缺 total 按 0 处理。
assert.equal(listPhaseOf({ loading: false, error: '', items: [], total: 5 }), 'empty')
assert.equal(listPhaseOf({ loading: false, error: '', items: [{ id: 1 }] } as never), 'ready')
})
test('test_task_052_user_list_state_invalid_input_rejected', () => {
// 异常输入:error 存在且非加载 → error 优先于 empty/ready。
assert.equal(listPhaseOf({ loading: false, error: '无权限', items: [{ id: 1 }], total: 9 }), 'error')
assert.match(userListErrorHint('服务器错误'), /服务器错误/)
})
test('test_task_052_user_list_state_dependency_failure_returns_actionable_message', () => {
// 依赖失败/状态落页面局部:UsersPage 有 error 状态、失败可重试,且复用局部状态模块。
const mod = readSource('src/pages/account/user-list-state.ts')
assert.equal(/axios|http\.|defineStore/.test(mod), false, '列表状态模型保持纯逻辑')
const page = readSource('src/pages/account/UsersPage.vue')
assert.match(page, /user-list-state/)
assert.match(page, /error/) // 显式错误态
assert.match(page, /loadUsers|retry|reload/i)
})