update user page
This commit is contained in:
@@ -1,7 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { api } from '@/utils/request'
|
||||
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<{
|
||||
open: boolean
|
||||
@@ -18,47 +27,263 @@ const store = useUserManageStore()
|
||||
|
||||
const loading = 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 platforms = ref<{ id: number; developer_id: number }[]>([])
|
||||
const stores = ref<{ id: number; company_id: number; platform_id: number; name: string; label: string }[]>([])
|
||||
const platforms = ref<{ id: number; name: string; label: string; developer_id: number }[]>([])
|
||||
const storeList = ref<{ id: number; company_id: number; platform_id: number; name: string; label: string }[]>([])
|
||||
|
||||
const scopeTypeOptions = [
|
||||
{ value: 'company', label: '公司' },
|
||||
{ value: 'platform', label: '平台' },
|
||||
{ value: 'store', label: '店铺' },
|
||||
const modeOptions = [
|
||||
{ value: 'company', label: '按公司授权' },
|
||||
{ value: 'platform', label: '按平台授权' },
|
||||
]
|
||||
|
||||
function entityOptions(scopeType: string) {
|
||||
if (scopeType === 'company') {
|
||||
return companies.value.map((c) => ({ value: c.id, label: c.label || c.name }))
|
||||
// 名称查找辅助
|
||||
function getCompanyName(id: number): string {
|
||||
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 platforms.value.map((p) => ({ value: p.id, label: `平台 #${p.id}` }))
|
||||
return keys
|
||||
}
|
||||
|
||||
// 公司模式树: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,
|
||||
}
|
||||
if (scopeType === 'store') {
|
||||
return stores.value.map((s) => ({ value: s.id, label: s.label || s.name }))
|
||||
})
|
||||
return {
|
||||
key: `company:${c.id}`,
|
||||
title: label,
|
||||
rawTitle: label,
|
||||
scopeType: 'company' as const,
|
||||
scopeId: c.id,
|
||||
children,
|
||||
}
|
||||
return []
|
||||
})
|
||||
})
|
||||
|
||||
// 平台模式树: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 (lastIndex < text.length) {
|
||||
parts.push({ text: text.slice(lastIndex), highlight: false })
|
||||
}
|
||||
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() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
scopes.value.push({ scope_type: 'company', scope_id: 0 })
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
scopes.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleTypeChange(index: number) {
|
||||
const row = scopes.value[index]
|
||||
if (row) row.scope_id = 0
|
||||
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 storeList.value>('/api/v1/stores'),
|
||||
])
|
||||
companies.value = c
|
||||
platforms.value = p
|
||||
storeList.value = s
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '加载基础数据失败'
|
||||
message.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
@@ -70,47 +295,22 @@ async function loadData() {
|
||||
loadEntities(),
|
||||
])
|
||||
if (scopeData.status === 'fulfilled') {
|
||||
scopes.value = scopeData.value.scopes.map((s) => ({
|
||||
scope_type: s.scope_type,
|
||||
scope_id: s.scope_id,
|
||||
}))
|
||||
const userScopes = scopeData.value.scopes
|
||||
scopeMode.value = detectInitialMode(userScopes)
|
||||
await nextTick()
|
||||
checkedKeys.value = scopesToCheckedKeys(userScopes)
|
||||
} else {
|
||||
scopes.value = []
|
||||
checkedKeys.value = []
|
||||
}
|
||||
expandedKeys.value = getAllKeys(activeTree.value)
|
||||
} finally {
|
||||
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() {
|
||||
if (!props.userId) return
|
||||
// 过滤掉未选择实体的行,并去重
|
||||
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 }))
|
||||
const validScopes = checkedKeysToScopes()
|
||||
saving.value = true
|
||||
try {
|
||||
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(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
loadData()
|
||||
} else {
|
||||
scopes.value = []
|
||||
checkedKeys.value = []
|
||||
halfCheckedKeys.value = []
|
||||
searchValue.value = ''
|
||||
scopeMode.value = 'company'
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -142,48 +367,63 @@ watch(
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="`数据范围 - ${username}`"
|
||||
width="640px"
|
||||
width="720px"
|
||||
:confirm-loading="saving"
|
||||
@cancel="handleClose"
|
||||
@ok="handleSave"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<div class="mb-3">
|
||||
<a-button type="dashed" block @click="addRow">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加数据范围
|
||||
</a-button>
|
||||
</div>
|
||||
<!-- 模式切换 -->
|
||||
<a-segmented
|
||||
:value="scopeMode"
|
||||
:options="modeOptions"
|
||||
class="mb-3"
|
||||
@change="handleModeChange"
|
||||
/>
|
||||
|
||||
<div v-if="scopes.length === 0 && !loading" class="text-center text-gray-400 py-4">
|
||||
暂无数据范围配置,该用户将无法访问任何业务数据
|
||||
</div>
|
||||
<!-- 提示 -->
|
||||
<a-alert type="info" show-icon :message="tipMessage" class="mb-3" />
|
||||
|
||||
<div
|
||||
v-for="(scope, index) in scopes"
|
||||
:key="index"
|
||||
class="flex gap-2 mb-2 items-center"
|
||||
<!-- 搜索 -->
|
||||
<a-input-search
|
||||
v-model:value="searchValue"
|
||||
placeholder="搜索名称"
|
||||
allow-clear
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<a-empty v-if="activeTree.length === 0 && !loading" description="暂无可授权的数据" />
|
||||
|
||||
<!-- 树 -->
|
||||
<div v-else style="max-height: 400px; overflow-y: auto;">
|
||||
<a-tree
|
||||
:checked-keys="checkedKeys"
|
||||
checkable
|
||||
:tree-data="activeTree"
|
||||
:selectable="false"
|
||||
:expanded-keys="expandedKeys"
|
||||
:auto-expand-parent="autoExpandParent"
|
||||
@check="onCheck"
|
||||
@expand="onExpand"
|
||||
>
|
||||
<a-select
|
||||
:value="scope.scope_type"
|
||||
:options="scopeTypeOptions"
|
||||
style="width: 120px"
|
||||
@change="(val: unknown) => { scope.scope_type = val as ScopeRecord['scope_type']; handleTypeChange(index) }"
|
||||
/>
|
||||
<a-select
|
||||
:value="scope.scope_id || undefined"
|
||||
:options="entityOptions(scope.scope_type)"
|
||||
placeholder="选择实体"
|
||||
show-search
|
||||
:filter-option="(input: string, option: unknown) =>
|
||||
((option as { label: string }).label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
style="flex: 1"
|
||||
@change="(val: unknown) => { scope.scope_id = (val as number) ?? 0 }"
|
||||
/>
|
||||
<a-button type="text" danger size="small" @click="removeRow(index)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
<template #title="{ dataRef }">
|
||||
<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>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import DataScopeModal from '@/components/DataScopeModal.vue'
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
@@ -35,24 +35,24 @@ const mockScopes = {
|
||||
role: 'accessor',
|
||||
scopes: [
|
||||
{ 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 = [
|
||||
{ id: 1, name: 'acme', label: '阿克米公司', enabled: true },
|
||||
{ id: 2, name: 'beta', label: '贝塔公司', enabled: true },
|
||||
{ id: 1, name: 'acme', label: '阿克米公司' },
|
||||
{ id: 2, name: 'beta', label: '贝塔公司' },
|
||||
]
|
||||
|
||||
const mockPlatforms = [
|
||||
{ id: 1, developer_id: 1 },
|
||||
{ id: 2, developer_id: 1 },
|
||||
{ id: 1, name: 'shopify', label: 'Shopify', developer_id: 1 },
|
||||
{ id: 2, name: 'amazon', label: 'Amazon', developer_id: 1 },
|
||||
]
|
||||
|
||||
const mockStores = [
|
||||
{ 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 } = {}) {
|
||||
@@ -99,15 +99,26 @@ describe('DataScopeModal', () => {
|
||||
return document.body.querySelectorAll(selector)
|
||||
}
|
||||
|
||||
describe('数据加载', () => {
|
||||
it('打开时加载用户 scope 数据', async () => {
|
||||
describe('树渲染', () => {
|
||||
it('公司模式下渲染 Company → Store 层级', async () => {
|
||||
setupMocks()
|
||||
await mountModal()
|
||||
|
||||
expect(vi.mocked(api.get)).toHaveBeenCalled()
|
||||
const calls = vi.mocked(api.get).mock.calls.map((c) => c[0])
|
||||
expect(calls.some((url) => url.includes('/data-scope'))).toBe(true)
|
||||
expect(calls.some((url) => url.includes('/companies'))).toBe(true)
|
||||
const treeNodes = queryBody('.ant-tree-treenode')
|
||||
expect(treeNodes.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
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 () => {
|
||||
@@ -125,61 +136,88 @@ describe('DataScopeModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Scope 行操作', () => {
|
||||
it('加载后显示 scope 行', async () => {
|
||||
describe('平台模式', () => {
|
||||
it('切换到平台模式后渲染 Platform → Store 层级', async () => {
|
||||
setupMocks({
|
||||
scopes: { ...mockScopes, scopes: [] },
|
||||
})
|
||||
await mountModal()
|
||||
|
||||
const segments = Array.from(queryBody('.ant-segmented-item'))
|
||||
const platformSeg = segments.find((el) => el.textContent?.includes('按平台授权'))
|
||||
expect(platformSeg).toBeTruthy()
|
||||
;(platformSeg as HTMLElement).click()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const titles = Array.from(queryBody('.ant-tree-title')).map((el) => el.textContent)
|
||||
expect(titles.some((t) => t?.includes('Shopify'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('模式切换确认', () => {
|
||||
it('有勾选项时切换模式弹出确认框', async () => {
|
||||
setupMocks()
|
||||
await mountModal()
|
||||
|
||||
// 2 scope rows rendered as flex rows
|
||||
const selects = queryBody('.ant-select')
|
||||
// Each row has 2 selects (type + entity), so expect >= 4
|
||||
expect(selects.length).toBeGreaterThanOrEqual(4)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
it('点击添加按钮增加一行', async () => {
|
||||
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 addBtn = Array.from(queryBody('.ant-btn-dashed')).find(
|
||||
(btn) => btn.textContent?.includes('添加数据范围'),
|
||||
) as HTMLElement
|
||||
expect(addBtn).toBeTruthy()
|
||||
addBtn.click()
|
||||
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()
|
||||
|
||||
// Should have at least one row with selects now
|
||||
const selects = queryBody('.ant-select')
|
||||
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 highlighted = queryBody('.text-red-500')
|
||||
expect(highlighted.length).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
|
||||
const afterDeleteBtns = queryBody('.anticon-delete')
|
||||
expect(afterDeleteBtns.length).toBe(initialCount - 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('保存', () => {
|
||||
it('保存调用正确 API', async () => {
|
||||
describe('保存逻辑', () => {
|
||||
it('全选公司节点保存为 company scope', async () => {
|
||||
setupMocks()
|
||||
await mountModal()
|
||||
|
||||
// Click OK button
|
||||
const okBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
||||
(btn) => btn.textContent?.includes('确') || btn.textContent?.includes('OK'),
|
||||
) as HTMLElement
|
||||
@@ -190,12 +228,11 @@ describe('DataScopeModal', () => {
|
||||
|
||||
expect(vi.mocked(api.put)).toHaveBeenCalledWith(
|
||||
'/api/v1/users/1/data-scope',
|
||||
{
|
||||
scopes: [
|
||||
{ scope_type: 'company', scope_id: 1 },
|
||||
{ scope_type: 'store', scope_id: 5 },
|
||||
],
|
||||
},
|
||||
expect.objectContaining({
|
||||
scopes: expect.arrayContaining([
|
||||
expect.objectContaining({ scope_type: 'company', scope_id: 1 }),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -232,4 +269,14 @@ describe('DataScopeModal', () => {
|
||||
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.email).toBe('')
|
||||
expect(store.filters.status).toBeUndefined()
|
||||
expect(store.filters.role_id).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,6 +78,7 @@ describe('useUserManageStore', () => {
|
||||
username: undefined,
|
||||
email: undefined,
|
||||
status: undefined,
|
||||
role_id: undefined,
|
||||
})
|
||||
expect(store.users).toEqual(mockUsers)
|
||||
expect(store.pagination.total).toBe(2)
|
||||
@@ -95,11 +97,13 @@ describe('useUserManageStore', () => {
|
||||
const store = useUserManageStore()
|
||||
store.filters.username = 'admin'
|
||||
store.filters.status = 1
|
||||
store.filters.role_id = 2
|
||||
await store.fetchUsers()
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith('/api/v1/users', expect.objectContaining({
|
||||
username: 'admin',
|
||||
status: 1,
|
||||
role_id: 2,
|
||||
}))
|
||||
expect(store.users).toHaveLength(1)
|
||||
})
|
||||
@@ -155,6 +159,7 @@ describe('useUserManageStore', () => {
|
||||
store.filters.username = 'test'
|
||||
store.filters.email = 'test@test.com'
|
||||
store.filters.status = 1
|
||||
store.filters.role_id = 3
|
||||
store.pagination.page = 3
|
||||
|
||||
store.resetFilters()
|
||||
@@ -162,6 +167,7 @@ describe('useUserManageStore', () => {
|
||||
expect(store.filters.username).toBe('')
|
||||
expect(store.filters.email).toBe('')
|
||||
expect(store.filters.status).toBeUndefined()
|
||||
expect(store.filters.role_id).toBeUndefined()
|
||||
expect(store.pagination.page).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -181,9 +187,20 @@ describe('Users Page', () => {
|
||||
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')
|
||||
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 用户以显示操作按钮
|
||||
const userStore = useUserStore()
|
||||
@@ -293,4 +310,21 @@ describe('Users Page', () => {
|
||||
const drawer = document.body.querySelector('.ant-drawer')
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,9 @@ const assigningUser = ref<UserRecord | null>(null)
|
||||
const selectedRoleId = ref<number | undefined>(undefined)
|
||||
const roleOptions = ref<{ value: number; label: string }[]>([])
|
||||
|
||||
// Role filter options
|
||||
const roleFilterOptions = ref<{ value: number; label: string }[]>([])
|
||||
|
||||
// Data scope modal
|
||||
const scopeModalOpen = ref(false)
|
||||
const scopeUserId = ref<number | null>(null)
|
||||
@@ -48,6 +51,11 @@ const columns = [
|
||||
|
||||
onMounted(() => {
|
||||
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() {
|
||||
@@ -185,6 +193,15 @@ function formatTime(time: string) {
|
||||
<a-select-option :value="0">禁用</a-select-option>
|
||||
</a-select>
|
||||
</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-space>
|
||||
<a-button type="primary" html-type="submit">
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface UserFilters {
|
||||
username: string
|
||||
email: string
|
||||
status: number | undefined
|
||||
role_id: number | undefined
|
||||
}
|
||||
|
||||
export interface ScopeRecord {
|
||||
@@ -44,6 +45,7 @@ export const useUserManageStore = defineStore('user-manage', () => {
|
||||
username: '',
|
||||
email: '',
|
||||
status: undefined,
|
||||
role_id: undefined,
|
||||
})
|
||||
|
||||
async function fetchUsers() {
|
||||
@@ -55,6 +57,7 @@ export const useUserManageStore = defineStore('user-manage', () => {
|
||||
username: filters.username || undefined,
|
||||
email: filters.email || undefined,
|
||||
status: filters.status,
|
||||
role_id: filters.role_id,
|
||||
})
|
||||
users.value = data.items
|
||||
pagination.total = data.total
|
||||
@@ -71,6 +74,7 @@ export const useUserManageStore = defineStore('user-manage', () => {
|
||||
filters.username = ''
|
||||
filters.email = ''
|
||||
filters.status = undefined
|
||||
filters.role_id = undefined
|
||||
pagination.page = 1
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user