impl frontend permission management
This commit is contained in:
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { api } from '@/utils/request'
|
||||||
|
|
||||||
|
export interface CascadeValue {
|
||||||
|
company_id: number | undefined
|
||||||
|
platform_id: number | undefined
|
||||||
|
store_id: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = defineModel<CascadeValue>({ default: () => ({ company_id: undefined, platform_id: undefined, store_id: undefined }) })
|
||||||
|
|
||||||
|
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 loadingCompanies = ref(false)
|
||||||
|
const loadingStores = ref(false)
|
||||||
|
|
||||||
|
const companyOptions = computed(() =>
|
||||||
|
companies.value.map((c) => ({ value: c.id, label: c.label || c.name })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const platformOptions = computed(() =>
|
||||||
|
platforms.value.map((p) => ({ value: p.id, label: `平台 #${p.id}` })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const storeOptions = computed(() => {
|
||||||
|
let filtered = stores.value
|
||||||
|
if (model.value.company_id) {
|
||||||
|
filtered = filtered.filter((s) => s.company_id === model.value.company_id)
|
||||||
|
}
|
||||||
|
if (model.value.platform_id) {
|
||||||
|
filtered = filtered.filter((s) => s.platform_id === model.value.platform_id)
|
||||||
|
}
|
||||||
|
return filtered.map((s) => ({ value: s.id, label: s.label || s.name }))
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleCompanyChange(val: unknown) {
|
||||||
|
model.value = {
|
||||||
|
company_id: (val as number) ?? undefined,
|
||||||
|
platform_id: undefined,
|
||||||
|
store_id: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlatformChange(val: unknown) {
|
||||||
|
model.value = {
|
||||||
|
...model.value,
|
||||||
|
platform_id: (val as number) ?? undefined,
|
||||||
|
store_id: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStoreChange(val: unknown) {
|
||||||
|
model.value = {
|
||||||
|
...model.value,
|
||||||
|
store_id: (val as number) ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loadingCompanies.value = true
|
||||||
|
loadingStores.value = true
|
||||||
|
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)
|
||||||
|
} finally {
|
||||||
|
loadingCompanies.value = false
|
||||||
|
loadingStores.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<a-select
|
||||||
|
:value="model.company_id"
|
||||||
|
:options="companyOptions"
|
||||||
|
placeholder="选择公司"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="(input: string, option: unknown) =>
|
||||||
|
((option as { label: string }).label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||||
|
"
|
||||||
|
:loading="loadingCompanies"
|
||||||
|
style="min-width: 160px"
|
||||||
|
@change="handleCompanyChange"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
:value="model.platform_id"
|
||||||
|
:options="platformOptions"
|
||||||
|
placeholder="选择平台"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="(input: string, option: unknown) =>
|
||||||
|
((option as { label: string }).label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||||
|
"
|
||||||
|
style="min-width: 160px"
|
||||||
|
@change="handlePlatformChange"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
:value="model.store_id"
|
||||||
|
:options="storeOptions"
|
||||||
|
placeholder="选择店铺"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="(input: string, option: unknown) =>
|
||||||
|
((option as { label: string }).label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||||
|
"
|
||||||
|
:loading="loadingStores"
|
||||||
|
style="min-width: 160px"
|
||||||
|
@change="handleStoreChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<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'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
userId: number | null
|
||||||
|
username: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
saved: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useUserManageStore()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const scopes = ref<ScopeRecord[]>([])
|
||||||
|
|
||||||
|
// 基础数据列表
|
||||||
|
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 scopeTypeOptions = [
|
||||||
|
{ value: 'company', label: '公司' },
|
||||||
|
{ value: 'platform', label: '平台' },
|
||||||
|
{ value: 'store', label: '店铺' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function entityOptions(scopeType: string) {
|
||||||
|
if (scopeType === 'company') {
|
||||||
|
return companies.value.map((c) => ({ value: c.id, label: c.label || c.name }))
|
||||||
|
}
|
||||||
|
if (scopeType === 'platform') {
|
||||||
|
return platforms.value.map((p) => ({ value: p.id, label: `平台 #${p.id}` }))
|
||||||
|
}
|
||||||
|
if (scopeType === 'store') {
|
||||||
|
return stores.value.map((s) => ({ value: s.id, label: s.label || s.name }))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
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 loadData() {
|
||||||
|
if (!props.userId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [scopeData] = await Promise.allSettled([
|
||||||
|
store.fetchUserDataScope(props.userId),
|
||||||
|
loadEntities(),
|
||||||
|
])
|
||||||
|
if (scopeData.status === 'fulfilled') {
|
||||||
|
scopes.value = scopeData.value.scopes.map((s) => ({
|
||||||
|
scope_type: s.scope_type,
|
||||||
|
scope_id: s.scope_id,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
scopes.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 }))
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await store.saveUserDataScope(props.userId, validScopes)
|
||||||
|
message.success('数据范围保存成功')
|
||||||
|
emit('saved')
|
||||||
|
handleClose()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '保存失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
loadData()
|
||||||
|
} else {
|
||||||
|
scopes.value = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="`数据范围 - ${username}`"
|
||||||
|
width="640px"
|
||||||
|
: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>
|
||||||
|
|
||||||
|
<div v-if="scopes.length === 0 && !loading" class="text-center text-gray-400 py-4">
|
||||||
|
暂无数据范围配置,该用户将无法访问任何业务数据
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(scope, index) in scopes"
|
||||||
|
:key="index"
|
||||||
|
class="flex gap-2 mb-2 items-center"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
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 CascadeFilter from '@/components/CascadeFilter.vue'
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/utils/request', () => ({
|
||||||
|
api: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { api } from '@/utils/request'
|
||||||
|
|
||||||
|
const mockCompanies = [
|
||||||
|
{ id: 1, name: 'acme', label: '阿克米公司', enabled: true },
|
||||||
|
{ id: 2, name: 'beta', label: '贝塔公司', enabled: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockPlatforms = [
|
||||||
|
{ id: 1, developer_id: 1 },
|
||||||
|
{ id: 2, developer_id: 2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockStores = [
|
||||||
|
{ id: 1, company_id: 1, platform_id: 1, name: 'store-a', label: 'A 店铺' },
|
||||||
|
{ id: 2, company_id: 1, platform_id: 2, name: 'store-b', label: 'B 店铺' },
|
||||||
|
{ id: 3, company_id: 2, platform_id: 1, name: 'store-c', label: 'C 店铺' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function setupMocks() {
|
||||||
|
vi.mocked(api.get).mockImplementation((url: string) => {
|
||||||
|
if (url === '/api/v1/companies') return Promise.resolve(mockCompanies) as never
|
||||||
|
if (url === '/api/v1/platforms') return Promise.resolve(mockPlatforms) as never
|
||||||
|
if (url === '/api/v1/stores') return Promise.resolve(mockStores) as never
|
||||||
|
return Promise.resolve([]) as never
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CascadeFilter', () => {
|
||||||
|
let wrapper: ReturnType<typeof mount>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function mountFilter(props = {}) {
|
||||||
|
wrapper = mount(CascadeFilter, {
|
||||||
|
props: {
|
||||||
|
modelValue: { company_id: undefined, platform_id: undefined, store_id: undefined },
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryBody(selector: string) {
|
||||||
|
return document.body.querySelectorAll(selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('渲染三个下拉框', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountFilter()
|
||||||
|
const selects = queryBody('.ant-select')
|
||||||
|
expect(selects.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('挂载时加载基础数据', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountFilter()
|
||||||
|
|
||||||
|
const calls = vi.mocked(api.get).mock.calls.map((c) => c[0])
|
||||||
|
expect(calls).toContain('/api/v1/companies')
|
||||||
|
expect(calls).toContain('/api/v1/platforms')
|
||||||
|
expect(calls).toContain('/api/v1/stores')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选择公司后触发 update:modelValue', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountFilter()
|
||||||
|
|
||||||
|
// Simulate selecting company by triggering component method
|
||||||
|
const vm = wrapper.vm as unknown as { handleCompanyChange: (val: unknown) => void }
|
||||||
|
vm.handleCompanyChange(1)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const emitted = wrapper.emitted('update:modelValue')
|
||||||
|
expect(emitted).toBeTruthy()
|
||||||
|
const lastEvent = emitted!.at(-1)!
|
||||||
|
expect(lastEvent[0]).toEqual({
|
||||||
|
company_id: 1,
|
||||||
|
platform_id: undefined,
|
||||||
|
store_id: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选择平台后重置店铺', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountFilter({
|
||||||
|
modelValue: { company_id: 1, platform_id: undefined, store_id: 2 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const vm = wrapper.vm as unknown as { handlePlatformChange: (val: unknown) => void }
|
||||||
|
vm.handlePlatformChange(1)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const emitted = wrapper.emitted('update:modelValue')
|
||||||
|
expect(emitted).toBeTruthy()
|
||||||
|
const lastEvent = emitted!.at(-1)!
|
||||||
|
expect(lastEvent[0]).toEqual({
|
||||||
|
company_id: 1,
|
||||||
|
platform_id: 1,
|
||||||
|
store_id: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('清空公司时重置平台和店铺', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountFilter({
|
||||||
|
modelValue: { company_id: 1, platform_id: 1, store_id: 2 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const vm = wrapper.vm as unknown as { handleCompanyChange: (val: unknown) => void }
|
||||||
|
vm.handleCompanyChange(undefined)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const emitted = wrapper.emitted('update:modelValue')
|
||||||
|
expect(emitted).toBeTruthy()
|
||||||
|
const lastEvent = emitted!.at(-1)!
|
||||||
|
expect(lastEvent[0]).toEqual({
|
||||||
|
company_id: undefined,
|
||||||
|
platform_id: undefined,
|
||||||
|
store_id: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('API 失败时不崩溃', async () => {
|
||||||
|
vi.mocked(api.get).mockRejectedValue(new Error('网络错误'))
|
||||||
|
await mountFilter()
|
||||||
|
|
||||||
|
const selects = queryBody('.ant-select')
|
||||||
|
expect(selects.length).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
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 DataScopeModal from '@/components/DataScopeModal.vue'
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/utils/request', () => ({
|
||||||
|
api: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { api } from '@/utils/request'
|
||||||
|
|
||||||
|
const mockScopes = {
|
||||||
|
user_id: 1,
|
||||||
|
role: 'accessor',
|
||||||
|
scopes: [
|
||||||
|
{ scope_type: 'company', scope_id: 1, name: '阿克米公司' },
|
||||||
|
{ scope_type: 'store', scope_id: 5, name: '我的店铺' },
|
||||||
|
],
|
||||||
|
resolved_store_ids: [5],
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockCompanies = [
|
||||||
|
{ id: 1, name: 'acme', label: '阿克米公司', enabled: true },
|
||||||
|
{ id: 2, name: 'beta', label: '贝塔公司', enabled: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockPlatforms = [
|
||||||
|
{ id: 1, developer_id: 1 },
|
||||||
|
{ id: 2, 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: '其他店铺' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function setupMocks(overrides: { scopes?: typeof mockScopes } = {}) {
|
||||||
|
vi.mocked(api.get).mockImplementation((url: string) => {
|
||||||
|
if (url.match(/\/users\/\d+\/data-scope/)) return Promise.resolve(overrides.scopes ?? mockScopes) as never
|
||||||
|
if (url === '/api/v1/companies') return Promise.resolve(mockCompanies) as never
|
||||||
|
if (url === '/api/v1/platforms') return Promise.resolve(mockPlatforms) as never
|
||||||
|
if (url === '/api/v1/stores') return Promise.resolve(mockStores) as never
|
||||||
|
return Promise.resolve([]) as never
|
||||||
|
})
|
||||||
|
vi.mocked(api.put).mockResolvedValue(undefined as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DataScopeModal', () => {
|
||||||
|
let wrapper: ReturnType<typeof mount>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function mountModal(props = {}) {
|
||||||
|
wrapper = mount(DataScopeModal, {
|
||||||
|
props: {
|
||||||
|
open: true,
|
||||||
|
userId: 1,
|
||||||
|
username: 'testuser',
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryBody(selector: string) {
|
||||||
|
return document.body.querySelectorAll(selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('数据加载', () => {
|
||||||
|
it('打开时加载用户 scope 数据', 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('标题包含用户名', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal()
|
||||||
|
const title = document.body.querySelector('.ant-modal-title')
|
||||||
|
expect(title?.textContent).toContain('testuser')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('open=false 不渲染内容', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal({ open: false })
|
||||||
|
const modal = document.body.querySelector('.ant-modal')
|
||||||
|
expect(modal).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Scope 行操作', () => {
|
||||||
|
it('加载后显示 scope 行', 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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 afterDeleteBtns = queryBody('.anticon-delete')
|
||||||
|
expect(afterDeleteBtns.length).toBe(initialCount - 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('保存', () => {
|
||||||
|
it('保存调用正确 API', 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
|
||||||
|
if (okBtn) {
|
||||||
|
okBtn.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保存成功显示 success message', async () => {
|
||||||
|
setupMocks()
|
||||||
|
const spy = vi.spyOn(message, 'success')
|
||||||
|
await mountModal()
|
||||||
|
|
||||||
|
const okBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
||||||
|
(btn) => btn.textContent?.includes('确') || btn.textContent?.includes('OK'),
|
||||||
|
) as HTMLElement
|
||||||
|
if (okBtn) {
|
||||||
|
okBtn.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith('数据范围保存成功')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('API 失败显示 error message', async () => {
|
||||||
|
setupMocks()
|
||||||
|
vi.mocked(api.put).mockRejectedValueOnce(new Error('保存出错'))
|
||||||
|
const spy = vi.spyOn(message, 'error')
|
||||||
|
await mountModal()
|
||||||
|
|
||||||
|
const okBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
||||||
|
(btn) => btn.textContent?.includes('确') || btn.textContent?.includes('OK'),
|
||||||
|
) as HTMLElement
|
||||||
|
if (okBtn) {
|
||||||
|
okBtn.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith('保存出错')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import { api } from '@/utils/request'
|
|||||||
import { useUserManageStore, type UserRecord } from '@/stores/user-manage'
|
import { useUserManageStore, type UserRecord } from '@/stores/user-manage'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import UserFormModal from '@/components/UserFormModal.vue'
|
import UserFormModal from '@/components/UserFormModal.vue'
|
||||||
|
import DataScopeModal from '@/components/DataScopeModal.vue'
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@@ -29,6 +30,11 @@ 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 }[]>([])
|
||||||
|
|
||||||
|
// Data scope modal
|
||||||
|
const scopeModalOpen = ref(false)
|
||||||
|
const scopeUserId = ref<number | null>(null)
|
||||||
|
const scopeUsername = ref('')
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
{ title: '用户名', dataIndex: 'username' },
|
{ title: '用户名', dataIndex: 'username' },
|
||||||
@@ -36,7 +42,8 @@ const columns = [
|
|||||||
{ title: '角色', key: 'role', width: 120 },
|
{ title: '角色', key: 'role', width: 120 },
|
||||||
{ title: '状态', dataIndex: 'status', width: 100 },
|
{ title: '状态', dataIndex: 'status', width: 100 },
|
||||||
{ title: '创建时间', dataIndex: 'created_at', width: 180 },
|
{ title: '创建时间', dataIndex: 'created_at', width: 180 },
|
||||||
{ title: '操作', key: 'action', width: 280 },
|
{ title: '数据范围', key: 'data_scope', width: 120 },
|
||||||
|
{ title: '操作', key: 'action', width: 320 },
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -129,6 +136,16 @@ async function submitRoleAssign() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDataScope(record: UserRecord) {
|
||||||
|
scopeUserId.value = record.id
|
||||||
|
scopeUsername.value = record.username
|
||||||
|
scopeModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScopeSuccess() {
|
||||||
|
store.fetchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(time: string) {
|
function formatTime(time: string) {
|
||||||
return time ? time.replace('T', ' ').substring(0, 19) : '-'
|
return time ? time.replace('T', ' ').substring(0, 19) : '-'
|
||||||
}
|
}
|
||||||
@@ -204,6 +221,11 @@ function formatTime(time: string) {
|
|||||||
<a-tag v-if="record.role" color="blue">{{ record.role.name }}</a-tag>
|
<a-tag v-if="record.role" color="blue">{{ record.role.name }}</a-tag>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'data_scope'">
|
||||||
|
<a-tag v-if="record.role?.name === 'administrator'" color="green">全部权限</a-tag>
|
||||||
|
<a-tag v-else-if="record.role?.name === 'developer'" color="blue">平台级</a-tag>
|
||||||
|
<a-tag v-else color="orange">自定义</a-tag>
|
||||||
|
</template>
|
||||||
<template v-else-if="column.dataIndex === 'status'">
|
<template v-else-if="column.dataIndex === 'status'">
|
||||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||||
{{ record.status === 1 ? '启用' : '禁用' }}
|
{{ record.status === 1 ? '启用' : '禁用' }}
|
||||||
@@ -228,6 +250,14 @@ function formatTime(time: string) {
|
|||||||
>
|
>
|
||||||
分配角色
|
分配角色
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="userStore.isAdmin && record.role?.name !== 'administrator'"
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
@click="handleDataScope(record as UserRecord)"
|
||||||
|
>
|
||||||
|
数据范围
|
||||||
|
</a-button>
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
v-if="userStore.isAdmin"
|
v-if="userStore.isAdmin"
|
||||||
:title="`确定要${record.status === 1 ? '禁用' : '启用'}该用户吗?`"
|
:title="`确定要${record.status === 1 ? '禁用' : '启用'}该用户吗?`"
|
||||||
@@ -317,5 +347,13 @@ function formatTime(time: string) {
|
|||||||
:user-data="editingUser"
|
:user-data="editingUser"
|
||||||
@success="handleModalSuccess"
|
@success="handleModalSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Data scope modal -->
|
||||||
|
<DataScopeModal
|
||||||
|
v-model:open="scopeModalOpen"
|
||||||
|
:user-id="scopeUserId"
|
||||||
|
:username="scopeUsername"
|
||||||
|
@saved="handleScopeSuccess"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ export interface UserFilters {
|
|||||||
status: number | undefined
|
status: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScopeRecord {
|
||||||
|
scope_type: 'company' | 'platform' | 'store'
|
||||||
|
scope_id: number
|
||||||
|
name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDataScope {
|
||||||
|
user_id: number
|
||||||
|
role: string | null
|
||||||
|
scopes: ScopeRecord[]
|
||||||
|
resolved_store_ids: number[]
|
||||||
|
}
|
||||||
|
|
||||||
export const useUserManageStore = defineStore('user-manage', () => {
|
export const useUserManageStore = defineStore('user-manage', () => {
|
||||||
const users = ref<UserRecord[]>([])
|
const users = ref<UserRecord[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -61,6 +74,14 @@ export const useUserManageStore = defineStore('user-manage', () => {
|
|||||||
pagination.page = 1
|
pagination.page = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchUserDataScope(userId: number): Promise<UserDataScope> {
|
||||||
|
return api.get<UserDataScope>(`/api/v1/users/${userId}/data-scope`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUserDataScope(userId: number, scopes: Omit<ScopeRecord, 'name'>[]) {
|
||||||
|
await api.put(`/api/v1/users/${userId}/data-scope`, { scopes })
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
users,
|
users,
|
||||||
loading,
|
loading,
|
||||||
@@ -68,5 +89,7 @@ export const useUserManageStore = defineStore('user-manage', () => {
|
|||||||
filters,
|
filters,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
|
fetchUserDataScope,
|
||||||
|
saveUserDataScope,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user