feat: add equipment management frontend pages

This commit is contained in:
chiguyong 2026-03-24 00:03:36 +08:00
parent f111f4a8d5
commit 7956379f71
27 changed files with 5056 additions and 374 deletions

57
src/api/audit.ts Normal file
View File

@ -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'
})
}

77
src/api/equipment.ts Normal file
View File

@ -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 }
})
}

View File

@ -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 })
} }

View File

@ -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`)
}

38
src/api/space.ts Normal file
View File

@ -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}`)
}

16
src/api/system.ts Normal file
View File

@ -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
})
}

View File

@ -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>

View File

@ -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
})
// 332
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>

View File

@ -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: '设备详情' }
} }
] ]
} }

View File

@ -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 |

View File

@ -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'
}

125
src/types/project.ts Normal file
View File

@ -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
}

141
src/types/space.ts Normal file
View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

374
src/views/space/Space.vue Normal file
View File

@ -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>

View File

@ -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>

View File

@ -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 }
] ]

View File

@ -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>

View File

@ -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>

View File

@ -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>

699
test-project-e2e.cjs Normal file
View File

@ -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);
});