fetch(modify):修复角色分配权限
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 2m36s

This commit is contained in:
sexygoat
2026-02-02 17:08:49 +08:00
parent f62437379d
commit 06cde977ad
14 changed files with 789 additions and 267 deletions

View File

@@ -0,0 +1,32 @@
# Change: 重命名 API 字段 series_allocation_id 为 series_id
## Why
后端 API 字段命名不一致。前端表单使用 `series_id`套餐系列ID但 API 参数仍使用 `series_allocation_id`套餐系列分配ID。这导致命名混淆且不符合实际业务含义现在直接指向套餐系列 ID而非分配记录 ID
## What Changes
- **BREAKING**: 重命名 4 个 API 端点的请求/响应字段
- `PATCH /api/admin/iot-cards/series-binding` - 请求参数 `series_allocation_id``series_id`
- `PATCH /api/admin/devices/series-binding` - 请求参数 `series_allocation_id``series_id`
- `GET /api/admin/iot-cards/standalone` - 查询参数和响应字段 `series_allocation_id``series_id`
- `GET /api/admin/devices` - 查询参数和响应字段 `series_allocation_id``series_id`
- 前端同步修改:
- TypeScript 类型定义
- API 方法参数
- 页面组件调用代码
- 移除临时注释
## Impact
- **Affected specs**: iot-card-api, device-api
- **Affected code**:
- `src/types/api/device.ts` (BatchSetDeviceSeriesBindingRequest)
- `src/types/api/card.ts` (BatchSetCardSeriesBindingRequest)
- `src/api/modules/device.ts` (batchSetDeviceSeriesBinding)
- `src/api/modules/card.ts` (batchSetCardSeriesBinding)
- `src/views/asset-management/device-list/index.vue`
- `src/views/asset-management/iot-card-management/index.vue`
- **Breaking change**: 需要后端先部署更新,前端再部署
- **Migration**: 更新所有使用这些字段的 API 调用

View File

@@ -0,0 +1,100 @@
# Device API Specification Delta
## ADDED Requirements
### Requirement: Device Series ID Query Parameter
The system SHALL support filtering devices by package series ID in query parameters.
**Query Parameter**: `series_id` (number, optional)
#### Scenario: Filter devices by series
- **WHEN** admin queries devices with series_id parameter
- **THEN** system returns only devices associated with that package series
- **AND** returns empty list if no matches found
#### Scenario: Query without series filter
- **WHEN** admin queries devices without series_id parameter
- **THEN** system returns all devices matching other filter criteria
- **AND** series association is not considered
### Requirement: Device Series ID Response Field
The system SHALL include package series ID in device response data.
**Response Field**: `series_id` (number | null)
- Value is package series ID if device is bound to a series
- Value is null if device has no series binding
#### Scenario: Display series association
- **WHEN** system returns device in list response
- **THEN** each device includes series_id field
- **AND** field shows current series binding or null
#### Scenario: Null series binding
- **WHEN** device has no series binding
- **THEN** series_id field is null
- **AND** device can still be displayed in results
## RENAMED Requirements
### API Field Renaming
- FROM: `series_allocation_id` (in batch series binding endpoints)
- TO: `series_id`
**Reason**: Field name `series_allocation_id` implied a reference to an allocation record ID, but it actually stores the package series ID directly. Renaming to `series_id` clarifies this relationship and aligns with frontend naming conventions.
## MODIFIED Requirements
### Requirement: Batch Set Device Series Binding
The system SHALL provide an endpoint to batch set package series binding for devices.
**Endpoint**: `PATCH /api/admin/devices/series-binding`
**Request Body**:
```json
{
"device_ids": [1, 2, 3],
"series_id": 123 // Changed from series_allocation_id
}
```
**Field Definitions**:
- `device_ids`: Array of device IDs (max 500 items)
- `series_id`: Package series ID (tb_package_series.id), 0 means clear association
**Response**:
```json
{
"code": 0,
"data": {
"success_count": 3,
"fail_count": 0,
"failed_items": null
}
}
```
#### Scenario: Successful batch series binding
- **WHEN** admin calls batch series binding API with valid device IDs and series_id
- **THEN** system updates series association for all valid devices
- **AND** returns success count and failure details
#### Scenario: Clear series association
- **WHEN** admin calls batch series binding API with series_id = 0
- **THEN** system clears series association for specified devices
- **AND** returns success confirmation
#### Scenario: Partial failure handling
- **WHEN** some devices in the batch cannot be updated
- **THEN** system processes valid devices successfully
- **AND** returns failed_items list with reasons for failures

View File

@@ -0,0 +1,100 @@
# IoT Card API Specification Delta
## ADDED Requirements
### Requirement: IoT Card Series ID Query Parameter
The system SHALL support filtering standalone IoT cards by package series ID in query parameters.
**Query Parameter**: `series_id` (number, optional)
#### Scenario: Filter cards by series
- **WHEN** admin queries standalone cards with series_id parameter
- **THEN** system returns only cards associated with that package series
- **AND** returns empty list if no matches found
#### Scenario: Query without series filter
- **WHEN** admin queries cards without series_id parameter
- **THEN** system returns all cards matching other filter criteria
- **AND** series association is not considered
### Requirement: IoT Card Series ID Response Field
The system SHALL include package series ID in standalone IoT card response data.
**Response Field**: `series_id` (number | null)
- Value is package series ID if card is bound to a series
- Value is null if card has no series binding
#### Scenario: Display series association
- **WHEN** system returns IoT card in list response
- **THEN** each card includes series_id field
- **AND** field shows current series binding or null
#### Scenario: Null series binding
- **WHEN** card has no series binding
- **THEN** series_id field is null
- **AND** card can still be displayed in results
## RENAMED Requirements
### API Field Renaming
- FROM: `series_allocation_id` (in batch series binding endpoints)
- TO: `series_id`
**Reason**: Field name `series_allocation_id` implied a reference to an allocation record ID, but it actually stores the package series ID directly. Renaming to `series_id` clarifies this relationship and aligns with frontend naming conventions.
## MODIFIED Requirements
### Requirement: Batch Set Card Series Binding
The system SHALL provide an endpoint to batch set package series binding for IoT cards.
**Endpoint**: `PATCH /api/admin/iot-cards/series-binding`
**Request Body**:
```json
{
"iccids": ["898600..."],
"series_id": 123 // Changed from series_allocation_id
}
```
**Field Definitions**:
- `iccids`: Array of ICCIDs (max 500 items)
- `series_id`: Package series ID (tb_package_series.id), 0 means clear association
**Response**:
```json
{
"code": 0,
"data": {
"success_count": 100,
"fail_count": 0,
"failed_items": null
}
}
```
#### Scenario: Successful batch series binding
- **WHEN** admin calls batch series binding API with valid ICCIDs and series_id
- **THEN** system updates series association for all valid cards
- **AND** returns success count and failure details
#### Scenario: Clear series association
- **WHEN** admin calls batch series binding API with series_id = 0
- **THEN** system clears series association for specified cards
- **AND** returns success confirmation
#### Scenario: Partial failure handling
- **WHEN** some cards in the batch cannot be updated
- **THEN** system processes valid cards successfully
- **AND** returns failed_items list with reasons for failures

View File

@@ -0,0 +1,34 @@
# Implementation Tasks
## 1. 类型定义更新
### 1.1 批量设置接口参数重命名
- [x] 1.1.1 更新 `src/types/api/device.ts` 中 BatchSetDeviceSeriesBindingRequest 接口,将 `series_allocation_id` 改为 `series_id`
- [x] 1.1.2 更新 `src/types/api/card.ts` 中 BatchSetCardSeriesBindingRequest 接口,将 `series_allocation_id` 改为 `series_id`
### 1.2 查询参数新增字段
- [x] 1.2.1 在 `src/types/api/device.ts` 的 DeviceQueryParams 接口中添加 `series_id?: number` 查询参数
- [x] 1.2.2 在 `src/types/api/card.ts` 的 StandaloneCardQueryParams 接口中添加 `series_id?: number` 查询参数
### 1.3 响应类型新增字段
- [x] 1.3.1 在 `src/types/api/device.ts` 的 Device 接口中添加 `series_id?: number | null` 响应字段(在 updated_at 后面)
- [x] 1.3.2 在 `src/types/api/card.ts` 的 StandaloneIotCard 接口中添加 `series_id?: number | null` 响应字段(在 updated_at 后面)
## 2. API 方法更新
- [x] 2.1 更新 `src/api/modules/device.ts` 中 batchSetDeviceSeriesBinding 方法参数,将 `series_allocation_id` 改为 `series_id`
- [x] 2.2 更新 `src/api/modules/card.ts` 中 batchSetCardSeriesBinding 方法参数,将 `series_allocation_id` 改为 `series_id`
## 3. 页面组件更新
- [x] 3.1 更新 `src/views/asset-management/device-list/index.vue` 中调用 batchSetDeviceSeriesBinding 的代码,将参数名从 `series_allocation_id` 改为 `series_id`
- [x] 3.2 移除 device-list/index.vue 中第 1191 行的临时注释
- [x] 3.3 更新 `src/views/asset-management/iot-card-management/index.vue` 中调用 batchSetCardSeriesBinding 的代码,将参数名从 `series_allocation_id` 改为 `series_id`
- [x] 3.4 移除 iot-card-management/index.vue 中第 1326 行的临时注释
## 4. 验证
- [x] 4.1 运行 TypeScript 类型检查确认无类型错误
- [ ] 4.2 本地测试批量设置 IoT 卡系列绑定功能
- [ ] 4.3 本地测试批量设置设备系列绑定功能
- [ ] 4.4 确认所有相关功能正常工作

View File

@@ -363,7 +363,7 @@ export class CardService extends BaseService {
*/ */
static batchSetCardSeriesBinding(data: { static batchSetCardSeriesBinding(data: {
iccids: string[] iccids: string[]
series_allocation_id: number series_id: number
}): Promise<BaseResponse<any>> { }): Promise<BaseResponse<any>> {
return this.patch('/api/admin/iot-cards/series-binding', data) return this.patch('/api/admin/iot-cards/series-binding', data)
} }

View File

@@ -153,7 +153,7 @@ export class DeviceService extends BaseService {
*/ */
static batchSetDeviceSeriesBinding(data: { static batchSetDeviceSeriesBinding(data: {
device_ids: number[] device_ids: number[]
series_allocation_id: number series_id: number
}): Promise<BaseResponse<any>> { }): Promise<BaseResponse<any>> {
return this.patch('/api/admin/devices/series-binding', data) return this.patch('/api/admin/devices/series-binding', data)
} }

View File

@@ -173,7 +173,11 @@ function convertRouteComponent(
// 基础路由配置 // 基础路由配置
const converted: ConvertedRoute = { const converted: ConvertedRoute = {
...routeConfig, ...routeConfig,
component: undefined component: undefined,
meta: {
...routeConfig.meta,
dynamic: true // 标记为动态路由,用于退出时清理
}
} }
// 是否为一级菜单 // 是否为一级菜单
@@ -229,7 +233,10 @@ function handleLayoutRoute(
path: route.path, path: route.path,
name: route.name, name: route.name,
component: loadComponent(component as string, String(route.name)), component: loadComponent(component as string, String(route.name)),
meta: route.meta meta: {
...route.meta,
dynamic: true // 标记为动态路由,用于退出时清理
}
} }
] ]
} }

View File

@@ -318,6 +318,7 @@ export interface StandaloneCardQueryParams extends PaginationParams {
is_replaced?: boolean // 是否有换卡记录 is_replaced?: boolean // 是否有换卡记录
iccid_start?: string // ICCID起始号 iccid_start?: string // ICCID起始号
iccid_end?: string // ICCID结束号 iccid_end?: string // ICCID结束号
series_id?: number // 套餐系列ID
} }
// 单卡信息 // 单卡信息
@@ -347,6 +348,7 @@ export interface StandaloneIotCard {
activated_at?: string | null // 激活时间 (可选) activated_at?: string | null // 激活时间 (可选)
created_at: string // 创建时间 created_at: string // 创建时间
updated_at: string // 更新时间 updated_at: string // 更新时间
series_id?: number | null // 套餐系列ID
} }
// ========== 单卡批量分配和回收相关 ========== // ========== 单卡批量分配和回收相关 ==========
@@ -488,7 +490,7 @@ export interface IotCardDetailResponse {
// 批量设置卡的套餐系列绑定请求参数 // 批量设置卡的套餐系列绑定请求参数
export interface BatchSetCardSeriesBindingRequest { export interface BatchSetCardSeriesBindingRequest {
iccids: string[] // ICCID列表最多500个 iccids: string[] // ICCID列表最多500个
series_allocation_id: number // 套餐系列分配ID0表示清除关联 series_id: number // 套餐系列ID0表示清除关联
} }
// 卡套餐系列绑定失败项 // 卡套餐系列绑定失败项

View File

@@ -34,6 +34,7 @@ export interface Device {
activated_at: string | null // 激活时间 activated_at: string | null // 激活时间
created_at: string // 创建时间 created_at: string // 创建时间
updated_at: string // 更新时间 updated_at: string // 更新时间
series_id?: number | null // 套餐系列ID
} }
// 设备查询参数 // 设备查询参数
@@ -47,6 +48,7 @@ export interface DeviceQueryParams extends PaginationParams {
manufacturer?: string // 制造商(模糊查询) manufacturer?: string // 制造商(模糊查询)
created_at_start?: string // 创建时间起始 created_at_start?: string // 创建时间起始
created_at_end?: string // 创建时间结束 created_at_end?: string // 创建时间结束
series_id?: number // 套餐系列ID
} }
// 设备列表响应 // 设备列表响应
@@ -207,7 +209,7 @@ export interface DeviceImportTaskDetail extends DeviceImportTask {
// 批量设置设备的套餐系列绑定请求参数 // 批量设置设备的套餐系列绑定请求参数
export interface BatchSetDeviceSeriesBindingRequest { export interface BatchSetDeviceSeriesBindingRequest {
device_ids: number[] // 设备ID列表最多500个 device_ids: number[] // 设备ID列表最多500个
series_allocation_id: number // 套餐系列分配ID0表示清除关联 series_id: number // 套餐系列ID0表示清除关联
} }
// 设备套餐系列绑定失败项 // 设备套餐系列绑定失败项

View File

@@ -131,6 +131,7 @@ declare module 'vue' {
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker'] ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTransfer: typeof import('element-plus/es')['ElTransfer']
ElTree: typeof import('element-plus/es')['ElTree'] ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload'] ElUpload: typeof import('element-plus/es')['ElUpload']

View File

@@ -1188,7 +1188,7 @@
try { try {
const data = { const data = {
device_ids: selectedDevices.value.map((d) => d.id), device_ids: selectedDevices.value.map((d) => d.id),
series_allocation_id: seriesBindingForm.series_id! // 注意API参数名仍是series_allocation_id但前端使用series_id series_id: seriesBindingForm.series_id!
} }
const res = await DeviceService.batchSetDeviceSeriesBinding(data) const res = await DeviceService.batchSetDeviceSeriesBinding(data)
if (res.code === 0) { if (res.code === 0) {

View File

@@ -1323,7 +1323,7 @@
try { try {
const res = await CardService.batchSetCardSeriesBinding({ const res = await CardService.batchSetCardSeriesBinding({
iccids, iccids,
series_allocation_id: seriesBindingForm.series_id! // 注意API参数名仍是series_allocation_id但前端使用series_id series_id: seriesBindingForm.series_id!
}) })
if (res.code === 0) { if (res.code === 0) {

View File

@@ -63,10 +63,27 @@
v-model="createForm.package_ids" v-model="createForm.package_ids"
:placeholder="t('orderManagement.createForm.packageIdsPlaceholder')" :placeholder="t('orderManagement.createForm.packageIdsPlaceholder')"
multiple multiple
filterable
remote
reserve-keyword
:remote-method="searchPackages"
:loading="packageSearchLoading"
clearable
style="width: 100%" style="width: 100%"
> >
<!-- TODO: Load actual packages from API --> <ElOption
<ElOption label="套餐示例" :value="1" /> v-for="pkg in packageOptions"
:key="pkg.id"
:label="`${pkg.package_name} (¥${(pkg.price / 100).toFixed(2)})`"
:value="pkg.id"
>
<div style="display: flex; justify-content: space-between">
<span>{{ pkg.package_name }}</span>
<span style="color: var(--el-text-color-secondary); font-size: 12px">
¥{{ (pkg.price / 100).toFixed(2) }}
</span>
</div>
</ElOption>
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
<ElFormItem <ElFormItem
@@ -240,7 +257,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from 'vue' import { h } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { OrderService, CardService, DeviceService } from '@/api/modules' import { OrderService, CardService, DeviceService, PackageManageService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus' import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import type { import type {
@@ -253,7 +270,8 @@
OrderPaymentMethod, OrderPaymentMethod,
OrderCommissionStatus, OrderCommissionStatus,
StandaloneIotCard, StandaloneIotCard,
Device Device,
PackageResponse
} from '@/types/api' } from '@/types/api'
import type { SearchFormItem } from '@/types' import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -384,6 +402,10 @@
const orderList = ref<Order[]>([]) const orderList = ref<Order[]>([])
// 套餐搜索相关
const packageOptions = ref<PackageResponse[]>([])
const packageSearchLoading = ref(false)
// IoT卡搜索相关 // IoT卡搜索相关
const iotCardOptions = ref<StandaloneIotCard[]>([]) const iotCardOptions = ref<StandaloneIotCard[]>([])
const cardSearchLoading = ref(false) const cardSearchLoading = ref(false)
@@ -392,17 +414,34 @@
const deviceOptions = ref<Device[]>([]) const deviceOptions = ref<Device[]>([])
const deviceSearchLoading = ref(false) const deviceSearchLoading = ref(false)
// 搜索套餐(根据套餐名称)
const searchPackages = async (query: string) => {
packageSearchLoading.value = true
try {
const res = await PackageManageService.getPackages({
package_name: query || undefined,
page: 1,
page_size: 20,
status: 1, // 只获取启用的套餐
shelf_status: 1 // 只获取已上架的套餐
})
if (res.code === 0) {
packageOptions.value = res.data.items || []
}
} catch (error) {
console.error('Search packages failed:', error)
packageOptions.value = []
} finally {
packageSearchLoading.value = false
}
}
// 搜索IoT卡根据ICCID // 搜索IoT卡根据ICCID
const searchIotCards = async (query: string) => { const searchIotCards = async (query: string) => {
if (!query) {
iotCardOptions.value = []
return
}
cardSearchLoading.value = true cardSearchLoading.value = true
try { try {
const res = await CardService.getStandaloneIotCards({ const res = await CardService.getStandaloneIotCards({
iccid: query, iccid: query || undefined,
page: 1, page: 1,
page_size: 20 page_size: 20
}) })
@@ -419,15 +458,10 @@
// 搜索设备根据设备号device_no // 搜索设备根据设备号device_no
const searchDevices = async (query: string) => { const searchDevices = async (query: string) => {
if (!query) {
deviceOptions.value = []
return
}
deviceSearchLoading.value = true deviceSearchLoading.value = true
try { try {
const res = await DeviceService.getDevices({ const res = await DeviceService.getDevices({
device_no: query, device_no: query || undefined,
page: 1, page: 1,
page_size: 20 page_size: 20
}) })
@@ -656,8 +690,29 @@
// 显示创建订单对话框 // 显示创建订单对话框
const showCreateDialog = async () => { const showCreateDialog = async () => {
createDialogVisible.value = true createDialogVisible.value = true
// 默认加载20条IoT卡和设备数据 // 默认加载20条套餐、IoT卡和设备数据
await Promise.all([loadDefaultIotCards(), loadDefaultDevices()]) await Promise.all([loadDefaultPackages(), loadDefaultIotCards(), loadDefaultDevices()])
}
// 加载默认套餐列表
const loadDefaultPackages = async () => {
packageSearchLoading.value = true
try {
const res = await PackageManageService.getPackages({
page: 1,
page_size: 20,
status: 1, // 只获取启用的套餐
shelf_status: 1 // 只获取已上架的套餐
})
if (res.code === 0) {
packageOptions.value = res.data.items || []
}
} catch (error) {
console.error('Load default packages failed:', error)
packageOptions.value = []
} finally {
packageSearchLoading.value = false
}
} }
// 加载默认IoT卡列表 // 加载默认IoT卡列表
@@ -709,7 +764,8 @@
createForm.iot_card_id = null createForm.iot_card_id = null
createForm.device_id = null createForm.device_id = null
// 清空IoT卡和设备搜索结果 // 清空套餐、IoT卡和设备搜索结果
packageOptions.value = []
iotCardOptions.value = [] iotCardOptions.value = []
deviceOptions.value = [] deviceOptions.value = []
} }

View File

@@ -88,66 +88,114 @@
</ElDialog> </ElDialog>
<!-- 分配权限对话框 --> <!-- 分配权限对话框 -->
<ElDialog v-model="permissionDialogVisible" title="分配权限" width="800px"> <ElDialog v-model="permissionDialogVisible" width="800px">
<div class="permission-assignment-container"> <template #header>
<!-- 左侧权限树用于添加 --> <div class="dialog-header">
<div class="permission-tree-section"> <span class="dialog-title">分配权限</span>
<div class="section-title">可分配权限</div> <div class="role-info">
<ElTree <ElTag :type="currentRole.role_type === 1 ? 'primary' : 'success'" size="small">
ref="permissionTreeRef" {{ currentRole.role_type === 1 ? '平台角色' : '客户角色' }}
:data="permissionTreeData" </ElTag>
show-checkbox <span class="role-name">{{ currentRole.role_name }}</span>
node-key="id" </div>
:props="{ children: 'children', label: 'label' }" </div>
:default-expand-all="false" </template>
class="permission-tree" <div class="permission-tree-transfer-container">
@check="handlePermissionCheck" <!-- 左侧可分配权限树 -->
> <div class="transfer-panel">
<template #default="{ node, data }"> <div class="panel-header">
<span style="display: flex; align-items: center; gap: 8px"> <span class="panel-title">可分配权限</span>
<span>{{ node.label }}</span> <ElInput
<ElTag v-model="leftTreeFilter"
:type="data.perm_type === 1 ? 'info' : 'success'" placeholder="搜索权限"
size="small" clearable
> size="small"
{{ data.perm_type === 1 ? '菜单' : '按钮' }} style="width: 180px"
</ElTag> />
</span> </div>
</template> <div class="panel-body">
</ElTree> <ElTree
ref="leftTreeRef"
:data="availablePermissions"
:props="{ children: 'children', label: 'label' }"
node-key="id"
show-checkbox
:filter-node-method="filterNode"
:default-expand-all="false"
class="permission-tree"
>
<template #default="{ node, data }">
<span style="display: flex; align-items: center; gap: 8px">
<span>{{ node.label }}</span>
<ElTag
:type="data.perm_type === 1 ? 'info' : 'success'"
size="small"
>
{{ data.perm_type === 1 ? '菜单' : '按钮' }}
</ElTag>
</span>
</template>
</ElTree>
</div>
</div> </div>
<!-- 右侧已分配权限列表 --> <!-- 中间操作按钮 -->
<div class="assigned-permissions-section"> <div class="transfer-buttons">
<div class="section-title">已分配权限</div> <ElButton
<div class="assigned-list"> type="primary"
<div :icon="'ArrowRight'"
v-for="perm in assignedPermissionsList" @click="addPermissions"
:key="perm.id" :disabled="getLeftCheckedKeys().length === 0"
class="assigned-item" >
> 添加
<ElTag </ElButton>
:type="perm.perm_type === 1 ? 'info' : 'success'" </div>
size="small"
> <!-- 右侧已分配权限树 -->
{{ perm.perm_type === 1 ? '菜单' : '按钮' }} <div class="transfer-panel">
</ElTag> <div class="panel-header">
<span class="perm-name">{{ perm.perm_name }}</span> <span class="panel-title">已分配权限</span>
<ElButton <ElInput
type="danger" v-model="rightTreeFilter"
size="small" placeholder="搜索权限"
link clearable
@click="removePermission(perm.id)" size="small"
> style="width: 180px"
移除
</ElButton>
</div>
<ElEmpty
v-if="assignedPermissionsList.length === 0"
description="暂无已分配权限"
:image-size="80"
/> />
</div> </div>
<div class="panel-body">
<ElTree
ref="rightTreeRef"
:data="assignedPermissions"
:props="{ children: 'children', label: 'label' }"
node-key="id"
:filter-node-method="filterNode"
:default-expand-all="false"
class="permission-tree permission-tree-with-action"
>
<template #default="{ node, data }">
<span class="tree-node-content">
<span class="tree-node-label">
<span>{{ node.label }}</span>
<ElTag
:type="data.perm_type === 1 ? 'info' : 'success'"
size="small"
>
{{ data.perm_type === 1 ? '菜单' : '按钮' }}
</ElTag>
</span>
<ElButton
type="danger"
size="small"
link
@click="removeSinglePermission(data.id)"
>
移除
</ElButton>
</span>
</template>
</ElTree>
</div>
</div> </div>
</div> </div>
<template #footer> <template #footer>
@@ -169,7 +217,9 @@
ElMessageBox, ElMessageBox,
ElTag, ElTag,
ElTree, ElTree,
ElSwitch ElSwitch,
ElButton,
ElInput
} from 'element-plus' } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import type { PlatformRole, PermissionTreeNode } from '@/types/api' import type { PlatformRole, PermissionTreeNode } from '@/types/api'
@@ -188,16 +238,21 @@
const permissionDialogVisible = ref(false) const permissionDialogVisible = ref(false)
const loading = ref(false) const loading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const permissionSubmitLoading = ref(false)
const tableRef = ref() const tableRef = ref()
const permissionTreeRef = ref() const leftTreeRef = ref()
const rightTreeRef = ref()
const currentRoleId = ref<number>(0) const currentRoleId = ref<number>(0)
const currentRole = ref<{ role_name: string; role_type: number }>({
role_name: '',
role_type: 1
})
const selectedPermissions = ref<number[]>([]) const selectedPermissions = ref<number[]>([])
const originalPermissions = ref<number[]>([]) // 保存原始权限,用于对比 const originalPermissions = ref<number[]>([]) // 保存原始权限,用于对比
const permissionTreeData = ref<any[]>([]) const availablePermissions = ref<any[]>([]) // 左侧可分配权限树数据
const assignedPermissionsList = ref<any[]>([]) // 已分配权限列表(用于右侧显示) const assignedPermissions = ref<any[]>([]) // 右侧已分配权限树数据
const allPermissionsFlat = ref<PermissionTreeNode[]>([]) // 扁平化的所有权限数据 const allPermissionsMap = ref<Map<number, any>>(new Map()) // 所有权限的映射表
const isInitializingTree = ref(false) // 标记是否正在初始化树的选中状态 const leftTreeFilter = ref('') // 左侧树搜索关键字
const rightTreeFilter = ref('') // 右侧树搜索关键字
// 搜索表单初始值 // 搜索表单初始值
const initialSearchState = { const initialSearchState = {
@@ -355,22 +410,22 @@
getTableData() getTableData()
}) })
// 将权限树扁平化为一维数组 // 监听搜索关键字变化
const flattenPermissionTree = (treeNodes: PermissionTreeNode[]): PermissionTreeNode[] => { watch(leftTreeFilter, (val) => {
const result: PermissionTreeNode[] = [] leftTreeRef.value?.filter(val)
const flatten = (nodes: PermissionTreeNode[]) => { })
nodes.forEach((node) => {
result.push(node) watch(rightTreeFilter, (val) => {
if (node.children && node.children.length > 0) { rightTreeRef.value?.filter(val)
flatten(node.children) })
}
}) // 树节点过滤方法
} const filterNode = (value: string, data: any) => {
flatten(treeNodes) if (!value) return true
return result return data.label.toLowerCase().includes(value.toLowerCase())
} }
// 权限树节点转换为ElTree所需的格式 // 构建权限树数据结构
const buildTreeData = (treeNodes: PermissionTreeNode[]): any[] => { const buildTreeData = (treeNodes: PermissionTreeNode[]): any[] => {
return treeNodes.map((node) => ({ return treeNodes.map((node) => ({
id: node.id, id: node.id,
@@ -380,40 +435,224 @@
})) }))
} }
// 构建权限映射表(包括所有节点和子节点)
const buildPermissionMap = (treeNodes: PermissionTreeNode[], map: Map<number, any>) => {
treeNodes.forEach((node) => {
map.set(node.id, node)
if (node.children && node.children.length > 0) {
buildPermissionMap(node.children, map)
}
})
}
// 根据权限ID列表过滤树节点包含指定ID的节点
const filterTreeByIds = (treeNodes: any[], ids: number[]): any[] => {
const result: any[] = []
treeNodes.forEach((node) => {
if (ids.includes(node.id)) {
const newNode = { ...node }
if (node.children && node.children.length > 0) {
newNode.children = filterTreeByIds(node.children, ids)
}
result.push(newNode)
} else if (node.children && node.children.length > 0) {
const filteredChildren = filterTreeByIds(node.children, ids)
if (filteredChildren.length > 0) {
result.push({
...node,
children: filteredChildren
})
}
}
})
return result
}
// 根据权限ID列表设置树节点的禁用状态
const setTreeNodesDisabled = (treeNodes: any[], idsToDisable: number[]): any[] => {
return treeNodes.map((node) => {
const newNode = {
...node,
disabled: idsToDisable.includes(node.id)
}
if (node.children && node.children.length > 0) {
newNode.children = setTreeNodesDisabled(node.children, idsToDisable)
}
return newNode
})
}
// 获取树中所有节点的ID包括父节点和子节点
const getAllNodeIds = (treeNodes: any[]): number[] => {
const ids: number[] = []
const traverse = (nodes: any[]) => {
nodes.forEach((node) => {
ids.push(node.id)
if (node.children && node.children.length > 0) {
traverse(node.children)
}
})
}
traverse(treeNodes)
return ids
}
// 保存原始权限树数据
const originalPermissionTree = ref<PermissionTreeNode[]>([])
// 加载所有权限树 // 加载所有权限树
const loadAllPermissions = async () => { const loadAllPermissions = async () => {
try { try {
const res = await PermissionService.getPermissionTree() const res = await PermissionService.getPermissionTree()
if (res.code === 0) { if (res.code === 0) {
const treeData = res.data || [] const treeData = res.data || []
// 扁平化所有权限数据,用于查找
allPermissionsFlat.value = flattenPermissionTree(treeData) // 保存原始树数据
originalPermissionTree.value = treeData
// 构建权限映射表
const map = new Map()
buildPermissionMap(treeData, map)
allPermissionsMap.value = map
// 构建树形数据 // 构建树形数据
permissionTreeData.value = buildTreeData(treeData) const fullTreeData = buildTreeData(treeData)
// 左侧显示所有权限,但已分配的权限设置为禁用
availablePermissions.value = setTreeNodesDisabled(
JSON.parse(JSON.stringify(fullTreeData)),
selectedPermissions.value
)
// 右侧显示已分配的权限
if (selectedPermissions.value.length > 0) {
assignedPermissions.value = filterTreeByIds(fullTreeData, selectedPermissions.value)
} else {
assignedPermissions.value = []
}
} }
} catch (error) { } catch (error) {
console.error('获取权限树失败:', error) console.error('获取权限树失败:', error)
} }
} }
// 根据权限ID列表构建已分配权限列表 // 获取左侧树完全勾选的节点(不包括半选节点)
const buildAssignedPermissionsList = () => { const getLeftCheckedKeys = (): number[] => {
assignedPermissionsList.value = selectedPermissions.value if (!leftTreeRef.value) return []
.map((id) => allPermissionsFlat.value.find((p) => p.id === id)) return leftTreeRef.value.getCheckedKeys(false)
.filter((p) => p !== undefined) as PermissionTreeNode[] }
// 获取左侧树勾选的节点(包括半选节点,用于提交服务器)
const getLeftCheckedKeysWithHalf = (): number[] => {
if (!leftTreeRef.value) return []
const checkedKeys = leftTreeRef.value.getCheckedKeys(false)
const halfCheckedKeys = leftTreeRef.value.getHalfCheckedKeys()
return [...checkedKeys, ...halfCheckedKeys]
}
// 添加权限
const addPermissions = async () => {
const checkedKeys = getLeftCheckedKeys()
if (checkedKeys.length === 0) return
try {
// 提交到服务器时包含勾选和半选节点
const keysToSubmit = getLeftCheckedKeysWithHalf()
await RoleService.assignPermissions(currentRoleId.value, keysToSubmit)
// 本地只记录完全勾选的节点
selectedPermissions.value = [...new Set([...selectedPermissions.value, ...checkedKeys])]
// 重新构建左右两侧树
const fullTreeData = buildTreeData(originalPermissionTree.value)
// 左侧显示所有权限,但已分配的权限设置为禁用
availablePermissions.value = setTreeNodesDisabled(
JSON.parse(JSON.stringify(fullTreeData)),
selectedPermissions.value
)
// 右侧显示已分配的权限
assignedPermissions.value = filterTreeByIds(fullTreeData, selectedPermissions.value)
// 清空左侧树的勾选状态
leftTreeRef.value?.setCheckedKeys([], false)
ElMessage.success('权限添加成功')
} catch (error) {
console.error('添加权限失败:', error)
ElMessage.error('权限添加失败')
}
}
// 移除单个权限
const removeSinglePermission = async (permId: number) => {
try {
// 保存右侧树的展开节点
const expandedKeys = rightTreeRef.value?.store?.nodesMap
? Object.keys(rightTreeRef.value.store.nodesMap)
.filter(key => rightTreeRef.value.store.nodesMap[key].expanded)
.map(key => Number(key))
: []
await RoleService.removePermission(currentRoleId.value, permId)
// 更新已选权限列表
selectedPermissions.value = selectedPermissions.value.filter(id => id !== permId)
// 重新构建左右两侧树
const fullTreeData = buildTreeData(originalPermissionTree.value)
// 左侧显示所有权限,但已分配的权限设置为禁用
availablePermissions.value = setTreeNodesDisabled(
JSON.parse(JSON.stringify(fullTreeData)),
selectedPermissions.value
)
// 右侧显示已分配的权限
if (selectedPermissions.value.length > 0) {
assignedPermissions.value = filterTreeByIds(fullTreeData, selectedPermissions.value)
} else {
assignedPermissions.value = []
}
// 等待DOM更新后恢复展开状态
await nextTick()
if (rightTreeRef.value && expandedKeys.length > 0) {
expandedKeys.forEach(key => {
const node = rightTreeRef.value.store.nodesMap[key]
if (node && !node.isLeaf) {
node.expanded = true
}
})
}
ElMessage.success('权限移除成功')
} catch (error) {
console.error('移除权限失败:', error)
ElMessage.error('权限移除失败')
}
} }
// 显示分配权限对话框 // 显示分配权限对话框
const showPermissionDialog = async (row: PlatformRole) => { const showPermissionDialog = async (row: PlatformRole) => {
currentRoleId.value = row.ID currentRoleId.value = row.ID
currentRole.value = {
role_name: row.role_name,
role_type: row.role_type
}
selectedPermissions.value = [] selectedPermissions.value = []
originalPermissions.value = [] originalPermissions.value = []
assignedPermissionsList.value = [] leftTreeFilter.value = ''
rightTreeFilter.value = ''
try { try {
// 每次打开对话框时重新加载最新的权限列表
await loadAllPermissions()
// 加载当前角色的权限 // 加载当前角色的权限
const res = await RoleService.getRolePermissions(row.ID) const res = await RoleService.getRolePermissions(row.ID)
@@ -438,116 +677,18 @@
// 保存原始权限,用于后续对比 // 保存原始权限,用于后续对比
originalPermissions.value = [...selectedPermissions.value] originalPermissions.value = [...selectedPermissions.value]
// 构建已分配权限列表
buildAssignedPermissionsList()
// 数据加载完成后再打开对话框
permissionDialogVisible.value = true
// 等待DOM更新后设置树的初始选中状态
await nextTick()
if (permissionTreeRef.value) {
isInitializingTree.value = true
permissionTreeRef.value.setCheckedKeys(selectedPermissions.value, false)
// 延迟一点时间后取消初始化标记
setTimeout(() => {
isInitializingTree.value = false
}, 100)
}
} }
// 每次打开对话框时重新加载最新的权限列表
await loadAllPermissions()
// 数据加载完成后再打开对话框
permissionDialogVisible.value = true
} catch (error) { } catch (error) {
console.error('获取角色权限失败:', error) console.error('获取角色权限失败:', error)
} }
} }
// 处理权限勾选(只允许添加,不允许取消)
const handlePermissionCheck = async (data: any, checkedInfo: any) => {
if (!permissionTreeRef.value) return
// 如果正在初始化树忽略此次check事件
if (isInitializingTree.value) return
// 获取当前勾选的所有节点(不包括半选状态)
const checkedKeys = permissionTreeRef.value.getCheckedKeys(false)
// 找出新增的权限(当前勾选中不在已分配列表中的)
const newPermissions = checkedKeys.filter((id) => !selectedPermissions.value.includes(id))
if (newPermissions.length > 0) {
try {
// 调用API分配权限
await RoleService.assignPermissions(currentRoleId.value, newPermissions)
// 更新已分配权限列表
selectedPermissions.value = [...selectedPermissions.value, ...newPermissions]
buildAssignedPermissionsList()
ElMessage.success('权限添加成功')
} catch (error) {
console.error('添加权限失败:', error)
ElMessage.error('权限添加失败')
// 如果添加失败,恢复树的选中状态
nextTick(() => {
permissionTreeRef.value?.setCheckedKeys(selectedPermissions.value, false)
})
return
}
}
// 重新设置树的选中状态为已分配的权限(阻止取消勾选)
nextTick(() => {
permissionTreeRef.value?.setCheckedKeys(selectedPermissions.value, false)
})
}
// 移除单个权限
const removePermission = async (permId: number) => {
try {
await RoleService.removePermission(currentRoleId.value, permId)
// 重新从服务器获取最新的权限列表
const res = await RoleService.getRolePermissions(currentRoleId.value)
if (res.code === 0 || Array.isArray(res.data)) {
const permissions = res.data || []
// 清空并重新设置权限列表
if (Array.isArray(permissions) && permissions.length > 0) {
if (typeof permissions[0] === 'object') {
if ('ID' in permissions[0]) {
selectedPermissions.value = permissions.map((perm: any) => perm.ID)
} else if ('id' in permissions[0]) {
selectedPermissions.value = permissions.map((perm: any) => perm.id)
}
} else {
selectedPermissions.value = permissions
}
} else {
selectedPermissions.value = []
}
// 重建已分配权限列表
buildAssignedPermissionsList()
// 更新树的选中状态
await nextTick()
if (permissionTreeRef.value) {
isInitializingTree.value = true
permissionTreeRef.value.setCheckedKeys(selectedPermissions.value, false)
setTimeout(() => {
isInitializingTree.value = false
}, 100)
}
}
ElMessage.success('权限移除成功')
} catch (error) {
console.error('移除权限失败:', error)
ElMessage.error('权限移除失败')
}
}
// 获取角色列表 // 获取角色列表
const getTableData = async () => { const getTableData = async () => {
@@ -699,75 +840,122 @@
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.permission-assignment-container { .dialog-header {
display: flex; display: flex;
gap: 20px; justify-content: space-between;
height: 500px; align-items: center;
width: 100%;
.permission-tree-section, .dialog-title {
.assigned-permissions-section { font-size: 18px;
font-weight: 600;
}
.role-info {
display: flex;
align-items: center;
gap: 8px;
.role-name {
font-size: 14px;
color: var(--el-text-color-regular);
}
}
}
.permission-tree-transfer-container {
display: flex;
justify-content: space-between;
align-items: stretch;
gap: 20px;
padding: 20px 0;
min-height: 500px;
.transfer-panel {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: 1px solid var(--el-border-color); border: 1px solid var(--el-border-color);
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
max-width: 340px;
.section-title { .panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px; padding: 12px 16px;
background: var(--el-fill-color-light); background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color); border-bottom: 1px solid var(--el-border-color);
font-weight: 600;
font-size: 14px;
}
}
.permission-tree-section { .panel-title {
.permission-tree { font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.panel-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 12px; padding: 12px;
:deep(.el-tree-node) { .permission-tree {
margin: 6px 0; :deep(.el-tree-node) {
} margin: 4px 0;
:deep(.el-tree-node__content) {
height: 36px;
line-height: 36px;
}
}
}
.assigned-permissions-section {
.assigned-list {
flex: 1;
overflow-y: auto;
padding: 12px;
.assigned-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
margin-bottom: 8px;
background: var(--el-fill-color-lighter);
border-radius: 4px;
transition: all 0.3s;
&:hover {
background: var(--el-fill-color-light);
} }
.perm-name { :deep(.el-tree-node__content) {
height: 36px;
line-height: 36px;
padding-right: 8px;
}
:deep(.el-tree-node__label) {
flex: 1; flex: 1;
font-size: 14px;
}
.el-button {
padding: 4px 8px;
} }
} }
.permission-tree-with-action {
:deep(.el-tree-node__content) {
height: auto;
min-height: 36px;
padding: 4px 8px 4px 0;
}
.tree-node-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 8px;
.tree-node-label {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.el-button {
flex-shrink: 0;
margin-left: auto;
}
}
}
}
}
.transfer-buttons {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 12px;
padding: 0 10px;
.el-button {
width: 100px;
} }
} }
} }