优化
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
| 角色与角色成员 | `pages/RolesView.vue`;`system/roles` | `api/platformIam.ts`;`iam/` |
|
||||
| 权限资源与菜单 | `pages/PermissionsView.vue`、`utils/permissionTree.ts`;`system/permissions` | `api/platformIam.ts`;`iam/` 菜单资源 |
|
||||
| 客户作用域选择器 | `components/PlatformTenantSelector.vue` | `iam/` 客户主档 |
|
||||
| 启用客户远程候选(智能装柜、订舱草稿) | `useActiveCustomerOptions.ts`,经 `index.ts` 公开;按关键词查询、触底翻页,保留当前选中项 | `api/platformIam.ts` 的 `listPlatformTenants`,使用既有 `keyword/page/size/status` |
|
||||
| 权限判定与展示 | `utils/platformPermission.ts`、`../core/access/` | 服务端权限快照;前端不替代鉴权 |
|
||||
| 模块密度样式 | `iamDensity.css` | 按公共密度规范接入 |
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './api/platformIam'
|
||||
export { useActiveCustomerOptions } from './useActiveCustomerOptions'
|
||||
export { default as PlatformTenantSelector } from './components/PlatformTenantSelector.vue'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '../iamDensity.css'
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
@@ -334,7 +335,7 @@ async function toggleStatus(row: PlatformManagedUser) {
|
||||
const targetStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
|
||||
const targetLabel = targetStatus === 'ACTIVE' ? '启用' : '禁用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${targetLabel}用户“${row.displayName}”吗?`, `${targetLabel}用户 · ${row.displayName?.trim() || row.username?.trim() || '—'}`, { customClass: 'admin-density-confirm',
|
||||
await ElMessageBox.confirm(`确认${targetLabel}用户“${row.displayName}”吗?`, `${targetLabel}用户 · ${row.displayName?.trim() || row.username?.trim() || '—'}`, { customClass: 'admin-density-confirm admin-workbench',
|
||||
confirmButtonText: `确认${targetLabel}`,
|
||||
cancelButtonText: '取消',
|
||||
type: targetStatus === 'DISABLED' ? 'warning' : 'info',
|
||||
@@ -383,12 +384,12 @@ onMounted(loadInitialData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density iam-density" as="div">
|
||||
<AdminPage class="admin-density iam-density admin-workbench" as="div">
|
||||
<ListPageLayout aria-label="内部用户管理">
|
||||
|
||||
|
||||
|
||||
<section :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns" preference-key="internal-users-table"
|
||||
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns" preference-key="internal-users-table"
|
||||
:loading="loading" :total="total" aria-label="内部用户列表" row-key="membershipId" empty-text="没有符合筛选条件的内部用户">
|
||||
<template #toolbar-start><h1>用户管理</h1></template>
|
||||
<template #filters>
|
||||
@@ -432,9 +433,9 @@ onMounted(loadInitialData)
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density" fullscreenable destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
|
||||
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" :disabled="saving">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="用户基本资料">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户基本资料">
|
||||
<h3 class="density-dialog-section__title">用户资料</h3>
|
||||
<table class="density-table density-table--editable user-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="用户名" prop="username" required><span v-if="editingUser">{{form.username || '—'}}</span><el-input v-else v-model="form.username" placeholder="登录用户名" maxlength="64" /></el-form-item></td><td><el-form-item label="姓名" prop="displayName" required><span v-if="editingUser && !canEdit">{{form.displayName || '—'}}</span><el-input v-else v-model="form.displayName" placeholder="真实姓名" maxlength="64" /></el-form-item></td></tr>
|
||||
@@ -443,7 +444,7 @@ onMounted(loadInitialData)
|
||||
<tr><td :colspan="editingUser ? 1 : 2"><el-form-item label="状态" prop="status"><span v-if="editingUser && (!canChangeStatus || cannotDisable(editingUser))">{{form.status === 'ACTIVE' ? '正常' : '禁用'}}</span><el-select v-else v-model="form.status"><el-option label="正常" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item></td><td v-if="editingUser"><el-form-item label="最后登录"><span>{{formatLogin(editingUser.lastLoginAt)}}</span></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section" data-tone="work" aria-label="用户作业仓库">
|
||||
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户作业仓库">
|
||||
<h3 class="density-dialog-section__title">作业仓库</h3>
|
||||
<table class="density-table density-table--editable"><tbody>
|
||||
<tr v-if="hasOverseasPdaRole"><td><el-form-item label="实际海外收货仓" prop="overseasWarehouseId" label-width="145px" required>
|
||||
@@ -458,7 +459,7 @@ onMounted(loadInitialData)
|
||||
</el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="!editingUser || canResetPassword" class="density-dialog-section" data-tone="work" aria-label="用户登录密码">
|
||||
<section v-if="!editingUser || canResetPassword" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户登录密码">
|
||||
<h3 class="density-dialog-section__title">登录密码 <InlineHelp title="密码要求">密码为 8-64 位,并同时包含字母和数字。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable user-editor-table"><tbody>
|
||||
<tr v-if="editingUser"><td colspan="2"><el-checkbox v-model="form.resetPassword">同时重置密码</el-checkbox></td></tr>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '../iamDensity.css'
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
@@ -206,7 +207,7 @@ async function toggleStatus(row: Permission) {
|
||||
if (row.builtIn) return
|
||||
const target = row.status === '启用' ? '停用' : '启用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target}权限“${row.name}”吗?`, `${target}权限 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
await ElMessageBox.confirm(`确认${target}权限“${row.name}”吗?`, `${target}权限 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm admin-workbench', confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
rowActionId.value = row.id
|
||||
const updated = await changePlatformPermissionStatus(row.id, target === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
Object.assign(row, mapPlatformPermission(updated))
|
||||
@@ -222,7 +223,7 @@ async function toggleStatus(row: Permission) {
|
||||
async function removePermission(row: Permission) {
|
||||
if (row.builtIn) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除权限“${row.name}”吗?删除前需确保没有下级资源且未分配给角色。`, `删除权限 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm(`确认删除权限“${row.name}”吗?删除前需确保没有下级资源且未分配给角色。`, `删除权限 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm admin-workbench', confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
rowActionId.value = row.id
|
||||
await deletePlatformPermission(row.id)
|
||||
await loadPermissions()
|
||||
@@ -273,12 +274,12 @@ onMounted(loadPermissions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density iam-density" as="div">
|
||||
<AdminPage class="admin-density iam-density admin-workbench" as="div">
|
||||
<ListPageLayout aria-label="平台权限资源管理">
|
||||
|
||||
|
||||
|
||||
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
|
||||
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
|
||||
<template #toolbar-start><h1>菜单权限</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" /></template>
|
||||
<template #notices><template v-if="loadError">
|
||||
@@ -299,9 +300,9 @@ onMounted(loadPermissions)
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(720px, calc(100vw - 28px))" class="permission-editor-dialog admin-density-dialog iam-density" fullscreenable :confirm-loading="saving" :confirm-text="form.id ? '保存修改' : '保存权限'" :show-confirm="form.id ? canEdit : canCreate" @confirm="savePermission">
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(720px, calc(100vw - 28px))" class="permission-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable :confirm-loading="saving" :confirm-text="form.id ? '保存修改' : '保存权限'" :show-confirm="form.id ? canEdit : canCreate" @confirm="savePermission">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" class="density-form" :disabled="saving">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="权限资源定义">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="权限资源定义">
|
||||
<h3 class="density-dialog-section__title">资源定义 <InlineHelp title="权限编码约定">使用稳定的“业务域:资源:动作”编码;资源类型与后端 MENU、PAGE、BUTTON、API 契约一致。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable permission-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="权限名称" prop="name" required><el-input v-model="form.name" placeholder="用户可理解的中文名称" maxlength="64" /></el-form-item></td><td><el-form-item label="权限编码" prop="code" required><span v-if="form.id">{{form.code || '—'}}</span><el-input v-else v-model="form.code" placeholder="如:platform:customer:list" maxlength="128" /></el-form-item></td></tr>
|
||||
@@ -319,11 +320,11 @@ onMounted(loadPermissions)
|
||||
.permission-editor-table { table-layout: fixed; }
|
||||
.permission-editor-table td:not([colspan="2"]) { width: 50%; }
|
||||
.permission-tree-tools { display: flex; align-items: center; gap: var(--admin-space-6); }
|
||||
.permission-protection-hint { color: var(--cc-muted); }
|
||||
.permission-protection-hint { color: var(--workbench-muted); }
|
||||
.tree-view-actions { display: flex; align-items: center; gap: var(--admin-space-1); white-space: nowrap; }
|
||||
.tree-view-actions .el-button + .el-button { margin-left: 0; }
|
||||
.permission-name > div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.permission-name small { color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.35; }
|
||||
.permission-name small { color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 1.35; }
|
||||
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '../iamDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
@@ -197,7 +198,7 @@ async function removeRole(row: Role) {
|
||||
if (row.builtIn) { ElMessage.warning('系统内置角色不能删除'); return }
|
||||
if (row.userCount > 0) { ElMessage.warning('请先移除该角色下的用户'); return }
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除角色“${row.name}”吗?`, `删除角色 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm(`确认删除角色“${row.name}”吗?`, `删除角色 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm admin-workbench', confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
rowActionId.value = row.id
|
||||
await deletePlatformRole(row.id)
|
||||
serverRoles.value = serverRoles.value.filter((item) => item.id !== row.id)
|
||||
@@ -214,7 +215,7 @@ async function toggleStatus(row: Role) {
|
||||
if (row.builtIn) { ElMessage.warning('系统内置角色不能停用'); return }
|
||||
const target = row.status === '启用' ? '停用' : '启用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target}角色“${row.name}”吗?`, `${target}角色 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
await ElMessageBox.confirm(`确认${target}角色“${row.name}”吗?`, `${target}角色 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm admin-workbench', confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
rowActionId.value = row.id
|
||||
const updated = await changePlatformRoleStatus(row.id, target === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
Object.assign(row, mapPlatformRole(updated))
|
||||
@@ -236,12 +237,12 @@ onMounted(loadRoles)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density iam-density" as="div">
|
||||
<AdminPage class="admin-density iam-density admin-workbench" as="div">
|
||||
<ListPageLayout aria-label="平台角色管理">
|
||||
|
||||
|
||||
|
||||
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" border aria-label="平台角色列表" empty-text="没有符合条件的角色">
|
||||
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" border aria-label="平台角色列表" empty-text="没有符合条件的角色">
|
||||
<template #toolbar-start><h1>角色权限</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" ariaLabel="角色筛选" :fields="queryFields" preference-key="roles-query" @search="applyFilters" /></template>
|
||||
<template #notices><template v-if="loadError">
|
||||
@@ -261,9 +262,9 @@ onMounted(loadRoles)
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density" fullscreenable :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="90px" class="density-form density-dialog-sections role-editor-layout" :disabled="saving">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="角色基本资料">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="角色基本资料">
|
||||
<h3 class="density-dialog-section__title">基本信息</h3>
|
||||
<table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="角色名称" prop="name" required><span v-if="form.id && !canEdit">{{form.name || '—'}}</span><el-input v-else v-model="form.name" placeholder="如:订单运营专员" maxlength="64" /></el-form-item></td></tr>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { effectScope, ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useActiveCustomerOptions } from './useActiveCustomerOptions'
|
||||
import type { PlatformTenantPage, PlatformTenantQuery, PlatformTenantResource } from './api/platformIam'
|
||||
|
||||
const scopes: ReturnType<typeof effectScope>[] = []
|
||||
afterEach(() => { scopes.splice(0).forEach(scope => scope.stop()) })
|
||||
function customer(id: string, tenantName = `客户${id}`, status = 'ACTIVE') {
|
||||
return { id, tenantName, status } as PlatformTenantResource
|
||||
}
|
||||
function page(records: PlatformTenantResource[], current = 1, pages = 1): PlatformTenantPage {
|
||||
return { records, page: current, size: 50, pages, total: pages * 50 }
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (cause: Error) => void
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
function setup() {
|
||||
const selectedId = ref<string | null>(null)
|
||||
const load = vi.fn<(query: PlatformTenantQuery) => Promise<PlatformTenantPage>>()
|
||||
const scope = effectScope(); scopes.push(scope)
|
||||
const options = scope.run(() => useActiveCustomerOptions(() => selectedId.value, load))!
|
||||
return { ...options, load, selectedId, scope }
|
||||
}
|
||||
|
||||
describe('active customer remote options', () => {
|
||||
it('searches the complete customer directory by keyword instead of filtering the first page', async () => {
|
||||
const view = setup()
|
||||
view.load.mockResolvedValueOnce(page([customer('1', '跨客户车次-测试客户')], 1, 10))
|
||||
.mockResolvedValueOnce(page([customer('9007199254740999', '柏悦物流')]))
|
||||
await view.search()
|
||||
await view.search(' 柏悦 ')
|
||||
expect(view.load.mock.calls.map(([query]) => query)).toEqual([
|
||||
{ page: 1, size: 50, status: 'ACTIVE' },
|
||||
{ page: 1, size: 50, status: 'ACTIVE', keyword: '柏悦' },
|
||||
])
|
||||
expect(view.customers.value.map(row => row.tenantName)).toEqual(['柏悦物流'])
|
||||
expect(view.customers.value[0]!.id).toBe('9007199254740999')
|
||||
})
|
||||
|
||||
it('continues beyond 200 customers, deduplicates pages, and stops at the final page', async () => {
|
||||
const view = setup()
|
||||
view.load.mockImplementation(async query => page([customer('shared'), customer(String(query.page))], query.page, 5))
|
||||
await view.search('物流')
|
||||
for (let index = 0; index < 6; index++) await view.endReached('bottom')
|
||||
expect(view.load.mock.calls.map(([query]) => query.page)).toEqual([1, 2, 3, 4, 5])
|
||||
expect(view.load.mock.calls.every(([query]) => query.keyword === '物流')).toBe(true)
|
||||
expect(view.customers.value.map(row => row.id)).toEqual(['shared', '1', '2', '3', '4', '5'])
|
||||
})
|
||||
|
||||
it('ignores late pages and errors from an earlier keyword', async () => {
|
||||
const view = setup(), old = deferred<PlatformTenantPage>(), latest = deferred<PlatformTenantPage>()
|
||||
view.load.mockResolvedValueOnce(page([customer('1')], 1, 2)).mockReturnValueOnce(old.promise).mockReturnValueOnce(latest.promise)
|
||||
await view.search('旧')
|
||||
const oldRequest = view.endReached('bottom')
|
||||
const current = view.search('柏悦')
|
||||
old.reject(new Error('旧查询失败')); await oldRequest
|
||||
expect(view.loading.value).toBe(true); expect(view.error.value).toBeNull()
|
||||
latest.resolve(page([customer('2', '柏悦物流')])); await current
|
||||
expect(view.customers.value.map(row => row.tenantName)).toEqual(['柏悦物流'])
|
||||
expect(view.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not let a late successful search overwrite a newer result', async () => {
|
||||
const view = setup(), old = deferred<PlatformTenantPage>()
|
||||
view.load.mockReturnValueOnce(old.promise).mockResolvedValueOnce(page([customer('2', '柏悦物流')]))
|
||||
const first = view.search('旧'); await view.search('柏悦')
|
||||
old.resolve(page([customer('1', '旧客户')])); await first
|
||||
expect(view.customers.value.map(row => row.tenantName)).toEqual(['柏悦物流'])
|
||||
})
|
||||
|
||||
it('retains selection and loaded pages after an append failure and retries that page once', async () => {
|
||||
const view = setup(), next = deferred<PlatformTenantPage>()
|
||||
view.load.mockResolvedValueOnce(page([customer('1', '已选客户')], 1, 2)).mockReturnValueOnce(next.promise)
|
||||
.mockResolvedValueOnce(page([customer('2')], 2, 2))
|
||||
await view.search(); view.selectedId.value = '1'
|
||||
const pending = view.endReached('bottom'); await view.endReached('bottom')
|
||||
expect(view.load).toHaveBeenCalledTimes(2)
|
||||
next.reject(new Error('客户目录暂不可用')); await pending
|
||||
expect(view.error.value?.message).toContain('客户选项加载失败')
|
||||
expect(view.customers.value.map(row => row.id)).toEqual(['1'])
|
||||
await view.endReached('bottom'); expect(view.load).toHaveBeenCalledTimes(2)
|
||||
await view.retry()
|
||||
expect(view.load.mock.calls.map(([query]) => query.page)).toEqual([1, 2, 2])
|
||||
expect(view.selectedId.value).toBe('1'); expect(view.error.value).toBeNull()
|
||||
expect(view.customers.value.map(row => row.id)).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
it('keeps the selected label across searches and clears it when selection is cleared', async () => {
|
||||
const view = setup()
|
||||
view.load.mockResolvedValueOnce(page([customer('1', '柏悦物流')])).mockResolvedValueOnce(page([customer('2')]))
|
||||
await view.search('柏悦'); view.selectedId.value = '1'; await view.search('其他')
|
||||
expect(view.customers.value[0]!.tenantName).toBe('柏悦物流')
|
||||
view.selectedId.value = null
|
||||
expect(view.customers.value.map(row => row.id)).toEqual(['2'])
|
||||
})
|
||||
|
||||
it('shows initial errors, retries the same keyword, and excludes inactive customers', async () => {
|
||||
const view = setup()
|
||||
view.load.mockRejectedValueOnce(new Error('加载失败')).mockResolvedValueOnce(page([customer('1'), customer('2', '停用客户', 'DISABLED')]))
|
||||
await view.search('客户'); expect(view.error.value).not.toBeNull()
|
||||
await view.retry()
|
||||
expect(view.load.mock.calls[1]![0]).toEqual({ page: 1, size: 50, status: 'ACTIVE', keyword: '客户' })
|
||||
expect(view.customers.value.map(row => row.id)).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('ignores disposal and non-bottom scroll events', async () => {
|
||||
const view = setup(), pending = deferred<PlatformTenantPage>()
|
||||
view.load.mockReturnValueOnce(pending.promise)
|
||||
await view.endReached('top'); expect(view.load).not.toHaveBeenCalled()
|
||||
const request = view.search('柏悦'); view.scope.stop()
|
||||
pending.resolve(page([customer('1')])); await request
|
||||
expect(view.customers.value).toEqual([])
|
||||
await view.search('其他'); await view.retry(); expect(view.load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import type { PlatformTenantPage, PlatformTenantQuery, PlatformTenantResource } from './api/platformIam'
|
||||
|
||||
export function useActiveCustomerOptions(selectedId: () => string | null | undefined, loadPage: (query: PlatformTenantQuery) => Promise<PlatformTenantPage>) {
|
||||
const records = ref<PlatformTenantResource[]>([])
|
||||
const selected = ref<PlatformTenantResource | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const keyword = ref('')
|
||||
const page = ref(0)
|
||||
const hasMore = ref(false)
|
||||
let sequence = 0
|
||||
let disposed = false
|
||||
|
||||
watch(selectedId, id => {
|
||||
selected.value = id ? records.value.find(row => String(row.id) === id) || (String(selected.value?.id) === id ? selected.value : null) : null
|
||||
}, { flush: 'sync' })
|
||||
|
||||
const customers = computed(() => {
|
||||
const current = selected.value
|
||||
if (!current || records.value.some(row => String(row.id) === String(current.id))) return records.value
|
||||
return [current, ...records.value]
|
||||
})
|
||||
|
||||
async function fetchPage(nextPage: number, append: boolean) {
|
||||
if (disposed) return
|
||||
const request = ++sequence
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await loadPage({ page: nextPage, size: 50, status: 'ACTIVE', ...(keyword.value ? { keyword: keyword.value } : {}) })
|
||||
if (request !== sequence || disposed) return
|
||||
const rows = [...(append ? records.value : []), ...result.records.filter(row => row.status === 'ACTIVE')]
|
||||
records.value = [...new Map(rows.map(row => [String(row.id), row])).values()]
|
||||
page.value = nextPage
|
||||
hasMore.value = nextPage < result.pages
|
||||
} catch (cause) {
|
||||
if (request === sequence && !disposed) error.value = getApiErrorDetails(cause, '客户选项加载失败,请重试')
|
||||
} finally {
|
||||
if (request === sequence && !disposed) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search(value = '') {
|
||||
if (disposed) return
|
||||
keyword.value = value.trim()
|
||||
records.value = []
|
||||
page.value = 0
|
||||
hasMore.value = false
|
||||
return fetchPage(1, false)
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (loading.value || error.value || !hasMore.value) return
|
||||
return fetchPage(page.value + 1, true)
|
||||
}
|
||||
|
||||
function endReached(direction: string) {
|
||||
if (direction === 'bottom') return loadMore()
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (loading.value) return
|
||||
return fetchPage(page.value + 1, page.value > 0)
|
||||
}
|
||||
|
||||
onScopeDispose(() => { disposed = true; sequence++ })
|
||||
return { customers, loading, error, search, endReached, retry }
|
||||
}
|
||||
Reference in New Issue
Block a user