update user page

This commit is contained in:
2026-04-01 15:42:14 +08:00
parent 6f7e2fb599
commit 8438c31ee8
5 changed files with 503 additions and 161 deletions
+335 -95
View File
@@ -1,7 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { api } from '@/utils/request' import { api } from '@/utils/request'
import { useUserManageStore, type ScopeRecord } from '@/stores/user-manage' import { useUserManageStore, type ScopeRecord } from '@/stores/user-manage'
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
interface ScopeTreeNode {
key: string
title: string
children?: ScopeTreeNode[]
isLeaf?: boolean
scopeType?: 'company' | 'platform' | 'store'
scopeId?: number
rawTitle?: string
}
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
@@ -18,47 +27,263 @@ const store = useUserManageStore()
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const scopes = ref<ScopeRecord[]>([]) const scopeMode = ref<'company' | 'platform'>('company')
const searchValue = ref('')
const checkedKeys = ref<string[]>([])
const halfCheckedKeys = ref<string[]>([])
const expandedKeys = ref<string[]>([])
const autoExpandParent = ref(true)
// 基础数据列表 // 基础数据列表
const companies = ref<{ id: number; name: string; label: string }[]>([]) const companies = ref<{ id: number; name: string; label: string }[]>([])
const platforms = ref<{ id: number; developer_id: number }[]>([]) const platforms = ref<{ id: number; name: string; label: string; developer_id: number }[]>([])
const stores = ref<{ id: number; company_id: number; platform_id: number; name: string; label: string }[]>([]) const storeList = ref<{ id: number; company_id: number; platform_id: number; name: string; label: string }[]>([])
const scopeTypeOptions = [ const modeOptions = [
{ value: 'company', label: '公司' }, { value: 'company', label: '公司授权' },
{ value: 'platform', label: '平台' }, { value: 'platform', label: '平台授权' },
{ value: 'store', label: '店铺' },
] ]
function entityOptions(scopeType: string) { // 名称查找辅助
if (scopeType === 'company') { function getCompanyName(id: number): string {
return companies.value.map((c) => ({ value: c.id, label: c.label || c.name })) const c = companies.value.find((x) => x.id === id)
return c?.label || c?.name || `公司#${id}`
}
function getPlatformName(id: number): string {
const p = platforms.value.find((x) => x.id === id)
return p?.label || p?.name || `平台#${id}`
}
function getAllKeys(nodes: ScopeTreeNode[]): string[] {
const keys: string[] = []
for (const node of nodes) {
keys.push(node.key)
if (node.children) keys.push(...getAllKeys(node.children))
} }
if (scopeType === 'platform') { return keys
return platforms.value.map((p) => ({ value: p.id, label: `平台 #${p.id}` })) }
// 公司模式树:Company → [Platform] Store
const companyTree = computed<ScopeTreeNode[]>(() => {
return companies.value.map((c) => {
const label = c.label || c.name || `公司#${c.id}`
const children = storeList.value
.filter((s) => s.company_id === c.id)
.map((s) => {
const storeLabel = s.label || s.name || `店铺#${s.id}`
const title = `[${getPlatformName(s.platform_id)}] ${storeLabel}`
return {
key: `store:${s.id}`,
title,
rawTitle: title,
isLeaf: true,
scopeType: 'store' as const,
scopeId: s.id,
}
})
return {
key: `company:${c.id}`,
title: label,
rawTitle: label,
scopeType: 'company' as const,
scopeId: c.id,
children,
}
})
})
// 平台模式树:Platform → [Company] Store
const platformTree = computed<ScopeTreeNode[]>(() => {
return platforms.value
.map((p) => {
const label = p.label || p.name || `平台#${p.id}`
const children = storeList.value
.filter((s) => s.platform_id === p.id)
.map((s) => {
const storeLabel = s.label || s.name || `店铺#${s.id}`
const title = `[${getCompanyName(s.company_id)}] ${storeLabel}`
return {
key: `store:${s.id}`,
title,
rawTitle: title,
isLeaf: true,
scopeType: 'store' as const,
scopeId: s.id,
}
})
return {
key: `platform:${p.id}`,
title: label,
rawTitle: label,
scopeType: 'platform' as const,
scopeId: p.id,
children,
}
})
.filter((node) => node.children.length > 0)
})
const activeTree = computed(() =>
scopeMode.value === 'company' ? companyTree.value : platformTree.value
)
const tipMessage = computed(() =>
scopeMode.value === 'company'
? '勾选公司节点 = 授权其全部店铺;也可展开后仅勾选特定店铺'
: '勾选平台节点 = 授权该平台全部店铺(跨公司);也可展开后仅勾选特定店铺'
)
// 搜索高亮
function highlightParts(text: string, keyword: string): { text: string; highlight: boolean }[] {
if (!keyword) return [{ text, highlight: false }]
const lowerText = text.toLowerCase()
const lowerKw = keyword.toLowerCase()
const parts: { text: string; highlight: boolean }[] = []
let lastIndex = 0
let idx = lowerText.indexOf(lowerKw)
while (idx !== -1) {
if (idx > lastIndex) {
parts.push({ text: text.slice(lastIndex, idx), highlight: false })
}
parts.push({ text: text.slice(idx, idx + keyword.length), highlight: true })
lastIndex = idx + keyword.length
idx = lowerText.indexOf(lowerKw, lastIndex)
} }
if (scopeType === 'store') { if (lastIndex < text.length) {
return stores.value.map((s) => ({ value: s.id, label: s.label || s.name })) parts.push({ text: text.slice(lastIndex), highlight: false })
} }
return [] return parts
}
// 模式切换
function applyModeSwitch(mode: 'company' | 'platform') {
scopeMode.value = mode
checkedKeys.value = []
halfCheckedKeys.value = []
searchValue.value = ''
expandedKeys.value = getAllKeys(activeTree.value)
}
function handleModeChange(val: unknown) {
const newMode = val as 'company' | 'platform'
if (checkedKeys.value.length > 0) {
const oldMode = scopeMode.value
Modal.confirm({
title: '切换授权模式',
content: '切换模式将清空当前选择,确认?',
onOk() {
applyModeSwitch(newMode)
},
onCancel() {
// 强制刷新 segmented 受控值回弹
scopeMode.value = oldMode
},
})
} else {
applyModeSwitch(newMode)
}
}
// 树事件
function onCheck(checked: (string | number)[], info: { halfCheckedKeys: (string | number)[] }) {
checkedKeys.value = checked.map(String)
halfCheckedKeys.value = info.halfCheckedKeys.map(String)
}
function onExpand(keys: string[]) {
expandedKeys.value = keys
autoExpandParent.value = false
}
// 初始模式检测
function detectInitialMode(userScopes: ScopeRecord[]): 'company' | 'platform' {
const hasPlatform = userScopes.some((s) => s.scope_type === 'platform')
const hasCompany = userScopes.some((s) => s.scope_type === 'company')
if (hasPlatform && !hasCompany) return 'platform'
return 'company'
}
// scopes → checkedKeys(父节点需补全子节点 key,受控模式不触发级联)
function scopesToCheckedKeys(userScopes: ScopeRecord[]): string[] {
const keys: string[] = []
const tree = activeTree.value
for (const scope of userScopes) {
if (scopeMode.value === 'company') {
if (scope.scope_type === 'company') {
const parentKey = `company:${scope.scope_id}`
keys.push(parentKey)
const node = tree.find((n) => n.key === parentKey)
if (node?.children) keys.push(...node.children.map((c) => c.key))
} else if (scope.scope_type === 'store') {
keys.push(`store:${scope.scope_id}`)
}
} else {
if (scope.scope_type === 'platform') {
const parentKey = `platform:${scope.scope_id}`
keys.push(parentKey)
const node = tree.find((n) => n.key === parentKey)
if (node?.children) keys.push(...node.children.map((c) => c.key))
} else if (scope.scope_type === 'store') {
keys.push(`store:${scope.scope_id}`)
}
}
}
return keys
}
// checkedKeys → scopes(智能判断全选/部分选)
function checkedKeysToScopes(): { scope_type: string; scope_id: number }[] {
const scopes: { scope_type: string; scope_id: number }[] = []
const keySet = new Set(checkedKeys.value)
for (const key of checkedKeys.value) {
const [type, idStr] = key.split(':')
const id = Number(idStr)
if (!type || Number.isNaN(id)) continue
if (scopeMode.value === 'company') {
if (type === 'company') {
scopes.push({ scope_type: 'company', scope_id: id })
} else if (type === 'store') {
const s = storeList.value.find((x) => x.id === id)
if (s && !keySet.has(`company:${s.company_id}`)) {
scopes.push({ scope_type: 'store', scope_id: id })
}
}
} else {
if (type === 'platform') {
scopes.push({ scope_type: 'platform', scope_id: id })
} else if (type === 'store') {
const s = storeList.value.find((x) => x.id === id)
if (s && !keySet.has(`platform:${s.platform_id}`)) {
scopes.push({ scope_type: 'store', scope_id: id })
}
}
}
}
return scopes
} }
function handleClose() { function handleClose() {
emit('update:open', false) emit('update:open', false)
} }
function addRow() { async function loadEntities() {
scopes.value.push({ scope_type: 'company', scope_id: 0 }) try {
} const [c, p, s] = await Promise.all([
api.get<typeof companies.value>('/api/v1/companies'),
function removeRow(index: number) { api.get<typeof platforms.value>('/api/v1/platforms'),
scopes.value.splice(index, 1) api.get<typeof storeList.value>('/api/v1/stores'),
} ])
companies.value = c
function handleTypeChange(index: number) { platforms.value = p
const row = scopes.value[index] storeList.value = s
if (row) row.scope_id = 0 } catch (err: unknown) {
const msg = err instanceof Error ? err.message : '加载基础数据失败'
message.error(msg)
}
} }
async function loadData() { async function loadData() {
@@ -70,47 +295,22 @@ async function loadData() {
loadEntities(), loadEntities(),
]) ])
if (scopeData.status === 'fulfilled') { if (scopeData.status === 'fulfilled') {
scopes.value = scopeData.value.scopes.map((s) => ({ const userScopes = scopeData.value.scopes
scope_type: s.scope_type, scopeMode.value = detectInitialMode(userScopes)
scope_id: s.scope_id, await nextTick()
})) checkedKeys.value = scopesToCheckedKeys(userScopes)
} else { } else {
scopes.value = [] checkedKeys.value = []
} }
expandedKeys.value = getAllKeys(activeTree.value)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function loadEntities() {
try {
const [c, p, s] = await Promise.all([
api.get<typeof companies.value>('/api/v1/companies'),
api.get<typeof platforms.value>('/api/v1/platforms'),
api.get<typeof stores.value>('/api/v1/stores'),
])
companies.value = c
platforms.value = p
stores.value = s
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '加载基础数据失败'
message.error(msg)
}
}
async function handleSave() { async function handleSave() {
if (!props.userId) return if (!props.userId) return
// 过滤掉未选择实体的行,并去重 const validScopes = checkedKeysToScopes()
const seen = new Set<string>()
const validScopes = scopes.value
.filter((s) => {
if (s.scope_id <= 0) return false
const key = `${s.scope_type}:${s.scope_id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map(({ scope_type, scope_id }) => ({ scope_type, scope_id }))
saving.value = true saving.value = true
try { try {
await store.saveUserDataScope(props.userId, validScopes) await store.saveUserDataScope(props.userId, validScopes)
@@ -125,13 +325,38 @@ async function handleSave() {
} }
} }
// 搜索时自动展开匹配节点的父级
watch(searchValue, (val) => {
if (!val) {
expandedKeys.value = getAllKeys(activeTree.value)
return
}
const matched: string[] = []
const findParents = (nodes: ScopeTreeNode[], parents: string[]) => {
for (const node of nodes) {
if (node.rawTitle?.toLowerCase().includes(val.toLowerCase())) {
matched.push(...parents)
}
if (node.children) {
findParents(node.children, [...parents, node.key])
}
}
}
findParents(activeTree.value, [])
expandedKeys.value = [...new Set(matched)]
autoExpandParent.value = true
})
watch( watch(
() => props.open, () => props.open,
(val) => { (val) => {
if (val) { if (val) {
loadData() loadData()
} else { } else {
scopes.value = [] checkedKeys.value = []
halfCheckedKeys.value = []
searchValue.value = ''
scopeMode.value = 'company'
} }
}, },
{ immediate: true }, { immediate: true },
@@ -142,48 +367,63 @@ watch(
<a-modal <a-modal
:open="open" :open="open"
:title="`数据范围 - ${username}`" :title="`数据范围 - ${username}`"
width="640px" width="720px"
:confirm-loading="saving" :confirm-loading="saving"
@cancel="handleClose" @cancel="handleClose"
@ok="handleSave" @ok="handleSave"
> >
<a-spin :spinning="loading"> <a-spin :spinning="loading">
<div class="mb-3"> <!-- 模式切换 -->
<a-button type="dashed" block @click="addRow"> <a-segmented
<template #icon><PlusOutlined /></template> :value="scopeMode"
添加数据范围 :options="modeOptions"
</a-button> class="mb-3"
</div> @change="handleModeChange"
/>
<div v-if="scopes.length === 0 && !loading" class="text-center text-gray-400 py-4"> <!-- 提示 -->
暂无数据范围配置该用户将无法访问任何业务数据 <a-alert type="info" show-icon :message="tipMessage" class="mb-3" />
</div>
<div <!-- 搜索 -->
v-for="(scope, index) in scopes" <a-input-search
:key="index" v-model:value="searchValue"
class="flex gap-2 mb-2 items-center" placeholder="搜索名称"
> allow-clear
<a-select class="mb-3"
:value="scope.scope_type" />
:options="scopeTypeOptions"
style="width: 120px" <!-- 空状态 -->
@change="(val: unknown) => { scope.scope_type = val as ScopeRecord['scope_type']; handleTypeChange(index) }" <a-empty v-if="activeTree.length === 0 && !loading" description="暂无可授权的数据" />
/>
<a-select <!-- -->
:value="scope.scope_id || undefined" <div v-else style="max-height: 400px; overflow-y: auto;">
:options="entityOptions(scope.scope_type)" <a-tree
placeholder="选择实体" :checked-keys="checkedKeys"
show-search checkable
:filter-option="(input: string, option: unknown) => :tree-data="activeTree"
((option as { label: string }).label ?? '').toLowerCase().includes(input.toLowerCase()) :selectable="false"
" :expanded-keys="expandedKeys"
style="flex: 1" :auto-expand-parent="autoExpandParent"
@change="(val: unknown) => { scope.scope_id = (val as number) ?? 0 }" @check="onCheck"
/> @expand="onExpand"
<a-button type="text" danger size="small" @click="removeRow(index)"> >
<template #icon><DeleteOutlined /></template> <template #title="{ dataRef }">
</a-button> <template
v-if="searchValue && dataRef.rawTitle?.toLowerCase().includes(searchValue.toLowerCase())"
>
<span
v-for="(part, i) in highlightParts(dataRef.rawTitle, searchValue)"
:key="i"
>
<span v-if="part.highlight" class="text-red-500 font-semibold">{{
part.text
}}</span>
<span v-else>{{ part.text }}</span>
</span>
</template>
<span v-else>{{ dataRef.rawTitle || dataRef.title }}</span>
</template>
</a-tree>
</div> </div>
</a-spin> </a-spin>
</a-modal> </a-modal>
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { message } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue'
import DataScopeModal from '@/components/DataScopeModal.vue' import DataScopeModal from '@/components/DataScopeModal.vue'
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
@@ -35,24 +35,24 @@ const mockScopes = {
role: 'accessor', role: 'accessor',
scopes: [ scopes: [
{ scope_type: 'company', scope_id: 1, name: '阿克米公司' }, { scope_type: 'company', scope_id: 1, name: '阿克米公司' },
{ scope_type: 'store', scope_id: 5, name: '我的店铺' },
], ],
resolved_store_ids: [5], resolved_store_ids: [5, 6],
} }
const mockCompanies = [ const mockCompanies = [
{ id: 1, name: 'acme', label: '阿克米公司', enabled: true }, { id: 1, name: 'acme', label: '阿克米公司' },
{ id: 2, name: 'beta', label: '贝塔公司', enabled: true }, { id: 2, name: 'beta', label: '贝塔公司' },
] ]
const mockPlatforms = [ const mockPlatforms = [
{ id: 1, developer_id: 1 }, { id: 1, name: 'shopify', label: 'Shopify', developer_id: 1 },
{ id: 2, developer_id: 1 }, { id: 2, name: 'amazon', label: 'Amazon', developer_id: 1 },
] ]
const mockStores = [ const mockStores = [
{ id: 5, company_id: 1, platform_id: 1, name: 'my-store', label: '我的店铺' }, { id: 5, company_id: 1, platform_id: 1, name: 'my-store', label: '我的店铺' },
{ id: 6, company_id: 2, platform_id: 2, name: 'other-store', label: '其他店铺' }, { id: 6, company_id: 1, platform_id: 2, name: 'store-2', label: '店铺' },
{ id: 7, company_id: 2, platform_id: 1, name: 'beta-store', label: '贝塔店铺' },
] ]
function setupMocks(overrides: { scopes?: typeof mockScopes } = {}) { function setupMocks(overrides: { scopes?: typeof mockScopes } = {}) {
@@ -99,15 +99,26 @@ describe('DataScopeModal', () => {
return document.body.querySelectorAll(selector) return document.body.querySelectorAll(selector)
} }
describe('数据加载', () => { describe('树渲染', () => {
it('打开时加载用户 scope 数据', async () => { it('公司模式下渲染 Company → Store 层级', async () => {
setupMocks() setupMocks()
await mountModal() await mountModal()
expect(vi.mocked(api.get)).toHaveBeenCalled() const treeNodes = queryBody('.ant-tree-treenode')
const calls = vi.mocked(api.get).mock.calls.map((c) => c[0]) expect(treeNodes.length).toBeGreaterThanOrEqual(3)
expect(calls.some((url) => url.includes('/data-scope'))).toBe(true)
expect(calls.some((url) => url.includes('/companies'))).toBe(true) const titles = Array.from(queryBody('.ant-tree-title')).map((el) => el.textContent)
expect(titles.some((t) => t?.includes('阿克米公司'))).toBe(true)
expect(titles.some((t) => t?.includes('贝塔公司'))).toBe(true)
})
it('店铺节点标题包含平台名前缀', async () => {
setupMocks()
await mountModal()
const titles = Array.from(queryBody('.ant-tree-title')).map((el) => el.textContent)
expect(titles.some((t) => t?.includes('[Shopify]'))).toBe(true)
expect(titles.some((t) => t?.includes('[Amazon]'))).toBe(true)
}) })
it('标题包含用户名', async () => { it('标题包含用户名', async () => {
@@ -125,61 +136,88 @@ describe('DataScopeModal', () => {
}) })
}) })
describe('Scope 行操作', () => { describe('平台模式', () => {
it('加载后显示 scope 行', async () => { it('切换到平台模式后渲染 Platform → Store 层级', async () => {
setupMocks() setupMocks({
scopes: { ...mockScopes, scopes: [] },
})
await mountModal() await mountModal()
// 2 scope rows rendered as flex rows const segments = Array.from(queryBody('.ant-segmented-item'))
const selects = queryBody('.ant-select') const platformSeg = segments.find((el) => el.textContent?.includes('按平台授权'))
// Each row has 2 selects (type + entity), so expect >= 4 expect(platformSeg).toBeTruthy()
expect(selects.length).toBeGreaterThanOrEqual(4) ;(platformSeg as HTMLElement).click()
})
it('点击添加按钮增加一行', async () => {
setupMocks({ scopes: { ...mockScopes, scopes: [] } })
await mountModal()
const addBtn = Array.from(queryBody('.ant-btn-dashed')).find(
(btn) => btn.textContent?.includes('添加数据范围'),
) as HTMLElement
expect(addBtn).toBeTruthy()
addBtn.click()
await flushPromises() await flushPromises()
await nextTick() await nextTick()
// Should have at least one row with selects now const titles = Array.from(queryBody('.ant-tree-title')).map((el) => el.textContent)
const selects = queryBody('.ant-select') expect(titles.some((t) => t?.includes('Shopify'))).toBe(true)
expect(selects.length).toBeGreaterThanOrEqual(2)
})
it('点击删除按钮移除行', async () => {
setupMocks()
await mountModal()
const deleteBtns = queryBody('.anticon-delete')
const initialCount = deleteBtns.length
expect(initialCount).toBe(2)
// Click first delete
const firstDeleteBtn = deleteBtns[0]?.closest('button') as HTMLElement
if (firstDeleteBtn) {
firstDeleteBtn.click()
await flushPromises()
await nextTick()
}
const afterDeleteBtns = queryBody('.anticon-delete')
expect(afterDeleteBtns.length).toBe(initialCount - 1)
}) })
}) })
describe('保存', () => { describe('模式切换确认', () => {
it('保存调用正确 API', async () => { it('有勾选项时切换模式弹出确认框', async () => {
setupMocks()
await mountModal()
const confirmSpy = vi.spyOn(Modal, 'confirm')
const segments = Array.from(queryBody('.ant-segmented-item'))
const platformSeg = segments.find((el) => el.textContent?.includes('按平台授权'))
;(platformSeg as HTMLElement).click()
await flushPromises()
expect(confirmSpy).toHaveBeenCalled()
})
})
describe('初始化', () => {
it('已有 company scope 时打开弹窗后有勾选节点', async () => {
setupMocks()
await mountModal()
const checkedBoxes = queryBody('.ant-tree-checkbox-checked')
expect(checkedBoxes.length).toBeGreaterThanOrEqual(1)
})
it('已有 platform scope 时自动切换到平台模式', async () => {
setupMocks({
scopes: {
...mockScopes,
scopes: [{ scope_type: 'platform', scope_id: 1, name: 'Shopify' }],
},
})
await mountModal()
const activeSegment = document.body.querySelector('.ant-segmented-item-selected')
expect(activeSegment?.textContent).toContain('按平台授权')
})
})
describe('搜索高亮', () => {
it('搜索关键词后匹配节点文字被高亮', async () => {
setupMocks({ scopes: { ...mockScopes, scopes: [] } })
await mountModal()
const input = document.body.querySelector('.ant-input-search input') as HTMLInputElement
expect(input).toBeTruthy()
if (input) {
input.value = '阿克米'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
await nextTick()
const highlighted = queryBody('.text-red-500')
expect(highlighted.length).toBeGreaterThanOrEqual(1)
}
})
})
describe('保存逻辑', () => {
it('全选公司节点保存为 company scope', async () => {
setupMocks() setupMocks()
await mountModal() await mountModal()
// Click OK button
const okBtn = Array.from(queryBody('.ant-btn-primary')).find( const okBtn = Array.from(queryBody('.ant-btn-primary')).find(
(btn) => btn.textContent?.includes('确') || btn.textContent?.includes('OK'), (btn) => btn.textContent?.includes('确') || btn.textContent?.includes('OK'),
) as HTMLElement ) as HTMLElement
@@ -190,12 +228,11 @@ describe('DataScopeModal', () => {
expect(vi.mocked(api.put)).toHaveBeenCalledWith( expect(vi.mocked(api.put)).toHaveBeenCalledWith(
'/api/v1/users/1/data-scope', '/api/v1/users/1/data-scope',
{ expect.objectContaining({
scopes: [ scopes: expect.arrayContaining([
{ scope_type: 'company', scope_id: 1 }, expect.objectContaining({ scope_type: 'company', scope_id: 1 }),
{ scope_type: 'store', scope_id: 5 }, ]),
], }),
},
) )
}) })
@@ -232,4 +269,14 @@ describe('DataScopeModal', () => {
expect(spy).toHaveBeenCalledWith('保存出错') expect(spy).toHaveBeenCalledWith('保存出错')
}) })
}) })
describe('提示文案', () => {
it('公司模式显示公司授权提示', async () => {
setupMocks({ scopes: { ...mockScopes, scopes: [] } })
await mountModal()
const alert = document.body.querySelector('.ant-alert-message')
expect(alert?.textContent).toContain('勾选公司节点')
})
})
}) })
@@ -60,6 +60,7 @@ describe('useUserManageStore', () => {
expect(store.filters.username).toBe('') expect(store.filters.username).toBe('')
expect(store.filters.email).toBe('') expect(store.filters.email).toBe('')
expect(store.filters.status).toBeUndefined() expect(store.filters.status).toBeUndefined()
expect(store.filters.role_id).toBeUndefined()
}) })
}) })
@@ -77,6 +78,7 @@ describe('useUserManageStore', () => {
username: undefined, username: undefined,
email: undefined, email: undefined,
status: undefined, status: undefined,
role_id: undefined,
}) })
expect(store.users).toEqual(mockUsers) expect(store.users).toEqual(mockUsers)
expect(store.pagination.total).toBe(2) expect(store.pagination.total).toBe(2)
@@ -95,11 +97,13 @@ describe('useUserManageStore', () => {
const store = useUserManageStore() const store = useUserManageStore()
store.filters.username = 'admin' store.filters.username = 'admin'
store.filters.status = 1 store.filters.status = 1
store.filters.role_id = 2
await store.fetchUsers() await store.fetchUsers()
expect(api.get).toHaveBeenCalledWith('/api/v1/users', expect.objectContaining({ expect(api.get).toHaveBeenCalledWith('/api/v1/users', expect.objectContaining({
username: 'admin', username: 'admin',
status: 1, status: 1,
role_id: 2,
})) }))
expect(store.users).toHaveLength(1) expect(store.users).toHaveLength(1)
}) })
@@ -155,6 +159,7 @@ describe('useUserManageStore', () => {
store.filters.username = 'test' store.filters.username = 'test'
store.filters.email = 'test@test.com' store.filters.email = 'test@test.com'
store.filters.status = 1 store.filters.status = 1
store.filters.role_id = 3
store.pagination.page = 3 store.pagination.page = 3
store.resetFilters() store.resetFilters()
@@ -162,6 +167,7 @@ describe('useUserManageStore', () => {
expect(store.filters.username).toBe('') expect(store.filters.username).toBe('')
expect(store.filters.email).toBe('') expect(store.filters.email).toBe('')
expect(store.filters.status).toBeUndefined() expect(store.filters.status).toBeUndefined()
expect(store.filters.role_id).toBeUndefined()
expect(store.pagination.page).toBe(1) expect(store.pagination.page).toBe(1)
}) })
}) })
@@ -181,9 +187,20 @@ describe('Users Page', () => {
document.body.innerHTML = '' document.body.innerHTML = ''
}) })
async function mountPage() { const mockRoles = [
{ id: 1, name: 'administrator', description: '管理员' },
{ id: 2, name: 'accessor', description: '访问者' },
]
async function mountPage(opts: { rolesError?: boolean } = {}) {
const { api } = await import('@/utils/request') const { api } = await import('@/utils/request')
vi.mocked(api.get).mockResolvedValue(mockPaginatedResponse) vi.mocked(api.get).mockImplementation((url: string) => {
if (url === '/api/v1/roles') {
if (opts.rolesError) return Promise.reject(new Error('roles fail'))
return Promise.resolve(mockRoles) as never
}
return Promise.resolve(mockPaginatedResponse) as never
})
// 设置 admin 用户以显示操作按钮 // 设置 admin 用户以显示操作按钮
const userStore = useUserStore() const userStore = useUserStore()
@@ -293,4 +310,21 @@ describe('Users Page', () => {
const drawer = document.body.querySelector('.ant-drawer') const drawer = document.body.querySelector('.ant-drawer')
expect(drawer).toBeTruthy() expect(drawer).toBeTruthy()
}) })
it('renders role filter select with options', async () => {
await mountPage()
const labels = Array.from(document.body.querySelectorAll('.ant-form-item-label label'))
const roleLabel = labels.find((l) => l.textContent?.includes('角色'))
expect(roleLabel).toBeTruthy()
})
it('page still works when role list fails to load', async () => {
await mountPage({ rolesError: true })
// 页面标题仍正常渲染
expect(wrapper.find('h2').text()).toBe('用户管理')
// 表格仍正常渲染
const rows = document.body.querySelectorAll('.ant-table-tbody tr.ant-table-row')
expect(rows).toHaveLength(mockUsers.length)
})
}) })
+17
View File
@@ -30,6 +30,9 @@ const assigningUser = ref<UserRecord | null>(null)
const selectedRoleId = ref<number | undefined>(undefined) const selectedRoleId = ref<number | undefined>(undefined)
const roleOptions = ref<{ value: number; label: string }[]>([]) const roleOptions = ref<{ value: number; label: string }[]>([])
// Role filter options
const roleFilterOptions = ref<{ value: number; label: string }[]>([])
// Data scope modal // Data scope modal
const scopeModalOpen = ref(false) const scopeModalOpen = ref(false)
const scopeUserId = ref<number | null>(null) const scopeUserId = ref<number | null>(null)
@@ -48,6 +51,11 @@ const columns = [
onMounted(() => { onMounted(() => {
store.fetchUsers() store.fetchUsers()
api.get<{ id: number; name: string; description: string }[]>('/api/v1/roles')
.then((roles) => {
roleFilterOptions.value = roles.map((r) => ({ value: r.id, label: r.description || r.name }))
})
.catch(() => { /* 角色列表加载失败不影响页面 */ })
}) })
function handleSearch() { function handleSearch() {
@@ -185,6 +193,15 @@ function formatTime(time: string) {
<a-select-option :value="0">禁用</a-select-option> <a-select-option :value="0">禁用</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="角色">
<a-select
v-model:value="store.filters.role_id"
placeholder="全部"
allow-clear
style="width: 140px"
:options="roleFilterOptions"
/>
</a-form-item>
<a-form-item> <a-form-item>
<a-space> <a-space>
<a-button type="primary" html-type="submit"> <a-button type="primary" html-type="submit">
+4
View File
@@ -17,6 +17,7 @@ export interface UserFilters {
username: string username: string
email: string email: string
status: number | undefined status: number | undefined
role_id: number | undefined
} }
export interface ScopeRecord { export interface ScopeRecord {
@@ -44,6 +45,7 @@ export const useUserManageStore = defineStore('user-manage', () => {
username: '', username: '',
email: '', email: '',
status: undefined, status: undefined,
role_id: undefined,
}) })
async function fetchUsers() { async function fetchUsers() {
@@ -55,6 +57,7 @@ export const useUserManageStore = defineStore('user-manage', () => {
username: filters.username || undefined, username: filters.username || undefined,
email: filters.email || undefined, email: filters.email || undefined,
status: filters.status, status: filters.status,
role_id: filters.role_id,
}) })
users.value = data.items users.value = data.items
pagination.total = data.total pagination.total = data.total
@@ -71,6 +74,7 @@ export const useUserManageStore = defineStore('user-manage', () => {
filters.username = '' filters.username = ''
filters.email = '' filters.email = ''
filters.status = undefined filters.status = undefined
filters.role_id = undefined
pagination.page = 1 pagination.page = 1
} }