diff --git a/frontend/src/components/CascadeFilter.vue b/frontend/src/components/CascadeFilter.vue new file mode 100644 index 0000000..3d7293c --- /dev/null +++ b/frontend/src/components/CascadeFilter.vue @@ -0,0 +1,128 @@ + + + diff --git a/frontend/src/components/DataScopeModal.vue b/frontend/src/components/DataScopeModal.vue new file mode 100644 index 0000000..3897019 --- /dev/null +++ b/frontend/src/components/DataScopeModal.vue @@ -0,0 +1,190 @@ + + + diff --git a/frontend/src/components/__tests__/CascadeFilter.spec.ts b/frontend/src/components/__tests__/CascadeFilter.spec.ts new file mode 100644 index 0000000..a032dfb --- /dev/null +++ b/frontend/src/components/__tests__/CascadeFilter.spec.ts @@ -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 + + 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) + }) +}) diff --git a/frontend/src/components/__tests__/DataScopeModal.spec.ts b/frontend/src/components/__tests__/DataScopeModal.spec.ts new file mode 100644 index 0000000..94feb3c --- /dev/null +++ b/frontend/src/components/__tests__/DataScopeModal.spec.ts @@ -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 + + 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('保存出错') + }) + }) +}) diff --git a/frontend/src/pages/users/index.vue b/frontend/src/pages/users/index.vue index 4223f5a..538eef0 100644 --- a/frontend/src/pages/users/index.vue +++ b/frontend/src/pages/users/index.vue @@ -3,6 +3,7 @@ import { api } from '@/utils/request' import { useUserManageStore, type UserRecord } from '@/stores/user-manage' import { useUserStore } from '@/stores/user' import UserFormModal from '@/components/UserFormModal.vue' +import DataScopeModal from '@/components/DataScopeModal.vue' import { PlusOutlined, SearchOutlined, @@ -29,6 +30,11 @@ const assigningUser = ref(null) const selectedRoleId = ref(undefined) const roleOptions = ref<{ value: number; label: string }[]>([]) +// Data scope modal +const scopeModalOpen = ref(false) +const scopeUserId = ref(null) +const scopeUsername = ref('') + const columns = [ { title: 'ID', dataIndex: 'id', width: 80 }, { title: '用户名', dataIndex: 'username' }, @@ -36,7 +42,8 @@ const columns = [ { title: '角色', key: 'role', width: 120 }, { title: '状态', dataIndex: 'status', width: 100 }, { title: '创建时间', dataIndex: 'created_at', width: 180 }, - { title: '操作', key: 'action', width: 280 }, + { title: '数据范围', key: 'data_scope', width: 120 }, + { title: '操作', key: 'action', width: 320 }, ] 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) { return time ? time.replace('T', ' ').substring(0, 19) : '-' } @@ -204,6 +221,11 @@ function formatTime(time: string) { {{ record.role.name }} - + diff --git a/frontend/src/stores/user-manage.ts b/frontend/src/stores/user-manage.ts index 597220f..c85eec9 100644 --- a/frontend/src/stores/user-manage.ts +++ b/frontend/src/stores/user-manage.ts @@ -19,6 +19,19 @@ export interface UserFilters { 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', () => { const users = ref([]) const loading = ref(false) @@ -61,6 +74,14 @@ export const useUserManageStore = defineStore('user-manage', () => { pagination.page = 1 } + async function fetchUserDataScope(userId: number): Promise { + return api.get(`/api/v1/users/${userId}/data-scope`) + } + + async function saveUserDataScope(userId: number, scopes: Omit[]) { + await api.put(`/api/v1/users/${userId}/data-scope`, { scopes }) + } + return { users, loading, @@ -68,5 +89,7 @@ export const useUserManageStore = defineStore('user-manage', () => { filters, fetchUsers, resetFilters, + fetchUserDataScope, + saveUserDataScope, } })