feat: add equipment management frontend pages
This commit is contained in:
parent
f111f4a8d5
commit
7956379f71
|
|
@ -0,0 +1,57 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
operation: string
|
||||||
|
module: string
|
||||||
|
action: string
|
||||||
|
targetType?: string
|
||||||
|
targetId?: string
|
||||||
|
content?: string
|
||||||
|
ipAddress: string
|
||||||
|
requestMethod: string
|
||||||
|
requestUrl: string
|
||||||
|
status: 'SUCCESS' | 'FAIL'
|
||||||
|
executionTimeMs?: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogQuery {
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
module?: string
|
||||||
|
action?: string
|
||||||
|
username?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditLogs(params: AuditLogQuery) {
|
||||||
|
return request({
|
||||||
|
url: '/api/audit-logs',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditModules() {
|
||||||
|
return request({
|
||||||
|
url: '/api/audit-logs/modules',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditActions() {
|
||||||
|
return request({
|
||||||
|
url: '/api/audit-logs/actions',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditStats() {
|
||||||
|
return request({
|
||||||
|
url: '/api/audit-logs/stats',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// ==================== 设备相关类型 ====================
|
||||||
|
|
||||||
|
export interface EquipmentForm {
|
||||||
|
id?: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
isEquipment?: boolean
|
||||||
|
designLifeYears?: number
|
||||||
|
ratedPower?: number
|
||||||
|
ratedVoltage?: string
|
||||||
|
ratedCurrent?: number
|
||||||
|
maintenanceVendor?: string
|
||||||
|
maintenanceVendorPhone?: string
|
||||||
|
specialEquipmentType?: string
|
||||||
|
inspectionCycle?: number
|
||||||
|
nextInspectionDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Equipment {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
isEquipment: boolean
|
||||||
|
designLifeYears?: number
|
||||||
|
ratedPower?: number
|
||||||
|
ratedVoltage?: string
|
||||||
|
ratedCurrent?: number
|
||||||
|
maintenanceVendor?: string
|
||||||
|
maintenanceVendorPhone?: string
|
||||||
|
specialEquipmentType?: string
|
||||||
|
inspectionCycle?: number
|
||||||
|
nextInspectionDate?: string
|
||||||
|
spaceNodeId?: string
|
||||||
|
spaceNodeName?: string
|
||||||
|
projectId?: string
|
||||||
|
projectName?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageResponse<T> {
|
||||||
|
content: T[]
|
||||||
|
totalElements: number
|
||||||
|
totalPages: number
|
||||||
|
size: number
|
||||||
|
number: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 设备 API ====================
|
||||||
|
|
||||||
|
// 获取设备列表
|
||||||
|
export function getEquipmentList(projectId: string) {
|
||||||
|
return request.get<PageResponse<Equipment>>('/api/v1/mdm/space-nodes/equipment', {
|
||||||
|
params: { projectId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设备详情
|
||||||
|
export function getEquipmentDetail(id: string) {
|
||||||
|
return request.get<Equipment>(`/api/v1/mdm/space-nodes/${id}/equipment`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取特种设备列表
|
||||||
|
export function getSpecialEquipment(projectId: string) {
|
||||||
|
return request.get<Equipment[]>('/api/v1/mdm/space-nodes/special-equipment', {
|
||||||
|
params: { projectId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取即将年检设备
|
||||||
|
export function getExpiringInspection(projectId: string, daysAhead?: number) {
|
||||||
|
return request.get<Equipment[]>('/api/v1/mdm/space-nodes/expiring-inspection', {
|
||||||
|
params: { projectId, daysAhead }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,121 @@
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import type { Project } from '@/types'
|
import type { Project } from '@/types'
|
||||||
|
import type {
|
||||||
|
ProjectQuery,
|
||||||
|
PageResponse,
|
||||||
|
ProjectStatistics,
|
||||||
|
ProjectMember,
|
||||||
|
ProjectConfig,
|
||||||
|
ProjectSelectorItem,
|
||||||
|
StatusChangeRequest,
|
||||||
|
AddMemberRequest
|
||||||
|
} from '@/types/project'
|
||||||
|
|
||||||
|
// ==================== 基础 CRUD ====================
|
||||||
|
|
||||||
|
// PM-001 分页查询项目列表
|
||||||
|
export const queryProjects = (params: ProjectQuery) => {
|
||||||
|
return request.get<PageResponse<Project>>('/api/mdm/projects', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有项目(兼容旧接口)
|
||||||
export const getProjects = () => {
|
export const getProjects = () => {
|
||||||
return request.get<Project[]>('/projects')
|
return request.get<Project[]>('/api/mdm/projects/all')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取项目详情
|
||||||
export const getProject = (id: string) => {
|
export const getProject = (id: string) => {
|
||||||
return request.get<Project>(`/projects/${id}`)
|
return request.get<Project>(`/api/mdm/projects/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据编码获取项目
|
||||||
export const getProjectByCode = (code: string) => {
|
export const getProjectByCode = (code: string) => {
|
||||||
return request.get<Project>(`/projects/code/${code}`)
|
return request.get<Project>(`/api/mdm/projects/code/${code}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建项目
|
||||||
export const createProject = (data: Partial<Project>) => {
|
export const createProject = (data: Partial<Project>) => {
|
||||||
return request.post<Project>('/projects', data)
|
return request.post<Project>('/api/mdm/projects', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新项目
|
||||||
export const updateProject = (id: string, data: Partial<Project>) => {
|
export const updateProject = (id: string, data: Partial<Project>) => {
|
||||||
return request.put<Project>(`/projects/${id}`, data)
|
return request.put<Project>(`/api/mdm/projects/${id}`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除项目
|
||||||
export const deleteProject = (id: string) => {
|
export const deleteProject = (id: string) => {
|
||||||
return request.delete(`/projects/${id}`)
|
return request.delete(`/api/mdm/projects/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 统计数据 ====================
|
||||||
|
|
||||||
|
// PM-002 获取项目统计数据
|
||||||
|
export const getProjectStatistics = (id: string) => {
|
||||||
|
return request.get<ProjectStatistics>(`/api/mdm/projects/${id}/statistics`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 成员管理 ====================
|
||||||
|
|
||||||
|
// PM-003 获取项目成员列表
|
||||||
|
export const getProjectMembers = (projectId: string, params?: { page?: number; size?: number }) => {
|
||||||
|
return request.get<PageResponse<ProjectMember>>(`/api/mdm/projects/${projectId}/members`, { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加项目成员
|
||||||
|
export const addProjectMembers = (projectId: string, data: AddMemberRequest) => {
|
||||||
|
return request.post(`/api/mdm/projects/${projectId}/members`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除项目成员
|
||||||
|
export const removeProjectMember = (projectId: string, memberId: string) => {
|
||||||
|
return request.delete(`/api/mdm/projects/${projectId}/members/${memberId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新成员角色
|
||||||
|
export const updateMemberRole = (projectId: string, memberId: string, roleInProject: string) => {
|
||||||
|
return request.put(`/api/mdm/projects/${projectId}/members/${memberId}/role`, { roleInProject })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 编码生成 ====================
|
||||||
|
|
||||||
|
// PM-005 生成项目编码
|
||||||
|
export const generateProjectCode = () => {
|
||||||
|
return request.get<{ code: string }>('/api/mdm/projects/generate-code')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 状态管理 ====================
|
||||||
|
|
||||||
|
// PM-006 变更项目状态
|
||||||
|
export const changeProjectStatus = (id: string, data: StatusChangeRequest) => {
|
||||||
|
return request.put(`/api/mdm/projects/${id}/status`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用项目
|
||||||
|
export const enableProject = (id: string) => {
|
||||||
|
return changeProjectStatus(id, { status: 'ACTIVE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁用项目
|
||||||
|
export const disableProject = (id: string, reason?: string) => {
|
||||||
|
return changeProjectStatus(id, { status: 'DISABLED', reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 配置管理 ====================
|
||||||
|
|
||||||
|
// PM-008 获取项目配置
|
||||||
|
export const getProjectConfig = (id: string) => {
|
||||||
|
return request.get<ProjectConfig>(`/api/mdm/projects/${id}/config`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新项目配置
|
||||||
|
export const updateProjectConfig = (id: string, data: Partial<ProjectConfig>) => {
|
||||||
|
return request.put<ProjectConfig>(`/api/mdm/projects/${id}/config`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 选择器 ====================
|
||||||
|
|
||||||
|
// PM-010 获取项目选择器列表
|
||||||
|
export const getProjectSelectorList = (params?: { keyword?: string }) => {
|
||||||
|
return request.get<ProjectSelectorItem[]>('/api/mdm/projects/selector', { params })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import type { Role } from '@/types'
|
import type { Role, Permission } from '@/types'
|
||||||
|
|
||||||
export const getRoles = () => {
|
export const getRoles = () => {
|
||||||
return request.get<Role[]>('/api/roles')
|
return request.get<Role[]>('/api/roles')
|
||||||
|
|
@ -9,6 +9,10 @@ export const getRole = (id: string) => {
|
||||||
return request.get<Role>(`/api/roles/${id}`)
|
return request.get<Role>(`/api/roles/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getRolePermissions = (id: string) => {
|
||||||
|
return request.get<Permission[]>(`/api/roles/${id}/permissions`)
|
||||||
|
}
|
||||||
|
|
||||||
export const getRolesByProject = (projectId: string) => {
|
export const getRolesByProject = (projectId: string) => {
|
||||||
return request.get<Role[]>(`/api/roles/project/${projectId}`)
|
return request.get<Role[]>(`/api/roles/project/${projectId}`)
|
||||||
}
|
}
|
||||||
|
|
@ -36,3 +40,7 @@ export const getUserRoles = (userId: string) => {
|
||||||
export const removeRoleFromUser = (userId: string, roleId: string) => {
|
export const removeRoleFromUser = (userId: string, roleId: string) => {
|
||||||
return request.delete(`/api/users/${userId}/roles/${roleId}`)
|
return request.delete(`/api/users/${userId}/roles/${roleId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getRoleUsers = (roleId: string) => {
|
||||||
|
return request.get(`/api/roles/${roleId}/users`)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import type { SpaceNode, SpaceNodeTree, SpaceNodeCreateForm, SpaceNodeUpdateForm } from '@/types/space'
|
||||||
|
|
||||||
|
export const getSpaceNodes = (projectId: string) => {
|
||||||
|
return request.get<SpaceNode[]>(`/api/v1/mdm/space-nodes/project/${projectId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSpaceTree = (projectId: string) => {
|
||||||
|
return request.get<SpaceNodeTree[]>(`/api/v1/mdm/space-nodes/project/${projectId}/tree`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSpaceRoots = (projectId: string) => {
|
||||||
|
return request.get<SpaceNode[]>(`/api/v1/mdm/space-nodes/project/${projectId}/roots`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSpaceChildren = (parentId: string) => {
|
||||||
|
return request.get<SpaceNode[]>(`/api/v1/mdm/space-nodes/parent/${parentId}/children`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSpaceNode = (id: string) => {
|
||||||
|
return request.get<SpaceNode>(`/api/v1/mdm/space-nodes/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSpaceNodesByType = (projectId: string, nodeType: string) => {
|
||||||
|
return request.get<SpaceNode[]>(`/api/v1/mdm/space-nodes/project/${projectId}/type/${nodeType}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createSpaceNode = (data: SpaceNodeCreateForm) => {
|
||||||
|
return request.post<SpaceNode>('/api/v1/mdm/space-nodes', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateSpaceNode = (id: string, data: SpaceNodeUpdateForm) => {
|
||||||
|
return request.put<SpaceNode>(`/api/v1/mdm/space-nodes/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteSpaceNode = (id: string) => {
|
||||||
|
return request.delete(`/api/v1/mdm/space-nodes/${id}`)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function getConfig() {
|
||||||
|
return request({
|
||||||
|
url: '/api/config',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateConfig(data: Record<string, string>) {
|
||||||
|
return request({
|
||||||
|
url: '/api/config',
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, h } from 'vue'
|
||||||
|
import { CheckCircleOutlined, CloseCircleFilled, ExclamationCircleFilled, MinusCircleFilled } from '@ant-design/icons-vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: string
|
status: string
|
||||||
map?: Record<string, { color: string; label: string }>
|
map?: Record<string, { color: string; label: string; icon?: string }>
|
||||||
defaultColor?: string
|
defaultColor?: string
|
||||||
defaultLabel?: string
|
defaultLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
map: () => ({
|
map: () => ({
|
||||||
ACTIVE: { color: 'success', label: '正常' },
|
ACTIVE: { color: 'success', label: '正常', icon: 'check' },
|
||||||
LOCKED: { color: 'warning', label: '锁定' },
|
ENABLED: { color: 'success', label: '启用', icon: 'check' },
|
||||||
DISABLED: { color: 'error', label: '禁用' }
|
LOCKED: { color: 'warning', label: '锁定', icon: 'warning' },
|
||||||
|
DISABLED: { color: 'error', label: '禁用', icon: 'close' }
|
||||||
}),
|
}),
|
||||||
defaultColor: 'default',
|
defaultColor: 'default',
|
||||||
defaultLabel: ''
|
defaultLabel: ''
|
||||||
|
|
@ -33,25 +35,43 @@ const bgMap: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentStatus = computed(() => {
|
const currentStatus = computed(() => {
|
||||||
return props.map[props.status] || { color: props.defaultColor, label: props.defaultLabel || props.status }
|
return props.map[props.status] || { color: props.defaultColor, label: props.defaultLabel || props.status, icon: 'minus' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const tagColor = computed(() => colorMap[currentStatus.value.color] || colorMap.default)
|
const tagColor = computed(() => colorMap[currentStatus.value.color] || colorMap.default)
|
||||||
const tagBg = computed(() => bgMap[currentStatus.value.color] || bgMap.default)
|
const tagBg = computed(() => bgMap[currentStatus.value.color] || bgMap.default)
|
||||||
|
|
||||||
|
const renderIcon = () => {
|
||||||
|
const icon = currentStatus.value.icon || 'minus'
|
||||||
|
const style = { fontSize: '12px', marginRight: '4px' }
|
||||||
|
switch (icon) {
|
||||||
|
case 'check':
|
||||||
|
return h(CheckCircleOutlined, { style })
|
||||||
|
case 'close':
|
||||||
|
return h(CloseCircleFilled, { style })
|
||||||
|
case 'warning':
|
||||||
|
return h(ExclamationCircleFilled, { style })
|
||||||
|
default:
|
||||||
|
return h(MinusCircleFilled, { style })
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="status-tag" :style="{ color: tagColor, backgroundColor: tagBg }">
|
<span class="status-tag" :style="{ color: tagColor, backgroundColor: tagBg }">
|
||||||
|
<component :is="renderIcon" />
|
||||||
{{ currentStatus.label }}
|
{{ currentStatus.label }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.status-tag {
|
.status-tag {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,201 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
import { EllipsisOutlined } from '@ant-design/icons-vue'
|
||||||
import { Popconfirm } from 'ant-design-vue'
|
import { Dropdown, Menu, Popconfirm } from 'ant-design-vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
interface ActionItem {
|
interface ActionItem {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
icon?: any
|
|
||||||
danger?: boolean
|
danger?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
actions?: ActionItem[]
|
actions?: ActionItem[]
|
||||||
|
showView?: boolean
|
||||||
showEdit?: boolean
|
showEdit?: boolean
|
||||||
showDelete?: boolean
|
showDelete?: boolean
|
||||||
|
viewText?: string
|
||||||
editText?: string
|
editText?: string
|
||||||
deleteText?: string
|
deleteText?: string
|
||||||
deleteTitle?: string
|
deleteTitle?: string
|
||||||
deleteDescription?: string
|
deleteDescription?: string
|
||||||
|
maxVisible?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
actions: () => [],
|
actions: () => [],
|
||||||
|
showView: false,
|
||||||
showEdit: true,
|
showEdit: true,
|
||||||
showDelete: true,
|
showDelete: true,
|
||||||
|
viewText: '查看',
|
||||||
editText: '编辑',
|
editText: '编辑',
|
||||||
deleteText: '删除',
|
deleteText: '删除',
|
||||||
deleteTitle: '确认删除',
|
deleteTitle: '确认删除',
|
||||||
deleteDescription: '删除后不可恢复,是否继续?'
|
deleteDescription: '删除后不可恢复,是否继续?',
|
||||||
|
maxVisible: 3
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'edit'): void
|
(e: 'edit'): void
|
||||||
(e: 'delete'): void
|
(e: 'delete'): void
|
||||||
|
(e: 'view'): void
|
||||||
(e: 'action', key: string): void
|
(e: 'action', key: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const handleAction = (key: string) => {
|
// 所有按钮(固定 + actions + 删除)
|
||||||
|
const allActions = computed(() => {
|
||||||
|
const result: ActionItem[] = []
|
||||||
|
// 查看按钮优先
|
||||||
|
if (props.showView) {
|
||||||
|
result.push({ key: 'view', label: props.viewText })
|
||||||
|
}
|
||||||
|
// 编辑按钮
|
||||||
|
if (props.showEdit) {
|
||||||
|
result.push({ key: 'edit', label: props.editText })
|
||||||
|
}
|
||||||
|
// actions 数组
|
||||||
|
result.push(...props.actions)
|
||||||
|
// 删除按钮
|
||||||
|
if (props.showDelete) {
|
||||||
|
result.push({ key: 'delete', label: props.deleteText, danger: true })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// 可见按钮数量(3个以内全显示,超过3个只显示2个)
|
||||||
|
const visibleCount = computed(() => {
|
||||||
|
return allActions.value.length <= 3 ? allActions.value.length : 2
|
||||||
|
})
|
||||||
|
|
||||||
|
// 可见按钮
|
||||||
|
const visibleActions = computed(() => {
|
||||||
|
return allActions.value.slice(0, visibleCount.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更多按钮
|
||||||
|
const moreActions = computed(() => {
|
||||||
|
return allActions.value.slice(visibleCount.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasMoreActions = computed(() => moreActions.value.length > 0)
|
||||||
|
|
||||||
|
const handleActionClick = (key: string) => {
|
||||||
|
if (key === 'edit') {
|
||||||
|
emit('edit')
|
||||||
|
} else if (key === 'view') {
|
||||||
|
emit('view')
|
||||||
|
} else if (key === 'delete') {
|
||||||
|
emit('delete')
|
||||||
|
} else {
|
||||||
emit('action', key)
|
emit('action', key)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuClick = (e: { key: string | number }) => {
|
||||||
|
const key = String(e.key)
|
||||||
|
if (key === 'edit') {
|
||||||
|
emit('edit')
|
||||||
|
} else if (key === 'view') {
|
||||||
|
emit('view')
|
||||||
|
} else if (key === 'delete') {
|
||||||
|
emit('delete')
|
||||||
|
} else {
|
||||||
|
emit('action', key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFixedClick = (key: string) => {
|
||||||
|
if (key === 'view') {
|
||||||
|
emit('view')
|
||||||
|
} else if (key === 'edit') {
|
||||||
|
emit('edit')
|
||||||
|
} else {
|
||||||
|
emit('action', key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
emit('delete')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="table-actions">
|
<span class="table-actions">
|
||||||
<!-- 自定义操作 -->
|
<!-- 可见按钮 -->
|
||||||
<template v-for="action in actions" :key="action.key">
|
<template v-for="action in visibleActions" :key="action.key">
|
||||||
<a-button
|
<!-- 删除按钮需要 Popconfirm -->
|
||||||
v-if="action.danger"
|
|
||||||
type="link"
|
|
||||||
danger
|
|
||||||
size="small"
|
|
||||||
@click="handleAction(action.key)"
|
|
||||||
>
|
|
||||||
<component v-if="action.icon" :is="action.icon" />
|
|
||||||
{{ action.label }}
|
|
||||||
</a-button>
|
|
||||||
<a-button
|
|
||||||
v-else
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
@click="handleAction(action.key)"
|
|
||||||
>
|
|
||||||
<component v-if="action.icon" :is="action.icon" />
|
|
||||||
{{ action.label }}
|
|
||||||
</a-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 编辑按钮 -->
|
|
||||||
<a-button
|
|
||||||
v-if="showEdit"
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
@click="emit('edit')"
|
|
||||||
>
|
|
||||||
<EditOutlined /> {{ editText }}
|
|
||||||
</a-button>
|
|
||||||
|
|
||||||
<!-- 删除按钮 -->
|
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
v-if="showDelete"
|
v-if="action.key === 'delete'"
|
||||||
:title="deleteTitle"
|
:title="deleteTitle"
|
||||||
:description="deleteDescription"
|
:description="deleteDescription"
|
||||||
ok-text="确认"
|
ok-text="确认"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
@confirm="emit('delete')"
|
@confirm="handleDeleteConfirm"
|
||||||
>
|
>
|
||||||
<a-button type="link" danger size="small">
|
<a-button type="link" danger size="small" class="table-action-btn">
|
||||||
<DeleteOutlined /> {{ deleteText }}
|
{{ action.label }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</div>
|
<!-- 普通按钮 -->
|
||||||
|
<a-button
|
||||||
|
v-else
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
class="table-action-btn"
|
||||||
|
@click="handleFixedClick(action.key)"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 更多操作下拉菜单 -->
|
||||||
|
<Dropdown v-if="hasMoreActions" placement="bottomRight" :overlay-style="{ minWidth: '80px' }">
|
||||||
|
<a-button type="link" size="small" class="table-action-btn more-btn">
|
||||||
|
<EllipsisOutlined />
|
||||||
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<Menu @click="handleMenuClick" class="more-menu">
|
||||||
|
<Menu.Item
|
||||||
|
v-for="action in moreActions"
|
||||||
|
:key="action.key"
|
||||||
|
class="more-menu-item"
|
||||||
|
>
|
||||||
|
<span :class="{ 'text-danger': action.danger }">{{ action.label }}</span>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.table-actions {
|
.table-actions {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-actions :deep(.ant-btn) {
|
.table-action-btn {
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-action-btn:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-btn {
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.more-menu) {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.more-menu-item) {
|
||||||
|
padding: 8px 16px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,41 @@ const router = createRouter({
|
||||||
component: () => import('@/views/system/Audit.vue'),
|
component: () => import('@/views/system/Audit.vue'),
|
||||||
meta: { title: '审计日志' }
|
meta: { title: '审计日志' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'system/settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: () => import('@/views/system/Settings.vue'),
|
||||||
|
meta: { title: '系统设置' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'project/list',
|
path: 'project/list',
|
||||||
name: 'ProjectList',
|
name: 'ProjectList',
|
||||||
component: () => import('@/views/project/List.vue'),
|
component: () => import('@/views/project/List.vue'),
|
||||||
meta: { title: '项目管理' }
|
meta: { title: '项目管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'project/detail/:id',
|
||||||
|
name: 'ProjectDetail',
|
||||||
|
component: () => import('@/views/project/Detail.vue'),
|
||||||
|
meta: { title: '项目详情' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'project/:id/space',
|
||||||
|
name: 'ProjectSpace',
|
||||||
|
component: () => import('@/views/space/Space.vue'),
|
||||||
|
meta: { title: '空间管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'equipment/list',
|
||||||
|
name: 'EquipmentList',
|
||||||
|
component: () => import('@/views/equipment/EquipmentList.vue'),
|
||||||
|
meta: { title: '设备管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'equipment/detail/:id',
|
||||||
|
name: 'EquipmentDetail',
|
||||||
|
component: () => import('@/views/equipment/EquipmentDetail.vue'),
|
||||||
|
meta: { title: '设备详情' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,27 +148,58 @@ interface Props {
|
||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
#### TableActions 行操作
|
#### TableActions 行操作(统一组件)
|
||||||
|
|
||||||
|
**使用方式:**
|
||||||
```vue
|
```vue
|
||||||
<template>
|
<!-- 基础用法:编辑 + 删除 -->
|
||||||
<div class="table-actions">
|
<TableActions @edit="handleEdit(record)" @delete="handleDelete(record.id)" />
|
||||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
|
||||||
<EditOutlined /> 编辑
|
<!-- 带查看按钮 -->
|
||||||
</a-button>
|
<TableActions show-view @view="handleView(record)" @edit="handleEdit(record)" @delete="handleDelete(record.id)" />
|
||||||
<a-popconfirm
|
|
||||||
title="确认删除?"
|
<!-- 自定义操作 -->
|
||||||
ok-text="确认"
|
<TableActions :actions="[{ key: 'export', label: '导出', danger: false }]" @action="handleAction" />
|
||||||
cancel-text="取消"
|
|
||||||
@confirm="handleDelete(record)"
|
|
||||||
>
|
|
||||||
<a-button type="link" danger size="small">
|
|
||||||
<DeleteOutlined /> 删除
|
|
||||||
</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**属性说明:**
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| showView | boolean | false | 是否显示查看按钮 |
|
||||||
|
| showEdit | boolean | true | 是否显示编辑按钮 |
|
||||||
|
| showDelete | boolean | true | 是否显示删除按钮 |
|
||||||
|
| viewText | string | '查看' | 查看按钮文本 |
|
||||||
|
| editText | string | '编辑' | 编辑按钮文本 |
|
||||||
|
| deleteText | string | '删除' | 删除按钮文本 |
|
||||||
|
| deleteTitle | string | '确认删除' | 删除确认标题 |
|
||||||
|
| deleteDescription | string | '删除后不可恢复,是否继续?' | 删除确认描述 |
|
||||||
|
| actions | ActionItem[] | [] | 自定义操作按钮列表 |
|
||||||
|
|
||||||
|
**ActionItem 类型:**
|
||||||
|
```typescript
|
||||||
|
interface ActionItem {
|
||||||
|
key: string // 按钮标识
|
||||||
|
label: string // 按钮文本
|
||||||
|
danger?: boolean // 是否危险操作(红色)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**事件说明:**
|
||||||
|
|
||||||
|
| 事件 | 参数 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| view | - | 点击查看按钮 |
|
||||||
|
| edit | - | 点击编辑按钮 |
|
||||||
|
| delete | - | 点击删除按钮(确认后触发) |
|
||||||
|
| action | key: string | 点击自定义操作按钮 |
|
||||||
|
|
||||||
|
**样式规范:**
|
||||||
|
- 使用 `Space` 组件包裹,间距为 0
|
||||||
|
- 按钮使用 `type="link"` + `size="small"`
|
||||||
|
- 按钮内边距 `padding: 0 4px`
|
||||||
|
- 删除按钮使用 `danger` 属性
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. Detail 详情组件
|
### 4. Detail 详情组件
|
||||||
|
|
@ -804,10 +835,12 @@ const handleChange = (value: string) => {
|
||||||
#### TableActions 表格行操作
|
#### TableActions 表格行操作
|
||||||
| 状态 | 功能 | 说明 |
|
| 状态 | 功能 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| ✅ 已支持 | 查看按钮 | showView |
|
||||||
| ✅ 已支持 | 编辑按钮 | showEdit |
|
| ✅ 已支持 | 编辑按钮 | showEdit |
|
||||||
| ✅ 已支持 | 删除按钮 | showDelete |
|
| ✅ 已支持 | 删除按钮 | showDelete |
|
||||||
| ✅ 已支持 | 自定义操作 | actions prop |
|
| ✅ 已支持 | 自定义操作 | actions prop |
|
||||||
| ✅ 已支持 | 删除确认 | Popconfirm |
|
| ✅ 已支持 | 删除确认 | Popconfirm |
|
||||||
|
| ✅ 已支持 | 按钮文本配置 | viewText/editText/deleteText |
|
||||||
| 🔲 待开发 | 更多操作 | more-actions dropdown |
|
| 🔲 待开发 | 更多操作 | more-actions dropdown |
|
||||||
| 🔲 待开发 | 成功反馈 | success-message |
|
| 🔲 待开发 | 成功反馈 | success-message |
|
||||||
| 🔲 待开发 | 二次确认配置 | confirm-title/description |
|
| 🔲 待开发 | 二次确认配置 | confirm-title/description |
|
||||||
|
|
|
||||||
|
|
@ -55,23 +55,17 @@ export interface Project {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
address?: string
|
address?: string
|
||||||
|
projectType?: 'RESIDENTIAL' | 'OFFICE' | 'INDUSTRIAL_PARK'
|
||||||
province?: string
|
province?: string
|
||||||
city?: string
|
city?: string
|
||||||
district?: string
|
district?: string
|
||||||
status: 'ACTIVE' | 'DISABLED'
|
status: 'ACTIVE' | 'DISABLED' | 'PENDING' | 'ARCHIVED'
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpaceNode {
|
// 导出项目相关类型
|
||||||
id: string
|
export * from './project'
|
||||||
code: string
|
|
||||||
name: string
|
// 导出空间相关类型
|
||||||
projectCode: string
|
export * from './space'
|
||||||
nodeType: string
|
|
||||||
parentCode?: string
|
|
||||||
building?: string
|
|
||||||
unit?: string
|
|
||||||
floor?: string
|
|
||||||
roomNumber?: string
|
|
||||||
area?: number
|
|
||||||
status: 'ACTIVE' | 'DISABLED'
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
// 项目状态枚举
|
||||||
|
export type ProjectStatus = 'ACTIVE' | 'DISABLED' | 'PENDING' | 'ARCHIVED'
|
||||||
|
|
||||||
|
// 项目状态映射
|
||||||
|
export const ProjectStatusMap: Record<ProjectStatus, { label: string; color: string }> = {
|
||||||
|
ACTIVE: { label: '正常', color: 'success' },
|
||||||
|
DISABLED: { label: '禁用', color: 'error' },
|
||||||
|
PENDING: { label: '待审核', color: 'warning' },
|
||||||
|
ARCHIVED: { label: '已归档', color: 'default' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目类型枚举
|
||||||
|
export type ProjectType = 'RESIDENTIAL' | 'OFFICE' | 'INDUSTRIAL_PARK'
|
||||||
|
|
||||||
|
// 项目类型映射
|
||||||
|
export const ProjectTypeMap: Record<ProjectType, { label: string; color: string }> = {
|
||||||
|
RESIDENTIAL: { label: '住宅', color: 'green' },
|
||||||
|
OFFICE: { label: '办公', color: 'blue' },
|
||||||
|
INDUSTRIAL_PARK: { label: '产业园区', color: 'purple' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目查询参数
|
||||||
|
export interface ProjectQuery {
|
||||||
|
keyword?: string
|
||||||
|
status?: ProjectStatus
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
sort?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页响应
|
||||||
|
export interface PageResponse<T> {
|
||||||
|
content: T[]
|
||||||
|
totalElements: number
|
||||||
|
totalPages: number
|
||||||
|
size: number
|
||||||
|
number: number
|
||||||
|
first: boolean
|
||||||
|
last: boolean
|
||||||
|
empty: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目统计信息
|
||||||
|
export interface ProjectStatistics {
|
||||||
|
memberCount: number
|
||||||
|
buildingCount: number
|
||||||
|
roomCount: number
|
||||||
|
ownerCount: number
|
||||||
|
tenantCount: number
|
||||||
|
activeTaskCount: number
|
||||||
|
completedTaskCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目成员
|
||||||
|
export interface ProjectMember {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
|
userId: string
|
||||||
|
userName: string
|
||||||
|
realName?: string
|
||||||
|
phone?: string
|
||||||
|
roleInProject: string
|
||||||
|
joinedAt: string
|
||||||
|
status: 'ACTIVE' | 'INACTIVE'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目成员角色
|
||||||
|
export const ProjectMemberRoleMap: Record<string, { label: string; color: string }> = {
|
||||||
|
PROJECT_MANAGER: { label: '项目经理', color: 'blue' },
|
||||||
|
PROJECT_ADMIN: { label: '项目管理员', color: 'green' },
|
||||||
|
OPERATION_STAFF: { label: '运营人员', color: 'orange' },
|
||||||
|
FINANCE_STAFF: { label: '财务人员', color: 'purple' },
|
||||||
|
VIEWER: { label: '查看者', color: 'default' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目配置
|
||||||
|
export interface ProjectConfig {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
|
enableReservation: boolean
|
||||||
|
enableVisitor: boolean
|
||||||
|
enableComplaint: boolean
|
||||||
|
enablePayment: boolean
|
||||||
|
enableAnnouncement: boolean
|
||||||
|
enableSurvey: boolean
|
||||||
|
enableVote: boolean
|
||||||
|
enableMaintenance: boolean
|
||||||
|
enableAsset: boolean
|
||||||
|
customConfig?: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目选择器项
|
||||||
|
export interface ProjectSelectorItem {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
status: ProjectStatus
|
||||||
|
address?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目表单数据
|
||||||
|
export interface ProjectFormData {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
address?: string
|
||||||
|
projectType?: ProjectType
|
||||||
|
province?: string
|
||||||
|
city?: string
|
||||||
|
district?: string
|
||||||
|
status?: ProjectStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态变更请求
|
||||||
|
export interface StatusChangeRequest {
|
||||||
|
status: ProjectStatus
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加成员请求
|
||||||
|
export interface AddMemberRequest {
|
||||||
|
userIds: string[]
|
||||||
|
roleInProject: string
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
export type SpaceNodeCategory = 'BUILDING' | 'PARKING' | 'FACILITY' | 'AREA'
|
||||||
|
|
||||||
|
export type SpaceNodeType =
|
||||||
|
| 'BUILDING'
|
||||||
|
| 'UNIT'
|
||||||
|
| 'FLOOR'
|
||||||
|
| 'ROOM'
|
||||||
|
| 'SHOP'
|
||||||
|
| 'GARAGE'
|
||||||
|
| 'PARKING_AREA'
|
||||||
|
| 'PARKING_SPACE'
|
||||||
|
| 'EQUIPMENT_ROOM'
|
||||||
|
| 'PROPERTY_OFFICE'
|
||||||
|
| 'SECURITY_ROOM'
|
||||||
|
| 'PUBLIC_AREA'
|
||||||
|
| 'GREEN_AREA'
|
||||||
|
| 'ROAD'
|
||||||
|
|
||||||
|
export const SpaceNodeCategoryMap: Record<SpaceNodeCategory, { label: string }> = {
|
||||||
|
BUILDING: { label: '建筑空间' },
|
||||||
|
PARKING: { label: '停车空间' },
|
||||||
|
FACILITY: { label: '设施空间' },
|
||||||
|
AREA: { label: '区域空间' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpaceNodeTypeMap: Record<SpaceNodeType, { label: string; category: SpaceNodeCategory }> = {
|
||||||
|
BUILDING: { label: '楼栋', category: 'BUILDING' },
|
||||||
|
UNIT: { label: '单元', category: 'BUILDING' },
|
||||||
|
FLOOR: { label: '楼层', category: 'BUILDING' },
|
||||||
|
ROOM: { label: '房间', category: 'BUILDING' },
|
||||||
|
SHOP: { label: '商铺', category: 'BUILDING' },
|
||||||
|
GARAGE: { label: '车库', category: 'PARKING' },
|
||||||
|
PARKING_AREA: { label: '停车区域', category: 'PARKING' },
|
||||||
|
PARKING_SPACE: { label: '车位', category: 'PARKING' },
|
||||||
|
EQUIPMENT_ROOM: { label: '设备房', category: 'FACILITY' },
|
||||||
|
PROPERTY_OFFICE: { label: '物业用房', category: 'FACILITY' },
|
||||||
|
SECURITY_ROOM: { label: '门岗', category: 'FACILITY' },
|
||||||
|
PUBLIC_AREA: { label: '公共区域', category: 'AREA' },
|
||||||
|
GREEN_AREA: { label: '绿化区域', category: 'AREA' },
|
||||||
|
ROAD: { label: '道路', category: 'AREA' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpaceNode {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
fullName?: string
|
||||||
|
shortName?: string
|
||||||
|
nodeCategory: SpaceNodeCategory
|
||||||
|
nodeType: SpaceNodeType
|
||||||
|
usageType?: string
|
||||||
|
parentId?: string
|
||||||
|
parentCode?: string
|
||||||
|
treePath?: string
|
||||||
|
treePathName?: string
|
||||||
|
level?: number
|
||||||
|
sortOrder?: number
|
||||||
|
status?: string
|
||||||
|
deliveryStatus?: string
|
||||||
|
decorationStatus?: string
|
||||||
|
buildingArea?: number
|
||||||
|
usableArea?: number
|
||||||
|
sharedArea?: number
|
||||||
|
landArea?: number
|
||||||
|
longitude?: number
|
||||||
|
latitude?: number
|
||||||
|
altitude?: number
|
||||||
|
floorNumber?: number
|
||||||
|
province?: string
|
||||||
|
city?: string
|
||||||
|
district?: string
|
||||||
|
street?: string
|
||||||
|
address?: string
|
||||||
|
attributes?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
createdBy?: string
|
||||||
|
updatedBy?: string
|
||||||
|
isDeleted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpaceNodeTree extends SpaceNode {
|
||||||
|
children: SpaceNodeTree[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpaceNodeCreateForm {
|
||||||
|
projectId: string
|
||||||
|
name: string
|
||||||
|
fullName?: string
|
||||||
|
shortName?: string
|
||||||
|
nodeCategory: SpaceNodeCategory
|
||||||
|
nodeType: SpaceNodeType
|
||||||
|
usageType?: string
|
||||||
|
parentId?: string
|
||||||
|
sortOrder?: number
|
||||||
|
status?: string
|
||||||
|
deliveryStatus?: string
|
||||||
|
decorationStatus?: string
|
||||||
|
buildingArea?: number
|
||||||
|
usableArea?: number
|
||||||
|
sharedArea?: number
|
||||||
|
landArea?: number
|
||||||
|
longitude?: number
|
||||||
|
latitude?: number
|
||||||
|
altitude?: number
|
||||||
|
floorNumber?: number
|
||||||
|
province?: string
|
||||||
|
city?: string
|
||||||
|
district?: string
|
||||||
|
street?: string
|
||||||
|
address?: string
|
||||||
|
attributes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpaceNodeUpdateForm {
|
||||||
|
name?: string
|
||||||
|
fullName?: string
|
||||||
|
shortName?: string
|
||||||
|
nodeCategory?: SpaceNodeCategory
|
||||||
|
nodeType?: SpaceNodeType
|
||||||
|
usageType?: string
|
||||||
|
sortOrder?: number
|
||||||
|
status?: string
|
||||||
|
deliveryStatus?: string
|
||||||
|
decorationStatus?: string
|
||||||
|
buildingArea?: number
|
||||||
|
usableArea?: number
|
||||||
|
sharedArea?: number
|
||||||
|
landArea?: number
|
||||||
|
longitude?: number
|
||||||
|
latitude?: number
|
||||||
|
altitude?: number
|
||||||
|
floorNumber?: number
|
||||||
|
province?: string
|
||||||
|
city?: string
|
||||||
|
district?: string
|
||||||
|
street?: string
|
||||||
|
address?: string
|
||||||
|
attributes?: string
|
||||||
|
}
|
||||||
|
|
@ -15,17 +15,55 @@ import {
|
||||||
UserOutlined
|
UserOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { Col, Row } from 'ant-design-vue'
|
import { Col, Row } from 'ant-design-vue'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: '用户总数', value: '1,286', change: '+12.5%', up: true, icon: UserOutlined },
|
{ label: '用户总数', value: 1286, change: '+12.5%', up: true, icon: UserOutlined },
|
||||||
{ label: '角色总数', value: '8', change: '-', up: true, icon: TeamOutlined },
|
{ label: '角色总数', value: 8, change: '-', up: true, icon: TeamOutlined },
|
||||||
{ label: '项目总数', value: '24', change: '+8.3%', up: true, icon: ProjectOutlined },
|
{ label: '项目总数', value: 24, change: '+8.3%', up: true, icon: ProjectOutlined },
|
||||||
{ label: '空间节点', value: '156', change: '-2.1%', up: false, icon: ApartmentOutlined }
|
{ label: '空间节点', value: 156, change: '-2.1%', up: false, icon: ApartmentOutlined }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const displayValues = ref(stats.map(() => 0))
|
||||||
|
const animationComplete = ref(stats.map(() => false))
|
||||||
|
|
||||||
|
const easeOutQuart = (t: number): number => {
|
||||||
|
return 1 - Math.pow(1 - t, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
const animateValue = (index: number, endValue: number, duration: number = 1500) => {
|
||||||
|
const startTime = performance.now()
|
||||||
|
const startValue = 0
|
||||||
|
|
||||||
|
const tick = (currentTime: number) => {
|
||||||
|
const elapsed = currentTime - startTime
|
||||||
|
const progress = Math.min(elapsed / duration, 1)
|
||||||
|
const easedProgress = easeOutQuart(progress)
|
||||||
|
const currentValue = Math.round(startValue + (endValue - startValue) * easedProgress)
|
||||||
|
|
||||||
|
displayValues.value[index] = currentValue
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(tick)
|
||||||
|
} else {
|
||||||
|
animationComplete.value[index] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
stats.forEach((stat, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
animateValue(index, stat.value, 1500)
|
||||||
|
}, index * 150)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const todos = [
|
const todos = [
|
||||||
{ title: '待处理工单', count: 12 },
|
{ title: '待处理工单', count: 12 },
|
||||||
{ title: '待审核报修', count: 5 },
|
{ title: '待审核报修', count: 5 },
|
||||||
|
|
@ -48,6 +86,38 @@ const notices = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const chartData = [65, 78, 52, 91, 68, 85, 73]
|
const chartData = [65, 78, 52, 91, 68, 85, 73]
|
||||||
|
const displayHeights = ref(chartData.map(() => 0))
|
||||||
|
const chartAnimationComplete = ref(false)
|
||||||
|
|
||||||
|
const animateChart = () => {
|
||||||
|
const duration = 1200
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
const tick = (currentTime: number) => {
|
||||||
|
const elapsed = currentTime - startTime
|
||||||
|
const progress = Math.min(elapsed / duration, 1)
|
||||||
|
const easedProgress = easeOutQuart(progress)
|
||||||
|
|
||||||
|
displayHeights.value = chartData.map(v => v * easedProgress)
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(tick)
|
||||||
|
} else {
|
||||||
|
chartAnimationComplete.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
stats.forEach((stat, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
animateValue(index, stat.value, 1500)
|
||||||
|
}, index * 150)
|
||||||
|
})
|
||||||
|
setTimeout(animateChart, 600)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -60,13 +130,15 @@ const chartData = [65, 78, 52, 91, 68, 85, 73]
|
||||||
|
|
||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<div v-for="s in stats" :key="s.label" class="stat-card">
|
<div v-for="(s, index) in stats" :key="s.label" class="stat-card">
|
||||||
<div class="stat-icon">
|
<div class="stat-icon">
|
||||||
<component :is="s.icon" />
|
<component :is="s.icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-label">{{ s.label }}</div>
|
<div class="stat-label">{{ s.label }}</div>
|
||||||
<div class="stat-value">{{ s.value }}</div>
|
<div class="stat-value" :class="{ 'counting': !animationComplete[index] }">
|
||||||
|
{{ displayValues[index].toLocaleString() }}
|
||||||
|
</div>
|
||||||
<div v-if="s.change !== '-'" class="stat-change" :class="s.up ? 'up' : 'down'">
|
<div v-if="s.change !== '-'" class="stat-change" :class="s.up ? 'up' : 'down'">
|
||||||
<component :is="s.up ? ArrowUpOutlined : ArrowDownOutlined" />
|
<component :is="s.up ? ArrowUpOutlined : ArrowDownOutlined" />
|
||||||
{{ s.change }}
|
{{ s.change }}
|
||||||
|
|
@ -84,7 +156,7 @@ const chartData = [65, 78, 52, 91, 68, 85, 73]
|
||||||
</h3>
|
</h3>
|
||||||
<div class="chart">
|
<div class="chart">
|
||||||
<div v-for="(v, i) in chartData" :key="i" class="bar-item">
|
<div v-for="(v, i) in chartData" :key="i" class="bar-item">
|
||||||
<div class="bar" :style="{ height: v + '%' }"></div>
|
<div class="bar" :style="{ height: displayHeights[i] + '%' }"></div>
|
||||||
<span class="bar-label">周{{ ['一', '二', '三', '四', '五', '六', '日'][i] }}</span>
|
<span class="bar-label">周{{ ['一', '二', '三', '四', '五', '六', '日'][i] }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -186,4 +258,12 @@ const chartData = [65, 78, 52, 91, 68, 85, 73]
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.counting {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { h } from 'vue'
|
import { h, computed } from 'vue'
|
||||||
import { RouterView, useRouter } from 'vue-router'
|
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { Layout, Menu, Button } from 'ant-design-vue'
|
import { Layout, Menu, Button } from 'ant-design-vue'
|
||||||
import type { MenuProps } from 'ant-design-vue'
|
import type { MenuProps } from 'ant-design-vue'
|
||||||
|
|
@ -11,20 +11,53 @@ import {
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
BuildOutlined,
|
BuildOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
AuditOutlined
|
AuditOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
ToolOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout
|
const { Header, Sider, Content } = Layout
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const selectedKeys = computed(() => [route.path])
|
||||||
|
|
||||||
const menuItems: MenuProps['items'] = [
|
const menuItems: MenuProps['items'] = [
|
||||||
{ key: '/dashboard', icon: () => h(DashboardOutlined), label: '仪表盘' },
|
{
|
||||||
|
key: 'workbench',
|
||||||
|
label: '工作台',
|
||||||
|
type: 'group',
|
||||||
|
children: [
|
||||||
|
{ key: '/dashboard', icon: () => h(DashboardOutlined), label: '仪表盘' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'basic',
|
||||||
|
label: '基础管理',
|
||||||
|
type: 'group',
|
||||||
|
children: [
|
||||||
|
{ key: '/project/list', icon: () => h(BuildOutlined), label: '项目管理' },
|
||||||
|
{ key: '/equipment/list', icon: () => h(ToolOutlined), label: '设备管理' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'operation',
|
||||||
|
label: '运营管理',
|
||||||
|
type: 'group',
|
||||||
|
children: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
label: '系统管理',
|
||||||
|
type: 'group',
|
||||||
|
children: [
|
||||||
{ key: '/system/users', icon: () => h(UserOutlined), label: '用户管理' },
|
{ key: '/system/users', icon: () => h(UserOutlined), label: '用户管理' },
|
||||||
{ key: '/system/roles', icon: () => h(TeamOutlined), label: '角色管理' },
|
{ key: '/system/roles', icon: () => h(TeamOutlined), label: '角色管理' },
|
||||||
{ key: '/system/permissions', icon: () => h(AppstoreOutlined), label: '权限管理' },
|
|
||||||
{ key: '/system/audit', icon: () => h(AuditOutlined), label: '审计日志' },
|
{ key: '/system/audit', icon: () => h(AuditOutlined), label: '审计日志' },
|
||||||
{ key: '/project/list', icon: () => h(BuildOutlined), label: '项目管理' }
|
{ key: '/system/settings', icon: () => h(SettingOutlined), label: '系统设置' }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const handleMenuClick = (e: any) => {
|
const handleMenuClick = (e: any) => {
|
||||||
|
|
@ -45,6 +78,7 @@ const handleLogout = async () => {
|
||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
:items="menuItems"
|
:items="menuItems"
|
||||||
|
:selectedKeys="selectedKeys"
|
||||||
@click="handleMenuClick"
|
@click="handleMenuClick"
|
||||||
/>
|
/>
|
||||||
</Sider>
|
</Sider>
|
||||||
|
|
@ -72,4 +106,9 @@ const handleLogout = async () => {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 分组标题颜色比菜单项淡 */
|
||||||
|
:deep(.ant-menu-item-group-title) {
|
||||||
|
color: rgba(255, 255, 255, 0.45) !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Descriptions, DescriptionsItem, Tabs, TabPane, Tag, message, Spin } from 'ant-design-vue'
|
||||||
|
import { ArrowLeftOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { getEquipmentDetail, type Equipment } from '@/api/equipment'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const equipment = ref<Equipment | null>(null)
|
||||||
|
|
||||||
|
// 获取设备详情
|
||||||
|
const fetchEquipmentDetail = async () => {
|
||||||
|
const id = route.params.id as string
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getEquipmentDetail(id)
|
||||||
|
equipment.value = res.data.data
|
||||||
|
} catch {
|
||||||
|
message.error('获取设备详情失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回列表
|
||||||
|
const handleBack = () => {
|
||||||
|
router.push('/equipment/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (date: string | Date | undefined) => {
|
||||||
|
if (!date) return '-'
|
||||||
|
const d = new Date(date)
|
||||||
|
const year = d.getFullYear()
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断年检状态
|
||||||
|
const getInspectionStatus = (dateStr: string | undefined) => {
|
||||||
|
if (!dateStr) return { color: 'default', text: '未设置' }
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = date.getTime() - now.getTime()
|
||||||
|
const days = diff / (1000 * 60 * 60 * 24)
|
||||||
|
|
||||||
|
if (days < 0) {
|
||||||
|
return { color: 'red', text: '已过期' }
|
||||||
|
}
|
||||||
|
if (days <= 30) {
|
||||||
|
return { color: 'orange', text: '即将年检' }
|
||||||
|
}
|
||||||
|
return { color: 'green', text: '正常' }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchEquipmentDetail()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<!-- 页面标题 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-header-left">
|
||||||
|
<Button type="text" @click="handleBack">
|
||||||
|
<ArrowLeftOutlined /> 返回
|
||||||
|
</Button>
|
||||||
|
<h2 class="page-title">{{ equipment?.name || '设备详情' }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="page-header-right">
|
||||||
|
<Tag v-if="equipment?.isEquipment" color="blue">设备</Tag>
|
||||||
|
<Tag v-if="equipment?.specialEquipmentType" color="orange">{{ equipment.specialEquipmentType }}</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="equipment">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<div class="section-card">
|
||||||
|
<h3 class="section-title">基本信息</h3>
|
||||||
|
<Descriptions :column="3" bordered size="small">
|
||||||
|
<DescriptionsItem label="设备编码">{{ equipment.code || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="设备名称">{{ equipment.name || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="所属项目">{{ equipment.projectName || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="安装位置">{{ equipment.spaceNodeName || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="设计寿命">{{ equipment.designLifeYears ? `${equipment.designLifeYears} 年` : '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="创建时间">{{ formatDate(equipment.createdAt) }}</DescriptionsItem>
|
||||||
|
</Descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签页详情 -->
|
||||||
|
<div class="section-card">
|
||||||
|
<Tabs>
|
||||||
|
<!-- 技术参数 -->
|
||||||
|
<TabPane key="tech" tab="技术参数">
|
||||||
|
<Descriptions :column="2" bordered size="small">
|
||||||
|
<DescriptionsItem label="额定功率">
|
||||||
|
{{ equipment.ratedPower ? `${equipment.ratedPower} kW` : '-' }}
|
||||||
|
</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="额定电压">{{ equipment.ratedVoltage || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="额定电流">
|
||||||
|
{{ equipment.ratedCurrent ? `${equipment.ratedCurrent} A` : '-' }}
|
||||||
|
</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="设计寿命">
|
||||||
|
{{ equipment.designLifeYears ? `${equipment.designLifeYears} 年` : '-' }}
|
||||||
|
</DescriptionsItem>
|
||||||
|
</Descriptions>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
<!-- 维保信息 -->
|
||||||
|
<TabPane key="maintenance" tab="维保信息">
|
||||||
|
<Descriptions :column="2" bordered size="small">
|
||||||
|
<DescriptionsItem label="维保商">{{ equipment.maintenanceVendor || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="维保商电话">{{ equipment.maintenanceVendorPhone || '-' }}</DescriptionsItem>
|
||||||
|
</Descriptions>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
<!-- 特种设备 -->
|
||||||
|
<TabPane v-if="equipment.specialEquipmentType" key="special" tab="特种设备">
|
||||||
|
<Descriptions :column="2" bordered size="small">
|
||||||
|
<DescriptionsItem label="特种设备类型">
|
||||||
|
<Tag color="orange">{{ equipment.specialEquipmentType }}</Tag>
|
||||||
|
</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="年检周期">
|
||||||
|
{{ equipment.inspectionCycle ? `${equipment.inspectionCycle} 月` : '-' }}
|
||||||
|
</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="下次年检日期">
|
||||||
|
{{ formatDate(equipment.nextInspectionDate) }}
|
||||||
|
<Tag
|
||||||
|
:color="getInspectionStatus(equipment.nextInspectionDate).color"
|
||||||
|
style="margin-left: 8px"
|
||||||
|
>
|
||||||
|
{{ getInspectionStatus(equipment.nextInspectionDate).text }}
|
||||||
|
</Tag>
|
||||||
|
</DescriptionsItem>
|
||||||
|
</Descriptions>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 无数据 -->
|
||||||
|
<a-empty v-if="!loading && !equipment" description="未找到设备信息" />
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,421 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Button, Select, Space, message, Tag, Tabs, Badge } from 'ant-design-vue'
|
||||||
|
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
ExclamationCircleOutlined
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import { getEquipmentList, getSpecialEquipment, getExpiringInspection, type Equipment } from '@/api/equipment'
|
||||||
|
import { getProjectSelectorList } from '@/api/project'
|
||||||
|
import { TableActions, Pagination } from '@/components'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (date: string | Date | undefined) => {
|
||||||
|
if (!date) return '-'
|
||||||
|
const d = new Date(date)
|
||||||
|
const year = d.getFullYear()
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否即将过期(30天内)
|
||||||
|
const isExpiringSoon = (dateStr: string | undefined) => {
|
||||||
|
if (!dateStr) return false
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = date.getTime() - now.getTime()
|
||||||
|
const days = diff / (1000 * 60 * 60 * 24)
|
||||||
|
return days > 0 && days <= 30
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否已过期
|
||||||
|
const isExpired = (dateStr: string | undefined) => {
|
||||||
|
if (!dateStr) return false
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
return date < now
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格列定义
|
||||||
|
const columns: ColumnsType = [
|
||||||
|
{ title: '设备编码', dataIndex: 'code', key: 'code', width: 140 },
|
||||||
|
{ title: '设备名称', dataIndex: 'name', key: 'name', width: 180 },
|
||||||
|
{ title: '所属项目', dataIndex: 'projectName', key: 'projectName', width: 150 },
|
||||||
|
{ title: '安装位置', dataIndex: 'spaceNodeName', key: 'spaceNodeName', width: 150 },
|
||||||
|
{ title: '额定功率', dataIndex: 'ratedPower', key: 'ratedPower', width: 100 },
|
||||||
|
{ title: '额定电压', dataIndex: 'ratedVoltage', key: 'ratedVoltage', width: 100 },
|
||||||
|
{ title: '特种设备', dataIndex: 'specialEquipmentType', key: 'specialEquipmentType', width: 120 },
|
||||||
|
{ title: '下次年检', dataIndex: 'nextInspectionDate', key: 'nextInspectionDate', width: 120 },
|
||||||
|
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 特种设备列定义
|
||||||
|
const specialColumns: ColumnsType = [
|
||||||
|
{ title: '设备编码', dataIndex: 'code', key: 'code', width: 140 },
|
||||||
|
{ title: '设备名称', dataIndex: 'name', key: 'name', width: 180 },
|
||||||
|
{ title: '特种设备类型', dataIndex: 'specialEquipmentType', key: 'specialEquipmentType', width: 120 },
|
||||||
|
{ title: '年检周期', dataIndex: 'inspectionCycle', key: 'inspectionCycle', width: 100 },
|
||||||
|
{ title: '下次年检', dataIndex: 'nextInspectionDate', key: 'nextInspectionDate', width: 120 },
|
||||||
|
{ title: '维保商', dataIndex: 'maintenanceVendor', key: 'maintenanceVendor', width: 150 },
|
||||||
|
{ title: '联系电话', dataIndex: 'maintenanceVendorPhone', key: 'maintenanceVendorPhone', width: 120 },
|
||||||
|
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 项目选择选项
|
||||||
|
const projectOptions = ref<{ value: string; label: string }[]>([])
|
||||||
|
|
||||||
|
// 查询参数
|
||||||
|
const queryParams = reactive({
|
||||||
|
projectId: '',
|
||||||
|
tabKey: 'all'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 数据状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const tableData = ref<Equipment[]>([])
|
||||||
|
const specialData = ref<Equipment[]>([])
|
||||||
|
const expiringData = ref<Equipment[]>([])
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前展示的数据
|
||||||
|
const displayData = computed(() => {
|
||||||
|
if (queryParams.tabKey === 'special') return specialData.value
|
||||||
|
if (queryParams.tabKey === 'expiring') return expiringData.value
|
||||||
|
return tableData.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取项目列表
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getProjectSelectorList()
|
||||||
|
projectOptions.value = (res.data.data || []).map((item: any) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.name
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
message.error('获取项目列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设备列表
|
||||||
|
const fetchEquipmentList = async () => {
|
||||||
|
if (!queryParams.projectId) {
|
||||||
|
message.warning('请先选择项目')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getEquipmentList(queryParams.projectId)
|
||||||
|
const data = res.data.data
|
||||||
|
tableData.value = data.content || []
|
||||||
|
pagination.total = data.totalElements || 0
|
||||||
|
} catch {
|
||||||
|
message.error('获取设备列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取特种设备列表
|
||||||
|
const fetchSpecialEquipment = async () => {
|
||||||
|
if (!queryParams.projectId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getSpecialEquipment(queryParams.projectId)
|
||||||
|
specialData.value = res.data.data || []
|
||||||
|
} catch {
|
||||||
|
message.error('获取特种设备列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取即将年检设备
|
||||||
|
const fetchExpiringEquipment = async () => {
|
||||||
|
if (!queryParams.projectId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getExpiringInspection(queryParams.projectId, 90)
|
||||||
|
expiringData.value = res.data.data || []
|
||||||
|
} catch {
|
||||||
|
message.error('获取即将年检设备失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
if (queryParams.tabKey === 'all') {
|
||||||
|
fetchEquipmentList()
|
||||||
|
} else if (queryParams.tabKey === 'special') {
|
||||||
|
fetchSpecialEquipment()
|
||||||
|
} else if (queryParams.tabKey === 'expiring') {
|
||||||
|
fetchExpiringEquipment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.projectId = ''
|
||||||
|
pagination.current = 1
|
||||||
|
tableData.value = []
|
||||||
|
specialData.value = []
|
||||||
|
expiringData.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页变化
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
pagination.current = page
|
||||||
|
pagination.pageSize = pageSize
|
||||||
|
if (queryParams.tabKey === 'all') {
|
||||||
|
fetchEquipmentList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab 切换
|
||||||
|
const handleTabChange = (key: string) => {
|
||||||
|
queryParams.tabKey = key
|
||||||
|
pagination.current = 1
|
||||||
|
if (key === 'all') {
|
||||||
|
if (tableData.value.length === 0 && queryParams.projectId) {
|
||||||
|
fetchEquipmentList()
|
||||||
|
}
|
||||||
|
} else if (key === 'special') {
|
||||||
|
if (specialData.value.length === 0 && queryParams.projectId) {
|
||||||
|
fetchSpecialEquipment()
|
||||||
|
}
|
||||||
|
} else if (key === 'expiring') {
|
||||||
|
if (expiringData.value.length === 0 && queryParams.projectId) {
|
||||||
|
fetchExpiringEquipment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看设备详情
|
||||||
|
const handleView = (record: Equipment) => {
|
||||||
|
router.push(`/equipment/detail/${record.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 年检状态标签
|
||||||
|
const getInspectionStatus = (record: Equipment) => {
|
||||||
|
if (!record.nextInspectionDate) return null
|
||||||
|
if (isExpired(record.nextInspectionDate)) {
|
||||||
|
return { color: 'red', text: '已过期' }
|
||||||
|
}
|
||||||
|
if (isExpiringSoon(record.nextInspectionDate)) {
|
||||||
|
return { color: 'orange', text: '即将年检' }
|
||||||
|
}
|
||||||
|
return { color: 'green', text: '正常' }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchProjects()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<!-- 页面标题 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<h2 class="page-title">设备管理</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选区 -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
v-model:value="queryParams.projectId"
|
||||||
|
placeholder="请选择项目"
|
||||||
|
style="width: 240px"
|
||||||
|
allow-clear
|
||||||
|
:options="projectOptions"
|
||||||
|
/>
|
||||||
|
<Button type="primary" @click="handleSearch">
|
||||||
|
<SearchOutlined /> 查询
|
||||||
|
</Button>
|
||||||
|
<Button @click="handleReset">
|
||||||
|
<ReloadOutlined /> 重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签页 -->
|
||||||
|
<div class="table-card">
|
||||||
|
<Tabs v-model:activeKey="queryParams.tabKey" @change="handleTabChange">
|
||||||
|
<Tabs.TabPane key="all">
|
||||||
|
<template #tab>全部设备</template>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane key="special">
|
||||||
|
<template #tab>特种设备</template>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane key="expiring">
|
||||||
|
<template #tab>
|
||||||
|
<Badge :count="expiringData.length" :offset="[10, 0]" :overflow-count="99">
|
||||||
|
即将年检
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- 全部设备表格 -->
|
||||||
|
<a-table
|
||||||
|
v-if="queryParams.tabKey === 'all'"
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="displayData"
|
||||||
|
:loading="loading"
|
||||||
|
:row-key="(record: Equipment) => record.id"
|
||||||
|
:pagination="{
|
||||||
|
current: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
total: pagination.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 条`
|
||||||
|
}"
|
||||||
|
@change="(pag: any) => handlePageChange(pag.current, pag.pageSize)"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'ratedPower'">
|
||||||
|
{{ record.ratedPower ? `${record.ratedPower} kW` : '-' }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'ratedVoltage'">
|
||||||
|
{{ record.ratedVoltage || '-' }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'specialEquipmentType'">
|
||||||
|
<Tag v-if="record.specialEquipmentType" color="orange">
|
||||||
|
{{ record.specialEquipmentType }}
|
||||||
|
</Tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'nextInspectionDate'">
|
||||||
|
<Badge
|
||||||
|
v-if="getInspectionStatus(record)"
|
||||||
|
:status="getInspectionStatus(record)?.color === 'red' ? 'error' : getInspectionStatus(record)?.color === 'orange' ? 'warning' : 'success'"
|
||||||
|
:text="getInspectionStatus(record)?.text"
|
||||||
|
/>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<TableActions
|
||||||
|
:show-edit="false"
|
||||||
|
:show-delete="false"
|
||||||
|
:actions="[
|
||||||
|
{ key: 'view', label: '查看' }
|
||||||
|
]"
|
||||||
|
@view="handleView(record as Equipment)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<!-- 特种设备表格 -->
|
||||||
|
<a-table
|
||||||
|
v-else-if="queryParams.tabKey === 'special'"
|
||||||
|
:columns="specialColumns"
|
||||||
|
:data-source="displayData"
|
||||||
|
:loading="loading"
|
||||||
|
:row-key="(record: Equipment) => record.id"
|
||||||
|
:pagination="false"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'inspectionCycle'">
|
||||||
|
{{ record.inspectionCycle ? `${record.inspectionCycle} 月` : '-' }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'nextInspectionDate'">
|
||||||
|
<Badge
|
||||||
|
v-if="getInspectionStatus(record)"
|
||||||
|
:status="getInspectionStatus(record)?.color === 'red' ? 'error' : getInspectionStatus(record)?.color === 'orange' ? 'warning' : 'success'"
|
||||||
|
:text="getInspectionStatus(record)?.text"
|
||||||
|
/>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<TableActions
|
||||||
|
:show-edit="false"
|
||||||
|
:show-delete="false"
|
||||||
|
:actions="[
|
||||||
|
{ key: 'view', label: '查看' }
|
||||||
|
]"
|
||||||
|
@view="handleView(record as Equipment)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<!-- 即将年检表格 -->
|
||||||
|
<a-table
|
||||||
|
v-else-if="queryParams.tabKey === 'expiring'"
|
||||||
|
:columns="specialColumns"
|
||||||
|
:data-source="displayData"
|
||||||
|
:loading="loading"
|
||||||
|
:row-key="(record: Equipment) => record.id"
|
||||||
|
:pagination="false"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'inspectionCycle'">
|
||||||
|
{{ record.inspectionCycle ? `${record.inspectionCycle} 月` : '-' }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'nextInspectionDate'">
|
||||||
|
<span style="color: #faad14">
|
||||||
|
<ExclamationCircleOutlined /> {{ formatDate(record.nextInspectionDate) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<TableActions
|
||||||
|
:show-edit="false"
|
||||||
|
:show-delete="false"
|
||||||
|
:actions="[
|
||||||
|
{ key: 'view', label: '查看' }
|
||||||
|
]"
|
||||||
|
@view="handleView(record as Equipment)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<!-- 未选择项目提示 -->
|
||||||
|
<a-empty v-if="!queryParams.projectId && displayData.length === 0" description="请先选择项目" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,598 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import type { Key } from 'ant-design-vue/es/_util/type'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Tabs,
|
||||||
|
TabPane,
|
||||||
|
Descriptions,
|
||||||
|
DescriptionsItem,
|
||||||
|
Tag,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
Statistic,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
Spin,
|
||||||
|
Empty,
|
||||||
|
Modal,
|
||||||
|
Drawer,
|
||||||
|
Space
|
||||||
|
} from 'ant-design-vue'
|
||||||
|
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
UserAddOutlined,
|
||||||
|
DeleteOutlined
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import {
|
||||||
|
getProject,
|
||||||
|
getProjectStatistics,
|
||||||
|
getProjectMembers,
|
||||||
|
addProjectMembers,
|
||||||
|
removeProjectMember,
|
||||||
|
getProjectConfig,
|
||||||
|
updateProjectConfig,
|
||||||
|
updateProject
|
||||||
|
} from '@/api/project'
|
||||||
|
import type { Project } from '@/types'
|
||||||
|
import type { ProjectStatistics, ProjectMember, ProjectConfig, PageResponse, ProjectStatus, ProjectType } from '@/types/project'
|
||||||
|
import { ProjectStatusMap, ProjectMemberRoleMap, ProjectTypeMap } from '@/types/project'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 项目ID
|
||||||
|
const projectId = computed(() => route.params.id as string)
|
||||||
|
const activeTab = computed(() => route.query.tab as string || 'info')
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const statisticsLoading = ref(false)
|
||||||
|
const membersLoading = ref(false)
|
||||||
|
const configLoading = ref(false)
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const project = ref<Project | null>(null)
|
||||||
|
const statistics = ref<ProjectStatistics | null>(null)
|
||||||
|
const members = ref<ProjectMember[]>([])
|
||||||
|
const config = ref<ProjectConfig | null>(null)
|
||||||
|
|
||||||
|
// 成员分页
|
||||||
|
const memberPagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加成员弹窗
|
||||||
|
const addMemberVisible = ref(false)
|
||||||
|
const addMemberForm = reactive({
|
||||||
|
userIds: [] as string[],
|
||||||
|
roleInProject: 'VIEWER'
|
||||||
|
})
|
||||||
|
const addMemberLoading = ref(false)
|
||||||
|
|
||||||
|
// 配置保存
|
||||||
|
const configSaving = ref(false)
|
||||||
|
|
||||||
|
// 编辑抽屉
|
||||||
|
const editDrawerVisible = ref(false)
|
||||||
|
const editDrawerTitle = ref('编辑项目')
|
||||||
|
const editFormRef = ref()
|
||||||
|
const editSubmitting = ref(false)
|
||||||
|
|
||||||
|
const editFormState = ref({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
address: '',
|
||||||
|
projectType: 'RESIDENTIAL' as ProjectType,
|
||||||
|
province: '',
|
||||||
|
city: '',
|
||||||
|
district: '',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取项目详情
|
||||||
|
const fetchProject = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getProject(projectId.value)
|
||||||
|
project.value = res.data
|
||||||
|
} catch {
|
||||||
|
message.error('获取项目详情失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取统计数据
|
||||||
|
const fetchStatistics = async () => {
|
||||||
|
statisticsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getProjectStatistics(projectId.value)
|
||||||
|
statistics.value = res.data
|
||||||
|
} catch {
|
||||||
|
// 忽略错误
|
||||||
|
} finally {
|
||||||
|
statisticsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取成员列表
|
||||||
|
const fetchMembers = async () => {
|
||||||
|
membersLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getProjectMembers(projectId.value, {
|
||||||
|
page: memberPagination.current - 1,
|
||||||
|
size: memberPagination.pageSize
|
||||||
|
})
|
||||||
|
const data = res.data as PageResponse<ProjectMember>
|
||||||
|
members.value = data.content
|
||||||
|
memberPagination.total = data.totalElements
|
||||||
|
} catch {
|
||||||
|
message.error('获取成员列表失败')
|
||||||
|
} finally {
|
||||||
|
membersLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
configLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getProjectConfig(projectId.value)
|
||||||
|
config.value = res.data
|
||||||
|
} catch {
|
||||||
|
message.error('获取配置失败')
|
||||||
|
} finally {
|
||||||
|
configLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab切换
|
||||||
|
const handleTabChange = (key: Key) => {
|
||||||
|
router.replace({ query: { tab: String(key) } })
|
||||||
|
if (key === 'member' && members.value.length === 0) {
|
||||||
|
fetchMembers()
|
||||||
|
} else if (key === 'config' && !config.value) {
|
||||||
|
fetchConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回列表
|
||||||
|
const handleBack = () => {
|
||||||
|
router.push('/project/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑项目
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (!project.value) return
|
||||||
|
editFormState.value = {
|
||||||
|
name: project.value.name || '',
|
||||||
|
description: project.value.description || '',
|
||||||
|
address: project.value.address || '',
|
||||||
|
projectType: project.value.projectType || 'RESIDENTIAL',
|
||||||
|
province: project.value.province || '',
|
||||||
|
city: project.value.city || '',
|
||||||
|
district: project.value.district || '',
|
||||||
|
status: project.value.status || 'ACTIVE'
|
||||||
|
}
|
||||||
|
editDrawerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交编辑
|
||||||
|
const submitEdit = async () => {
|
||||||
|
try {
|
||||||
|
await editFormRef.value.validate()
|
||||||
|
editSubmitting.value = true
|
||||||
|
await updateProject(projectId.value, editFormState.value)
|
||||||
|
message.success('更新成功')
|
||||||
|
editDrawerVisible.value = false
|
||||||
|
fetchProject()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.errorFields) return
|
||||||
|
message.error('更新失败')
|
||||||
|
} finally {
|
||||||
|
editSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加成员
|
||||||
|
const handleAddMember = () => {
|
||||||
|
addMemberForm.userIds = []
|
||||||
|
addMemberForm.roleInProject = 'VIEWER'
|
||||||
|
addMemberVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交添加成员
|
||||||
|
const submitAddMember = async () => {
|
||||||
|
if (addMemberForm.userIds.length === 0) {
|
||||||
|
message.warning('请选择要添加的成员')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addMemberLoading.value = true
|
||||||
|
try {
|
||||||
|
await addProjectMembers(projectId.value, {
|
||||||
|
userIds: addMemberForm.userIds,
|
||||||
|
roleInProject: addMemberForm.roleInProject
|
||||||
|
})
|
||||||
|
message.success('添加成功')
|
||||||
|
addMemberVisible.value = false
|
||||||
|
fetchMembers()
|
||||||
|
} catch {
|
||||||
|
message.error('添加失败')
|
||||||
|
} finally {
|
||||||
|
addMemberLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除成员
|
||||||
|
const handleRemoveMember = async (memberId: string) => {
|
||||||
|
try {
|
||||||
|
await removeProjectMember(projectId.value, memberId)
|
||||||
|
message.success('移除成功')
|
||||||
|
fetchMembers()
|
||||||
|
} catch {
|
||||||
|
message.error('移除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成员分页变化
|
||||||
|
const handleMemberTableChange = (pag: any) => {
|
||||||
|
memberPagination.current = pag.current
|
||||||
|
memberPagination.pageSize = pag.pageSize
|
||||||
|
fetchMembers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
const handleSaveConfig = async () => {
|
||||||
|
if (!config.value) return
|
||||||
|
configSaving.value = true
|
||||||
|
try {
|
||||||
|
await updateProjectConfig(projectId.value, config.value)
|
||||||
|
message.success('保存成功')
|
||||||
|
} catch {
|
||||||
|
message.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
configSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态标签
|
||||||
|
const getStatusTag = (status: ProjectStatus) => {
|
||||||
|
const config = ProjectStatusMap[status] || { label: status, color: 'default' }
|
||||||
|
return { color: config.color, label: config.label }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成员角色选项
|
||||||
|
const roleOptions = Object.entries(ProjectMemberRoleMap).map(([value, { label }]) => ({
|
||||||
|
value,
|
||||||
|
label
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 状态选项
|
||||||
|
const statusOptions = Object.entries(ProjectStatusMap).map(([value, { label }]) => ({
|
||||||
|
value,
|
||||||
|
label
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 类型选项
|
||||||
|
const typeOptions = Object.entries(ProjectTypeMap).map(([value, { label }]) => ({
|
||||||
|
value,
|
||||||
|
label
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 成员表格列
|
||||||
|
const memberColumns: ColumnsType = [
|
||||||
|
{ title: '用户名', dataIndex: 'userName', key: 'userName', width: 120 },
|
||||||
|
{ title: '姓名', dataIndex: 'realName', key: 'realName', width: 120 },
|
||||||
|
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 140 },
|
||||||
|
{ title: '角色', dataIndex: 'roleInProject', key: 'roleInProject', width: 120 },
|
||||||
|
{ title: '加入时间', dataIndex: 'joinedAt', key: 'joinedAt', width: 180 },
|
||||||
|
{ title: '操作', key: 'action', width: 100, fixed: 'right' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
fetchProject()
|
||||||
|
fetchStatistics()
|
||||||
|
if (activeTab.value === 'member') {
|
||||||
|
fetchMembers()
|
||||||
|
} else if (activeTab.value === 'config') {
|
||||||
|
fetchConfig()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<!-- 页面头部 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<Button type="text" @click="handleBack">
|
||||||
|
<ArrowLeftOutlined /> 返回
|
||||||
|
</Button>
|
||||||
|
<h2 class="page-title">项目详情</h2>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<Button type="primary" @click="handleEdit">
|
||||||
|
<EditOutlined /> 编辑
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<template v-if="project">
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<Row :gutter="16" class="statistics-row">
|
||||||
|
<Col :span="4">
|
||||||
|
<Card>
|
||||||
|
<Statistic title="成员数" :value="statistics?.memberCount || 0" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :span="4">
|
||||||
|
<Card>
|
||||||
|
<Statistic title="楼栋数" :value="statistics?.buildingCount || 0" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :span="4">
|
||||||
|
<Card>
|
||||||
|
<Statistic title="房间数" :value="statistics?.roomCount || 0" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :span="4">
|
||||||
|
<Card>
|
||||||
|
<Statistic title="业主数" :value="statistics?.ownerCount || 0" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :span="4">
|
||||||
|
<Card>
|
||||||
|
<Statistic title="租户数" :value="statistics?.tenantCount || 0" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :span="4">
|
||||||
|
<Card>
|
||||||
|
<Statistic title="进行中任务" :value="statistics?.activeTaskCount || 0" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<!-- Tab 页签 -->
|
||||||
|
<Card class="content-card">
|
||||||
|
<Tabs :active-key="activeTab" @change="handleTabChange">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<TabPane key="info" tab="基本信息">
|
||||||
|
<Descriptions :column="2" bordered>
|
||||||
|
<DescriptionsItem label="项目编码">{{ project.code }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="项目名称">{{ project.name }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="状态">
|
||||||
|
<Tag :color="getStatusTag(project.status).color">
|
||||||
|
{{ getStatusTag(project.status).label }}
|
||||||
|
</Tag>
|
||||||
|
</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="省份">{{ project.province || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="城市">{{ project.city || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="区县">{{ project.district || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="详细地址" :span="2">{{ project.address || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="描述" :span="2">{{ project.description || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="创建时间">{{ project.createdAt || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="更新时间">{{ project.updatedAt || '-' }}</DescriptionsItem>
|
||||||
|
</Descriptions>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
<!-- 成员管理 -->
|
||||||
|
<TabPane key="member" tab="成员管理">
|
||||||
|
<div class="tab-header">
|
||||||
|
<Button type="primary" @click="handleAddMember">
|
||||||
|
<UserAddOutlined /> 添加成员
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
:columns="memberColumns"
|
||||||
|
:data-source="members"
|
||||||
|
:loading="membersLoading"
|
||||||
|
:row-key="(record: ProjectMember) => record.id"
|
||||||
|
:pagination="memberPagination"
|
||||||
|
@change="handleMemberTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'roleInProject'">
|
||||||
|
<Tag :color="ProjectMemberRoleMap[record.roleInProject]?.color || 'default'">
|
||||||
|
{{ ProjectMemberRoleMap[record.roleInProject]?.label || record.roleInProject }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<Popconfirm
|
||||||
|
title="确认移除该成员?"
|
||||||
|
ok-text="确认"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="handleRemoveMember(record.id)"
|
||||||
|
>
|
||||||
|
<Button type="link" danger size="small">
|
||||||
|
<DeleteOutlined /> 移除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
<!-- 项目配置 -->
|
||||||
|
<TabPane key="config" tab="项目配置">
|
||||||
|
<Spin :spinning="configLoading">
|
||||||
|
<template v-if="config">
|
||||||
|
<Form :label-col="{ span: 6 }" :wrapper-col="{ span: 14 }">
|
||||||
|
<Card title="业务功能开关" size="small" class="config-card">
|
||||||
|
<FormItem label="预约功能">
|
||||||
|
<Switch v-model:checked="config.enableReservation" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="访客管理">
|
||||||
|
<Switch v-model:checked="config.enableVisitor" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="投诉建议">
|
||||||
|
<Switch v-model:checked="config.enableComplaint" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="在线缴费">
|
||||||
|
<Switch v-model:checked="config.enablePayment" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="公告通知">
|
||||||
|
<Switch v-model:checked="config.enableAnnouncement" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="问卷调查">
|
||||||
|
<Switch v-model:checked="config.enableSurvey" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="投票表决">
|
||||||
|
<Switch v-model:checked="config.enableVote" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="设备维保">
|
||||||
|
<Switch v-model:checked="config.enableMaintenance" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="资产管理">
|
||||||
|
<Switch v-model:checked="config.enableAsset" />
|
||||||
|
</FormItem>
|
||||||
|
</Card>
|
||||||
|
<div class="config-footer">
|
||||||
|
<Button type="primary" :loading="configSaving" @click="handleSaveConfig">
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</template>
|
||||||
|
<Empty v-else description="暂无配置数据" />
|
||||||
|
</Spin>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
<!-- 操作日志 -->
|
||||||
|
<TabPane key="log" tab="操作日志">
|
||||||
|
<Empty description="暂无操作日志" />
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
<Empty v-else description="项目不存在" />
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
<!-- 添加成员弹窗 -->
|
||||||
|
<Modal
|
||||||
|
v-model:open="addMemberVisible"
|
||||||
|
title="添加成员"
|
||||||
|
:confirm-loading="addMemberLoading"
|
||||||
|
@ok="submitAddMember"
|
||||||
|
>
|
||||||
|
<Form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||||
|
<FormItem label="用户ID" required>
|
||||||
|
<Input
|
||||||
|
v-model:value="addMemberForm.userIds[0]"
|
||||||
|
placeholder="请输入用户ID(暂支持单个添加)"
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="角色" required>
|
||||||
|
<Select v-model:value="addMemberForm.roleInProject" :options="roleOptions" />
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
v-model:open="editDrawerVisible"
|
||||||
|
:title="editDrawerTitle"
|
||||||
|
width="500px"
|
||||||
|
@close="editDrawerVisible = false"
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
ref="editFormRef"
|
||||||
|
:model="editFormState"
|
||||||
|
layout="vertical"
|
||||||
|
:rules="{
|
||||||
|
name: [{ required: true, message: '请输入项目名称' }]
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Form.Item label="项目名称" name="name">
|
||||||
|
<Input v-model:value="editFormState.name" placeholder="请输入项目名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="项目类型" name="projectType">
|
||||||
|
<Select v-model:value="editFormState.projectType" :options="typeOptions" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="描述" name="description">
|
||||||
|
<Input.TextArea v-model:value="editFormState.description" placeholder="请输入描述" :rows="2" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="省份" name="province">
|
||||||
|
<Input v-model:value="editFormState.province" placeholder="请输入省份" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="城市" name="city">
|
||||||
|
<Input v-model:value="editFormState.city" placeholder="请输入城市" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="区县" name="district">
|
||||||
|
<Input v-model:value="editFormState.district" placeholder="请输入区县" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="详细地址" name="address">
|
||||||
|
<Input v-model:value="editFormState.address" placeholder="请输入详细地址" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="状态" name="status">
|
||||||
|
<Select v-model:value="editFormState.status" :options="statusOptions" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<template #footer>
|
||||||
|
<Space>
|
||||||
|
<Button @click="editDrawerVisible = false">取消</Button>
|
||||||
|
<Button type="primary" :loading="editSubmitting" @click="submitEdit">确定</Button>
|
||||||
|
</Space>
|
||||||
|
</template>
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-container {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,59 +1,148 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import { Table, Button, Drawer, Form, Input, Select, Space, Popconfirm, message } from 'ant-design-vue'
|
import { useRouter } from 'vue-router'
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
import { Button, Drawer, Form, Input, Select, Space, message, Tag, Descriptions, DescriptionsItem, Divider, Card, Statistic, Row, Col } from 'ant-design-vue'
|
||||||
import { getProjects, createProject, updateProject, deleteProject } from '@/api/project'
|
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import {
|
||||||
|
queryProjects,
|
||||||
|
createProject,
|
||||||
|
updateProject,
|
||||||
|
deleteProject,
|
||||||
|
enableProject,
|
||||||
|
disableProject
|
||||||
|
} from '@/api/project'
|
||||||
|
import { TableActions, Pagination, StatusTag } from '@/components'
|
||||||
import type { Project } from '@/types'
|
import type { Project } from '@/types'
|
||||||
|
import type { ProjectQuery, ProjectStatus, ProjectFormData, PageResponse, ProjectType } from '@/types/project'
|
||||||
|
import { ProjectStatusMap, ProjectTypeMap } from '@/types/project'
|
||||||
|
|
||||||
interface ProjectFormData {
|
const router = useRouter()
|
||||||
id?: string
|
|
||||||
code?: string
|
// 格式化日期
|
||||||
name?: string
|
const formatDate = (date: string | Date) => {
|
||||||
description?: string
|
if (!date) return '-'
|
||||||
address?: string
|
const d = new Date(date)
|
||||||
province?: string
|
const year = d.getFullYear()
|
||||||
city?: string
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
district?: string
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
status?: string
|
return `${year}-${month}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
// 表格列定义
|
||||||
{ title: '项目编码', dataIndex: 'code', key: 'code', width: 120 },
|
const columns: ColumnsType = [
|
||||||
{ title: '项目名称', dataIndex: 'name', key: 'name', width: 200 },
|
{ title: '项目名称', dataIndex: 'name', key: 'name', width: 200 },
|
||||||
|
{ title: '类型', dataIndex: 'projectType', key: 'projectType', width: 100 },
|
||||||
{ title: '地址', dataIndex: 'address', key: 'address', ellipsis: true },
|
{ title: '地址', dataIndex: 'address', key: 'address', ellipsis: true },
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||||
{ title: '操作', key: 'action', width: 120, fixed: 'right' }
|
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 120 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 200,
|
||||||
|
fixed: 'right' as const,
|
||||||
|
customRender: () => undefined
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const projects = ref<Project[]>([])
|
// 状态选项
|
||||||
|
const statusOptions = Object.entries(ProjectStatusMap).map(([value, { label }]) => ({
|
||||||
|
value,
|
||||||
|
label
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 类型选项
|
||||||
|
const typeOptions = Object.entries(ProjectTypeMap).map(([value, { label }]) => ({
|
||||||
|
value,
|
||||||
|
label
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 查询参数
|
||||||
|
const queryParams = reactive<ProjectQuery>({
|
||||||
|
keyword: '',
|
||||||
|
status: undefined,
|
||||||
|
page: 0,
|
||||||
|
size: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
// 数据状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const tableData = ref<Project[]>([])
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 分页后的数据
|
||||||
|
const paginatedData = computed(() => {
|
||||||
|
const start = (pagination.current - 1) * pagination.pageSize
|
||||||
|
const end = start + pagination.pageSize
|
||||||
|
return tableData.value.slice(start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 抽屉状态
|
||||||
const drawerVisible = ref(false)
|
const drawerVisible = ref(false)
|
||||||
const drawerTitle = ref('')
|
const drawerTitle = ref('')
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
// 查看抽屉状态
|
||||||
|
const viewDrawerVisible = ref(false)
|
||||||
|
const viewProject = ref<Project | null>(null)
|
||||||
|
const viewLoading = ref(false)
|
||||||
|
const viewStatistics = ref<any>(null)
|
||||||
|
const viewMembers = ref<any[]>([])
|
||||||
|
const viewActiveTab = ref('info')
|
||||||
|
|
||||||
|
// 点击项目名称查看详情
|
||||||
|
const handleNameClick = async (record: Project) => {
|
||||||
|
viewProject.value = record
|
||||||
|
viewActiveTab.value = 'info'
|
||||||
|
viewDrawerVisible.value = true
|
||||||
|
await fetchViewData(record.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchViewData = async (id: string) => {
|
||||||
|
viewLoading.value = true
|
||||||
|
try {
|
||||||
|
const [statsRes] = await Promise.all([
|
||||||
|
getProjectStatistics(id).catch(() => ({ data: { data: null } }))
|
||||||
|
])
|
||||||
|
viewStatistics.value = statsRes.data?.data || null
|
||||||
|
} finally {
|
||||||
|
viewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
const formState = ref<ProjectFormData>({
|
const formState = ref<ProjectFormData>({
|
||||||
id: '',
|
id: '',
|
||||||
code: '',
|
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
address: '',
|
address: '',
|
||||||
|
projectType: 'RESIDENTIAL',
|
||||||
province: '',
|
province: '',
|
||||||
city: '',
|
city: '',
|
||||||
district: '',
|
district: '',
|
||||||
status: 'ACTIVE'
|
status: 'ACTIVE'
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusOptions = [
|
// 获取项目列表
|
||||||
{ value: 'ACTIVE', label: '正常', color: 'success' },
|
|
||||||
{ value: 'DISABLED', label: '禁用', color: 'error' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getProjects()
|
const params: ProjectQuery = {
|
||||||
projects.value = res.data
|
...queryParams,
|
||||||
|
page: pagination.current - 1,
|
||||||
|
size: pagination.pageSize
|
||||||
|
}
|
||||||
|
const res = await queryProjects(params)
|
||||||
|
const data = res.data.data as PageResponse<Project>
|
||||||
|
tableData.value = data.content
|
||||||
|
pagination.total = data.totalElements
|
||||||
} catch {
|
} catch {
|
||||||
message.error('获取项目列表失败')
|
message.error('获取项目列表失败')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -61,14 +150,36 @@ const fetchProjects = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAdd = () => {
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
fetchProjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.keyword = ''
|
||||||
|
queryParams.status = undefined
|
||||||
|
pagination.current = 1
|
||||||
|
fetchProjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页变化
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
pagination.current = page
|
||||||
|
pagination.pageSize = pageSize
|
||||||
|
fetchProjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增
|
||||||
|
const handleAdd = async () => {
|
||||||
drawerTitle.value = '新增项目'
|
drawerTitle.value = '新增项目'
|
||||||
formState.value = {
|
formState.value = {
|
||||||
id: '',
|
id: '',
|
||||||
code: '',
|
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
address: '',
|
address: '',
|
||||||
|
projectType: 'RESIDENTIAL',
|
||||||
province: '',
|
province: '',
|
||||||
city: '',
|
city: '',
|
||||||
district: '',
|
district: '',
|
||||||
|
|
@ -77,14 +188,15 @@ const handleAdd = () => {
|
||||||
drawerVisible.value = true
|
drawerVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 编辑
|
||||||
const handleEdit = (record: Project) => {
|
const handleEdit = (record: Project) => {
|
||||||
drawerTitle.value = '编辑项目'
|
drawerTitle.value = '编辑项目'
|
||||||
formState.value = {
|
formState.value = {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
code: record.code,
|
|
||||||
name: record.name,
|
name: record.name,
|
||||||
description: record.description || '',
|
description: record.description || '',
|
||||||
address: record.address || '',
|
address: record.address || '',
|
||||||
|
projectType: record.projectType,
|
||||||
province: record.province || '',
|
province: record.province || '',
|
||||||
city: record.city || '',
|
city: record.city || '',
|
||||||
district: record.district || '',
|
district: record.district || '',
|
||||||
|
|
@ -93,6 +205,38 @@ const handleEdit = (record: Project) => {
|
||||||
drawerVisible.value = true
|
drawerVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const handleView = (record: Project) => {
|
||||||
|
router.push(`/project/detail/${record.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成员管理
|
||||||
|
const handleMemberManage = (record: Project) => {
|
||||||
|
router.push(`/project/detail/${record.id}?tab=member`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空间管理
|
||||||
|
const handleSpaceManage = (record: Project) => {
|
||||||
|
router.push(`/project/${record.id}/space?name=${encodeURIComponent(record.name)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换状态
|
||||||
|
const handleToggleStatus = async (record: Project) => {
|
||||||
|
try {
|
||||||
|
if (record.status === 'ACTIVE') {
|
||||||
|
await disableProject(record.id)
|
||||||
|
message.success('已禁用项目')
|
||||||
|
} else {
|
||||||
|
await enableProject(record.id)
|
||||||
|
message.success('已启用项目')
|
||||||
|
}
|
||||||
|
fetchProjects()
|
||||||
|
} catch {
|
||||||
|
message.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteProject(id)
|
await deleteProject(id)
|
||||||
|
|
@ -103,6 +247,7 @@ const handleDelete = async (id: string) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await formRef.value.validate()
|
await formRef.value.validate()
|
||||||
|
|
@ -125,25 +270,34 @@ const handleSubmit = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关闭抽屉
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
formRef.value?.resetFields()
|
formRef.value?.resetFields()
|
||||||
drawerVisible.value = false
|
drawerVisible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
// 状态标签配置
|
||||||
const map: Record<string, string> = {
|
const statusTagMap = {
|
||||||
ACTIVE: 'success',
|
ACTIVE: { color: 'success', label: '正常', icon: 'check' },
|
||||||
DISABLED: 'error'
|
INACTIVE: { color: 'error', label: '禁用', icon: 'close' },
|
||||||
}
|
DRAFT: { color: 'warning', label: '草稿', icon: 'warning' },
|
||||||
return map[status] || 'default'
|
ARCHIVED: { color: 'default', label: '归档', icon: 'minus' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
// 是否可以切换状态
|
||||||
const map: Record<string, string> = {
|
const canToggleStatus = (status: ProjectStatus) => {
|
||||||
ACTIVE: '正常',
|
return status === 'ACTIVE' || status === 'INACTIVE'
|
||||||
DISABLED: '禁用'
|
}
|
||||||
|
|
||||||
|
// 获取切换状态按钮配置
|
||||||
|
const getToggleAction = (record: Project) => {
|
||||||
|
if (!canToggleStatus(record.status)) return null
|
||||||
|
const isActive = record.status === 'ACTIVE'
|
||||||
|
return {
|
||||||
|
key: 'toggle',
|
||||||
|
label: isActive ? '禁用' : '启用',
|
||||||
|
danger: isActive
|
||||||
}
|
}
|
||||||
return map[status] || status
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchProjects)
|
onMounted(fetchProjects)
|
||||||
|
|
@ -161,41 +315,90 @@ onMounted(fetchProjects)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选区 -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<Space>
|
||||||
|
<Input
|
||||||
|
v-model:value="queryParams.keyword"
|
||||||
|
placeholder="搜索项目名称/编码"
|
||||||
|
style="width: 240px"
|
||||||
|
allow-clear
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
v-model:value="queryParams.status"
|
||||||
|
placeholder="请选择状态"
|
||||||
|
allow-clear
|
||||||
|
style="width: 150px"
|
||||||
|
:options="statusOptions"
|
||||||
|
/>
|
||||||
|
<Button type="primary" @click="handleSearch">
|
||||||
|
<SearchOutlined /> 查询
|
||||||
|
</Button>
|
||||||
|
<Button @click="handleReset">
|
||||||
|
<ReloadOutlined /> 重置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 表格 -->
|
<!-- 表格 -->
|
||||||
<div class="table-card">
|
<div class="table-card">
|
||||||
<Table
|
<a-table
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data-source="projects"
|
:data-source="paginatedData"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:row-key="(record: Project) => record.id"
|
:row-key="(record: Project) => record.id"
|
||||||
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (total: number) => `共 ${total} 条` }"
|
:pagination="false"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'status'">
|
<template v-if="column.key === 'name'">
|
||||||
<a-tag :color="getStatusColor(record.status)">
|
<a @click="handleNameClick(record as Project)" class="project-name-link">
|
||||||
{{ getStatusLabel(record.status) }}
|
{{ record.name }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'projectType'">
|
||||||
|
<a-tag :color="ProjectTypeMap[record.projectType as ProjectType]?.color">
|
||||||
|
{{ ProjectTypeMap[record.projectType as ProjectType]?.label || '-' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'createdAt'">
|
||||||
|
{{ formatDate(record.createdAt) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
|
<StatusTag :status="record.status" :map="statusTagMap" />
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'action'">
|
<template v-else-if="column.key === 'action'">
|
||||||
<Space>
|
<TableActions
|
||||||
<Button type="link" size="small" @click="handleEdit(record)">
|
:show-edit="false"
|
||||||
<EditOutlined /> 编辑
|
:show-delete="false"
|
||||||
</Button>
|
:actions="[
|
||||||
<Popconfirm
|
{ key: 'view', label: '查看' },
|
||||||
title="确认删除"
|
{ key: 'edit', label: '编辑' },
|
||||||
description="删除后不可恢复,是否继续?"
|
{ key: 'space', label: '空间' },
|
||||||
ok-text="确认"
|
{ key: 'member', label: '成员' },
|
||||||
cancel-text="取消"
|
getToggleAction(record as Project),
|
||||||
@confirm="handleDelete(record.id)"
|
{ key: 'delete', label: '删除', danger: true }
|
||||||
>
|
].filter(Boolean)"
|
||||||
<Button type="link" danger size="small">
|
@action="(key) => {
|
||||||
<DeleteOutlined /> 删除
|
if (key === 'space') handleSpaceManage(record as Project)
|
||||||
</Button>
|
else if (key === 'member') handleMemberManage(record as Project)
|
||||||
</Popconfirm>
|
else if (key === 'toggle') handleToggleStatus(record as Project)
|
||||||
</Space>
|
else if (key === 'delete') handleDelete((record as Project).id)
|
||||||
|
}"
|
||||||
|
@view="handleView(record as Project)"
|
||||||
|
@edit="handleEdit(record as Project)"
|
||||||
|
@delete="handleDelete((record as Project).id)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</a-table>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
v-model:current="pagination.current"
|
||||||
|
v-model:pageSize="pagination.pageSize"
|
||||||
|
:total="pagination.total"
|
||||||
|
@change="handlePageChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 抽屉 -->
|
<!-- 抽屉 -->
|
||||||
|
|
@ -211,22 +414,18 @@ onMounted(fetchProjects)
|
||||||
:model="formState"
|
:model="formState"
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
:rules="{
|
:rules="{
|
||||||
code: [{ required: true, message: '请输入项目编码' }],
|
|
||||||
name: [{ required: true, message: '请输入项目名称' }]
|
name: [{ required: true, message: '请输入项目名称' }]
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Form.Item label="项目编码" name="code">
|
|
||||||
<Input v-model:value="formState.code" :disabled="!!formState.id" placeholder="请输入项目编码" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="项目名称" name="name">
|
<Form.Item label="项目名称" name="name">
|
||||||
<Input v-model:value="formState.name" placeholder="请输入项目名称" />
|
<Input v-model:value="formState.name" placeholder="请输入项目名称" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label="项目类型" name="projectType">
|
||||||
|
<Select v-model:value="formState.projectType" placeholder="请选择项目类型" :options="typeOptions" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label="描述" name="description">
|
<Form.Item label="描述" name="description">
|
||||||
<Input.TextArea v-model:value="formState.description" placeholder="请输入描述" :rows="2" />
|
<Input.TextArea v-model:value="formState.description" placeholder="请输入描述" :rows="2" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="地址" name="address">
|
|
||||||
<Input v-model:value="formState.address" placeholder="请输入详细地址" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="省份" name="province">
|
<Form.Item label="省份" name="province">
|
||||||
<Input v-model:value="formState.province" placeholder="请输入省份" />
|
<Input v-model:value="formState.province" placeholder="请输入省份" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
@ -236,6 +435,9 @@ onMounted(fetchProjects)
|
||||||
<Form.Item label="区县" name="district">
|
<Form.Item label="区县" name="district">
|
||||||
<Input v-model:value="formState.district" placeholder="请输入区县" />
|
<Input v-model:value="formState.district" placeholder="请输入区县" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label="详细地址" name="address">
|
||||||
|
<Input v-model:value="formState.address" placeholder="请输入详细地址" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label="状态" name="status">
|
<Form.Item label="状态" name="status">
|
||||||
<Select v-model:value="formState.status" :options="statusOptions" />
|
<Select v-model:value="formState.status" :options="statusOptions" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
@ -247,5 +449,87 @@ onMounted(fetchProjects)
|
||||||
</Space>
|
</Space>
|
||||||
</template>
|
</template>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
v-model:open="viewDrawerVisible"
|
||||||
|
title="项目详情"
|
||||||
|
width="600px"
|
||||||
|
>
|
||||||
|
<template v-if="viewProject">
|
||||||
|
<Descriptions :column="2" bordered size="small">
|
||||||
|
<DescriptionsItem label="项目名称">{{ viewProject.name }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="项目类型">
|
||||||
|
<a-tag :color="ProjectTypeMap[viewProject.projectType as ProjectType]?.color">
|
||||||
|
{{ ProjectTypeMap[viewProject.projectType as ProjectType]?.label || '-' }}
|
||||||
|
</a-tag>
|
||||||
|
</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="状态">
|
||||||
|
<a-tag :color="ProjectStatusMap[viewProject.status as ProjectStatus]?.color">
|
||||||
|
{{ ProjectStatusMap[viewProject.status as ProjectStatus]?.label || '-' }}
|
||||||
|
</a-tag>
|
||||||
|
</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="描述" :span="2">{{ viewProject.description || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="省份">{{ viewProject.province || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="城市">{{ viewProject.city || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="区县">{{ viewProject.district || '-' }}</DescriptionsItem>
|
||||||
|
<DescriptionsItem label="详细地址" :span="2">{{ viewProject.address || '-' }}</DescriptionsItem>
|
||||||
|
</Descriptions>
|
||||||
|
<Divider />
|
||||||
|
<Row :gutter="16">
|
||||||
|
<Col :span="8">
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic title="楼栋数" :value="viewProject.buildingCount || 0" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :span="8">
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic title="单元数" :value="viewProject.unitCount || 0" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :span="8">
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic title="房间数" :value="viewProject.roomCount || 0" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</template>
|
||||||
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name-link {
|
||||||
|
color: #1890ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name-link:hover {
|
||||||
|
color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import { Select, Spin, Tag } from 'ant-design-vue'
|
||||||
|
import type { SelectValue } from 'ant-design-vue/es/select'
|
||||||
|
import { getProjectSelectorList } from '@/api/project'
|
||||||
|
import type { ProjectSelectorItem } from '@/types/project'
|
||||||
|
import { ProjectStatusMap } from '@/types/project'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps<{
|
||||||
|
value?: string | string[]
|
||||||
|
multiple?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
filterStatus?: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:value', value: string | string[] | undefined): void
|
||||||
|
(e: 'change', value: string | string[] | undefined, item: ProjectSelectorItem | ProjectSelectorItem[] | undefined): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const options = ref<ProjectSelectorItem[]>([])
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const selectedValue = computed({
|
||||||
|
get: () => props.value,
|
||||||
|
set: (val) => emit('update:value', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取项目列表
|
||||||
|
const fetchProjects = async (keyword?: string) => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getProjectSelectorList({ keyword })
|
||||||
|
let data = res.data || []
|
||||||
|
|
||||||
|
// 过滤状态
|
||||||
|
if (props.filterStatus && props.filterStatus.length > 0) {
|
||||||
|
data = data.filter(item => props.filterStatus!.includes(item.status))
|
||||||
|
}
|
||||||
|
|
||||||
|
options.value = data
|
||||||
|
} catch {
|
||||||
|
options.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
searchKeyword.value = value
|
||||||
|
fetchProjects(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择变化
|
||||||
|
const handleChange = (value: SelectValue) => {
|
||||||
|
let selectedItems: ProjectSelectorItem | ProjectSelectorItem[] | undefined
|
||||||
|
|
||||||
|
if (props.multiple && Array.isArray(value)) {
|
||||||
|
const values = value as string[]
|
||||||
|
selectedItems = options.value.filter(item => values.includes(item.id))
|
||||||
|
emit('change', values, selectedItems)
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
selectedItems = options.value.find(item => item.id === value)
|
||||||
|
emit('change', value, selectedItems)
|
||||||
|
} else {
|
||||||
|
emit('change', undefined, undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉展开时加载
|
||||||
|
const handleDropdownVisibleChange = (open: boolean) => {
|
||||||
|
if (open && options.value.length === 0) {
|
||||||
|
fetchProjects()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态标签颜色
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
return ProjectStatusMap[status as keyof typeof ProjectStatusMap]?.color || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化加载
|
||||||
|
watch(() => props.value, (val) => {
|
||||||
|
if (val && options.value.length === 0) {
|
||||||
|
fetchProjects()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Select
|
||||||
|
v-model:value="selectedValue"
|
||||||
|
:mode="multiple ? 'multiple' : undefined"
|
||||||
|
:placeholder="placeholder || '请选择项目'"
|
||||||
|
:disabled="disabled"
|
||||||
|
:loading="loading"
|
||||||
|
:filter-option="false"
|
||||||
|
show-search
|
||||||
|
allow-clear
|
||||||
|
@search="handleSearch"
|
||||||
|
@change="handleChange"
|
||||||
|
@dropdown-visible-change="handleDropdownVisibleChange"
|
||||||
|
>
|
||||||
|
<template #notFoundContent>
|
||||||
|
<Spin v-if="loading" size="small" />
|
||||||
|
<span v-else>暂无数据</span>
|
||||||
|
</template>
|
||||||
|
<Select.Option
|
||||||
|
v-for="item in options"
|
||||||
|
:key="item.id"
|
||||||
|
:value="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
>
|
||||||
|
<div class="project-option">
|
||||||
|
<span class="project-name">{{ item.name }}</span>
|
||||||
|
<span class="project-code">{{ item.code }}</span>
|
||||||
|
<Tag :color="getStatusColor(item.status)" size="small">
|
||||||
|
{{ ProjectStatusMap[item.status as keyof typeof ProjectStatusMap]?.label || item.status }}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.project-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-code {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,374 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Button, Tree, Card, Table, Form, Input, Select, Modal, message, Drawer, Space, InputNumber } from 'ant-design-vue'
|
||||||
|
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, HomeOutlined, ApartmentOutlined } from '@ant-design/icons-vue'
|
||||||
|
import {
|
||||||
|
getSpaceTree,
|
||||||
|
getSpaceNode,
|
||||||
|
getSpaceChildren,
|
||||||
|
createSpaceNode,
|
||||||
|
updateSpaceNode,
|
||||||
|
deleteSpaceNode
|
||||||
|
} from '@/api/space'
|
||||||
|
import { StatusTag, Pagination, TableActions } from '@/components'
|
||||||
|
import type { SpaceNode, SpaceNodeTree, SpaceNodeCreateForm, SpaceNodeUpdateForm, SpaceNodeCategory, SpaceNodeType } from '@/types/space'
|
||||||
|
import { SpaceNodeTypeMap, SpaceNodeCategoryMap } from '@/types/space'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const projectId = computed(() => route.params.id as string)
|
||||||
|
const projectName = computed(() => route.query.name as string || '项目空间')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const treeLoading = ref(false)
|
||||||
|
const selectedNode = ref<SpaceNode | null>(null)
|
||||||
|
const treeData = ref<SpaceNodeTree[]>([])
|
||||||
|
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const drawerTitle = ref('')
|
||||||
|
const formRef = ref()
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const formState = ref<SpaceNodeCreateForm>({
|
||||||
|
projectId: projectId.value,
|
||||||
|
name: '',
|
||||||
|
nodeCategory: 'BUILDING',
|
||||||
|
nodeType: 'BUILDING',
|
||||||
|
parentId: undefined,
|
||||||
|
sortOrder: 0,
|
||||||
|
status: 'ACTIVE'
|
||||||
|
})
|
||||||
|
|
||||||
|
const expandedKeys = ref<string[]>([])
|
||||||
|
const selectedKeys = ref<string[]>([])
|
||||||
|
|
||||||
|
const fetchTree = async () => {
|
||||||
|
treeLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getSpaceTree(projectId.value)
|
||||||
|
treeData.value = res.data.data || []
|
||||||
|
if (treeData.value.length > 0 && expandedKeys.value.length === 0) {
|
||||||
|
expandedKeys.value = [treeData.value[0].id]
|
||||||
|
selectedKeys.value = [treeData.value[0].id]
|
||||||
|
selectedNode.value = treeData.value[0]
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('获取空间树失败')
|
||||||
|
} finally {
|
||||||
|
treeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTreeSelect = async (keys: string[], info: any) => {
|
||||||
|
if (keys.length === 0) return
|
||||||
|
const nodeId = keys[0]
|
||||||
|
selectedKeys.value = [nodeId]
|
||||||
|
try {
|
||||||
|
const res = await getSpaceNode(nodeId)
|
||||||
|
selectedNode.value = res.data.data
|
||||||
|
} catch {
|
||||||
|
message.error('获取节点详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTreeExpand = (keys: string[]) => {
|
||||||
|
expandedKeys.value = keys
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = (parentId?: string) => {
|
||||||
|
drawerTitle.value = parentId ? '新增子节点' : '新增根节点'
|
||||||
|
formState.value = {
|
||||||
|
projectId: projectId.value,
|
||||||
|
name: '',
|
||||||
|
nodeCategory: 'BUILDING',
|
||||||
|
nodeType: 'BUILDING',
|
||||||
|
parentId: parentId,
|
||||||
|
sortOrder: 0,
|
||||||
|
status: 'ACTIVE'
|
||||||
|
}
|
||||||
|
drawerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (record: SpaceNode) => {
|
||||||
|
drawerTitle.value = '编辑节点'
|
||||||
|
formState.value = {
|
||||||
|
projectId: projectId.value,
|
||||||
|
name: record.name,
|
||||||
|
fullName: record.fullName,
|
||||||
|
shortName: record.shortName,
|
||||||
|
nodeCategory: record.nodeCategory,
|
||||||
|
nodeType: record.nodeType,
|
||||||
|
usageType: record.usageType,
|
||||||
|
parentId: record.parentId,
|
||||||
|
sortOrder: record.sortOrder || 0,
|
||||||
|
status: record.status || 'ACTIVE',
|
||||||
|
buildingArea: record.buildingArea,
|
||||||
|
usableArea: record.usableArea,
|
||||||
|
floorNumber: record.floorNumber,
|
||||||
|
address: record.address
|
||||||
|
}
|
||||||
|
drawerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
await createSpaceNode(formState.value as any)
|
||||||
|
message.success('创建成功')
|
||||||
|
drawerVisible.value = false
|
||||||
|
fetchTree()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.errorFields) return
|
||||||
|
message.error('操作失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteSpaceNode(id)
|
||||||
|
message.success('删除成功')
|
||||||
|
fetchTree()
|
||||||
|
selectedNode.value = null
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
formRef.value?.resetFields()
|
||||||
|
drawerVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryOptions = computed(() =>
|
||||||
|
Object.entries(SpaceNodeCategoryMap).map(([value, { label }]) => ({ value, label }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeOptions = computed(() => {
|
||||||
|
const category = formState.value.nodeCategory
|
||||||
|
return Object.entries(SpaceNodeTypeMap)
|
||||||
|
.filter(([_, config]) => config.category === category)
|
||||||
|
.map(([value, config]) => ({ value, label: config.label }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'ACTIVE', label: '正常' },
|
||||||
|
{ value: 'INACTIVE', label: '禁用' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusTagMap = {
|
||||||
|
ACTIVE: { color: 'success', label: '正常' },
|
||||||
|
INACTIVE: { color: 'error', label: '禁用' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType = [
|
||||||
|
{ title: '名称', dataIndex: 'name', key: 'name', width: 150 },
|
||||||
|
{ title: '类型', dataIndex: 'nodeType', key: 'nodeType', width: 80 },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
|
||||||
|
{ title: '面积', dataIndex: 'buildingArea', key: 'buildingArea', width: 100 },
|
||||||
|
{ title: '地址', dataIndex: 'address', key: 'address', ellipsis: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const getNodeTypeLabel = (type: SpaceNodeType) => {
|
||||||
|
return SpaceNodeTypeMap[type]?.label || type
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchTree)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2 class="page-title">{{ projectName }} - 空间管理</h2>
|
||||||
|
<div class="page-header-actions">
|
||||||
|
<Button type="primary" @click="handleAdd()">
|
||||||
|
<PlusOutlined /> 新增节点
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-layout">
|
||||||
|
<Card class="tree-card" :loading="treeLoading">
|
||||||
|
<template #title>
|
||||||
|
<span>空间结构</span>
|
||||||
|
</template>
|
||||||
|
<template #extra>
|
||||||
|
<Button type="link" size="small" @click="handleAdd()">
|
||||||
|
<PlusOutlined />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<div class="tree-container">
|
||||||
|
<Tree
|
||||||
|
v-if="treeData.length > 0"
|
||||||
|
:tree-data="treeData"
|
||||||
|
:expanded-keys="expandedKeys"
|
||||||
|
:selected-keys="selectedKeys"
|
||||||
|
:show-icon="true"
|
||||||
|
@select="handleTreeSelect"
|
||||||
|
@expand="handleTreeExpand"
|
||||||
|
>
|
||||||
|
<template #icon="{ node }">
|
||||||
|
<HomeOutlined v-if="(node as any).nodeType === 'ROOM'" />
|
||||||
|
<ApartmentOutlined v-else />
|
||||||
|
</template>
|
||||||
|
</Tree>
|
||||||
|
<a-empty v-else description="暂无空间数据">
|
||||||
|
<Button type="primary" @click="handleAdd()">添加第一个节点</Button>
|
||||||
|
</a-empty>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="detail-card">
|
||||||
|
<template #title>
|
||||||
|
<span>节点详情</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="selectedNode">
|
||||||
|
<div class="detail-info">
|
||||||
|
<a-descriptions :column="2" size="small" bordered>
|
||||||
|
<a-descriptions-item label="名称">{{ selectedNode.name }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="类型">{{ getNodeTypeLabel(selectedNode.nodeType) }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="状态">
|
||||||
|
<StatusTag :status="selectedNode.status" :map="statusTagMap" />
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="建筑面积">{{ selectedNode.buildingArea }} ㎡</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="使用面积">{{ selectedNode.usableArea }} ㎡</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="楼层" :span="2">{{ selectedNode.floorNumber }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="地址" :span="2">{{ selectedNode.address || '-' }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="完整路径" :span="2">{{ selectedNode.treePathName || '-' }}</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<Button type="primary" @click="handleEdit(selectedNode)">
|
||||||
|
<EditOutlined /> 编辑
|
||||||
|
</Button>
|
||||||
|
<Button danger @click="handleDelete(selectedNode.id)">
|
||||||
|
<DeleteOutlined /> 删除
|
||||||
|
</Button>
|
||||||
|
<Button @click="handleAdd(selectedNode.id, selectedNode.code)">
|
||||||
|
<PlusOutlined /> 添加子节点
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<a-empty v-else description="请从左侧选择节点查看详情" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
v-model:open="drawerVisible"
|
||||||
|
:title="drawerTitle"
|
||||||
|
width="500px"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
layout="vertical"
|
||||||
|
:rules="{
|
||||||
|
name: [{ required: true, message: '请输入名称' }],
|
||||||
|
nodeCategory: [{ required: true, message: '请选择节点大类' }],
|
||||||
|
nodeType: [{ required: true, message: '请选择节点类型' }]
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Form.Item label="节点大类" name="nodeCategory">
|
||||||
|
<Select
|
||||||
|
v-model:value="formState.nodeCategory"
|
||||||
|
:options="categoryOptions"
|
||||||
|
@change="formState.nodeType = undefined"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="节点类型" name="nodeType">
|
||||||
|
<Select
|
||||||
|
v-model:value="formState.nodeType"
|
||||||
|
:options="typeOptions"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="名称" name="name">
|
||||||
|
<Input v-model:value="formState.name" placeholder="请输入名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="全称" name="fullName">
|
||||||
|
<Input v-model:value="formState.fullName" placeholder="请输入全称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="简称" name="shortName">
|
||||||
|
<Input v-model:value="formState.shortName" placeholder="请输入简称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="状态" name="status">
|
||||||
|
<Select v-model:value="formState.status" :options="statusOptions" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="建筑面积" name="buildingArea">
|
||||||
|
<InputNumber v-model:value="formState.buildingArea" placeholder="请输入建筑面积" style="width: 100%" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="使用面积" name="usableArea">
|
||||||
|
<InputNumber v-model:value="formState.usableArea" placeholder="请输入使用面积" style="width: 100%" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="楼层" name="floorNumber">
|
||||||
|
<InputNumber v-model:value="formState.floorNumber" placeholder="请输入楼层" style="width: 100%" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="地址" name="address">
|
||||||
|
<Input v-model:value="formState.address" placeholder="请输入地址" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<template #footer>
|
||||||
|
<Space>
|
||||||
|
<Button @click="handleClose">取消</Button>
|
||||||
|
<Button type="primary" :loading="submitting" @click="handleSubmit">确定</Button>
|
||||||
|
</Space>
|
||||||
|
</template>
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-card {
|
||||||
|
width: 320px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-container {
|
||||||
|
max-height: calc(100vh - 300px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,175 +1,280 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { Table, Button, Space, Input, Select, DatePicker, Tag, message } from 'ant-design-vue'
|
import { Table, Button, Space, Input, Select, DatePicker, Tag, message, ConfigProvider } from 'ant-design-vue'
|
||||||
|
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||||
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import 'dayjs/locale/zh-cn'
|
||||||
|
import type { Dayjs } from 'dayjs'
|
||||||
|
import { getAuditLogs, getAuditModules, getAuditActions, getAuditStats } from '@/api/audit'
|
||||||
|
import type { AuditLog } from '@/api/audit'
|
||||||
|
|
||||||
// 审计日志类型
|
dayjs.locale('zh-cn')
|
||||||
interface AuditLog {
|
|
||||||
id: string
|
|
||||||
time: string
|
|
||||||
operator: string
|
|
||||||
type: 'PERMISSION' | 'ROLE' | 'PROJECT'
|
|
||||||
content: string
|
|
||||||
target: string
|
|
||||||
ip: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表格列定义
|
// 表格列定义
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '时间', dataIndex: 'time', key: 'time', width: 180 },
|
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 170 },
|
||||||
{ title: '操作用户', dataIndex: 'operator', key: 'operator', width: 120 },
|
{ title: '操作用户', dataIndex: 'username', key: 'username', width: 100 },
|
||||||
{ title: '操作类型', dataIndex: 'type', key: 'type', width: 100 },
|
{ title: '功能模块', dataIndex: 'module', key: 'module', width: 100 },
|
||||||
{ title: '操作内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
{ title: '操作类型', dataIndex: 'action', key: 'action', width: 90 },
|
||||||
{ title: '目标对象', dataIndex: 'target', key: 'target', width: 150 },
|
{ title: '操作描述', dataIndex: 'operation', key: 'operation', width: 200 },
|
||||||
{ title: 'IP地址', dataIndex: 'ip', key: 'ip', width: 140 }
|
{ title: 'IP地址', dataIndex: 'ipAddress', key: 'ipAddress', width: 130 },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
|
||||||
|
{ title: '耗时', dataIndex: 'executionTimeMs', key: 'executionTimeMs', width: 80 }
|
||||||
]
|
]
|
||||||
|
|
||||||
// 数据
|
// 数据
|
||||||
const logs = ref<AuditLog[]>([])
|
const logs = ref<AuditLog[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const pagination = ref({
|
||||||
// 筛选
|
current: 1,
|
||||||
const filters = ref({
|
pageSize: 10,
|
||||||
type: undefined as string | undefined,
|
total: 0
|
||||||
dateRange: [] as [dayjs.Dayjs, dayjs.Dayjs] | null,
|
|
||||||
operator: ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 操作类型选项
|
// 统计数据
|
||||||
const typeOptions = [
|
const stats = ref({
|
||||||
{ value: 'PERMISSION', label: '权限变更' },
|
total: 0,
|
||||||
{ value: 'ROLE', label: '角色分配' },
|
retentionDays: 30
|
||||||
{ value: 'PROJECT', label: '项目参与' }
|
})
|
||||||
|
|
||||||
|
// 筛选选项
|
||||||
|
const moduleOptions = ref<{ value: string; label: string }[]>([])
|
||||||
|
const actionOptions = ref<{ value: string; label: string }[]>([])
|
||||||
|
|
||||||
|
// 筛选条件
|
||||||
|
const filters = ref({
|
||||||
|
module: undefined as string | undefined,
|
||||||
|
action: undefined as string | undefined,
|
||||||
|
username: '',
|
||||||
|
dateRange: null as [Dayjs, Dayjs] | null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载模块选项
|
||||||
|
const loadModules = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getAuditModules()
|
||||||
|
moduleOptions.value = res.data.data || []
|
||||||
|
} catch {
|
||||||
|
// 使用默认值
|
||||||
|
moduleOptions.value = [
|
||||||
|
{ value: 'USER', label: '用户管理' },
|
||||||
|
{ value: 'ROLE', label: '角色管理' },
|
||||||
|
{ value: 'PROJECT', label: '项目管理' },
|
||||||
|
{ value: 'AUTH', label: '登录认证' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// 模拟数据
|
|
||||||
const mockLogs: AuditLog[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
time: '2026-03-21 10:30:25',
|
|
||||||
operator: 'admin',
|
|
||||||
type: 'PERMISSION',
|
|
||||||
content: '修改用户「张三」的项目权限,添加「数据导出」权限',
|
|
||||||
target: '用户:张三',
|
|
||||||
ip: '192.168.1.100'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
time: '2026-03-21 09:15:42',
|
|
||||||
operator: 'admin',
|
|
||||||
type: 'ROLE',
|
|
||||||
content: '为用户「李四」分配「项目经理」角色',
|
|
||||||
target: '用户:李四',
|
|
||||||
ip: '192.168.1.100'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
time: '2026-03-20 16:45:33',
|
|
||||||
operator: 'manager',
|
|
||||||
type: 'PROJECT',
|
|
||||||
content: '将「王五」从「智慧社区项目」中移除',
|
|
||||||
target: '智慧社区项目',
|
|
||||||
ip: '192.168.1.105'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
time: '2026-03-20 14:20:18',
|
|
||||||
operator: 'admin',
|
|
||||||
type: 'PERMISSION',
|
|
||||||
content: '撤销用户「赵六」的「系统管理」权限',
|
|
||||||
target: '用户:赵六',
|
|
||||||
ip: '192.168.1.100'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
time: '2026-03-19 11:05:56',
|
|
||||||
operator: 'manager',
|
|
||||||
type: 'ROLE',
|
|
||||||
content: '更新角色「审计员」的权限配置',
|
|
||||||
target: '角色:审计员',
|
|
||||||
ip: '192.168.1.105'
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载操作类型选项
|
||||||
|
const loadActions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getAuditActions()
|
||||||
|
actionOptions.value = res.data.data || []
|
||||||
|
} catch {
|
||||||
|
// 使用默认值
|
||||||
|
actionOptions.value = [
|
||||||
|
{ value: 'CREATE', label: '创建' },
|
||||||
|
{ value: 'UPDATE', label: '修改' },
|
||||||
|
{ value: 'DELETE', label: '删除' },
|
||||||
|
{ value: 'QUERY', label: '查询' },
|
||||||
|
{ value: 'LOGIN', label: '登录' },
|
||||||
|
{ value: 'LOGOUT', label: '登出' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// 获取操作类型标签
|
|
||||||
const getTypeColor = (type: string) => {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
PERMISSION: 'blue',
|
|
||||||
ROLE: 'green',
|
|
||||||
PROJECT: 'orange'
|
|
||||||
}
|
}
|
||||||
return map[type] || 'default'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTypeLabel = (type: string) => {
|
// 加载统计数据
|
||||||
const map: Record<string, string> = {
|
const loadStats = async () => {
|
||||||
PERMISSION: '权限变更',
|
try {
|
||||||
ROLE: '角色分配',
|
const res = await getAuditStats()
|
||||||
PROJECT: '项目参与'
|
stats.value = res.data.data || { total: 0, retentionDays: 30 }
|
||||||
|
} catch {
|
||||||
|
// 忽略错误
|
||||||
}
|
}
|
||||||
return map[type] || type
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
const loadData = () => {
|
const loadData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
// 模拟API延迟
|
try {
|
||||||
setTimeout(() => {
|
const params: any = {
|
||||||
logs.value = mockLogs.filter((log) => {
|
page: pagination.value.current - 1,
|
||||||
// 按类型筛选
|
size: pagination.value.pageSize
|
||||||
if (filters.value.type && log.type !== filters.value.type) return false
|
|
||||||
// 按用户筛选
|
|
||||||
if (filters.value.operator && !log.operator.includes(filters.value.operator)) return false
|
|
||||||
// 按日期范围筛选
|
|
||||||
if (filters.value.dateRange && filters.value.dateRange.length === 2) {
|
|
||||||
const logDate = dayjs(log.time).startOf('day')
|
|
||||||
const [start, end] = filters.value.dateRange
|
|
||||||
if (logDate.isBefore(start, 'day') || logDate.isAfter(end, 'day')) return false
|
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
})
|
if (filters.value.module) {
|
||||||
|
params.module = filters.value.module
|
||||||
|
}
|
||||||
|
if (filters.value.action) {
|
||||||
|
params.action = filters.value.action
|
||||||
|
}
|
||||||
|
if (filters.value.username) {
|
||||||
|
params.username = filters.value.username
|
||||||
|
}
|
||||||
|
if (filters.value.dateRange && filters.value.dateRange.length === 2) {
|
||||||
|
params.startDate = filters.value.dateRange[0].format('YYYY-MM-DDTHH:mm:ss')
|
||||||
|
params.endDate = filters.value.dateRange[1].format('YYYY-MM-DDTHH:mm:ss')
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getAuditLogs(params)
|
||||||
|
const data = res.data.data
|
||||||
|
logs.value = data.content || []
|
||||||
|
pagination.value.total = data.totalElements || 0
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取审计日志失败')
|
||||||
|
logs.value = []
|
||||||
|
pagination.value.total = 0
|
||||||
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}, 300)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页变化
|
||||||
|
const handleTableChange = (pag: any) => {
|
||||||
|
pagination.value.current = pag.current
|
||||||
|
pagination.value.pageSize = pag.pageSize
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.value.current = 1
|
||||||
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置
|
// 重置
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
filters.value = {
|
filters.value = {
|
||||||
type: undefined,
|
module: undefined,
|
||||||
dateRange: null,
|
action: undefined,
|
||||||
operator: ''
|
username: '',
|
||||||
|
dateRange: null
|
||||||
}
|
}
|
||||||
|
pagination.value.current = 1
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadData)
|
// 获取模块标签
|
||||||
|
const getModuleLabel = (module: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
USER: '用户管理',
|
||||||
|
ROLE: '角色管理',
|
||||||
|
PERMISSION: '权限管理',
|
||||||
|
PROJECT: '项目管理',
|
||||||
|
AUTH: '登录认证'
|
||||||
|
}
|
||||||
|
return map[module] || module
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取操作类型标签
|
||||||
|
const getActionLabel = (action: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
CREATE: '创建',
|
||||||
|
UPDATE: '修改',
|
||||||
|
DELETE: '删除',
|
||||||
|
QUERY: '查询',
|
||||||
|
LOGIN: '登录',
|
||||||
|
LOGOUT: '登出',
|
||||||
|
EXPORT: '导出',
|
||||||
|
IMPORT: '导入',
|
||||||
|
ASSIGN: '分配',
|
||||||
|
REVOKE: '撤销'
|
||||||
|
}
|
||||||
|
return map[action] || action
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取操作类型颜色
|
||||||
|
const getActionColor = (action: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
CREATE: 'green',
|
||||||
|
UPDATE: 'blue',
|
||||||
|
DELETE: 'red',
|
||||||
|
QUERY: 'default',
|
||||||
|
LOGIN: 'cyan',
|
||||||
|
LOGOUT: 'default',
|
||||||
|
EXPORT: 'purple',
|
||||||
|
IMPORT: 'orange',
|
||||||
|
ASSIGN: 'blue',
|
||||||
|
REVOKE: 'orange'
|
||||||
|
}
|
||||||
|
return map[action] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态标签
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
return status === 'SUCCESS' ? '成功' : '失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态颜色
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
return status === 'SUCCESS' ? 'success' : 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化耗时
|
||||||
|
const formatDuration = (ms?: number) => {
|
||||||
|
if (ms === undefined || ms === null) return '-'
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
return `${(ms / 1000).toFixed(2)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁用30天前的日期
|
||||||
|
const disabledDate = (current: Dayjs) => {
|
||||||
|
const thirtyDaysAgo = dayjs().subtract(30, 'day').startOf('day')
|
||||||
|
return current && (current < thirtyDaysAgo || current > dayjs().endOf('day'))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadModules()
|
||||||
|
loadActions()
|
||||||
|
loadStats()
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<ConfigProvider :locale="zhCN">
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2 class="page-title">操作审计日志</h2>
|
<h2 class="page-title">操作审计日志</h2>
|
||||||
|
<div class="page-subtitle">
|
||||||
|
保留最近 {{ stats.retentionDays }} 天的操作记录,共 {{ stats.total }} 条
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 筛选区 -->
|
<!-- 筛选区 -->
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Select
|
<Select
|
||||||
v-model:value="filters.type"
|
v-model:value="filters.module"
|
||||||
placeholder="操作类型"
|
placeholder="功能模块"
|
||||||
:options="typeOptions"
|
:options="moduleOptions"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 140px"
|
style="width: 140px"
|
||||||
/>
|
/>
|
||||||
<DatePicker.RangePicker v-model:value="filters.dateRange" style="width: 260px" />
|
<Select
|
||||||
<Input
|
v-model:value="filters.action"
|
||||||
v-model:value="filters.operator"
|
placeholder="操作类型"
|
||||||
placeholder="操作用户"
|
:options="actionOptions"
|
||||||
|
allow-clear
|
||||||
style="width: 140px"
|
style="width: 140px"
|
||||||
/>
|
/>
|
||||||
<Button type="primary" @click="loadData">
|
<DatePicker.RangePicker
|
||||||
|
v-model:value="filters.dateRange"
|
||||||
|
style="width: 320px"
|
||||||
|
:disabled-date="disabledDate"
|
||||||
|
show-time
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
:placeholder="['开始时间', '结束时间']"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model:value="filters.username"
|
||||||
|
placeholder="操作用户"
|
||||||
|
style="width: 140px"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
<Button type="primary" @click="handleSearch">
|
||||||
<SearchOutlined /> 查询
|
<SearchOutlined /> 查询
|
||||||
</Button>
|
</Button>
|
||||||
<Button @click="handleReset">
|
<Button @click="handleReset">
|
||||||
|
|
@ -185,16 +290,65 @@ onMounted(loadData)
|
||||||
:data-source="logs"
|
:data-source="logs"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:row-key="(record: AuditLog) => record.id"
|
:row-key="(record: AuditLog) => record.id"
|
||||||
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (total: number) => `共 ${total} 条` }"
|
:pagination="{
|
||||||
|
current: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
total: pagination.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 条`
|
||||||
|
}"
|
||||||
|
@change="handleTableChange"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'type'">
|
<template v-if="column.key === 'module'">
|
||||||
<Tag :color="getTypeColor(record.type)">
|
{{ getModuleLabel(record.module) }}
|
||||||
{{ getTypeLabel(record.type) }}
|
</template>
|
||||||
|
<template v-else-if="column.key === 'action'">
|
||||||
|
<Tag :color="getActionColor(record.action)">
|
||||||
|
{{ getActionLabel(record.action) }}
|
||||||
</Tag>
|
</Tag>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
|
<Tag :color="getStatusColor(record.status)">
|
||||||
|
{{ getStatusLabel(record.status) }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'executionTimeMs'">
|
||||||
|
{{ formatDuration(record.executionTimeMs) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'createdAt'">
|
||||||
|
{{ dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss') }}
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ConfigProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,12 @@ import {
|
||||||
|
|
||||||
// 表格列定义
|
// 表格列定义
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '权限编码', dataIndex: 'code', key: 'code', width: 140 },
|
{ title: '权限编码', dataIndex: 'code', key: 'code', width: 160 },
|
||||||
{ title: '权限名称', dataIndex: 'name', key: 'name', width: 120 },
|
{ title: '权限名称', dataIndex: 'name', key: 'name', width: 120 },
|
||||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 80 },
|
{ title: '类型', dataIndex: 'type', key: 'type', width: 90 },
|
||||||
{ title: '资源', dataIndex: 'resource', key: 'resource', width: 160, ellipsis: true },
|
{ title: '资源', dataIndex: 'resource', key: 'resource', width: 200, ellipsis: true },
|
||||||
{ title: '方法', dataIndex: 'method', key: 'method', width: 70 },
|
{ title: '方法', dataIndex: 'method', key: 'method', width: 80 },
|
||||||
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
{ title: '描述', dataIndex: 'description', key: 'description', width: 180, ellipsis: true },
|
||||||
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const }
|
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, reactive, computed } from 'vue'
|
import { ref, onMounted, reactive, computed, watch } from 'vue'
|
||||||
import { Button, Drawer, Input, Select, Form, Space, message } from 'ant-design-vue'
|
import { Button, Drawer, Input, Select, Form, Space, message, Tabs, TabPane, Table, Tag, Checkbox, Avatar } from 'ant-design-vue'
|
||||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
import { PlusOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||||
import { getRoles, createRole, updateRole, deleteRole } from '@/api/role'
|
import { getRoles, createRole, updateRole, deleteRole, getRolePermissions, assignPermissions, getRoleUsers } from '@/api/role'
|
||||||
import type { Role } from '@/types'
|
import { getPermissions } from '@/api/permission'
|
||||||
|
import type { Role, Permission, User } from '@/types'
|
||||||
import {
|
import {
|
||||||
TableToolbar,
|
TableToolbar,
|
||||||
TableActions,
|
TableActions,
|
||||||
|
|
@ -12,34 +13,67 @@ import {
|
||||||
} from '@/components'
|
} from '@/components'
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '角色编码', dataIndex: 'code', key: 'code', width: 100 },
|
{ title: '角色编码', dataIndex: 'code', key: 'code', width: 140 },
|
||||||
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 100 },
|
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 120 },
|
||||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 90 },
|
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
|
||||||
{ title: '数据权限', dataIndex: 'dataScope', key: 'dataScope', width: 100 },
|
{ title: '数据权限', dataIndex: 'dataScope', key: 'dataScope', width: 120 },
|
||||||
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
{ title: '描述', dataIndex: 'description', key: 'description', width: 200, ellipsis: true },
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 70 },
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
|
||||||
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const }
|
{ title: '操作', key: 'action', width: 180, fixed: 'right' as const }
|
||||||
|
]
|
||||||
|
|
||||||
|
const permissionColumns = [
|
||||||
|
{ title: '', key: 'checkbox', width: 50 },
|
||||||
|
{ title: '权限名称', dataIndex: 'name', key: 'name', width: 200 },
|
||||||
|
{ title: '权限编码', dataIndex: 'code', key: 'code', width: 200 },
|
||||||
|
{ title: '类型', dataIndex: 'type', key: 'type', width: 80 },
|
||||||
|
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true }
|
||||||
]
|
]
|
||||||
|
|
||||||
const roles = ref<Role[]>([])
|
const roles = ref<Role[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const drawerVisible = ref(false)
|
const drawerVisible = ref(false)
|
||||||
|
const viewDrawerVisible = ref(false)
|
||||||
const drawerTitle = ref('')
|
const drawerTitle = ref('')
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const activeTab = ref('basic')
|
||||||
|
|
||||||
|
const allPermissions = ref<Permission[]>([])
|
||||||
|
const selectedPermissionIds = ref<string[]>([])
|
||||||
|
const permissionsLoading = ref(false)
|
||||||
|
const currentRolePermissions = ref<Permission[]>([])
|
||||||
|
const currentRoleGroupedPermissions = computed(() => {
|
||||||
|
const grouped = currentRolePermissions.value.reduce((acc, p) => {
|
||||||
|
const m = extractModule(p.code)
|
||||||
|
if (!acc[m]) acc[m] = []
|
||||||
|
acc[m].push(p)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, Permission[]>)
|
||||||
|
Object.keys(grouped).forEach(key => {
|
||||||
|
grouped[key].sort((a, b) => {
|
||||||
|
if (a.type === 'MENU' && b.type !== 'MENU') return -1
|
||||||
|
if (a.type !== 'MENU' && b.type === 'MENU') return 1
|
||||||
|
return (a.sortOrder || 0) - (b.sortOrder || 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return grouped
|
||||||
|
})
|
||||||
|
|
||||||
|
// 角色用户列表
|
||||||
|
const roleUsers = ref<User[]>([])
|
||||||
|
const roleUsersLoading = ref(false)
|
||||||
|
const selectedModule = ref('all')
|
||||||
|
|
||||||
// 筛选
|
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const searchStatus = ref('')
|
const searchStatus = ref('')
|
||||||
|
|
||||||
// 分页
|
|
||||||
const pagination = reactive({
|
const pagination = reactive({
|
||||||
current: 1,
|
current: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
total: 0
|
total: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 分页后的数据
|
|
||||||
const paginatedData = computed(() => {
|
const paginatedData = computed(() => {
|
||||||
const start = (pagination.current - 1) * pagination.pageSize
|
const start = (pagination.current - 1) * pagination.pageSize
|
||||||
const end = start + pagination.pageSize
|
const end = start + pagination.pageSize
|
||||||
|
|
@ -53,7 +87,7 @@ const formState = ref({
|
||||||
description: '',
|
description: '',
|
||||||
type: '',
|
type: '',
|
||||||
dataScope: 'SELF',
|
dataScope: 'SELF',
|
||||||
status: 'ACTIVE'
|
status: 'ENABLED'
|
||||||
})
|
})
|
||||||
|
|
||||||
const typeOptions = [
|
const typeOptions = [
|
||||||
|
|
@ -68,6 +102,60 @@ const dataScopeOptions = [
|
||||||
{ value: 'SELF', label: '本人数据' }
|
{ value: 'SELF', label: '本人数据' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const moduleList = computed(() => {
|
||||||
|
const modules = new Map<string, { key: string; name: string; count: number }>()
|
||||||
|
modules.set('all', { key: 'all', name: '全部', count: allPermissions.value.length })
|
||||||
|
|
||||||
|
allPermissions.value.forEach(perm => {
|
||||||
|
const moduleKey = extractModule(perm.code)
|
||||||
|
const moduleName = getModuleName(moduleKey)
|
||||||
|
if (!modules.has(moduleKey)) {
|
||||||
|
modules.set(moduleKey, { key: moduleKey, name: moduleName, count: 0 })
|
||||||
|
}
|
||||||
|
modules.get(moduleKey)!.count++
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(modules.values())
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredPermissions = computed(() => {
|
||||||
|
let perms = allPermissions.value
|
||||||
|
if (selectedModule.value !== 'all') {
|
||||||
|
perms = perms.filter(p => extractModule(p.code) === selectedModule.value)
|
||||||
|
}
|
||||||
|
return perms.sort((a, b) => {
|
||||||
|
if (a.type === 'MENU' && b.type !== 'MENU') return -1
|
||||||
|
if (a.type !== 'MENU' && b.type === 'MENU') return 1
|
||||||
|
return (a.sortOrder || 0) - (b.sortOrder || 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const extractModule = (code: string): string => {
|
||||||
|
const parts = code.split(':')
|
||||||
|
if (parts[0] === 'system') {
|
||||||
|
if (parts[1] === 'user') return 'user'
|
||||||
|
if (parts[1] === 'role' || parts[1] === 'permission') return 'role'
|
||||||
|
}
|
||||||
|
return parts[0] || 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getModuleName = (module: string): string => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
dashboard: '仪表盘',
|
||||||
|
system: '系统管理',
|
||||||
|
user: '用户管理',
|
||||||
|
role: '角色管理',
|
||||||
|
permission: '权限管理',
|
||||||
|
project: '项目管理',
|
||||||
|
space: '空间管理',
|
||||||
|
asset: '资产管理',
|
||||||
|
audit: '审计管理',
|
||||||
|
finance: '财务管理',
|
||||||
|
other: '其他'
|
||||||
|
}
|
||||||
|
return map[module] || module
|
||||||
|
}
|
||||||
|
|
||||||
const fetchRoles = async () => {
|
const fetchRoles = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -81,6 +169,28 @@ const fetchRoles = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchAllPermissions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getPermissions()
|
||||||
|
allPermissions.value = res.data.data || []
|
||||||
|
} catch {
|
||||||
|
message.error('获取权限列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRolePermissions = async (roleId: string) => {
|
||||||
|
permissionsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getRolePermissions(roleId)
|
||||||
|
currentRolePermissions.value = res.data.data || []
|
||||||
|
selectedPermissionIds.value = currentRolePermissions.value.map((p: Permission) => p.id)
|
||||||
|
} catch {
|
||||||
|
message.error('获取角色权限失败')
|
||||||
|
} finally {
|
||||||
|
permissionsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
pagination.current = 1
|
pagination.current = 1
|
||||||
fetchRoles()
|
fetchRoles()
|
||||||
|
|
@ -101,6 +211,7 @@ const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
drawerTitle.value = '新增角色'
|
drawerTitle.value = '新增角色'
|
||||||
|
activeTab.value = 'basic'
|
||||||
formState.value = {
|
formState.value = {
|
||||||
id: '',
|
id: '',
|
||||||
code: '',
|
code: '',
|
||||||
|
|
@ -108,13 +219,16 @@ const handleAdd = () => {
|
||||||
description: '',
|
description: '',
|
||||||
type: '',
|
type: '',
|
||||||
dataScope: 'SELF',
|
dataScope: 'SELF',
|
||||||
status: 'ACTIVE'
|
status: 'ENABLED'
|
||||||
}
|
}
|
||||||
|
selectedPermissionIds.value = []
|
||||||
|
currentRolePermissions.value = []
|
||||||
drawerVisible.value = true
|
drawerVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (record: Role) => {
|
const handleEdit = async (record: Role) => {
|
||||||
drawerTitle.value = '编辑角色'
|
drawerTitle.value = '编辑角色'
|
||||||
|
activeTab.value = 'basic'
|
||||||
formState.value = {
|
formState.value = {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
code: record.code,
|
code: record.code,
|
||||||
|
|
@ -124,9 +238,59 @@ const handleEdit = (record: Role) => {
|
||||||
dataScope: record.dataScope || 'SELF',
|
dataScope: record.dataScope || 'SELF',
|
||||||
status: record.status
|
status: record.status
|
||||||
}
|
}
|
||||||
|
selectedPermissionIds.value = []
|
||||||
|
currentRolePermissions.value = []
|
||||||
|
permissionsLoading.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
fetchAllPermissions(),
|
||||||
|
fetchRolePermissions(record.id)
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
permissionsLoading.value = false
|
||||||
|
}
|
||||||
drawerVisible.value = true
|
drawerVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleView = async (record: Role) => {
|
||||||
|
drawerTitle.value = `查看角色 - ${record.name}`
|
||||||
|
formState.value = {
|
||||||
|
id: record.id,
|
||||||
|
code: record.code,
|
||||||
|
name: record.name,
|
||||||
|
description: record.description || '',
|
||||||
|
type: record.type || '',
|
||||||
|
dataScope: record.dataScope || 'SELF',
|
||||||
|
status: record.status
|
||||||
|
}
|
||||||
|
permissionsLoading.value = true
|
||||||
|
roleUsersLoading.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
fetchAllPermissions(),
|
||||||
|
fetchRolePermissions(record.id),
|
||||||
|
fetchRoleUsers(record.id)
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
permissionsLoading.value = false
|
||||||
|
roleUsersLoading.value = false
|
||||||
|
}
|
||||||
|
viewDrawerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRoleUsers = async (roleId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await getRoleUsers(roleId)
|
||||||
|
roleUsers.value = res.data.data || []
|
||||||
|
} catch {
|
||||||
|
roleUsers.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTabChange = (key: string) => {
|
||||||
|
activeTab.value = key
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteRole(id)
|
await deleteRole(id)
|
||||||
|
|
@ -144,9 +308,13 @@ const handleSubmit = async () => {
|
||||||
|
|
||||||
if (formState.value.id) {
|
if (formState.value.id) {
|
||||||
await updateRole(formState.value.id, formState.value)
|
await updateRole(formState.value.id, formState.value)
|
||||||
|
await assignPermissions(formState.value.id, selectedPermissionIds.value)
|
||||||
message.success('更新成功')
|
message.success('更新成功')
|
||||||
} else {
|
} else {
|
||||||
await createRole(formState.value)
|
const newRole = await createRole(formState.value)
|
||||||
|
if (newRole.data.data?.id && selectedPermissionIds.value.length > 0) {
|
||||||
|
await assignPermissions(newRole.data.data.id, selectedPermissionIds.value)
|
||||||
|
}
|
||||||
message.success('创建成功')
|
message.success('创建成功')
|
||||||
}
|
}
|
||||||
drawerVisible.value = false
|
drawerVisible.value = false
|
||||||
|
|
@ -164,6 +332,27 @@ const handleClose = () => {
|
||||||
drawerVisible.value = false
|
drawerVisible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleViewClose = () => {
|
||||||
|
viewDrawerVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePermissionCheck = (permissionId: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
if (!selectedPermissionIds.value.includes(permissionId)) {
|
||||||
|
selectedPermissionIds.value.push(permissionId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const index = selectedPermissionIds.value.indexOf(permissionId)
|
||||||
|
if (index > -1) {
|
||||||
|
selectedPermissionIds.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPermissionChecked = (permissionId: string) => {
|
||||||
|
return selectedPermissionIds.value.includes(permissionId)
|
||||||
|
}
|
||||||
|
|
||||||
const getTypeLabel = (type: string) => {
|
const getTypeLabel = (type: string) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
SYSTEM: '系统角色',
|
SYSTEM: '系统角色',
|
||||||
|
|
@ -173,6 +362,15 @@ const getTypeLabel = (type: string) => {
|
||||||
return map[type] || type
|
return map[type] || type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
SYSTEM: 'blue',
|
||||||
|
PROJECT: 'cyan',
|
||||||
|
DEPARTMENT: 'orange'
|
||||||
|
}
|
||||||
|
return map[type] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
const getDataScopeLabel = (dataScope: string) => {
|
const getDataScopeLabel = (dataScope: string) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
ALL: '全部数据',
|
ALL: '全部数据',
|
||||||
|
|
@ -182,12 +380,50 @@ const getDataScopeLabel = (dataScope: string) => {
|
||||||
return map[dataScope] || dataScope
|
return map[dataScope] || dataScope
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDataScopeColor = (dataScope: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
ALL: 'red',
|
||||||
|
PROJECT: 'blue',
|
||||||
|
SELF: 'green'
|
||||||
|
}
|
||||||
|
return map[dataScope] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPermissionTypeLabel = (type: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
MENU: '菜单',
|
||||||
|
BUTTON: '按钮',
|
||||||
|
API: '接口'
|
||||||
|
}
|
||||||
|
return map[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPermissionTypeColor = (type: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
MENU: 'blue',
|
||||||
|
BUTTON: 'green',
|
||||||
|
API: 'purple'
|
||||||
|
}
|
||||||
|
return map[type] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(drawerVisible, (val) => {
|
||||||
|
if (val && !allPermissions.value.length) {
|
||||||
|
fetchAllPermissions()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(viewDrawerVisible, (val) => {
|
||||||
|
if (val && !allPermissions.value.length) {
|
||||||
|
fetchAllPermissions()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(fetchRoles)
|
onMounted(fetchRoles)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<!-- 页面标题 -->
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2 class="page-title">角色管理</h2>
|
<h2 class="page-title">角色管理</h2>
|
||||||
<div class="page-header-actions">
|
<div class="page-header-actions">
|
||||||
|
|
@ -197,7 +433,6 @@ onMounted(fetchRoles)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 筛选区 -->
|
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-input
|
<a-input
|
||||||
|
|
@ -212,7 +447,6 @@ onMounted(fetchRoles)
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 表格 -->
|
|
||||||
<div class="table-card">
|
<div class="table-card">
|
||||||
<TableToolbar @refresh="fetchRoles" />
|
<TableToolbar @refresh="fetchRoles" />
|
||||||
|
|
||||||
|
|
@ -224,17 +458,20 @@ onMounted(fetchRoles)
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'type'">
|
<template v-if="column.key === 'name'">
|
||||||
<a-tag>{{ getTypeLabel(record.type) }}</a-tag>
|
<a @click="handleView(record)" class="clickable-text">{{ record.name }}</a>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'type'">
|
||||||
|
{{ getTypeLabel(record.type) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'dataScope'">
|
<template v-else-if="column.key === 'dataScope'">
|
||||||
<a-tag>{{ getDataScopeLabel(record.dataScope) }}</a-tag>
|
{{ getDataScopeLabel(record.dataScope) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'status'">
|
<template v-else-if="column.key === 'status'">
|
||||||
<StatusTag :status="record.status" />
|
<StatusTag :status="record.status" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'action'">
|
<template v-else-if="column.key === 'action'">
|
||||||
<TableActions @edit="handleEdit(record)" @delete="handleDelete(record.id)" />
|
<TableActions show-view @view="handleView(record)" @edit="handleEdit(record)" @delete="handleDelete(record.id)" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
|
|
@ -247,14 +484,16 @@ onMounted(fetchRoles)
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 抽屉 -->
|
<!-- 编辑/新增抽屉 -->
|
||||||
<Drawer
|
<Drawer
|
||||||
v-model:open="drawerVisible"
|
v-model:open="drawerVisible"
|
||||||
:title="drawerTitle"
|
:title="drawerTitle"
|
||||||
width="480px"
|
width="900px"
|
||||||
:footer-style="{ textAlign: 'right' }"
|
:footer-style="{ textAlign: 'right' }"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
|
<Tabs v-model:activeKey="activeTab" @change="handleTabChange">
|
||||||
|
<TabPane key="basic" tab="基本信息">
|
||||||
<Form
|
<Form
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="formState"
|
:model="formState"
|
||||||
|
|
@ -280,9 +519,61 @@ onMounted(fetchRoles)
|
||||||
<Select v-model:value="formState.dataScope" :options="dataScopeOptions" placeholder="请选择数据权限" />
|
<Select v-model:value="formState.dataScope" :options="dataScopeOptions" placeholder="请选择数据权限" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="状态" name="status">
|
<Form.Item label="状态" name="status">
|
||||||
<Select v-model:value="formState.status" :options="[{ value: 'ACTIVE', label: '正常' }, { value: 'DISABLED', label: '禁用' }]" />
|
<Select v-model:value="formState.status" :options="[{ value: 'ENABLED', label: '启用' }, { value: 'DISABLED', label: '禁用' }]" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
</TabPane>
|
||||||
|
<TabPane key="permissions" tab="权限配置">
|
||||||
|
<div v-if="!formState.id" class="permission-tip">
|
||||||
|
请先保存角色基本信息后再配置权限
|
||||||
|
</div>
|
||||||
|
<div v-else class="permission-config">
|
||||||
|
<div class="permission-layout">
|
||||||
|
<!-- 左侧分类导航 -->
|
||||||
|
<div class="permission-sidebar">
|
||||||
|
<div
|
||||||
|
v-for="module in moduleList"
|
||||||
|
:key="module.key"
|
||||||
|
class="module-item"
|
||||||
|
:class="{ active: selectedModule === module.key }"
|
||||||
|
@click="selectedModule = module.key"
|
||||||
|
>
|
||||||
|
<span class="module-name">{{ module.name }}</span>
|
||||||
|
<span class="module-count">{{ module.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧权限表格 -->
|
||||||
|
<div class="permission-content">
|
||||||
|
<div class="permission-tip">勾选需要分配给该角色的权限</div>
|
||||||
|
<a-spin :spinning="permissionsLoading">
|
||||||
|
<Table
|
||||||
|
:columns="permissionColumns"
|
||||||
|
:data-source="filteredPermissions"
|
||||||
|
:pagination="false"
|
||||||
|
size="small"
|
||||||
|
:row-key="(record: Permission) => record.id"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'checkbox'">
|
||||||
|
<Checkbox
|
||||||
|
:checked="isPermissionChecked(record.id)"
|
||||||
|
@change="(e: any) => handlePermissionCheck(record.id, e.target.checked)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'type'">
|
||||||
|
<Tag :color="getPermissionTypeColor(record.type)">
|
||||||
|
{{ getPermissionTypeLabel(record.type) }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Space>
|
<Space>
|
||||||
<Button @click="handleClose">取消</Button>
|
<Button @click="handleClose">取消</Button>
|
||||||
|
|
@ -290,5 +581,301 @@ onMounted(fetchRoles)
|
||||||
</Space>
|
</Space>
|
||||||
</template>
|
</template>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<!-- 查看抽屉 -->
|
||||||
|
<Drawer
|
||||||
|
v-model:open="viewDrawerVisible"
|
||||||
|
:title="drawerTitle"
|
||||||
|
width="700px"
|
||||||
|
@close="handleViewClose"
|
||||||
|
>
|
||||||
|
<div class="view-section">
|
||||||
|
<h3 class="section-title">基本信息</h3>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">角色编码:</span>
|
||||||
|
<span class="info-value">{{ formState.code }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">角色名称:</span>
|
||||||
|
<span class="info-value">{{ formState.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">类型:</span>
|
||||||
|
<a-tag :color="getTypeColor(formState.type)">{{ getTypeLabel(formState.type) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">数据权限:</span>
|
||||||
|
<a-tag :color="getDataScopeColor(formState.dataScope)">{{ getDataScopeLabel(formState.dataScope) }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">状态:</span>
|
||||||
|
<span class="info-value"><StatusTag :status="formState.status" /></span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item full-width">
|
||||||
|
<span class="info-label">描述:</span>
|
||||||
|
<span class="info-value">{{ formState.description || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-section">
|
||||||
|
<h3 class="section-title">
|
||||||
|
权限清单
|
||||||
|
<Tag color="blue">{{ currentRolePermissions.length }} 个权限</Tag>
|
||||||
|
</h3>
|
||||||
|
<a-spin :spinning="permissionsLoading">
|
||||||
|
<div v-if="currentRolePermissions.length === 0" class="empty-permissions">
|
||||||
|
该角色暂无权限
|
||||||
|
</div>
|
||||||
|
<div v-else class="permission-group-list">
|
||||||
|
<div
|
||||||
|
v-for="(perms, module) in currentRoleGroupedPermissions"
|
||||||
|
:key="module"
|
||||||
|
class="permission-group"
|
||||||
|
>
|
||||||
|
<div class="group-title">{{ getModuleName(module) }}</div>
|
||||||
|
<div class="group-items">
|
||||||
|
<div v-for="perm in perms" :key="perm.id" class="permission-item">
|
||||||
|
<Tag size="small" :color="getPermissionTypeColor(perm.type)">
|
||||||
|
{{ getPermissionTypeLabel(perm.type) }}
|
||||||
|
</Tag>
|
||||||
|
<span class="perm-name">{{ perm.name }}</span>
|
||||||
|
<span class="perm-code">{{ perm.code }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-section">
|
||||||
|
<h3 class="section-title">
|
||||||
|
已分配用户
|
||||||
|
<Tag color="green">{{ roleUsers.length }} 人</Tag>
|
||||||
|
</h3>
|
||||||
|
<a-spin :spinning="roleUsersLoading">
|
||||||
|
<div v-if="roleUsers.length === 0" class="empty-permissions">
|
||||||
|
暂无用户分配此角色
|
||||||
|
</div>
|
||||||
|
<div v-else class="user-list">
|
||||||
|
<div v-for="user in roleUsers" :key="user.id" class="user-item">
|
||||||
|
<Avatar :size="32" :icon="UserOutlined" />
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name">{{ user.realName || user.username }}</div>
|
||||||
|
<div class="user-username">{{ user.username }}</div>
|
||||||
|
</div>
|
||||||
|
<Tag v-if="user.status === 'ACTIVE'" color="success" size="small">正常</Tag>
|
||||||
|
<Tag v-else-if="user.status === 'LOCKED'" color="warning" size="small">锁定</Tag>
|
||||||
|
<Tag v-else color="default" size="small">禁用</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.permission-tip {
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-config {
|
||||||
|
height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-sidebar {
|
||||||
|
width: 160px;
|
||||||
|
border-right: 1px solid #f0f0f0;
|
||||||
|
background: #fafafa;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-item:hover {
|
||||||
|
background: #e6f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-item.active {
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-item.active .module-count {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-name {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-count {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #e8e8e8;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 查看抽屉样式 */
|
||||||
|
.view-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item.full-width {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
color: #666;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-permissions {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-group-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-group {
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
background: #fafafa;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-items {
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-code {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户列表样式 */
|
||||||
|
.user-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-username {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-text {
|
||||||
|
color: #1890ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-text:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Card, Form, FormItem, Input, Button, message, Breadcrumb, BreadcrumbItem } from 'ant-design-vue'
|
||||||
|
import { HomeOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { getConfig, updateConfig } from '@/api/system'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formState = ref({
|
||||||
|
propertyCompanyName: '',
|
||||||
|
propertyCompanyAddress: '',
|
||||||
|
propertyCompanyPhone: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getConfig()
|
||||||
|
const data = res.data.data || {}
|
||||||
|
formState.value.propertyCompanyName = data.property_company_name || ''
|
||||||
|
formState.value.propertyCompanyAddress = data.property_company_address || ''
|
||||||
|
formState.value.propertyCompanyPhone = data.property_company_phone || ''
|
||||||
|
} catch {
|
||||||
|
message.error('获取系统设置失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await updateConfig({
|
||||||
|
property_company_name: formState.value.propertyCompanyName,
|
||||||
|
property_company_address: formState.value.propertyCompanyAddress,
|
||||||
|
property_company_phone: formState.value.propertyCompanyPhone
|
||||||
|
})
|
||||||
|
message.success('保存成功')
|
||||||
|
} catch {
|
||||||
|
message.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<HomeOutlined />
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem>系统管理</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem>系统设置</BreadcrumbItem>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h2 class="page-title">系统设置</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card :loading="loading">
|
||||||
|
<Form layout="vertical">
|
||||||
|
<FormItem label="物业企业名称">
|
||||||
|
<Input
|
||||||
|
v-model:value="formState.propertyCompanyName"
|
||||||
|
placeholder="请输入物业企业名称"
|
||||||
|
:maxlength="100"
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="物业企业地址">
|
||||||
|
<Input
|
||||||
|
v-model:value="formState.propertyCompanyAddress"
|
||||||
|
placeholder="请输入物业企业地址"
|
||||||
|
:maxlength="200"
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="物业企业电话">
|
||||||
|
<Input
|
||||||
|
v-model:value="formState.propertyCompanyPhone"
|
||||||
|
placeholder="请输入物业企业电话"
|
||||||
|
:maxlength="20"
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem>
|
||||||
|
<Button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
保存设置
|
||||||
|
</Button>
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, reactive, computed } from 'vue'
|
import { ref, onMounted, reactive, computed } from 'vue'
|
||||||
import { Button, Drawer, Form, Space, message } from 'ant-design-vue'
|
import { Button, Drawer, Form, Space, message, Tag, Checkbox, Spin, Descriptions, DescriptionsItem } from 'ant-design-vue'
|
||||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
import { PlusOutlined, SafetyOutlined } from '@ant-design/icons-vue'
|
||||||
import { getUsers, createUser, updateUser, deleteUser } from '@/api/user'
|
import { getUsers, createUser, updateUser, deleteUser, assignRoles } from '@/api/user'
|
||||||
import type { User } from '@/types'
|
import { getRoles } from '@/api/role'
|
||||||
|
import type { User, Role } from '@/types'
|
||||||
import {
|
import {
|
||||||
PageHeader,
|
PageHeader,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
|
|
@ -19,11 +20,12 @@ import {
|
||||||
|
|
||||||
// 表格列定义
|
// 表格列定义
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '用户名', dataIndex: 'username', key: 'username', width: 100 },
|
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
|
||||||
{ title: '姓名', dataIndex: 'realName', key: 'realName', width: 80 },
|
{ title: '姓名', dataIndex: 'realName', key: 'realName', width: 100 },
|
||||||
{ title: '手机', dataIndex: 'phone', key: 'phone', width: 110 },
|
{ title: '手机', dataIndex: 'phone', key: 'phone', width: 120 },
|
||||||
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180, ellipsis: true },
|
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180, ellipsis: true },
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 70 },
|
{ title: '角色', key: 'roles', width: 150 },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
|
||||||
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const }
|
{ title: '操作', key: 'action', width: 140, fixed: 'right' as const }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -173,6 +175,90 @@ const handleClose = () => {
|
||||||
drawerVisible.value = false
|
drawerVisible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查看
|
||||||
|
const viewDrawerVisible = ref(false)
|
||||||
|
const viewDrawerTitle = ref('')
|
||||||
|
const viewUser = ref<User | null>(null)
|
||||||
|
|
||||||
|
const handleView = (record: User) => {
|
||||||
|
viewUser.value = record
|
||||||
|
viewDrawerTitle.value = `查看用户 - ${record.realName || record.username}`
|
||||||
|
viewDrawerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewClose = () => {
|
||||||
|
viewDrawerVisible.value = false
|
||||||
|
viewUser.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限授予相关
|
||||||
|
const permissionDrawerVisible = ref(false)
|
||||||
|
const permissionDrawerTitle = ref('')
|
||||||
|
const currentUser = ref<User | null>(null)
|
||||||
|
const allRoles = ref<Role[]>([])
|
||||||
|
const selectedRoleIds = ref<string[]>([])
|
||||||
|
const permissionLoading = ref(false)
|
||||||
|
const permissionSubmitting = ref(false)
|
||||||
|
|
||||||
|
// 打开权限授予抽屉
|
||||||
|
const handleAssignRoles = async (record: User) => {
|
||||||
|
currentUser.value = record
|
||||||
|
permissionDrawerTitle.value = `权限授予 - ${record.realName || record.username}`
|
||||||
|
selectedRoleIds.value = record.roles?.map(r => r.id) || []
|
||||||
|
permissionDrawerVisible.value = true
|
||||||
|
permissionLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getRoles()
|
||||||
|
allRoles.value = res.data.data || []
|
||||||
|
} catch {
|
||||||
|
message.error('获取角色列表失败')
|
||||||
|
} finally {
|
||||||
|
permissionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭权限授予抽屉
|
||||||
|
const handlePermissionClose = () => {
|
||||||
|
permissionDrawerVisible.value = false
|
||||||
|
currentUser.value = null
|
||||||
|
selectedRoleIds.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交权限授予
|
||||||
|
const handlePermissionSubmit = async () => {
|
||||||
|
if (!currentUser.value) return
|
||||||
|
permissionSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await assignRoles(currentUser.value.id, selectedRoleIds.value)
|
||||||
|
message.success('权限授予成功')
|
||||||
|
permissionDrawerVisible.value = false
|
||||||
|
fetchUsers()
|
||||||
|
} catch {
|
||||||
|
message.error('权限授予失败')
|
||||||
|
} finally {
|
||||||
|
permissionSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换角色选择
|
||||||
|
const toggleRole = (roleId: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
if (!selectedRoleIds.value.includes(roleId)) {
|
||||||
|
selectedRoleIds.value.push(roleId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const index = selectedRoleIds.value.indexOf(roleId)
|
||||||
|
if (index > -1) {
|
||||||
|
selectedRoleIds.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否已选择角色
|
||||||
|
const isRoleSelected = (roleId: string) => {
|
||||||
|
return selectedRoleIds.value.includes(roleId)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(fetchUsers)
|
onMounted(fetchUsers)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -216,11 +302,34 @@ onMounted(fetchUsers)
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'status'">
|
<template v-if="column.key === 'username'">
|
||||||
|
<a @click="handleView(record)" class="clickable-text">{{ record.username }}</a>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
<StatusTag :status="record.status" />
|
<StatusTag :status="record.status" />
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'roles'">
|
||||||
|
<span v-if="!record.roles || record.roles.length === 0">-</span>
|
||||||
|
<span v-else class="roles-cell">
|
||||||
|
<Tag v-for="role in record.roles.slice(0, 2)" :key="role.id" size="small" color="blue">
|
||||||
|
{{ role.name }}
|
||||||
|
</Tag>
|
||||||
|
<Tag v-if="record.roles.length > 2" size="small" color="default">
|
||||||
|
+{{ record.roles.length - 2 }}
|
||||||
|
</Tag>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'action'">
|
<template v-else-if="column.key === 'action'">
|
||||||
<TableActions @edit="handleEdit(record)" @delete="handleDelete(record.id)" />
|
<TableActions
|
||||||
|
:actions="[
|
||||||
|
{ key: 'permission', label: '权限授予' }
|
||||||
|
]"
|
||||||
|
show-view
|
||||||
|
@view="handleView(record)"
|
||||||
|
@action="(key) => key === 'permission' && handleAssignRoles(record)"
|
||||||
|
@edit="handleEdit(record)"
|
||||||
|
@delete="handleDelete(record.id)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
|
|
@ -273,5 +382,175 @@ onMounted(fetchUsers)
|
||||||
</Space>
|
</Space>
|
||||||
</template>
|
</template>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<!-- 查看抽屉 -->
|
||||||
|
<Drawer
|
||||||
|
v-model:open="viewDrawerVisible"
|
||||||
|
:title="viewDrawerTitle"
|
||||||
|
width="480px"
|
||||||
|
@close="handleViewClose"
|
||||||
|
>
|
||||||
|
<div v-if="viewUser" class="view-content">
|
||||||
|
<a-descriptions :column="1" bordered size="small">
|
||||||
|
<a-descriptions-item label="用户名">
|
||||||
|
{{ viewUser.username }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="姓名">
|
||||||
|
{{ viewUser.realName || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="手机">
|
||||||
|
{{ viewUser.phone || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="邮箱">
|
||||||
|
{{ viewUser.email || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="状态">
|
||||||
|
<StatusTag :status="viewUser.status" />
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="角色">
|
||||||
|
<span v-if="!viewUser.roles || viewUser.roles.length === 0">-</span>
|
||||||
|
<template v-else>
|
||||||
|
<Tag v-for="role in viewUser.roles" :key="role.id" size="small" color="blue">
|
||||||
|
{{ role.name }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Space>
|
||||||
|
<Button @click="handleViewClose">关闭</Button>
|
||||||
|
</Space>
|
||||||
|
</template>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<!-- 权限授予抽屉 -->
|
||||||
|
<Drawer
|
||||||
|
v-model:open="permissionDrawerVisible"
|
||||||
|
:title="permissionDrawerTitle"
|
||||||
|
width="480px"
|
||||||
|
:footer-style="{ textAlign: 'right' }"
|
||||||
|
@close="handlePermissionClose"
|
||||||
|
>
|
||||||
|
<Spin :spinning="permissionLoading">
|
||||||
|
<div class="permission-content">
|
||||||
|
<div class="permission-tip">
|
||||||
|
<SafetyOutlined /> 勾选需要分配给该用户的角色
|
||||||
|
</div>
|
||||||
|
<div v-if="allRoles.length === 0" class="empty-roles">
|
||||||
|
暂无可用角色
|
||||||
|
</div>
|
||||||
|
<div v-else class="role-list">
|
||||||
|
<div
|
||||||
|
v-for="role in allRoles"
|
||||||
|
:key="role.id"
|
||||||
|
class="role-item"
|
||||||
|
:class="{ selected: isRoleSelected(role.id) }"
|
||||||
|
@click="toggleRole(role.id, !isRoleSelected(role.id))"
|
||||||
|
>
|
||||||
|
<Checkbox :checked="isRoleSelected(role.id)" @click.stop />
|
||||||
|
<div class="role-info">
|
||||||
|
<div class="role-name">{{ role.name }}</div>
|
||||||
|
<div class="role-code">{{ role.code }}</div>
|
||||||
|
</div>
|
||||||
|
<Tag v-if="role.type === 'SYSTEM'" color="blue" size="small">系统</Tag>
|
||||||
|
<Tag v-else-if="role.type === 'PROJECT'" color="cyan" size="small">项目</Tag>
|
||||||
|
<Tag v-else color="default" size="small">其他</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
<template #footer>
|
||||||
|
<Space>
|
||||||
|
<Button @click="handlePermissionClose">取消</Button>
|
||||||
|
<Button type="primary" :loading="permissionSubmitting" @click="handlePermissionSubmit">
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</template>
|
||||||
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.roles-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-text {
|
||||||
|
color: #1890ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-text:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-content,
|
||||||
|
.view-content {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-tip {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-roles {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-item:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background: #e6f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-item.selected {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-code {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,699 @@
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
// 测试配置
|
||||||
|
const CONFIG = {
|
||||||
|
baseUrl: 'http://localhost:5175',
|
||||||
|
apiUrl: 'http://localhost:8080',
|
||||||
|
credentials: {
|
||||||
|
username: 'admin',
|
||||||
|
password: 'Admin@123'
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
screenshotDir: '/tmp/e2e-project-test'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试结果记录
|
||||||
|
const testResults = {
|
||||||
|
passed: [],
|
||||||
|
failed: [],
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// 工具函数
|
||||||
|
const log = {
|
||||||
|
info: (msg) => console.log(`[INFO] ${msg}`),
|
||||||
|
success: (msg) => console.log(`\x1b[32m[SUCCESS]\x1b[0m ${msg}`),
|
||||||
|
error: (msg) => console.log(`\x1b[31m[ERROR]\x1b[0m ${msg}`),
|
||||||
|
warning: (msg) => console.log(`\x1b[33m[WARNING]\x1b[0m ${msg}`),
|
||||||
|
test: (name, status) => {
|
||||||
|
const icon = status === 'PASS' ? '✓' : '✗';
|
||||||
|
const color = status === 'PASS' ? '\x1b[32m' : '\x1b[31m';
|
||||||
|
console.log(` ${color}${icon}\x1b[0m ${name}`);
|
||||||
|
if (status === 'PASS') {
|
||||||
|
testResults.passed.push(name);
|
||||||
|
} else {
|
||||||
|
testResults.failed.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 等待应用初始化
|
||||||
|
async function waitForAppInit(page, timeout = 30000) {
|
||||||
|
// 等待页面完全加载
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// 等待关键元素出现
|
||||||
|
try {
|
||||||
|
await page.waitForSelector('.ant-layout', { timeout: 5000 });
|
||||||
|
await page.waitForTimeout(1000); // 额外等待确保稳定
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
// 如果没有 layout,可能是登录页
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录函数
|
||||||
|
async function login(page) {
|
||||||
|
log.info('执行登录...');
|
||||||
|
|
||||||
|
await page.goto(`${CONFIG.baseUrl}/login`, { waitUntil: 'networkidle' });
|
||||||
|
await waitForAppInit(page);
|
||||||
|
|
||||||
|
// 等待登录表单加载
|
||||||
|
await page.waitForSelector('.login-form', { timeout: 10000 });
|
||||||
|
|
||||||
|
// 填写用户名
|
||||||
|
const usernameInput = await page.$('.login-form input[type="text"]');
|
||||||
|
if (usernameInput) {
|
||||||
|
await usernameInput.fill(CONFIG.credentials.username);
|
||||||
|
} else {
|
||||||
|
// 尝试其他选择器
|
||||||
|
const inputs = await page.$$('.login-form input');
|
||||||
|
if (inputs.length > 0) {
|
||||||
|
await inputs[0].fill(CONFIG.credentials.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填写密码
|
||||||
|
const passwordInput = await page.$('.login-form input[type="password"]');
|
||||||
|
if (passwordInput) {
|
||||||
|
await passwordInput.fill(CONFIG.credentials.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击登录按钮
|
||||||
|
const loginButton = await page.$('.login-btn');
|
||||||
|
if (loginButton) {
|
||||||
|
await loginButton.click();
|
||||||
|
} else {
|
||||||
|
// 尝试其他选择器
|
||||||
|
const buttons = await page.$$('.login-form button');
|
||||||
|
if (buttons.length > 0) {
|
||||||
|
await buttons[0].click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待登录成功
|
||||||
|
await page.waitForURL('**/dashboard', { timeout: 10000 });
|
||||||
|
|
||||||
|
const url = page.url();
|
||||||
|
if (url.includes('/login')) {
|
||||||
|
throw new Error('登录失败,仍在登录页');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.success('登录成功');
|
||||||
|
await page.screenshot({ path: `${CONFIG.screenshotDir}/01-login-success.png` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试1: 项目列表页面功能
|
||||||
|
async function testProjectList(page) {
|
||||||
|
log.info('测试项目列表页面功能...');
|
||||||
|
|
||||||
|
// 导航到项目列表
|
||||||
|
await page.goto(`${CONFIG.baseUrl}/project/list`, { waitUntil: 'networkidle' });
|
||||||
|
await waitForAppInit(page);
|
||||||
|
await page.waitForTimeout(3000); // 等待数据加载
|
||||||
|
await page.screenshot({ path: `${CONFIG.screenshotDir}/02-project-list.png` });
|
||||||
|
|
||||||
|
// 测试1.1: 验证页面标题
|
||||||
|
try {
|
||||||
|
const pageTitle = await page.textContent('.page-title');
|
||||||
|
if (pageTitle && pageTitle.includes('项目管理')) {
|
||||||
|
log.test('项目列表 - 页面标题显示正确', 'PASS');
|
||||||
|
} else {
|
||||||
|
log.test('项目列表 - 页面标题显示正确', 'FAIL');
|
||||||
|
testResults.warnings.push('页面标题不匹配');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.test('项目列表 - 页面标题显示正确', 'FAIL');
|
||||||
|
testResults.warnings.push('无法找到页面标题元素');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试1.2: 验证搜索功能
|
||||||
|
try {
|
||||||
|
const searchInput = await page.$('input[placeholder*="项目名称"]');
|
||||||
|
if (searchInput) {
|
||||||
|
await searchInput.fill('测试项目');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// 点击查询按钮
|
||||||
|
const searchButton = await page.$('button:has-text("查询")');
|
||||||
|
if (searchButton) {
|
||||||
|
await searchButton.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.test('项目列表 - 搜索功能可用', 'PASS');
|
||||||
|
} else {
|
||||||
|
log.test('项目列表 - 搜索功能可用', 'FAIL');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.test('项目列表 - 搜索功能可用', 'FAIL');
|
||||||
|
testResults.warnings.push(`搜索功能异常: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试1.3: 验证状态筛选功能
|
||||||
|
try {
|
||||||
|
// 重置搜索条件
|
||||||
|
const resetButton = await page.$('button:has-text("重置")');
|
||||||
|
if (resetButton) {
|
||||||
|
await resetButton.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找状态下拉框
|
||||||
|
const statusSelect = await page.$('.ant-select');
|
||||||
|
if (statusSelect) {
|
||||||
|
await statusSelect.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// 检查下拉选项
|
||||||
|
const options = await page.$$('.ant-select-item');
|
||||||
|
if (options.length > 0) {
|
||||||
|
log.test('项目列表 - 状态筛选功能可用', 'PASS');
|
||||||
|
// 关闭下拉框
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
} else {
|
||||||
|
log.test('项目列表 - 状态筛选功能可用', 'FAIL');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.test('项目列表 - 状态筛选功能可用', 'FAIL');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.test('项目列表 - 状态筛选功能可用', 'FAIL');
|
||||||
|
testResults.warnings.push(`状态筛选异常: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试1.4: 验证表格显示
|
||||||
|
try {
|
||||||
|
const table = await page.$('.ant-table');
|
||||||
|
if (table) {
|
||||||
|
// 等待表格数据加载
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const rows = await page.$$('.ant-table-tbody tr');
|
||||||
|
const rowCount = rows.length;
|
||||||
|
|
||||||
|
// 检查是否有空数据提示
|
||||||
|
const emptyText = await page.textContent('.ant-empty-description').catch(() => null);
|
||||||
|
if (emptyText && emptyText.includes('No data')) {
|
||||||
|
log.test('项目列表 - 表格显示正常 (无数据)', 'PASS');
|
||||||
|
testResults.warnings.push('项目列表无数据,可能需要检查API或数据');
|
||||||
|
} else if (rowCount > 0) {
|
||||||
|
// 验证表格内容
|
||||||
|
const firstRowText = await page.textContent('.ant-table-tbody tr:first-child').catch(() => '');
|
||||||
|
if (firstRowText && firstRowText.includes('PRJ')) {
|
||||||
|
log.test(`项目列表 - 表格显示正常 (共${rowCount}条记录)`, 'PASS');
|
||||||
|
} else {
|
||||||
|
log.test(`项目列表 - 表格显示正常 (共${rowCount}条记录)`, 'PASS');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.test('项目列表 - 表格显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.test('项目列表 - 表格显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.test('项目列表 - 表格显示正常', 'FAIL');
|
||||||
|
testResults.warnings.push(`表格显示异常: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试1.5: 验证分页功能(只有数据超过一页时才显示)
|
||||||
|
try {
|
||||||
|
const pagination = await page.$('.ant-pagination');
|
||||||
|
if (pagination) {
|
||||||
|
log.test('项目列表 - 分页组件显示正常', 'PASS');
|
||||||
|
} else {
|
||||||
|
// 检查是否因为数据太少而不显示分页
|
||||||
|
const rows = await page.$$('.ant-table-tbody tr');
|
||||||
|
if (rows.length <= 10) {
|
||||||
|
log.test('项目列表 - 分页组件显示正常 (数据少于10条,不显示分页)', 'PASS');
|
||||||
|
} else {
|
||||||
|
log.test('项目列表 - 分页组件显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.test('项目列表 - 分页组件显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.screenshot({ path: `${CONFIG.screenshotDir}/03-project-list-features.png` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试2: 项目详情页面功能
|
||||||
|
async function testProjectDetail(page) {
|
||||||
|
log.info('测试项目详情页面功能...');
|
||||||
|
|
||||||
|
// 返回项目列表
|
||||||
|
await page.goto(`${CONFIG.baseUrl}/project/list`, { waitUntil: 'networkidle' });
|
||||||
|
await waitForAppInit(page);
|
||||||
|
|
||||||
|
// 等待表格加载
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// 查找第一个项目的详情按钮
|
||||||
|
try {
|
||||||
|
// 先检查是否有数据
|
||||||
|
const rows = await page.$$('.ant-table-tbody tr');
|
||||||
|
if (rows.length === 0) {
|
||||||
|
log.warning('项目列表为空,跳过详情测试');
|
||||||
|
testResults.warnings.push('项目列表为空,无法测试详情页');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试多种方式查找详情按钮
|
||||||
|
let detailButton = await page.$('button:has-text("详情")');
|
||||||
|
|
||||||
|
if (!detailButton) {
|
||||||
|
// 尝试在操作列中查找
|
||||||
|
detailButton = await page.$('.ant-table-tbody tr:first-child button:has-text("详情")');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detailButton) {
|
||||||
|
// 尝试查找包含"详情"文本的按钮
|
||||||
|
const allButtons = await page.$$('.ant-table-tbody tr:first-child button');
|
||||||
|
for (const btn of allButtons) {
|
||||||
|
const text = await btn.textContent();
|
||||||
|
if (text && text.includes('详情')) {
|
||||||
|
detailButton = btn;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailButton) {
|
||||||
|
await detailButton.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
} else {
|
||||||
|
log.warning('未找到详情按钮,尝试从表格获取项目ID');
|
||||||
|
|
||||||
|
// 从表格获取项目ID
|
||||||
|
const firstRow = await page.$('.ant-table-tbody tr:first-child');
|
||||||
|
if (firstRow) {
|
||||||
|
const cells = await firstRow.$$('td');
|
||||||
|
if (cells.length > 0) {
|
||||||
|
const projectCode = await cells[0].textContent();
|
||||||
|
log.info(`找到项目编码: ${projectCode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过详情测试
|
||||||
|
log.warning('无法访问详情页,跳过详情测试');
|
||||||
|
testResults.warnings.push('无法访问项目详情页');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.screenshot({ path: `${CONFIG.screenshotDir}/04-project-detail.png` });
|
||||||
|
|
||||||
|
// 测试2.1: 验证统计卡片显示
|
||||||
|
try {
|
||||||
|
const statCards = await page.$$('.ant-statistic');
|
||||||
|
if (statCards.length >= 6) {
|
||||||
|
log.test('项目详情 - 统计卡片显示正常', 'PASS');
|
||||||
|
} else {
|
||||||
|
log.test('项目详情 - 统计卡片显示正常', 'FAIL');
|
||||||
|
testResults.warnings.push(`统计卡片数量不足: ${statCards.length}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.test('项目详情 - 统计卡片显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试2.2: 验证Tab页切换
|
||||||
|
try {
|
||||||
|
const tabs = await page.$$('.ant-tabs-tab');
|
||||||
|
if (tabs.length >= 4) {
|
||||||
|
log.test('项目详情 - Tab页显示正常', 'PASS');
|
||||||
|
|
||||||
|
// 测试切换到成员管理
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const text = await tab.textContent();
|
||||||
|
if (text && text.includes('成员管理')) {
|
||||||
|
await tab.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await page.screenshot({ path: `${CONFIG.screenshotDir}/05-member-tab.png` });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.test('项目详情 - Tab页显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.test('项目详情 - Tab页显示正常', 'FAIL');
|
||||||
|
testResults.warnings.push(`Tab页异常: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试2.3: 验证基本信息显示
|
||||||
|
try {
|
||||||
|
// 等待数据加载
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// 尝试多种选择器
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
// 方式1: 检查 descriptions-item
|
||||||
|
const descriptions = await page.$$('.ant-descriptions-item');
|
||||||
|
if (descriptions.length > 0) {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方式2: 检查内容是否包含项目信息
|
||||||
|
if (!found) {
|
||||||
|
const infoContent = await page.textContent('.ant-tabs-tabpane-active').catch(() => '');
|
||||||
|
if (infoContent && (infoContent.includes('项目编码') || infoContent.includes('PRJ') || infoContent.includes('项目名称'))) {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方式3: 检查是否有描述列表
|
||||||
|
if (!found) {
|
||||||
|
const descList = await page.$('.ant-descriptions');
|
||||||
|
if (descList) {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.test('项目详情 - 基本信息显示正常', found ? 'PASS' : 'FAIL');
|
||||||
|
} catch (e) {
|
||||||
|
log.test('项目详情 - 基本信息显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
log.error(`项目详情测试失败: ${e.message}`);
|
||||||
|
testResults.warnings.push(`项目详情测试异常: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试3: 项目成员管理
|
||||||
|
async function testProjectMember(page) {
|
||||||
|
log.info('测试项目成员管理功能...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 确保在成员管理Tab
|
||||||
|
const memberTab = await page.$('.ant-tabs-tab:has-text("成员管理")');
|
||||||
|
if (!memberTab) {
|
||||||
|
// 尝试点击成员管理Tab
|
||||||
|
const tabs = await page.$$('.ant-tabs-tab');
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const text = await tab.textContent();
|
||||||
|
if (text && text.includes('成员管理')) {
|
||||||
|
await tab.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试3.1: 验证成员列表显示
|
||||||
|
try {
|
||||||
|
const memberTable = await page.$('.ant-tabs-tabpane-active .ant-table');
|
||||||
|
if (memberTable) {
|
||||||
|
const rows = await page.$$('.ant-tabs-tabpane-active .ant-table-tbody tr');
|
||||||
|
log.test('成员管理 - 成员列表显示正常', 'PASS');
|
||||||
|
} else {
|
||||||
|
log.test('成员管理 - 成员列表显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.test('成员管理 - 成员列表显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试3.2: 验证添加成员按钮
|
||||||
|
try {
|
||||||
|
const addButton = await page.$('button:has-text("添加成员")');
|
||||||
|
if (addButton) {
|
||||||
|
log.test('成员管理 - 添加成员按钮存在', 'PASS');
|
||||||
|
|
||||||
|
// 点击添加成员按钮
|
||||||
|
await addButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// 检查弹窗是否显示
|
||||||
|
const modal = await page.$('.ant-modal');
|
||||||
|
if (modal) {
|
||||||
|
log.test('成员管理 - 添加成员弹窗显示正常', 'PASS');
|
||||||
|
await page.screenshot({ path: `${CONFIG.screenshotDir}/06-add-member-modal.png` });
|
||||||
|
|
||||||
|
// 关闭弹窗
|
||||||
|
const cancelButton = await page.$('.ant-modal button:has-text("取消")');
|
||||||
|
if (cancelButton) {
|
||||||
|
await cancelButton.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.test('成员管理 - 添加成员弹窗显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.test('成员管理 - 添加成员按钮存在', 'FAIL');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.test('成员管理 - 添加成员功能测试', 'FAIL');
|
||||||
|
testResults.warnings.push(`添加成员异常: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
log.error(`成员管理测试失败: ${e.message}`);
|
||||||
|
testResults.warnings.push(`成员管理测试异常: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试4: 项目状态管理
|
||||||
|
async function testProjectStatus(page) {
|
||||||
|
log.info('测试项目状态管理功能...');
|
||||||
|
|
||||||
|
// 返回项目列表
|
||||||
|
await page.goto(`${CONFIG.baseUrl}/project/list`, { waitUntil: 'networkidle' });
|
||||||
|
await waitForAppInit(page);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查是否有数据
|
||||||
|
const rows = await page.$$('.ant-table-tbody tr');
|
||||||
|
if (rows.length === 0) {
|
||||||
|
log.warning('项目列表为空,跳过状态测试');
|
||||||
|
testResults.warnings.push('项目列表为空,无法测试状态管理');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试4.1: 验证状态标签显示
|
||||||
|
try {
|
||||||
|
const statusTags = await page.$$('.ant-table-tbody .ant-tag');
|
||||||
|
if (statusTags.length > 0) {
|
||||||
|
log.test('项目状态 - 状态标签显示正常', 'PASS');
|
||||||
|
} else {
|
||||||
|
log.test('项目状态 - 状态标签显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.test('项目状态 - 状态标签显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试4.2: 验证状态切换按钮
|
||||||
|
try {
|
||||||
|
const firstRowButtons = await page.$$('.ant-table-tbody tr:first-child button');
|
||||||
|
let hasToggle = false;
|
||||||
|
|
||||||
|
for (const button of firstRowButtons) {
|
||||||
|
const text = await button.textContent();
|
||||||
|
if (text && (text.includes('禁用') || text.includes('启用'))) {
|
||||||
|
hasToggle = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.test('项目状态 - 状态切换按钮存在', hasToggle ? 'PASS' : 'FAIL');
|
||||||
|
} catch (e) {
|
||||||
|
log.test('项目状态 - 状态切换按钮存在', 'FAIL');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.screenshot({ path: `${CONFIG.screenshotDir}/07-project-status.png` });
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
log.error(`项目状态测试失败: ${e.message}`);
|
||||||
|
testResults.warnings.push(`项目状态测试异常: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试5: 项目配置管理
|
||||||
|
async function testProjectConfig(page) {
|
||||||
|
log.info('测试项目配置管理功能...');
|
||||||
|
|
||||||
|
// 导航到项目详情
|
||||||
|
await page.goto(`${CONFIG.baseUrl}/project/list`, { waitUntil: 'networkidle' });
|
||||||
|
await waitForAppInit(page);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查是否有数据
|
||||||
|
const rows = await page.$$('.ant-table-tbody tr');
|
||||||
|
if (rows.length === 0) {
|
||||||
|
log.warning('项目列表为空,跳过配置测试');
|
||||||
|
testResults.warnings.push('项目列表为空,无法测试项目配置');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击第一个项目的详情按钮
|
||||||
|
let detailButton = await page.$('.ant-table-tbody tr:first-child button:has-text("详情")');
|
||||||
|
|
||||||
|
if (!detailButton) {
|
||||||
|
const allButtons = await page.$$('.ant-table-tbody tr:first-child button');
|
||||||
|
for (const btn of allButtons) {
|
||||||
|
const text = await btn.textContent();
|
||||||
|
if (text && text.includes('详情')) {
|
||||||
|
detailButton = btn;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detailButton) {
|
||||||
|
log.warning('未找到详情按钮,跳过配置测试');
|
||||||
|
testResults.warnings.push('无法访问项目配置页');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await detailButton.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// 切换到配置Tab
|
||||||
|
const tabs = await page.$$('.ant-tabs-tab');
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const text = await tab.textContent();
|
||||||
|
if (text && text.includes('项目配置')) {
|
||||||
|
await tab.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.screenshot({ path: `${CONFIG.screenshotDir}/08-project-config.png` });
|
||||||
|
|
||||||
|
// 测试5.1: 验证配置项显示
|
||||||
|
try {
|
||||||
|
const switches = await page.$$('.ant-switch');
|
||||||
|
if (switches.length >= 8) {
|
||||||
|
log.test('项目配置 - 配置项显示正常', 'PASS');
|
||||||
|
} else {
|
||||||
|
log.test('项目配置 - 配置项显示正常', 'FAIL');
|
||||||
|
testResults.warnings.push(`配置项数量不足: ${switches.length}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.test('项目配置 - 配置项显示正常', 'FAIL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试5.2: 验证保存按钮
|
||||||
|
try {
|
||||||
|
const saveButton = await page.$('button:has-text("保存配置")');
|
||||||
|
if (saveButton) {
|
||||||
|
log.test('项目配置 - 保存按钮存在', 'PASS');
|
||||||
|
} else {
|
||||||
|
log.test('项目配置 - 保存按钮存在', 'FAIL');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.test('项目配置 - 保存按钮存在', 'FAIL');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
log.error(`项目配置测试失败: ${e.message}`);
|
||||||
|
testResults.warnings.push(`项目配置测试异常: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主测试流程
|
||||||
|
async function runTests() {
|
||||||
|
log.info('========================================');
|
||||||
|
log.info('Ether 项目管理功能 E2E 测试');
|
||||||
|
log.info('========================================');
|
||||||
|
log.info(`测试时间: ${new Date().toLocaleString('zh-CN')}`);
|
||||||
|
log.info(`前端地址: ${CONFIG.baseUrl}`);
|
||||||
|
log.info(`后端地址: ${CONFIG.apiUrl}`);
|
||||||
|
log.info('========================================\n');
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1920, height: 1080 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// 监听控制台输出
|
||||||
|
page.on('console', msg => {
|
||||||
|
const text = msg.text();
|
||||||
|
if (msg.type() === 'error') {
|
||||||
|
testResults.warnings.push(`浏览器控制台错误: ${text}`);
|
||||||
|
} else if (text.includes('API') || text.includes('请求') || text.includes('失败') || text.includes('登录')) {
|
||||||
|
log.info(`控制台: ${text}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听网络请求
|
||||||
|
page.on('response', async (response) => {
|
||||||
|
const url = response.url();
|
||||||
|
if (url.includes('/api/mdm/projects')) {
|
||||||
|
try {
|
||||||
|
const status = response.status();
|
||||||
|
log.info(`API请求: ${url} - 状态: ${status}`);
|
||||||
|
if (status === 200) {
|
||||||
|
const body = await response.json();
|
||||||
|
log.info(`API响应数据: ${JSON.stringify(body).substring(0, 200)}...`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on('pageerror', error => {
|
||||||
|
testResults.warnings.push(`页面错误: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 登录
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
// 执行测试
|
||||||
|
await testProjectList(page);
|
||||||
|
await testProjectDetail(page);
|
||||||
|
await testProjectMember(page);
|
||||||
|
await testProjectStatus(page);
|
||||||
|
await testProjectConfig(page);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`测试执行失败: ${error.message}`);
|
||||||
|
await page.screenshot({ path: `${CONFIG.screenshotDir}/error.png` });
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出测试报告
|
||||||
|
log.info('\n========================================');
|
||||||
|
log.info('测试报告');
|
||||||
|
log.info('========================================');
|
||||||
|
log.info(`通过: ${testResults.passed.length}`);
|
||||||
|
log.info(`失败: ${testResults.failed.length}`);
|
||||||
|
log.info(`警告: ${testResults.warnings.length}`);
|
||||||
|
|
||||||
|
if (testResults.failed.length > 0) {
|
||||||
|
log.error('\n失败的测试项:');
|
||||||
|
testResults.failed.forEach(item => log.error(` - ${item}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testResults.warnings.length > 0) {
|
||||||
|
log.warning('\n警告信息:');
|
||||||
|
testResults.warnings.forEach(item => log.warning(` - ${item}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('\n========================================');
|
||||||
|
log.info(`测试完成! 截图保存在: ${CONFIG.screenshotDir}`);
|
||||||
|
log.info('========================================\n');
|
||||||
|
|
||||||
|
// 返回退出码
|
||||||
|
process.exit(testResults.failed.length > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行测试
|
||||||
|
runTests().catch(error => {
|
||||||
|
log.error(`测试执行异常: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue