update roles
This commit is contained in:
@@ -0,0 +1,233 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoleManageStore } from '@/stores/role-manage'
|
||||||
|
import type { RoleRecord, RouteOverride } from '@/stores/role-manage'
|
||||||
|
import { useRouteGroupStore } from '@/stores/route-group'
|
||||||
|
import type { RouteRecord } from '@/stores/route-group'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
role: RoleRecord | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
saved: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const roleStore = useRoleManageStore()
|
||||||
|
const groupStore = useRouteGroupStore()
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const activeTab = ref('groups')
|
||||||
|
const loadingData = ref(false)
|
||||||
|
const savingGroups = ref(false)
|
||||||
|
const savingOverrides = ref(false)
|
||||||
|
|
||||||
|
// Tab 1: 路由组授权
|
||||||
|
const checkedGroupIds = ref<number[]>([])
|
||||||
|
|
||||||
|
// Tab 2: 单条路由覆盖
|
||||||
|
const overrides = ref<RouteOverride[]>([])
|
||||||
|
const allRoutes = ref<RouteRecord[]>([])
|
||||||
|
const addingRouteId = ref<number | null>(null)
|
||||||
|
|
||||||
|
// method 颜色映射
|
||||||
|
const methodColorMap: Record<string, string> = {
|
||||||
|
GET: 'green',
|
||||||
|
POST: 'blue',
|
||||||
|
PUT: 'orange',
|
||||||
|
DELETE: 'red',
|
||||||
|
PATCH: 'cyan',
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrideColumns = [
|
||||||
|
{ title: '方法', key: 'method', width: 80 },
|
||||||
|
{ title: '路径', dataIndex: 'path', key: 'path' },
|
||||||
|
{ title: '允许', key: 'allow', width: 80 },
|
||||||
|
{ title: '操作', key: 'action', width: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 可添加为覆盖的路由(排除已添加的)
|
||||||
|
const availableRoutes = computed(() => {
|
||||||
|
const existingIds = new Set(overrides.value.map((o) => o.route_id))
|
||||||
|
return allRoutes.value.filter((r) => !existingIds.has(r.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const routeSelectOptions = computed(() =>
|
||||||
|
availableRoutes.value.map((r) => ({
|
||||||
|
value: r.id,
|
||||||
|
label: `${r.method} ${r.path}`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:open', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
activeTab.value = 'groups'
|
||||||
|
checkedGroupIds.value = []
|
||||||
|
overrides.value = []
|
||||||
|
allRoutes.value = []
|
||||||
|
addingRouteId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
if (!props.role) return
|
||||||
|
loadingData.value = true
|
||||||
|
try {
|
||||||
|
const [groupIds, roleOverrides, , ] = await Promise.allSettled([
|
||||||
|
roleStore.fetchRoleRouteGroups(props.role.id),
|
||||||
|
roleStore.fetchRoleOverrides(props.role.id),
|
||||||
|
groupStore.fetchGroups(),
|
||||||
|
groupStore.fetchRoutes(),
|
||||||
|
])
|
||||||
|
checkedGroupIds.value = groupIds.status === 'fulfilled' ? groupIds.value : []
|
||||||
|
overrides.value = roleOverrides.status === 'fulfilled' ? roleOverrides.value : []
|
||||||
|
allRoutes.value = groupStore.routes
|
||||||
|
} finally {
|
||||||
|
loadingData.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGroups() {
|
||||||
|
if (!props.role) return
|
||||||
|
savingGroups.value = true
|
||||||
|
try {
|
||||||
|
await roleStore.setRoleRouteGroups(props.role.id, checkedGroupIds.value)
|
||||||
|
message.success('路由组授权保存成功')
|
||||||
|
emit('saved')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '保存失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
savingGroups.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOverride() {
|
||||||
|
if (addingRouteId.value === null) return
|
||||||
|
const route = allRoutes.value.find((r) => r.id === addingRouteId.value)
|
||||||
|
if (!route) return
|
||||||
|
overrides.value.push({
|
||||||
|
route_id: route.id,
|
||||||
|
method: route.method,
|
||||||
|
path: route.path,
|
||||||
|
allow: true,
|
||||||
|
})
|
||||||
|
addingRouteId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOverride(index: number) {
|
||||||
|
overrides.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOverrides() {
|
||||||
|
if (!props.role) return
|
||||||
|
savingOverrides.value = true
|
||||||
|
try {
|
||||||
|
await roleStore.setRoleOverrides(props.role.id, overrides.value)
|
||||||
|
message.success('路由覆盖保存成功')
|
||||||
|
emit('saved')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '保存失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
savingOverrides.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
loadData()
|
||||||
|
} else {
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="`配置权限 - ${role?.description || role?.name || ''}`"
|
||||||
|
width="720px"
|
||||||
|
:footer="null"
|
||||||
|
@cancel="handleClose"
|
||||||
|
>
|
||||||
|
<a-spin :spinning="loadingData">
|
||||||
|
<a-tabs v-model:activeKey="activeTab">
|
||||||
|
<!-- Tab 1: 路由组授权 -->
|
||||||
|
<a-tab-pane key="groups" tab="路由组授权">
|
||||||
|
<a-checkbox-group v-model:value="checkedGroupIds" class="w-full">
|
||||||
|
<a-row :gutter="[8, 8]">
|
||||||
|
<a-col v-for="group in groupStore.groups" :key="group.id" :span="8">
|
||||||
|
<a-checkbox :value="group.id">
|
||||||
|
{{ group.label || group.name }}
|
||||||
|
<span class="text-gray-400 text-xs">({{ group.route_count }} 条路由)</span>
|
||||||
|
</a-checkbox>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-checkbox-group>
|
||||||
|
<div class="mt-4 text-right">
|
||||||
|
<a-button type="primary" :loading="savingGroups" @click="saveGroups">
|
||||||
|
保存组授权
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<!-- Tab 2: 单条路由覆盖 -->
|
||||||
|
<a-tab-pane key="overrides" tab="单条路由覆盖">
|
||||||
|
<div class="mb-3 flex gap-2">
|
||||||
|
<a-select
|
||||||
|
:value="addingRouteId ?? undefined"
|
||||||
|
:options="routeSelectOptions"
|
||||||
|
placeholder="选择路由添加覆盖"
|
||||||
|
show-search
|
||||||
|
:filter-option="(input: string, option: unknown) =>
|
||||||
|
((option as { label: string }).label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||||
|
"
|
||||||
|
style="flex: 1"
|
||||||
|
allow-clear
|
||||||
|
@change="(val: unknown) => { addingRouteId = (val as number) ?? null }"
|
||||||
|
/>
|
||||||
|
<a-button type="primary" :disabled="addingRouteId === null" @click="addOverride">
|
||||||
|
添加覆盖
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<a-table
|
||||||
|
:columns="overrideColumns"
|
||||||
|
:data-source="overrides"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="route_id"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record, index }">
|
||||||
|
<template v-if="column.key === 'method'">
|
||||||
|
<a-tag :color="methodColorMap[record.method] || 'default'">
|
||||||
|
{{ record.method }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'allow'">
|
||||||
|
<a-switch v-model:checked="record.allow" size="small" />
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<a-button type="link" size="small" danger @click="removeOverride(index)">
|
||||||
|
移除
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
<div class="mt-4 text-right">
|
||||||
|
<a-button type="primary" :loading="savingOverrides" @click="saveOverrides">
|
||||||
|
保存覆盖
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
|
</a-spin>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
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 RolePermissionModal from '@/components/RolePermissionModal.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 mockRole = {
|
||||||
|
id: 2,
|
||||||
|
name: 'editor',
|
||||||
|
description: '编辑者',
|
||||||
|
user_count: 3,
|
||||||
|
route_group_count: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockGroups = [
|
||||||
|
{ id: 1, name: 'user-mgmt', label: '用户管理', description: '', sort_order: 1, route_count: 5 },
|
||||||
|
{ id: 2, name: 'product-mgmt', label: '产品管理', description: '', sort_order: 2, route_count: 3 },
|
||||||
|
{ id: 3, name: 'order-mgmt', label: '订单管理', description: '', sort_order: 3, route_count: 4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockRoutes = [
|
||||||
|
{ id: 1, method: 'GET', path: '/api/v1/users', description: '用户列表', group_id: 1, group_name: '用户管理' },
|
||||||
|
{ id: 2, method: 'POST', path: '/api/v1/users', description: '创建用户', group_id: 1, group_name: '用户管理' },
|
||||||
|
{ id: 3, method: 'GET', path: '/api/v1/products', description: '产品列表', group_id: 2, group_name: '产品管理' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockOverrides = [
|
||||||
|
{ route_id: 1, method: 'GET', path: '/api/v1/users', allow: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
function setupMocks(overrides: {
|
||||||
|
groupIds?: number[]
|
||||||
|
roleOverrides?: typeof mockOverrides
|
||||||
|
} = {}) {
|
||||||
|
vi.mocked(api.get).mockImplementation((url: string) => {
|
||||||
|
if (url.match(/\/roles\/\d+\/route-groups/)) return Promise.resolve(overrides.groupIds ?? [1, 2]) as never
|
||||||
|
if (url.match(/\/roles\/\d+\/route-overrides/)) return Promise.resolve(overrides.roleOverrides ?? mockOverrides) as never
|
||||||
|
if (url === '/api/v1/route-groups') return Promise.resolve(mockGroups) as never
|
||||||
|
if (url === '/api/v1/routes') return Promise.resolve(mockRoutes) as never
|
||||||
|
return Promise.resolve([]) as never
|
||||||
|
})
|
||||||
|
vi.mocked(api.put).mockResolvedValue(undefined as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RolePermissionModal', () => {
|
||||||
|
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(RolePermissionModal, {
|
||||||
|
props: {
|
||||||
|
open: true,
|
||||||
|
role: mockRole,
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryBody(selector: string) {
|
||||||
|
return document.body.querySelectorAll(selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Modal 渲染', () => {
|
||||||
|
it('标题包含角色名', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal()
|
||||||
|
const title = document.body.querySelector('.ant-modal-title')
|
||||||
|
expect(title?.textContent).toContain('编辑者')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('显示两个 tab', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal()
|
||||||
|
const tabs = queryBody('.ant-tabs-tab')
|
||||||
|
expect(tabs.length).toBe(2)
|
||||||
|
expect(tabs[0]?.textContent).toContain('路由组授权')
|
||||||
|
expect(tabs[1]?.textContent).toContain('单条路由覆盖')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('open=false 不渲染内容', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal({ open: false })
|
||||||
|
const modal = document.body.querySelector('.ant-modal')
|
||||||
|
expect(modal).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Tab 1: 路由组授权', () => {
|
||||||
|
it('加载路由组 checkbox', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal()
|
||||||
|
const checkboxes = queryBody('.ant-checkbox-wrapper')
|
||||||
|
expect(checkboxes.length).toBe(3)
|
||||||
|
expect(checkboxes[0]?.textContent).toContain('用户管理')
|
||||||
|
expect(checkboxes[1]?.textContent).toContain('产品管理')
|
||||||
|
expect(checkboxes[2]?.textContent).toContain('订单管理')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('预勾选已授权的路由组', async () => {
|
||||||
|
setupMocks({ groupIds: [1, 2] })
|
||||||
|
await mountModal()
|
||||||
|
const checked = queryBody('.ant-checkbox-wrapper-checked')
|
||||||
|
expect(checked.length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保存组授权调用正确 API', async () => {
|
||||||
|
setupMocks({ groupIds: [1] })
|
||||||
|
await mountModal()
|
||||||
|
|
||||||
|
const saveBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
||||||
|
(btn) => btn.textContent?.includes('保存组授权'),
|
||||||
|
) as HTMLElement
|
||||||
|
expect(saveBtn).toBeTruthy()
|
||||||
|
saveBtn.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(vi.mocked(api.put)).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/roles/2/route-groups',
|
||||||
|
{ group_ids: [1] },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保存成功显示 success message', async () => {
|
||||||
|
setupMocks({ groupIds: [1] })
|
||||||
|
const spy = vi.spyOn(message, 'success')
|
||||||
|
await mountModal()
|
||||||
|
|
||||||
|
const saveBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
||||||
|
(btn) => btn.textContent?.includes('保存组授权'),
|
||||||
|
) as HTMLElement
|
||||||
|
saveBtn.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith('路由组授权保存成功')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保存失败显示 error message', async () => {
|
||||||
|
setupMocks({ groupIds: [1] })
|
||||||
|
vi.mocked(api.put).mockRejectedValueOnce(new Error('保存出错'))
|
||||||
|
const spy = vi.spyOn(message, 'error')
|
||||||
|
await mountModal()
|
||||||
|
|
||||||
|
const saveBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
||||||
|
(btn) => btn.textContent?.includes('保存组授权'),
|
||||||
|
) as HTMLElement
|
||||||
|
saveBtn.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith('保存出错')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Tab 2: 单条路由覆盖', () => {
|
||||||
|
async function switchToOverridesTab() {
|
||||||
|
const tabs = queryBody('.ant-tabs-tab')
|
||||||
|
const overrideTab = tabs[1] as HTMLElement
|
||||||
|
overrideTab.click()
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('加载覆盖列表表格', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal()
|
||||||
|
await switchToOverridesTab()
|
||||||
|
|
||||||
|
const rows = queryBody('.ant-table-tbody tr')
|
||||||
|
expect(rows.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('method tag 使用正确颜色', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal()
|
||||||
|
await switchToOverridesTab()
|
||||||
|
|
||||||
|
const tags = queryBody('.ant-table-tbody .ant-tag')
|
||||||
|
if (tags.length > 0) {
|
||||||
|
expect(tags[0]?.className).toContain('green')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('移除覆盖按钮可用', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal()
|
||||||
|
await switchToOverridesTab()
|
||||||
|
|
||||||
|
const removeBtn = Array.from(queryBody('.ant-btn')).find(
|
||||||
|
(btn) => btn.textContent?.includes('移除'),
|
||||||
|
)
|
||||||
|
expect(removeBtn).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保存覆盖调用正确 API', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal()
|
||||||
|
await switchToOverridesTab()
|
||||||
|
|
||||||
|
const saveBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
||||||
|
(btn) => btn.textContent?.includes('保存覆盖'),
|
||||||
|
) as HTMLElement
|
||||||
|
expect(saveBtn).toBeTruthy()
|
||||||
|
saveBtn.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(vi.mocked(api.put)).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/roles/2/route-overrides',
|
||||||
|
{ overrides: mockOverrides },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('生命周期', () => {
|
||||||
|
it('打开时触发数据加载', 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('/route-groups'))).toBe(true)
|
||||||
|
expect(calls.some((url) => url.includes('/route-overrides'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('API 错误不阻塞渲染', async () => {
|
||||||
|
vi.mocked(api.get).mockRejectedValue(new Error('网络错误'))
|
||||||
|
await mountModal()
|
||||||
|
|
||||||
|
const modal = document.body.querySelector('.ant-modal')
|
||||||
|
expect(modal).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
ApartmentOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
@@ -75,6 +77,8 @@ const menuItems: MenuItem[] = [
|
|||||||
{ key: '/refund-items', icon: UnorderedListOutlined, label: '退款子项' },
|
{ key: '/refund-items', icon: UnorderedListOutlined, label: '退款子项' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ key: '/roles', icon: TeamOutlined, label: '角色管理', adminOnly: true },
|
||||||
|
{ key: '/route-groups', icon: ApartmentOutlined, label: '路由组管理', adminOnly: true },
|
||||||
{ key: '/mq-status', icon: MonitorOutlined, label: '队列监控', adminOnly: true },
|
{ key: '/mq-status', icon: MonitorOutlined, label: '队列监控', adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -106,6 +110,8 @@ const breadcrumbItems = computed(() => {
|
|||||||
'/order-items': '订单子项',
|
'/order-items': '订单子项',
|
||||||
'/refunds': '退款列表',
|
'/refunds': '退款列表',
|
||||||
'/refund-items': '退款子项',
|
'/refund-items': '退款子项',
|
||||||
|
'/roles': '角色管理',
|
||||||
|
'/route-groups': '路由组管理',
|
||||||
'/mq-status': '队列监控',
|
'/mq-status': '队列监控',
|
||||||
'/profile': '个人信息',
|
'/profile': '个人信息',
|
||||||
'/profile/password': '修改密码',
|
'/profile/password': '修改密码',
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ setTokenGetter(() => useUserStore().token)
|
|||||||
|
|
||||||
// 路由守卫
|
// 路由守卫
|
||||||
const authWhitelist = ['/login', '/register']
|
const authWhitelist = ['/login', '/register']
|
||||||
const adminOnlyPaths = ['/users', '/mq-status']
|
const adminOnlyPaths = ['/users', '/mq-status', '/roles', '/route-groups']
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const { useUserStore } = await import('./stores/user')
|
const { useUserStore } = await import('./stores/user')
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoleManageStore } from '@/stores/role-manage'
|
||||||
|
import type { RoleRecord } from '@/stores/role-manage'
|
||||||
|
import RolePermissionModal from '@/components/RolePermissionModal.vue'
|
||||||
|
|
||||||
|
const roleStore = useRoleManageStore()
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '角色名称', dataIndex: 'description', key: 'description' },
|
||||||
|
{ title: '标识', dataIndex: 'name', key: 'name' },
|
||||||
|
{ title: '用户数', dataIndex: 'user_count', key: 'user_count', width: 100 },
|
||||||
|
{ title: '路由组数', key: 'route_group_count', width: 120 },
|
||||||
|
{ title: '操作', key: 'action', width: 120 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const permModalOpen = ref(false)
|
||||||
|
const currentRole = ref<RoleRecord | null>(null)
|
||||||
|
|
||||||
|
function handleConfigPermission(record: RoleRecord) {
|
||||||
|
currentRole.value = record
|
||||||
|
permModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePermSaved() {
|
||||||
|
roleStore.fetchRoles()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
roleStore.fetchRoles()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-card title="角色管理">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="roleStore.roles"
|
||||||
|
:loading="roleStore.loading"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'route_group_count'">
|
||||||
|
<a-tag v-if="record.name === 'administrator'" color="blue">全部</a-tag>
|
||||||
|
<span v-else>{{ record.route_group_count }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<a-button
|
||||||
|
v-if="record.name !== 'administrator'"
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
@click="handleConfigPermission(record as RoleRecord)"
|
||||||
|
>
|
||||||
|
配置权限
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<RolePermissionModal
|
||||||
|
v-model:open="permModalOpen"
|
||||||
|
:role="currentRole"
|
||||||
|
@saved="handlePermSaved"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouteGroupStore } from '@/stores/route-group'
|
||||||
|
import type { RouteGroupRecord } from '@/stores/route-group'
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
const groupStore = useRouteGroupStore()
|
||||||
|
|
||||||
|
// 路由组表单
|
||||||
|
const modalOpen = ref(false)
|
||||||
|
const modalTitle = ref('新建路由组')
|
||||||
|
const editingGroupId = ref<number | null>(null)
|
||||||
|
const formState = reactive({
|
||||||
|
name: '',
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
sort_order: 0,
|
||||||
|
})
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// method 颜色映射
|
||||||
|
const methodColorMap: Record<string, string> = {
|
||||||
|
GET: 'green',
|
||||||
|
POST: 'blue',
|
||||||
|
PUT: 'orange',
|
||||||
|
DELETE: 'red',
|
||||||
|
PATCH: 'cyan',
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeColumns = [
|
||||||
|
{ title: '方法', key: 'method', width: 100 },
|
||||||
|
{ title: '路径', dataIndex: 'path', key: 'path' },
|
||||||
|
{ title: '描述', dataIndex: 'description', key: 'description', width: 200 },
|
||||||
|
{ title: '所属组', key: 'group', width: 180 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 组选项(含"无"选项)
|
||||||
|
const groupOptions = computed(() => [
|
||||||
|
{ value: null as number | null, label: '无(未分组)' },
|
||||||
|
...groupStore.groups.map((g) => ({ value: g.id, label: g.label || g.name })),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 筛选路由
|
||||||
|
const filteredRoutes = computed(() => {
|
||||||
|
if (groupStore.selectedGroupId === null) return groupStore.routes
|
||||||
|
if (groupStore.selectedGroupId === 'ungrouped') {
|
||||||
|
return groupStore.routes.filter((r) => r.group_id === null)
|
||||||
|
}
|
||||||
|
return groupStore.routes.filter((r) => r.group_id === groupStore.selectedGroupId)
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectGroup(id: number | null | 'ungrouped') {
|
||||||
|
groupStore.selectedGroupId = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
editingGroupId.value = null
|
||||||
|
modalTitle.value = '新建路由组'
|
||||||
|
formState.name = ''
|
||||||
|
formState.label = ''
|
||||||
|
formState.description = ''
|
||||||
|
formState.sort_order = 0
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(group: RouteGroupRecord) {
|
||||||
|
editingGroupId.value = group.id
|
||||||
|
modalTitle.value = '编辑路由组'
|
||||||
|
formState.name = group.name
|
||||||
|
formState.label = group.label
|
||||||
|
formState.description = group.description
|
||||||
|
formState.sort_order = group.sort_order
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingGroupId.value) {
|
||||||
|
await groupStore.updateGroup(editingGroupId.value, { ...formState })
|
||||||
|
message.success('路由组更新成功')
|
||||||
|
} else {
|
||||||
|
await groupStore.createGroup({ ...formState })
|
||||||
|
message.success('路由组创建成功')
|
||||||
|
}
|
||||||
|
modalOpen.value = false
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(group: RouteGroupRecord) {
|
||||||
|
try {
|
||||||
|
await groupStore.deleteGroup(group.id)
|
||||||
|
if (groupStore.selectedGroupId === group.id) {
|
||||||
|
groupStore.selectedGroupId = null
|
||||||
|
}
|
||||||
|
message.success('路由组删除成功')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '删除失败'
|
||||||
|
message.error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGroupChange(routeId: number, groupId: number | null) {
|
||||||
|
try {
|
||||||
|
await groupStore.assignRouteToGroup(routeId, groupId)
|
||||||
|
await groupStore.fetchRoutes()
|
||||||
|
await groupStore.fetchGroups()
|
||||||
|
message.success('路由分配成功')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '分配失败'
|
||||||
|
message.error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([groupStore.fetchGroups(), groupStore.fetchRoutes()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<!-- 左侧:路由组列表 -->
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-card title="路由组" :loading="groupStore.loading">
|
||||||
|
<template #extra>
|
||||||
|
<a-button type="primary" size="small" @click="openCreateModal">
|
||||||
|
<PlusOutlined /> 新建
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 未分组入口 -->
|
||||||
|
<div
|
||||||
|
class="cursor-pointer px-3 py-2 rounded mb-1"
|
||||||
|
:class="groupStore.selectedGroupId === 'ungrouped' ? 'bg-blue-50' : 'hover:bg-gray-50'"
|
||||||
|
@click="selectGroup('ungrouped')"
|
||||||
|
>
|
||||||
|
<div class="font-medium">未分组</div>
|
||||||
|
<div class="text-xs text-gray-400">未归入任何路由组的路由</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider class="my-2" />
|
||||||
|
|
||||||
|
<!-- 路由组列表 -->
|
||||||
|
<div
|
||||||
|
v-for="group in groupStore.groups"
|
||||||
|
:key="group.id"
|
||||||
|
class="cursor-pointer px-3 py-2 rounded mb-1 flex items-center justify-between"
|
||||||
|
:class="groupStore.selectedGroupId === group.id ? 'bg-blue-50' : 'hover:bg-gray-50'"
|
||||||
|
@click="selectGroup(group.id)"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ group.label || group.name }}</div>
|
||||||
|
<div class="text-xs text-gray-400">{{ group.route_count ?? 0 }} 条路由</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1" @click.stop>
|
||||||
|
<a-button type="text" size="small" @click="openEditModal(group)">
|
||||||
|
<EditOutlined />
|
||||||
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
title="删除后,关联的角色授权将受影响,确认删除?"
|
||||||
|
@confirm="handleDelete(group)"
|
||||||
|
>
|
||||||
|
<a-button type="text" size="small" danger>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- 右侧:路由列表 -->
|
||||||
|
<a-col :span="16">
|
||||||
|
<a-card title="路由列表">
|
||||||
|
<a-table
|
||||||
|
:columns="routeColumns"
|
||||||
|
:data-source="filteredRoutes"
|
||||||
|
:loading="groupStore.routesLoading"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'method'">
|
||||||
|
<a-tag :color="methodColorMap[record.method] || 'default'">
|
||||||
|
{{ record.method }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'group'">
|
||||||
|
<a-select
|
||||||
|
:value="record.group_id"
|
||||||
|
:options="groupOptions"
|
||||||
|
size="small"
|
||||||
|
style="width: 150px"
|
||||||
|
placeholder="选择路由组"
|
||||||
|
@change="(val: unknown) => handleGroupChange(record.id, val as number | null)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- 路由组新建/编辑 Modal -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="modalOpen"
|
||||||
|
:title="modalTitle"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
>
|
||||||
|
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }" class="mt-4">
|
||||||
|
<a-form-item label="组标识" required>
|
||||||
|
<a-input v-model:value="formState.name" placeholder="如 user-management" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="显示名称" required>
|
||||||
|
<a-input v-model:value="formState.label" placeholder="如 用户管理" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="描述">
|
||||||
|
<a-textarea v-model:value="formState.description" :rows="2" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="排序">
|
||||||
|
<a-input-number v-model:value="formState.sort_order" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
@@ -22,14 +22,21 @@ const modalOpen = ref(false)
|
|||||||
const modalMode = ref<'create' | 'edit'>('create')
|
const modalMode = ref<'create' | 'edit'>('create')
|
||||||
const editingUser = ref<UserRecord | null>(null)
|
const editingUser = ref<UserRecord | null>(null)
|
||||||
|
|
||||||
|
// Role assign modal
|
||||||
|
const roleModalOpen = ref(false)
|
||||||
|
const roleAssigning = ref(false)
|
||||||
|
const assigningUser = ref<UserRecord | null>(null)
|
||||||
|
const selectedRoleId = ref<number | undefined>(undefined)
|
||||||
|
const roleOptions = ref<{ value: number; label: string }[]>([])
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
{ title: '用户名', dataIndex: 'username' },
|
{ title: '用户名', dataIndex: 'username' },
|
||||||
{ title: '邮箱', dataIndex: 'email' },
|
{ title: '邮箱', dataIndex: 'email' },
|
||||||
{ title: '角色', dataIndex: 'role_name', 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: 200 },
|
{ title: '操作', key: 'action', width: 280 },
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -93,6 +100,35 @@ function handleModalSuccess() {
|
|||||||
store.fetchUsers()
|
store.fetchUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAssignRole(record: UserRecord) {
|
||||||
|
assigningUser.value = record
|
||||||
|
selectedRoleId.value = record.role_id || undefined
|
||||||
|
roleModalOpen.value = true
|
||||||
|
try {
|
||||||
|
const roles = await api.get<{ id: number; name: string; description: string }[]>('/api/v1/roles')
|
||||||
|
roleOptions.value = roles.map((r) => ({ value: r.id, label: r.description || r.name }))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '获取角色列表失败'
|
||||||
|
message.error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRoleAssign() {
|
||||||
|
if (!assigningUser.value || !selectedRoleId.value) return
|
||||||
|
roleAssigning.value = true
|
||||||
|
try {
|
||||||
|
await api.put(`/api/v1/users/${assigningUser.value.id}/role`, { role_id: selectedRoleId.value })
|
||||||
|
message.success('角色分配成功')
|
||||||
|
roleModalOpen.value = false
|
||||||
|
store.fetchUsers()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '分配失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
roleAssigning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(time: string) {
|
function formatTime(time: string) {
|
||||||
return time ? time.replace('T', ' ').substring(0, 19) : '-'
|
return time ? time.replace('T', ' ').substring(0, 19) : '-'
|
||||||
}
|
}
|
||||||
@@ -164,8 +200,9 @@ function formatTime(time: string) {
|
|||||||
row-key="id"
|
row-key="id"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.dataIndex === 'role_name'">
|
<template v-if="column.key === 'role'">
|
||||||
{{ record.role?.name || '-' }}
|
<a-tag v-if="record.role" color="blue">{{ record.role.name }}</a-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
</template>
|
</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'">
|
||||||
@@ -183,6 +220,14 @@ function formatTime(time: string) {
|
|||||||
<a-button v-if="userStore.isAdmin" type="link" size="small" @click="handleEdit(record as UserRecord)">
|
<a-button v-if="userStore.isAdmin" type="link" size="small" @click="handleEdit(record as UserRecord)">
|
||||||
编辑
|
编辑
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="userStore.isAdmin && record.id !== userStore.user?.id"
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
@click="handleAssignRole(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 ? '禁用' : '启用'}该用户吗?`"
|
||||||
@@ -243,6 +288,28 @@ function formatTime(time: string) {
|
|||||||
</a-spin>
|
</a-spin>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
|
|
||||||
|
<!-- Role assign modal -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="roleModalOpen"
|
||||||
|
title="分配角色"
|
||||||
|
:confirm-loading="roleAssigning"
|
||||||
|
@ok="submitRoleAssign"
|
||||||
|
>
|
||||||
|
<div class="py-2">
|
||||||
|
<p class="mb-3">
|
||||||
|
用户:<strong>{{ assigningUser?.username }}</strong>
|
||||||
|
</p>
|
||||||
|
<a-form-item label="选择角色">
|
||||||
|
<a-select
|
||||||
|
v-model:value="selectedRoleId"
|
||||||
|
:options="roleOptions"
|
||||||
|
placeholder="请选择角色"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
<!-- Create/Edit modal -->
|
<!-- Create/Edit modal -->
|
||||||
<UserFormModal
|
<UserFormModal
|
||||||
v-model:open="modalOpen"
|
v-model:open="modalOpen"
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { api } from '@/utils/request'
|
||||||
|
|
||||||
|
export interface RoleRecord {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
user_count: number
|
||||||
|
route_group_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteOverride {
|
||||||
|
route_id: number
|
||||||
|
method: string
|
||||||
|
path: string
|
||||||
|
allow: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRoleManageStore = defineStore('role-manage', () => {
|
||||||
|
const roles = ref<RoleRecord[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchRoles() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<RoleRecord[]>('/api/v1/roles')
|
||||||
|
roles.value = data
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '获取角色列表失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoleRouteGroups(roleId: number) {
|
||||||
|
const data = await api.get<number[]>(`/api/v1/roles/${roleId}/route-groups`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRoleRouteGroups(roleId: number, groupIds: number[]) {
|
||||||
|
await api.put(`/api/v1/roles/${roleId}/route-groups`, { group_ids: groupIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoleOverrides(roleId: number) {
|
||||||
|
const data = await api.get<RouteOverride[]>(`/api/v1/roles/${roleId}/route-overrides`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRoleOverrides(roleId: number, overrides: RouteOverride[]) {
|
||||||
|
await api.put(`/api/v1/roles/${roleId}/route-overrides`, { overrides })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles,
|
||||||
|
loading,
|
||||||
|
fetchRoles,
|
||||||
|
fetchRoleRouteGroups,
|
||||||
|
setRoleRouteGroups,
|
||||||
|
fetchRoleOverrides,
|
||||||
|
setRoleOverrides,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { api } from '@/utils/request'
|
||||||
|
|
||||||
|
export interface RouteGroupRecord {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
sort_order: number
|
||||||
|
route_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteRecord {
|
||||||
|
id: number
|
||||||
|
method: string
|
||||||
|
path: string
|
||||||
|
description: string
|
||||||
|
group_id: number | null
|
||||||
|
group_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRouteGroupStore = defineStore('route-group', () => {
|
||||||
|
const groups = ref<RouteGroupRecord[]>([])
|
||||||
|
const routes = ref<RouteRecord[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const routesLoading = ref(false)
|
||||||
|
const selectedGroupId = ref<number | null | 'ungrouped'>(null)
|
||||||
|
|
||||||
|
async function fetchGroups() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<RouteGroupRecord[]>('/api/v1/route-groups')
|
||||||
|
groups.value = data
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '获取路由组列表失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGroup(data: Partial<RouteGroupRecord>) {
|
||||||
|
await api.post('/api/v1/route-groups', data)
|
||||||
|
await fetchGroups()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateGroup(id: number, data: Partial<RouteGroupRecord>) {
|
||||||
|
await api.put(`/api/v1/route-groups/${id}`, data)
|
||||||
|
await fetchGroups()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGroup(id: number) {
|
||||||
|
await api.delete(`/api/v1/route-groups/${id}`)
|
||||||
|
await fetchGroups()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoutes(params?: { group_id?: number | null }) {
|
||||||
|
routesLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<RouteRecord[]>('/api/v1/routes', params as Record<string, unknown>)
|
||||||
|
routes.value = data
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '获取路由列表失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
routesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignRouteToGroup(routeId: number, groupId: number | null) {
|
||||||
|
await api.put(`/api/v1/routes/${routeId}/group`, { group_id: groupId })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
routes,
|
||||||
|
loading,
|
||||||
|
routesLoading,
|
||||||
|
selectedGroupId,
|
||||||
|
fetchGroups,
|
||||||
|
createGroup,
|
||||||
|
updateGroup,
|
||||||
|
deleteGroup,
|
||||||
|
fetchRoutes,
|
||||||
|
assignRouteToGroup,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user