impl frontend permission management

This commit is contained in:
2026-03-19 11:19:55 +08:00
parent e9de137e66
commit 49c8761f26
6 changed files with 786 additions and 1 deletions
+128
View File
@@ -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>
+190
View File
@@ -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('保存出错')
})
})
})