feat: 更新组件、API、页面等大量改动
This commit is contained in:
parent
a5e3011d5a
commit
72f7c891f3
|
|
@ -0,0 +1,4 @@
|
|||
# 开发环境配置
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_APP_TITLE=Ether 管理后台 - 开发
|
||||
VITE_REQUEST_TIMEOUT=10000
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# 生产环境配置
|
||||
VITE_API_BASE_URL=/
|
||||
VITE_APP_TITLE=Ether 管理后台
|
||||
VITE_REQUEST_TIMEOUT=15000
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
# Design System Master File
|
||||
|
||||
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
||||
> If that file exists, its rules **override** this Master file.
|
||||
> If not, strictly follow the rules below.
|
||||
|
||||
---
|
||||
|
||||
**Project:** Ether PMS
|
||||
**Generated:** 2026-04-13 09:34:11
|
||||
**Category:** Analytics Dashboard
|
||||
|
||||
---
|
||||
|
||||
## Global Rules
|
||||
|
||||
### Color Palette
|
||||
|
||||
| Role | Hex | CSS Variable |
|
||||
|------|-----|--------------|
|
||||
| Primary | `#0F766E` | `--color-primary` |
|
||||
| Secondary | `#14B8A6` | `--color-secondary` |
|
||||
| CTA/Accent | `#0369A1` | `--color-cta` |
|
||||
| Background | `#F0FDFA` | `--color-background` |
|
||||
| Text | `#134E4A` | `--color-text` |
|
||||
|
||||
**Color Notes:** Trust teal + professional blue
|
||||
|
||||
### Typography
|
||||
|
||||
- **Heading Font:** Fira Code
|
||||
- **Body Font:** Fira Sans
|
||||
- **Mood:** dashboard, data, analytics, code, technical, precise
|
||||
- **Google Fonts:** [Fira Code + Fira Sans](https://fonts.google.com/share?selection.family=Fira+Code:wght@400;500;600;700|Fira+Sans:wght@300;400;500;600;700)
|
||||
|
||||
**CSS Import:**
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap');
|
||||
```
|
||||
|
||||
### Spacing Variables
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
|
||||
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
|
||||
| `--space-md` | `16px` / `1rem` | Standard padding |
|
||||
| `--space-lg` | `24px` / `1.5rem` | Section padding |
|
||||
| `--space-xl` | `32px` / `2rem` | Large gaps |
|
||||
| `--space-2xl` | `48px` / `3rem` | Section margins |
|
||||
| `--space-3xl` | `64px` / `4rem` | Hero padding |
|
||||
|
||||
### Shadow Depths
|
||||
|
||||
| Level | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
||||
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
||||
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
||||
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
||||
|
||||
---
|
||||
|
||||
## Component Specs
|
||||
|
||||
### Buttons
|
||||
|
||||
```css
|
||||
/* Primary Button */
|
||||
.btn-primary {
|
||||
background: #0369A1;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Secondary Button */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #0F766E;
|
||||
border: 2px solid #0F766E;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: #F0FDFA;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```css
|
||||
.input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #0F766E;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px #0F766E20;
|
||||
}
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
```css
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
**Style:** Data-Dense Dashboard
|
||||
|
||||
**Keywords:** Multiple charts/widgets, data tables, KPI cards, minimal padding, grid layout, space-efficient, maximum data visibility
|
||||
|
||||
**Best For:** Business intelligence dashboards, financial analytics, enterprise reporting, operational dashboards, data warehousing
|
||||
|
||||
**Key Effects:** Hover tooltips, chart zoom on click, row highlighting on hover, smooth filter animations, data loading spinners
|
||||
|
||||
### Page Pattern
|
||||
|
||||
**Pattern Name:** Data-Dense + Drill-Down
|
||||
|
||||
- **CTA Placement:** Above fold
|
||||
- **Section Order:** Hero > Features > CTA
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns (Do NOT Use)
|
||||
|
||||
- ❌ Ornate design
|
||||
- ❌ No filtering
|
||||
|
||||
### Additional Forbidden Patterns
|
||||
|
||||
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
||||
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
||||
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
||||
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
||||
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
||||
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
||||
|
||||
---
|
||||
|
||||
## Pre-Delivery Checklist
|
||||
|
||||
Before delivering any UI code, verify:
|
||||
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||
- [ ] `cursor-pointer` on all clickable elements
|
||||
- [ ] Hover states with smooth transitions (150-300ms)
|
||||
- [ ] Light mode: text contrast 4.5:1 minimum
|
||||
- [ ] Focus states visible for keyboard navigation
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
||||
- [ ] No content hidden behind fixed navbars
|
||||
- [ ] No horizontal scroll on mobile
|
||||
|
|
@ -5,22 +5,20 @@ export interface Dept {
|
|||
id?: string
|
||||
parentId?: string
|
||||
deptName: string
|
||||
deptCode?: string
|
||||
deptType?: string
|
||||
deptTypeDesc?: string
|
||||
defaultRoleCode?: string
|
||||
leaderId?: string
|
||||
sortOrder?: number
|
||||
status?: string
|
||||
deptCode?: string
|
||||
defaultRoleCode?: string
|
||||
children?: Dept[]
|
||||
}
|
||||
|
||||
export interface DeptDTO {
|
||||
deptName: string
|
||||
deptCode?: string
|
||||
parentId?: string
|
||||
deptType?: string
|
||||
defaultRoleCode?: string
|
||||
leaderId?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import request from '@/utils/request'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
// ==================== 点检模板类型 ====================
|
||||
|
||||
|
|
@ -37,39 +38,27 @@ export interface TemplateFormData {
|
|||
|
||||
// 获取模板列表
|
||||
export function getInspectionTemplates(projectId: string) {
|
||||
return request.get<InspectionTemplate[]>('/api/ops/inspection-templates', {
|
||||
return request.get<ApiResponse<InspectionTemplate[]>>('/api/ops/inspection-templates', {
|
||||
params: { projectId }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取模板详情
|
||||
export function getInspectionTemplateDetail(id: string) {
|
||||
return request.get<InspectionTemplate>(`/api/ops/inspection-templates/${id}`)
|
||||
return request.get<ApiResponse<InspectionTemplate>>(`/api/ops/inspection-templates/${id}`)
|
||||
}
|
||||
|
||||
// 创建模板
|
||||
export function createInspectionTemplate(data: TemplateFormData) {
|
||||
return request.post<InspectionTemplate>('/api/ops/inspection-templates', data)
|
||||
return request.post('/api/ops/inspection-templates', data)
|
||||
}
|
||||
|
||||
// 更新模板
|
||||
export function updateInspectionTemplate(id: string, data: TemplateFormData) {
|
||||
return request.put<InspectionTemplate>(`/api/ops/inspection-templates/${id}`, data)
|
||||
}
|
||||
|
||||
// 复制模板
|
||||
export function copyInspectionTemplate(id: string, targetProjectId?: string) {
|
||||
return request.post<InspectionTemplate>(`/api/ops/inspection-templates/${id}/copy`, {
|
||||
targetProjectId
|
||||
})
|
||||
}
|
||||
|
||||
// 按设备类型获取模板
|
||||
export function getTemplatesByEquipmentType(equipmentType: string) {
|
||||
return request.get<InspectionTemplate[]>(`/api/ops/inspection-templates/by-type/${equipmentType}`)
|
||||
return request.put(`/api/ops/inspection-templates/${id}`, data)
|
||||
}
|
||||
|
||||
// 删除模板
|
||||
export function deleteInspectionTemplate(id: string) {
|
||||
return request.delete(`/api/ops/inspection-templates/${id}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import request from '@/utils/request'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
// ==================== 维保计划相关类型 ====================
|
||||
|
||||
|
|
@ -72,85 +73,61 @@ export interface TaskQueryParams {
|
|||
|
||||
// 获取维保计划列表
|
||||
export function getMaintenancePlans(projectId: string, triggerType?: string) {
|
||||
return request.get<MaintenancePlan[]>({
|
||||
url: '/api/ops/maintenance-plans',
|
||||
return request.get<ApiResponse<MaintenancePlan[]>>('/api/ops/maintenance-plans', {
|
||||
params: { projectId, triggerType }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取维保计划详情
|
||||
export function getMaintenancePlan(id: string) {
|
||||
return request.get<MaintenancePlan>({
|
||||
url: `/api/ops/maintenance-plans/${id}`
|
||||
})
|
||||
return request.get<ApiResponse<MaintenancePlan>>(`/api/ops/maintenance-plans/${id}`)
|
||||
}
|
||||
|
||||
// 创建维保计划
|
||||
export function createMaintenancePlan(data: MaintenancePlanForm) {
|
||||
return request.post({
|
||||
url: '/api/ops/maintenance-plans',
|
||||
data
|
||||
})
|
||||
return request.post('/api/ops/maintenance-plans', data)
|
||||
}
|
||||
|
||||
// 更新维保计划
|
||||
export function updateMaintenancePlan(id: string, data: MaintenancePlanForm) {
|
||||
return request.put({
|
||||
url: `/api/ops/maintenance-plans/${id}`,
|
||||
data
|
||||
})
|
||||
return request.put(`/api/ops/maintenance-plans/${id}`, data)
|
||||
}
|
||||
|
||||
// 删除/停用维保计划
|
||||
export function deleteMaintenancePlan(id: string) {
|
||||
return request.delete({
|
||||
url: `/api/ops/maintenance-plans/${id}`
|
||||
})
|
||||
return request.delete(`/api/ops/maintenance-plans/${id}`)
|
||||
}
|
||||
|
||||
// ==================== 维保任务 API ====================
|
||||
|
||||
// 获取维保任务列表
|
||||
export function getMaintenanceTasks(params: TaskQueryParams) {
|
||||
return request.get({
|
||||
url: '/api/ops/maintenance-tasks',
|
||||
params
|
||||
})
|
||||
return request.get<ApiResponse<MaintenanceTask[]>>('/api/ops/maintenance-tasks', { params })
|
||||
}
|
||||
|
||||
// 获取维保任务详情
|
||||
export function getMaintenanceTask(id: string) {
|
||||
return request.get<MaintenanceTask>({
|
||||
url: `/api/ops/maintenance-tasks/${id}`
|
||||
})
|
||||
return request.get<ApiResponse<MaintenanceTask>>(`/api/ops/maintenance-tasks/${id}`)
|
||||
}
|
||||
|
||||
// 接受任务
|
||||
export function acceptMaintenanceTask(id: string, userId: string) {
|
||||
return request.post({
|
||||
url: `/api/ops/maintenance-tasks/${id}/accept`,
|
||||
return request.post(`/api/ops/maintenance-tasks/${id}/accept`, null, {
|
||||
params: { userId }
|
||||
})
|
||||
}
|
||||
|
||||
// 开始执行任务
|
||||
export function startMaintenanceTask(id: string) {
|
||||
return request.post({
|
||||
url: `/api/ops/maintenance-tasks/${id}/start`
|
||||
})
|
||||
return request.post(`/api/ops/maintenance-tasks/${id}/start`)
|
||||
}
|
||||
|
||||
// 完成维保
|
||||
export function completeMaintenanceTask(id: string, data: { completionNotes?: string }) {
|
||||
return request.post({
|
||||
url: `/api/ops/maintenance-tasks/${id}/complete`,
|
||||
data
|
||||
})
|
||||
return request.post(`/api/ops/maintenance-tasks/${id}/complete`, data)
|
||||
}
|
||||
|
||||
// 取消任务
|
||||
export function cancelMaintenanceTask(id: string) {
|
||||
return request.post({
|
||||
url: `/api/ops/maintenance-tasks/${id}/cancel`
|
||||
})
|
||||
}
|
||||
return request.post(`/api/ops/maintenance-tasks/${id}/cancel`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import request from '@/utils/request'
|
||||
import type { Permission } from '@/types'
|
||||
import type { ApiResponse } from '@/types'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
/**
|
||||
* 获取权限列表
|
||||
* @description 获取系统中所有权限的列表
|
||||
* @returns 返回包含所有权限的数组
|
||||
* 获取权限列表(支持分页)
|
||||
* @description 获取系统中所有权限的列表,支持分页和关键词搜索
|
||||
* @param params - 分页和查询参数
|
||||
* @param params.page - 页码(从0开始)
|
||||
* @param params.size - 每页大小
|
||||
* @param params.keyword - 搜索关键词(权限编码/名称)
|
||||
* @returns 返回包含权限列表的分页响应
|
||||
*/
|
||||
export const getPermissions = () => {
|
||||
return request.get<ApiResponse<Permission[]>>('/api/auth/permissions')
|
||||
export const getPermissions = (params?: { page?: number; size?: number; keyword?: string }) => {
|
||||
return request.get<ApiResponse<any>>('/api/auth/permissions', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -48,8 +53,8 @@ export const updatePermission = (id: string, data: Partial<Permission>) => {
|
|||
* @param id - 权限唯一标识符
|
||||
* @returns 返回删除操作的结果
|
||||
*/
|
||||
export const deletePermission = (id: string) => {
|
||||
return request.delete(`/api/auth/permissions/${id}`)
|
||||
export const deletePermission = (id: string): Promise<AxiosResponse<ApiResponse<void>>> => {
|
||||
return request.delete<ApiResponse<void>>(`/api/auth/permissions/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import request from '@/utils/request'
|
||||
import type { Project } from '@/types'
|
||||
import type { ApiResponse, Project } from '@/types'
|
||||
import type {
|
||||
ProjectQuery,
|
||||
PageResponse,
|
||||
|
|
@ -16,32 +16,32 @@ import type {
|
|||
|
||||
// PM-001 分页查询项目列表
|
||||
export const queryProjects = (params: ProjectQuery) => {
|
||||
return request.get<PageResponse<Project>>('/api/mdm/projects', { params })
|
||||
return request.get<ApiResponse<PageResponse<Project>>>('/api/mdm/projects', { params })
|
||||
}
|
||||
|
||||
// 获取所有项目(兼容旧接口)
|
||||
export const getProjects = () => {
|
||||
return request.get<Project[]>('/api/mdm/projects/all')
|
||||
return request.get<ApiResponse<Project[]>>('/api/mdm/projects/all')
|
||||
}
|
||||
|
||||
// 获取项目详情
|
||||
export const getProject = (id: string) => {
|
||||
return request.get<Project>(`/api/mdm/projects/${id}`)
|
||||
return request.get<ApiResponse<Project>>(`/api/mdm/projects/${id}`)
|
||||
}
|
||||
|
||||
// 根据编码获取项目
|
||||
export const getProjectByCode = (code: string) => {
|
||||
return request.get<Project>(`/api/mdm/projects/code/${code}`)
|
||||
return request.get<ApiResponse<Project>>(`/api/mdm/projects/code/${code}`)
|
||||
}
|
||||
|
||||
// 创建项目
|
||||
export const createProject = (data: Partial<Project>) => {
|
||||
return request.post<Project>('/api/mdm/projects', data)
|
||||
return request.post<ApiResponse<Project>>('/api/mdm/projects', data)
|
||||
}
|
||||
|
||||
// 更新项目
|
||||
export const updateProject = (id: string, data: Partial<Project>) => {
|
||||
return request.put<Project>(`/api/mdm/projects/${id}`, data)
|
||||
return request.put<ApiResponse<Project>>(`/api/mdm/projects/${id}`, data)
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
|
|
@ -51,21 +51,21 @@ export const deleteProject = (id: string) => {
|
|||
|
||||
// 检查项目删除可行性
|
||||
export const checkProjectDelete = (projectId: string) => {
|
||||
return request.get<ProjectDeleteCheckVO>(`/api/mdm/projects/${projectId}/delete-check`)
|
||||
return request.get<ApiResponse<ProjectDeleteCheckVO>>(`/api/mdm/projects/${projectId}/delete-check`)
|
||||
}
|
||||
|
||||
// ==================== 统计数据 ====================
|
||||
|
||||
// PM-002 获取项目统计数据
|
||||
export const getProjectStatistics = (id: string) => {
|
||||
return request.get<ProjectStatistics>(`/api/mdm/projects/${id}/statistics`)
|
||||
return request.get<ApiResponse<ProjectStatistics>>(`/api/mdm/projects/${id}/statistics`)
|
||||
}
|
||||
|
||||
// ==================== 成员管理 ====================
|
||||
|
||||
// PM-003 获取项目成员列表
|
||||
export const getProjectMembers = (projectId: string, params?: { page?: number; size?: number }) => {
|
||||
return request.get<PageResponse<ProjectMember>>(`/api/auth/projects/${projectId}/members`, { params })
|
||||
return request.get<ApiResponse<PageResponse<ProjectMember>>>(`/api/auth/projects/${projectId}/members`, { params })
|
||||
}
|
||||
|
||||
// 添加项目成员
|
||||
|
|
@ -87,7 +87,7 @@ export const updateMemberRole = (projectId: string, memberId: string, roleInProj
|
|||
|
||||
// PM-005 生成项目编码
|
||||
export const generateProjectCode = () => {
|
||||
return request.get<{ code: string }>('/api/mdm/projects/generate-code')
|
||||
return request.get<ApiResponse<{ code: string }>>('/api/mdm/projects/generate-code')
|
||||
}
|
||||
|
||||
// ==================== 状态管理 ====================
|
||||
|
|
@ -111,17 +111,17 @@ export const disableProject = (id: string, reason?: string) => {
|
|||
|
||||
// PM-008 获取项目配置
|
||||
export const getProjectConfig = (id: string) => {
|
||||
return request.get<ProjectConfig>(`/api/mdm/projects/${id}/config`)
|
||||
return request.get<ApiResponse<ProjectConfig>>(`/api/mdm/projects/${id}/config`)
|
||||
}
|
||||
|
||||
// 更新项目配置
|
||||
export const updateProjectConfig = (id: string, data: Partial<ProjectConfig>) => {
|
||||
return request.put<ProjectConfig>(`/api/mdm/projects/${id}/config`, data)
|
||||
return request.put<ApiResponse<ProjectConfig>>(`/api/mdm/projects/${id}/config`, data)
|
||||
}
|
||||
|
||||
// ==================== 选择器 ====================
|
||||
|
||||
// PM-010 获取项目选择器列表
|
||||
export const getProjectSelectorList = (params?: { keyword?: string }) => {
|
||||
return request.get<ProjectSelectorItem[]>('/api/mdm/projects/selector', { params })
|
||||
return request.get<ApiResponse<ProjectSelectorItem[]>>('/api/mdm/projects/selector', { params })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import request from '@/utils/request'
|
||||
import type { Role, Permission } from '@/types'
|
||||
import type { Role, Permission, User } from '@/types'
|
||||
import type { ApiResponse } from '@/types'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
/**
|
||||
* 获取角色列表
|
||||
* @description 获取系统中所有角色的列表
|
||||
* @returns 返回包含所有角色的数组
|
||||
* 获取角色列表(支持分页)
|
||||
* @description 获取系统中所有角色的列表,支持分页和关键词搜索
|
||||
* @param params - 分页和查询参数
|
||||
* @param params.page - 页码(从0开始)
|
||||
* @param params.size - 每页大小
|
||||
* @param params.keyword - 搜索关键词(角色编码/名称)
|
||||
* @returns 返回包含角色列表的分页响应
|
||||
*/
|
||||
export const getRoles = () => {
|
||||
return request.get<ApiResponse<Role[]>>('/api/auth/roles')
|
||||
export const getRoles = (params?: { page?: number; size?: number; keyword?: string }) => {
|
||||
return request.get<ApiResponse<any>>('/api/auth/roles', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -68,8 +73,8 @@ export const updateRole = (id: string, data: Partial<Role>) => {
|
|||
* @param id - 角色唯一标识符
|
||||
* @returns 返回删除操作的结果
|
||||
*/
|
||||
export const deleteRole = (id: string) => {
|
||||
return request.delete(`/api/auth/roles/${id}`)
|
||||
export const deleteRole = (id: string): Promise<AxiosResponse<ApiResponse<void>>> => {
|
||||
return request.delete<ApiResponse<void>>(`/api/auth/roles/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -79,8 +84,8 @@ export const deleteRole = (id: string) => {
|
|||
* @param permissionIds - 权限ID数组,表示要分配给角色的权限
|
||||
* @returns 返回权限分配操作的结果
|
||||
*/
|
||||
export const assignPermissions = (roleId: string, permissionIds: string[]) => {
|
||||
return request.post(`/api/auth/roles/${roleId}/permissions`, permissionIds)
|
||||
export const assignPermissions = (roleId: string, permissionIds: string[]): Promise<AxiosResponse<ApiResponse<void>>> => {
|
||||
return request.post<ApiResponse<void>>(`/api/auth/roles/${roleId}/permissions`, permissionIds)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,6 +115,6 @@ export const removeRoleFromUser = (userId: string, roleId: string) => {
|
|||
* @param roleId - 角色唯一标识符
|
||||
* @returns 返回拥有该角色的用户列表
|
||||
*/
|
||||
export const getRoleUsers = (roleId: string) => {
|
||||
return request.get(`/api/auth/roles/${roleId}/users`)
|
||||
export const getRoleUsers = (roleId: string): Promise<AxiosResponse<ApiResponse<User[]>>> => {
|
||||
return request.get<ApiResponse<User[]>>(`/api/auth/roles/${roleId}/users`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,37 @@
|
|||
import request from '@/utils/request'
|
||||
import type { SpaceNode, SpaceNodeTree, SpaceNodeCreateForm, SpaceNodeUpdateForm, FloorInfoVO } from '@/types/space'
|
||||
import type { ApiResponse } from '@/types'
|
||||
import type { SpaceNode, SpaceNodeTree, SpaceNodeCreateForm, SpaceNodeUpdateForm, FloorInfoVO, SpaceNodeDeleteCheck } from '@/types/space'
|
||||
|
||||
export const getSpaceNodes = (projectId: string) => {
|
||||
return request.get<SpaceNode[]>(`/api/mdm/space-nodes/project/${projectId}`)
|
||||
return request.get<ApiResponse<SpaceNode[]>>(`/api/mdm/space-nodes/project/${projectId}`)
|
||||
}
|
||||
|
||||
export const getSpaceTree = (projectId: string) => {
|
||||
return request.get<SpaceNodeTree[]>(`/api/mdm/space-nodes/project/${projectId}/tree`)
|
||||
return request.get<ApiResponse<SpaceNodeTree[]>>(`/api/mdm/space-nodes/project/${projectId}/tree`)
|
||||
}
|
||||
|
||||
export const getSpaceRoots = (projectId: string) => {
|
||||
return request.get<SpaceNode[]>(`/api/mdm/space-nodes/project/${projectId}/roots`)
|
||||
return request.get<ApiResponse<SpaceNode[]>>(`/api/mdm/space-nodes/project/${projectId}/roots`)
|
||||
}
|
||||
|
||||
export const getSpaceChildren = (parentId: string) => {
|
||||
return request.get<SpaceNode[]>(`/api/mdm/space-nodes/parent/${parentId}/children`)
|
||||
return request.get<ApiResponse<SpaceNode[]>>(`/api/mdm/space-nodes/parent/${parentId}/children`)
|
||||
}
|
||||
|
||||
export const getSpaceNode = (id: string) => {
|
||||
return request.get<SpaceNode>(`/api/mdm/space-nodes/${id}`)
|
||||
return request.get<ApiResponse<SpaceNode>>(`/api/mdm/space-nodes/${id}`)
|
||||
}
|
||||
|
||||
export const getSpaceNodesByType = (projectId: string, nodeType: string) => {
|
||||
return request.get<SpaceNode[]>(`/api/mdm/space-nodes/project/${projectId}/type/${nodeType}`)
|
||||
return request.get<ApiResponse<SpaceNode[]>>(`/api/mdm/space-nodes/project/${projectId}/type/${nodeType}`)
|
||||
}
|
||||
|
||||
export const createSpaceNode = (data: SpaceNodeCreateForm) => {
|
||||
return request.post<SpaceNode>('/api/mdm/space-nodes', data)
|
||||
return request.post<ApiResponse<SpaceNode>>('/api/mdm/space-nodes', data)
|
||||
}
|
||||
|
||||
export const updateSpaceNode = (id: string, data: SpaceNodeUpdateForm) => {
|
||||
return request.put<SpaceNode>(`/api/mdm/space-nodes/${id}`, data)
|
||||
return request.put<ApiResponse<SpaceNode>>(`/api/mdm/space-nodes/${id}`, data)
|
||||
}
|
||||
|
||||
export const deleteSpaceNode = (id: string) => {
|
||||
|
|
@ -38,7 +39,7 @@ export const deleteSpaceNode = (id: string) => {
|
|||
}
|
||||
|
||||
export const checkSpaceNodeDelete = (id: string) => {
|
||||
return request.get<{ code: number, message: string, data: SpaceNodeDeleteCheck }>(`/api/mdm/space-nodes/${id}/delete-check`)
|
||||
return request.get<ApiResponse<SpaceNodeDeleteCheck>>(`/api/mdm/space-nodes/${id}/delete-check`)
|
||||
}
|
||||
|
||||
export const deleteSpaceNodeWithChildren = (id: string) => {
|
||||
|
|
@ -46,5 +47,5 @@ export const deleteSpaceNodeWithChildren = (id: string) => {
|
|||
}
|
||||
|
||||
export const getBuildingFloorInfo = (buildingId: string) => {
|
||||
return request.get<FloorInfoVO>(`/api/mdm/space-nodes/${buildingId}/floor-info`)
|
||||
}
|
||||
return request.get<ApiResponse<FloorInfoVO>>(`/api/mdm/space-nodes/${buildingId}/floor-info`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import request from '@/utils/request'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
// ==================== 备件相关类型 ====================
|
||||
|
||||
|
|
@ -88,7 +89,7 @@ export interface PageResponse<T> {
|
|||
|
||||
// 获取分类列表
|
||||
export function getSparePartCategories() {
|
||||
return request.get<SparePartCategory[]>('/api/ops/spare-parts/categories')
|
||||
return request.get<ApiResponse<SparePartCategory[]>>('/api/ops/spare-parts/categories')
|
||||
}
|
||||
|
||||
// 创建分类
|
||||
|
|
@ -100,14 +101,14 @@ export function createSparePartCategory(data: { name: string; description?: stri
|
|||
|
||||
// 获取备件列表
|
||||
export function getSparePartList(projectId: string, categoryId?: string) {
|
||||
return request.get<PageResponse<SparePart>>('/api/ops/spare-parts', {
|
||||
return request.get<ApiResponse<PageResponse<SparePart>>>('/api/ops/spare-parts', {
|
||||
params: { projectId, categoryId }
|
||||
})
|
||||
}
|
||||
|
||||
// 获取备件详情
|
||||
export function getSparePartDetail(id: string) {
|
||||
return request.get<SparePart>(`/api/ops/spare-parts/${id}`)
|
||||
return request.get<ApiResponse<SparePart>>(`/api/ops/spare-parts/${id}`)
|
||||
}
|
||||
|
||||
// 创建备件
|
||||
|
|
@ -127,7 +128,7 @@ export function deleteSparePart(id: string) {
|
|||
|
||||
// 获取低库存备件
|
||||
export function getLowStockSpareParts(projectId: string) {
|
||||
return request.get<SparePart[]>('/api/ops/spare-parts/low-stock', {
|
||||
return request.get<ApiResponse<SparePart[]>>('/api/ops/spare-parts/low-stock', {
|
||||
params: { projectId }
|
||||
})
|
||||
}
|
||||
|
|
@ -144,5 +145,5 @@ export function outStock(data: OutStockRequest) {
|
|||
|
||||
// 获取备件记录
|
||||
export function getSparePartRecords(id: string) {
|
||||
return request.get<StockRecord[]>(`/api/ops/spare-parts/${id}/records`)
|
||||
}
|
||||
return request.get<ApiResponse<StockRecord[]>>(`/api/ops/spare-parts/${id}/records`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
import request from '@/utils/request'
|
||||
import type { User } from '@/types'
|
||||
import type { ApiResponse } from '@/types'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
* @description 获取系统中所有用户的列表
|
||||
* @returns 返回包含所有用户的数组
|
||||
* 获取用户列表(支持分页)
|
||||
* @description 获取系统中所有用户的列表,支持分页和关键词搜索
|
||||
* @param params - 分页和查询参数
|
||||
* @param params.page - 页码(从0开始)
|
||||
* @param params.size - 每页大小
|
||||
* @param params.keyword - 搜索关键词(用户名/姓名/手机号)
|
||||
* @param params.status - 用户状态筛选
|
||||
* @returns 返回包含用户列表的分页响应
|
||||
*/
|
||||
export const getUsers = () => {
|
||||
return request.get<ApiResponse<User[]>>('/api/auth/users')
|
||||
export const getUsers = (params?: { page?: number; size?: number; keyword?: string; status?: string }) => {
|
||||
return request.get<ApiResponse<any>>('/api/auth/users', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -48,8 +54,8 @@ export const updateUser = (id: string, data: Partial<User>) => {
|
|||
* @param id - 用户唯一标识符
|
||||
* @returns 返回删除操作的结果
|
||||
*/
|
||||
export const deleteUser = (id: string) => {
|
||||
return request.delete(`/api/auth/users/${id}`)
|
||||
export const deleteUser = (id: string): Promise<AxiosResponse<ApiResponse<void>>> => {
|
||||
return request.delete<ApiResponse<void>>(`/api/auth/users/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -60,8 +66,8 @@ export const deleteUser = (id: string) => {
|
|||
* @param newPassword - 用户想要设置的新密码
|
||||
* @returns 返回密码修改操作的结果
|
||||
*/
|
||||
export const updatePassword = (id: string, oldPassword: string, newPassword: string) => {
|
||||
return request.put(`/api/auth/users/${id}/password`, { oldPassword, newPassword })
|
||||
export const updatePassword = (id: string, oldPassword: string, newPassword: string): Promise<AxiosResponse<ApiResponse<void>>> => {
|
||||
return request.put<ApiResponse<void>>(`/api/auth/users/${id}/password`, { oldPassword, newPassword })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -71,8 +77,8 @@ export const updatePassword = (id: string, oldPassword: string, newPassword: str
|
|||
* @param roleIds - 角色ID数组,表示要分配给用户的角色
|
||||
* @returns 返回角色分配操作的结果
|
||||
*/
|
||||
export const assignRoles = (userId: string, roleIds: string[]) => {
|
||||
return request.post(`/api/auth/users/${userId}/roles`, roleIds)
|
||||
export const assignRoles = (userId: string, roleIds: string[]): Promise<AxiosResponse<ApiResponse<void>>> => {
|
||||
return request.post<ApiResponse<void>>(`/api/auth/users/${userId}/roles`, roleIds)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Tag } from 'ant-design-vue'
|
||||
import { Tag, Dropdown, Menu, Button } from 'ant-design-vue'
|
||||
import { MoreOutlined, ApartmentOutlined, CarOutlined, ShopOutlined, EnvironmentOutlined } from '@ant-design/icons-vue'
|
||||
// 样式更新版本 2025-01-13-v2
|
||||
|
||||
interface SpaceNode {
|
||||
id: string
|
||||
projectId?: string
|
||||
code?: string
|
||||
name: string
|
||||
nodeType: string
|
||||
nodeCategory: string
|
||||
nodeCategory?: string
|
||||
status?: string
|
||||
buildingArea?: number
|
||||
floorNumber?: number
|
||||
|
|
@ -23,6 +27,7 @@ const emit = defineEmits<{
|
|||
(e: 'room-click', room: SpaceNode): void
|
||||
(e: 'building-click', building: SpaceNode): void
|
||||
(e: 'node-click', node: SpaceNode): void
|
||||
(e: 'view-logs', building: SpaceNode): void
|
||||
}>()
|
||||
|
||||
const typeNameMap: Record<string, string> = {
|
||||
|
|
@ -39,7 +44,8 @@ const typeNameMap: Record<string, string> = {
|
|||
SECURITY_ROOM: '门岗',
|
||||
PUBLIC_AREA: '公共区域',
|
||||
GREEN_AREA: '绿化区域',
|
||||
ROAD: '道路'
|
||||
ROAD: '道路',
|
||||
PUBLIC_ROOM: '公共房间'
|
||||
}
|
||||
|
||||
// 建筑空间 - 楼栋列表
|
||||
|
|
@ -80,6 +86,8 @@ interface BuildingData {
|
|||
name: string
|
||||
floors: FloorData[]
|
||||
maxRoomsPerFloor: number
|
||||
totalFloors: number
|
||||
totalRooms: number
|
||||
}
|
||||
|
||||
const buildingVisualData = computed<BuildingData[]>(() => {
|
||||
|
|
@ -89,17 +97,15 @@ const buildingVisualData = computed<BuildingData[]>(() => {
|
|||
const collectRooms = (nodes: SpaceNode[]) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.nodeType === 'ROOM' || node.nodeType === 'SHOP') {
|
||||
// 使用 floorNumber 字段,如果没有则尝试从名称解析
|
||||
let floor = node.floorNumber
|
||||
if (floor === undefined || floor === null) {
|
||||
// 从名称解析楼层:101室 -> 1楼, 1201室 -> 12楼
|
||||
const match = node.name.match(/(\d+)/)
|
||||
if (match) {
|
||||
const num = parseInt(match[1])
|
||||
if (num >= 1000) {
|
||||
floor = Math.floor(num / 100) // 4位数:取前两位
|
||||
floor = Math.floor(num / 100)
|
||||
} else if (num >= 100) {
|
||||
floor = Math.floor(num / 100) // 3位数:取第一位
|
||||
floor = Math.floor(num / 100)
|
||||
} else {
|
||||
floor = num
|
||||
}
|
||||
|
|
@ -112,7 +118,6 @@ const buildingVisualData = computed<BuildingData[]>(() => {
|
|||
}
|
||||
floors.get(floor)!.push(node)
|
||||
}
|
||||
// 递归处理子节点(如果有 FLOOR 层级)
|
||||
if (node.children) {
|
||||
collectRooms(node.children)
|
||||
}
|
||||
|
|
@ -124,9 +129,10 @@ const buildingVisualData = computed<BuildingData[]>(() => {
|
|||
}
|
||||
|
||||
const sortedFloors = Array.from(floors.entries())
|
||||
.sort((a, b) => b[0] - a[0]) // 降序排列(高楼层在上)
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
|
||||
const maxRooms = Math.max(...sortedFloors.map(f => f[1].length), 1)
|
||||
const totalRooms = sortedFloors.reduce((sum, [, rooms]) => sum + rooms.length, 0)
|
||||
|
||||
return {
|
||||
id: building.id,
|
||||
|
|
@ -135,7 +141,9 @@ const buildingVisualData = computed<BuildingData[]>(() => {
|
|||
floor,
|
||||
rooms: rooms.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
||||
})),
|
||||
maxRoomsPerFloor: maxRooms
|
||||
maxRoomsPerFloor: maxRooms,
|
||||
totalFloors: sortedFloors.length,
|
||||
totalRooms
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -148,31 +156,82 @@ const handleBuildingClick = (building: SpaceNode) => {
|
|||
emit('building-click', building)
|
||||
}
|
||||
|
||||
const handleBuildingClickById = (buildingId: string) => {
|
||||
const building = buildingList.value.find(b => b.id === buildingId)
|
||||
if (building) {
|
||||
emit('building-click', building)
|
||||
}
|
||||
const handleViewLogs = (building: SpaceNode) => {
|
||||
emit('view-logs', building)
|
||||
}
|
||||
|
||||
const handleNodeClick = (node: SpaceNode) => {
|
||||
emit('node-click', node)
|
||||
}
|
||||
|
||||
// 获取房间状态样式
|
||||
const getRoomStatusClass = (room: SpaceNode): string => {
|
||||
// 根据房间状态返回不同的样式类
|
||||
// 这里可以根据实际需求调整逻辑
|
||||
if (room.status === 'OCCUPIED') return 'room-occupied'
|
||||
if (room.status === 'VACANT') return 'room-vacant'
|
||||
if (room.status === 'MAINTENANCE') return 'room-maintenance'
|
||||
return 'room-normal'
|
||||
}
|
||||
|
||||
const buildingMenuItems = [
|
||||
{ key: 'edit', label: '编辑' },
|
||||
{ key: 'delete', label: '删除' },
|
||||
{ key: 'logs', label: '查看日志' }
|
||||
]
|
||||
|
||||
const handleMenuClick = (key: string | number, building: SpaceNode) => {
|
||||
if (key === 'logs') {
|
||||
handleViewLogs(building)
|
||||
} else if (key === 'edit') {
|
||||
handleBuildingClick(building)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="building-visualization">
|
||||
<!-- 建筑空间 - 楼栋区域 -->
|
||||
<div v-if="buildingVisualData.length > 0" class="buildings-container">
|
||||
<div class="section-title">建筑空间</div>
|
||||
<div class="section-title building-section-title">
|
||||
<ApartmentOutlined class="section-icon" /> 建筑空间
|
||||
</div>
|
||||
<div class="buildings-grid">
|
||||
<div
|
||||
v-for="building in buildingVisualData"
|
||||
:key="building.id"
|
||||
class="building-card"
|
||||
@click="handleBuildingClickById(building.id)"
|
||||
>
|
||||
<div class="building-header">{{ building.name }}</div>
|
||||
<!-- 卡片头部 -->
|
||||
<div class="building-header">
|
||||
<div class="building-header-left">
|
||||
<div class="building-icon">
|
||||
<ApartmentOutlined />
|
||||
</div>
|
||||
<div class="building-info">
|
||||
<div class="building-name">{{ building.name }}</div>
|
||||
<div class="building-stats">
|
||||
{{ building.totalFloors }} 层 • {{ building.totalRooms }} 单元
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="building-actions">
|
||||
<Dropdown>
|
||||
<Button type="text" size="small">
|
||||
<MoreOutlined />
|
||||
</Button>
|
||||
<template #overlay>
|
||||
<Menu @click="({ key }) => handleMenuClick(key, buildingList.find(b => b.id === building.id)!)">
|
||||
<Menu.Item v-for="item in buildingMenuItems" :key="item.key">
|
||||
{{ item.label }}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片主体 -->
|
||||
<div class="building-body">
|
||||
<div
|
||||
v-for="floor in building.floors"
|
||||
|
|
@ -180,35 +239,56 @@ const handleNodeClick = (node: SpaceNode) => {
|
|||
class="floor-row"
|
||||
>
|
||||
<div class="floor-label">{{ floor.floor }}F</div>
|
||||
<div class="rooms-grid" :style="{ gridTemplateColumns: `repeat(${building.maxRoomsPerFloor}, minmax(60px, 1fr))` }">
|
||||
<div class="rooms-grid" :style="{ gridTemplateColumns: `repeat(${building.maxRoomsPerFloor}, minmax(0, 1fr))` }">
|
||||
<div
|
||||
v-for="room in floor.rooms"
|
||||
:key="room.id"
|
||||
class="room-cell"
|
||||
:class="getRoomStatusClass(room)"
|
||||
@click="handleRoomClick(room)"
|
||||
:title="room.name"
|
||||
>
|
||||
{{ room.name }}
|
||||
<span class="room-name">{{ room.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片底部 -->
|
||||
<div class="building-footer">
|
||||
<span class="footer-text">最后检查: 2天前</span>
|
||||
<a class="footer-link" @click="handleViewLogs(buildingList.find(b => b.id === building.id)!)">查看维护日志</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 停车空间 -->
|
||||
<div v-if="parkingList.length > 0" class="other-section">
|
||||
<div class="section-title">停车空间</div>
|
||||
<div class="section-content">
|
||||
<div v-for="node in parkingList" :key="node.id" class="node-card" @click="handleNodeClick(node)">
|
||||
<div class="node-info">
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
<Tag size="small">{{ typeNameMap[node.nodeType] || node.nodeType }}</Tag>
|
||||
<div class="section-title parking-title">
|
||||
<CarOutlined class="section-icon" /> 停车空间
|
||||
</div>
|
||||
<div class="other-section-grid">
|
||||
<div v-for="node in parkingList" :key="node.id" class="other-area-card parking-area-card">
|
||||
<div class="other-area-header" :class="{ 'has-children': node.children && node.children.length > 0 }">
|
||||
<div class="other-area-header-left">
|
||||
<div class="other-area-icon parking-icon">
|
||||
<CarOutlined />
|
||||
</div>
|
||||
<span class="other-area-name">{{ node.name }}</span>
|
||||
</div>
|
||||
<Tag size="small" class="parking-tag">{{ typeNameMap[node.nodeType] || node.nodeType }}</Tag>
|
||||
</div>
|
||||
<div v-if="node.children && node.children.length > 0" class="node-children">
|
||||
<Tag v-for="child in node.children" :key="child.id" size="small">
|
||||
{{ child.name }}
|
||||
</Tag>
|
||||
<div v-if="node.children && node.children.length > 0" class="other-area-rooms">
|
||||
<div
|
||||
v-for="child in node.children.slice().sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true }))"
|
||||
:key="child.id"
|
||||
class="room-cell parking-room"
|
||||
@click="handleNodeClick(child)"
|
||||
:title="child.name"
|
||||
>
|
||||
<span class="room-name">{{ child.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -216,17 +296,30 @@ const handleNodeClick = (node: SpaceNode) => {
|
|||
|
||||
<!-- 配套空间 -->
|
||||
<div v-if="facilityList.length > 0" class="other-section">
|
||||
<div class="section-title">配套空间</div>
|
||||
<div class="section-content">
|
||||
<div v-for="node in facilityList" :key="node.id" class="node-card" @click="handleNodeClick(node)">
|
||||
<div class="node-info">
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
<Tag size="small">{{ typeNameMap[node.nodeType] || node.nodeType }}</Tag>
|
||||
<div class="section-title facility-title">
|
||||
<ShopOutlined class="section-icon" /> 配套空间
|
||||
</div>
|
||||
<div class="other-section-grid">
|
||||
<div v-for="node in facilityList" :key="node.id" class="other-area-card facility-area-card">
|
||||
<div class="other-area-header" :class="{ 'has-children': node.children && node.children.length > 0 }">
|
||||
<div class="other-area-header-left">
|
||||
<div class="other-area-icon facility-icon">
|
||||
<ShopOutlined />
|
||||
</div>
|
||||
<span class="other-area-name">{{ node.name }}</span>
|
||||
</div>
|
||||
<Tag size="small" class="facility-tag">{{ typeNameMap[node.nodeType] || node.nodeType }}</Tag>
|
||||
</div>
|
||||
<div v-if="node.children && node.children.length > 0" class="node-children">
|
||||
<Tag v-for="child in node.children" :key="child.id" size="small">
|
||||
{{ child.name }}
|
||||
</Tag>
|
||||
<div v-if="node.children && node.children.length > 0" class="other-area-rooms">
|
||||
<div
|
||||
v-for="child in node.children"
|
||||
:key="child.id"
|
||||
class="room-cell facility-room"
|
||||
@click="handleNodeClick(child)"
|
||||
:title="child.name"
|
||||
>
|
||||
<span class="room-name">{{ child.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -234,17 +327,30 @@ const handleNodeClick = (node: SpaceNode) => {
|
|||
|
||||
<!-- 公共区域 -->
|
||||
<div v-if="publicAreaList.length > 0" class="other-section">
|
||||
<div class="section-title">公共区域</div>
|
||||
<div class="section-content">
|
||||
<div v-for="node in publicAreaList" :key="node.id" class="node-card" @click="handleNodeClick(node)">
|
||||
<div class="node-info">
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
<Tag size="small">{{ typeNameMap[node.nodeType] || node.nodeType }}</Tag>
|
||||
<div class="section-title public-title">
|
||||
<EnvironmentOutlined class="section-icon" /> 公共区域
|
||||
</div>
|
||||
<div class="other-section-grid">
|
||||
<div v-for="node in publicAreaList" :key="node.id" class="other-area-card public-area-card">
|
||||
<div class="other-area-header" :class="{ 'has-children': node.children && node.children.length > 0 }">
|
||||
<div class="other-area-header-left">
|
||||
<div class="other-area-icon public-icon">
|
||||
<EnvironmentOutlined />
|
||||
</div>
|
||||
<span class="other-area-name">{{ node.name }}</span>
|
||||
</div>
|
||||
<Tag size="small" class="public-tag">{{ typeNameMap[node.nodeType] || node.nodeType }}</Tag>
|
||||
</div>
|
||||
<div v-if="node.children && node.children.length > 0" class="node-children">
|
||||
<Tag v-for="child in node.children" :key="child.id" size="small">
|
||||
{{ child.name }}
|
||||
</Tag>
|
||||
<div v-if="node.children && node.children.length > 0" class="other-area-rooms">
|
||||
<div
|
||||
v-for="child in node.children"
|
||||
:key="child.id"
|
||||
class="room-cell public-room"
|
||||
@click="handleNodeClick(child)"
|
||||
:title="child.name"
|
||||
>
|
||||
<span class="room-name">{{ child.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -256,6 +362,8 @@ const handleNodeClick = (node: SpaceNode) => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '@/styles/building-colors.css';
|
||||
|
||||
.building-visualization {
|
||||
padding: 16px;
|
||||
}
|
||||
|
|
@ -263,10 +371,23 @@ const handleNodeClick = (node: SpaceNode) => {
|
|||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
color: var(--color-title, #1f1f1f);
|
||||
margin-bottom: 16px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #1890ff;
|
||||
border-left: 3px solid var(--building-primary, #1890ff);
|
||||
}
|
||||
|
||||
/* 各区域标题颜色区分 */
|
||||
.section-title.parking-title {
|
||||
border-left-color: var(--building-parking, #722ed1);
|
||||
}
|
||||
|
||||
.section-title.facility-title {
|
||||
border-left-color: var(--building-facility, #eb2f96);
|
||||
}
|
||||
|
||||
.section-title.public-title {
|
||||
border-left-color: var(--building-public, #52c41a);
|
||||
}
|
||||
|
||||
.buildings-container {
|
||||
|
|
@ -278,43 +399,91 @@ const handleNodeClick = (node: SpaceNode) => {
|
|||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-page, #f8f9fa);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
.building-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
background: var(--color-bg-card, #ffffff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
flex: 0 1 auto;
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border, #f0f0f0);
|
||||
}
|
||||
|
||||
.building-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* 卡片头部样式 */
|
||||
.building-header {
|
||||
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
|
||||
color: #ffffff;
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: var(--color-bg-card, #ffffff);
|
||||
border-bottom: 1px solid var(--color-border, #f0f0f0);
|
||||
}
|
||||
|
||||
.building-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.building-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--building-primary-bg, #e6f7ff);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
color: var(--building-primary, #1890ff);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.building-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.building-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #262626);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.building-stats {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-placeholder, #8c8c8c);
|
||||
}
|
||||
|
||||
.building-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 卡片主体 */
|
||||
.building-body {
|
||||
padding: 12px;
|
||||
padding: 16px 20px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.floor-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.floor-row:last-child {
|
||||
|
|
@ -322,86 +491,311 @@ const handleNodeClick = (node: SpaceNode) => {
|
|||
}
|
||||
|
||||
.floor-label {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-default, #f5f5f5);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #595959);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rooms-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 房间基础样式 - 固定宽度保证显示6个字 */
|
||||
.room-cell {
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.room-cell:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.room-cell .room-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 房间状态样式 - 使用浅色背景 */
|
||||
.room-cell.room-normal {
|
||||
background: var(--room-normal-bg, #e6f4ff);
|
||||
color: var(--room-normal-text, #0958d9);
|
||||
}
|
||||
|
||||
.room-cell.room-normal:hover {
|
||||
background: var(--room-normal-hover, #bae0ff);
|
||||
}
|
||||
|
||||
.room-cell.room-occupied {
|
||||
background: var(--room-occupied-bg, #f6ffed);
|
||||
color: var(--room-occupied-text, #389e0d);
|
||||
}
|
||||
|
||||
.room-cell.room-occupied:hover {
|
||||
background: var(--room-occupied-hover, #d9f7be);
|
||||
}
|
||||
|
||||
.room-cell.room-vacant {
|
||||
background: var(--room-vacant-bg, #f5f5f5);
|
||||
color: var(--room-vacant-text, #8c8c8c);
|
||||
}
|
||||
|
||||
.room-cell.room-vacant:hover {
|
||||
background: var(--room-vacant-hover, #e8e8e8);
|
||||
}
|
||||
|
||||
.room-cell.room-maintenance {
|
||||
background: var(--room-maintenance-bg, #fff7e6);
|
||||
color: var(--room-maintenance-text, #d46b08);
|
||||
}
|
||||
|
||||
.room-cell.room-maintenance:hover {
|
||||
background: var(--room-maintenance-hover, #ffe7ba);
|
||||
}
|
||||
|
||||
/* 卡片底部 */
|
||||
.building-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: var(--color-bg-filter, #fafafa);
|
||||
border-top: 1px solid var(--color-border, #f0f0f0);
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-placeholder, #8c8c8c);
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
font-size: 13px;
|
||||
color: var(--color-primary, #1890ff);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: var(--color-primary-light, #40a9ff);
|
||||
}
|
||||
|
||||
/* 其他区域样式 - 与楼栋统一 */
|
||||
.other-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.other-section-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
background: var(--color-bg-page, #f8f9fa);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
.other-area-card {
|
||||
background: var(--color-bg-card, #ffffff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
flex: 0 1 auto;
|
||||
min-width: 280px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.other-area-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.other-area-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.other-area-header.has-children {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--color-border, #f0f0f0);
|
||||
}
|
||||
|
||||
.other-area-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.other-area-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e6f7ff;
|
||||
border: 1px solid #91d5ff;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1890ff;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.rooms-grid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
.other-area-icon.parking-icon {
|
||||
background: var(--building-parking-bg, #f9f0ff);
|
||||
color: var(--building-parking, #722ed1);
|
||||
}
|
||||
|
||||
.room-cell {
|
||||
min-width: 60px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
padding: 0 4px;
|
||||
.other-area-icon.facility-icon {
|
||||
background: var(--building-facility-bg, #fff7e6);
|
||||
color: var(--building-facility, #fa8c16);
|
||||
}
|
||||
|
||||
.room-cell:hover {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
color: #ffffff;
|
||||
transform: scale(1.02);
|
||||
.other-area-icon.public-icon {
|
||||
background: var(--building-public-bg, #f6ffed);
|
||||
color: var(--building-public, #52c41a);
|
||||
}
|
||||
|
||||
.other-section {
|
||||
margin-bottom: 24px;
|
||||
.other-area-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #262626);
|
||||
}
|
||||
|
||||
.section-content {
|
||||
.section-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.other-area-rooms {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.node-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
/* 停车空间卡片样式 - 紫色系 */
|
||||
.parking-tag {
|
||||
background: var(--building-parking-bg, #f9f0ff) !important;
|
||||
color: var(--building-parking, #722ed1) !important;
|
||||
border-color: var(--building-parking, #722ed1) !important;
|
||||
}
|
||||
|
||||
.node-children {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
.parking-room {
|
||||
background: var(--building-parking-bg, #f9f0ff);
|
||||
color: var(--building-parking, #722ed1);
|
||||
}
|
||||
|
||||
.parking-room:hover {
|
||||
background: var(--building-parking-light, #efdbff);
|
||||
}
|
||||
|
||||
/* 配套空间卡片样式 - 橙色系 */
|
||||
.facility-tag {
|
||||
background: var(--building-facility-bg, #fff7e6) !important;
|
||||
color: var(--building-facility, #fa8c16) !important;
|
||||
border-color: var(--building-facility, #fa8c16) !important;
|
||||
}
|
||||
|
||||
.facility-room {
|
||||
background: var(--building-facility-bg, #fff7e6);
|
||||
color: var(--building-facility, #fa8c16);
|
||||
}
|
||||
|
||||
.facility-room:hover {
|
||||
background: var(--building-facility-light, #ffe7ba);
|
||||
}
|
||||
|
||||
/* 公共区域卡片样式 - 绿色系 */
|
||||
.public-tag {
|
||||
background: var(--building-public-bg, #f6ffed) !important;
|
||||
color: var(--building-public, #52c41a) !important;
|
||||
border-color: var(--building-public, #52c41a) !important;
|
||||
}
|
||||
|
||||
.public-room {
|
||||
background: var(--building-public-bg, #f6ffed);
|
||||
color: var(--building-public, #52c41a);
|
||||
}
|
||||
|
||||
.public-room:hover {
|
||||
background: var(--building-public-light, #d9f7be);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.building-card {
|
||||
flex: 0 0 300px;
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.buildings-grid {
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.building-card {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.building-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.building-body {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.floor-label {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.room-cell {
|
||||
width: 68px;
|
||||
min-width: 68px;
|
||||
height: 40px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
min-width: 100px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
title?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
|
|
@ -26,31 +26,31 @@ defineProps<Props>()
|
|||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
background: var(--color-bg-card, #fff);
|
||||
border: 1px solid var(--color-border, #e8e8e8);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--space-lg, 24px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: var(--space-md, 20px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
color: var(--color-text, #262626);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
color: #1890ff;
|
||||
color: var(--color-primary, #1890ff);
|
||||
}
|
||||
|
||||
.card-extra {
|
||||
|
|
|
|||
|
|
@ -55,19 +55,19 @@ const currentIcon = computed(() => iconMap[props.type!] || InboxOutlined)
|
|||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
color: #d9d9d9;
|
||||
color: var(--color-text-disabled, #d9d9d9);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
color: #262626;
|
||||
color: var(--color-text, #262626);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
color: var(--color-text-placeholder, #8c8c8c);
|
||||
margin-bottom: 24px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
title: ''
|
||||
})
|
||||
|
||||
const { title } = props
|
||||
|
||||
defineEmits<{
|
||||
(e: 'back'): void
|
||||
}>()
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getProjects } from '@/api/project'
|
||||
import type { Project } from '@/types'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | string[]
|
||||
multiple?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
disabled: false,
|
||||
placeholder: '请选择项目'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | string[]): void
|
||||
(e: 'change', value: string | string[]): void
|
||||
}>()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val!)
|
||||
emit('change', val!)
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed(() =>
|
||||
projects.value.map((project) => ({
|
||||
value: project.code,
|
||||
label: project.name
|
||||
}))
|
||||
)
|
||||
|
||||
const fetchProjects = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getProjects()
|
||||
projects.value = res.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
if (projects.value.length === 0) {
|
||||
fetchProjects()
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-select
|
||||
v-model:value="selectedValue"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:mode="multiple ? 'multiple' : undefined"
|
||||
:show-search="true"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { Card, Button, Space, Table, Tag, message, Drawer, Form, Input, InputNumber, Select, Popconfirm, Empty, Dropdown, Modal, Menu, Row, Col, Descriptions, Tree, Tabs, Alert } from 'ant-design-vue'
|
||||
import type { TabsProps } from 'ant-design-vue'
|
||||
import { FolderOutlined, FileOutlined, HomeOutlined } from '@ant-design/icons-vue'
|
||||
import { Card, Button, Space, Table, Tag, message, Drawer, Form, Input, InputNumber, Select, Empty, Dropdown, Modal, Menu, Row, Col, Descriptions, Alert } from 'ant-design-vue'
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||
import type { MenuProps } from 'ant-design-vue'
|
||||
import { PlusOutlined, MenuOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
getSpaceTree,
|
||||
getSpaceNode,
|
||||
getSpaceChildren,
|
||||
createSpaceNode,
|
||||
updateSpaceNode,
|
||||
deleteSpaceNode,
|
||||
checkSpaceNodeDelete,
|
||||
deleteSpaceNodeWithChildren,
|
||||
|
|
@ -36,7 +34,6 @@ const drawerVisible = ref(false)
|
|||
const drawerTitle = ref('')
|
||||
const submitting = ref(false)
|
||||
const selectedCategory = ref('')
|
||||
const selectedType = ref('')
|
||||
const isEditMode = ref(false)
|
||||
|
||||
interface SpaceNodeTree extends SpaceNode {
|
||||
|
|
@ -50,7 +47,6 @@ const sortedBuildingList = computed(() => {
|
|||
})
|
||||
|
||||
// 列表+详情相关
|
||||
const leftActiveCategory = ref('BUILDING')
|
||||
const selectedNodeId = ref<string | null>(null)
|
||||
const selectedChildIds = ref<string[]>([])
|
||||
|
||||
|
|
@ -142,11 +138,6 @@ const selectNode = (node: SpaceNodeTree) => {
|
|||
selectedNode.value = node
|
||||
}
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (!selectedNodeId.value) return
|
||||
handleDelete(selectedNodeId.value)
|
||||
}
|
||||
|
||||
const typeNameMap: Record<string, string> = {
|
||||
BUILDING: '楼栋',
|
||||
UNIT: '单元',
|
||||
|
|
@ -201,7 +192,7 @@ const getCategoryByType = (nodeType: string): string => {
|
|||
return typeToCategoryMap[nodeType] || 'BUILDING'
|
||||
}
|
||||
|
||||
const formState = ref({
|
||||
const formState = ref<Record<string, any>>({
|
||||
projectId: props.projectId,
|
||||
address: '',
|
||||
name: '',
|
||||
|
|
@ -210,7 +201,8 @@ const formState = ref({
|
|||
parentId: undefined,
|
||||
sortOrder: 0,
|
||||
status: 'ACTIVE',
|
||||
id: undefined as string | undefined
|
||||
id: undefined,
|
||||
buildingArea: undefined
|
||||
})
|
||||
|
||||
const batchModalVisible = ref(false)
|
||||
|
|
@ -231,12 +223,30 @@ const batchFormState = ref({
|
|||
endFloor: undefined as number | undefined,
|
||||
avoidFloorsInput: '',
|
||||
parentId: undefined as string | undefined,
|
||||
parentIds: [] as string[],
|
||||
buildingArea: undefined as number | undefined,
|
||||
codeLength: 2
|
||||
})
|
||||
const generatedNames = ref<string[]>([])
|
||||
const floorInfo = ref<FloorInfoVO | null>(null)
|
||||
|
||||
// 从名称中提取楼层号(如 "A栋101室" -> 1)
|
||||
const extractFloorNumber = (name: string): number => {
|
||||
// 匹配楼层号:通常是名称中连续数字的第一组
|
||||
const match = name.match(/\d+/)
|
||||
if (match) {
|
||||
// 如果是4位数(如101室),前两位是楼层
|
||||
// 如果是3位数(如201室),前一位或两位是楼层
|
||||
const num = parseInt(match[0])
|
||||
if (num >= 100) {
|
||||
// 3位数以上,很可能是楼层+房间号,取前1-2位作为楼层
|
||||
return Math.floor(num / 100)
|
||||
}
|
||||
return num
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
const openBatchModal = (type: BatchType) => {
|
||||
batchType.value = type
|
||||
const titles: Record<BatchType, string> = {
|
||||
|
|
@ -247,7 +257,7 @@ const openBatchModal = (type: BatchType) => {
|
|||
publicRoom: '批量添加公共用房'
|
||||
}
|
||||
batchModalTitle.value = titles[type]
|
||||
const defaultCodeLength = type === 'building' || type === 'public' ? 1 : 3
|
||||
const defaultCodeLength = type === 'building' ? 1 : 3
|
||||
batchFormState.value = {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
|
|
@ -260,6 +270,7 @@ const openBatchModal = (type: BatchType) => {
|
|||
endFloor: undefined,
|
||||
avoidFloorsInput: '',
|
||||
parentId: undefined,
|
||||
parentIds: [],
|
||||
buildingArea: undefined,
|
||||
codeLength: defaultCodeLength
|
||||
}
|
||||
|
|
@ -279,12 +290,22 @@ const onBuildingChange = async (buildingId: string) => {
|
|||
batchFormState.value.endFloor = floorInfo.value.totalFloors
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取楼层信息失败', err)
|
||||
// 楼层信息获取失败时使用默认值
|
||||
}
|
||||
}
|
||||
|
||||
const onBuildingsChange = async (buildingIds: any) => {
|
||||
batchFormState.value.parentIds = buildingIds || []
|
||||
// 如果只选了一个楼栋,显示该楼栋的楼层信息
|
||||
if (buildingIds.length === 1) {
|
||||
await onBuildingChange(buildingIds[0])
|
||||
} else {
|
||||
floorInfo.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const generateNames = () => {
|
||||
const { prefix, suffix, floorCount, roomsPerFloor, floorSuffix, separator, roomSuffix, startFloor, endFloor, avoidFloorsInput, codeLength } = batchFormState.value
|
||||
const { prefix, suffix, floorCount, roomsPerFloor, separator, roomSuffix, startFloor, endFloor, avoidFloorsInput, codeLength } = batchFormState.value
|
||||
const names: string[] = []
|
||||
const len = codeLength || 2
|
||||
const start = startFloor || 1
|
||||
|
|
@ -312,7 +333,7 @@ const generateNames = () => {
|
|||
} else if (batchType.value === 'parking') {
|
||||
let count = 0
|
||||
let i = start
|
||||
while (count < floorCount) {
|
||||
while (count < (floorCount || 0)) {
|
||||
if (!avoidSet.has(i)) {
|
||||
const numStr = String(i).padStart(len, '0')
|
||||
names.push(`${prefix}${separator || ''}${numStr}${suffix}`)
|
||||
|
|
@ -324,7 +345,7 @@ const generateNames = () => {
|
|||
// 楼栋和公共用房
|
||||
let count = 0
|
||||
let i = start
|
||||
while (count < floorCount) {
|
||||
while (count < (floorCount || 0)) {
|
||||
if (!avoidSet.has(i)) {
|
||||
const numStr = String(i).padStart(len, '0')
|
||||
names.push(`${prefix}${numStr}${suffix}`)
|
||||
|
|
@ -334,23 +355,6 @@ const generateNames = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 从名称中提取楼层号(如 "A栋101室" -> 1)
|
||||
const extractFloorNumber = (name: string): number => {
|
||||
// 匹配楼层号:通常是名称中连续数字的第一组
|
||||
const match = name.match(/\d+/)
|
||||
if (match) {
|
||||
// 如果是4位数(如101室),前两位是楼层
|
||||
// 如果是3位数(如201室),前一位或两位是楼层
|
||||
const num = parseInt(match[0])
|
||||
if (num >= 100) {
|
||||
// 3位数以上,很可能是楼层+房间号,取前1-2位作为楼层
|
||||
return Math.floor(num / 100)
|
||||
}
|
||||
return num
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// 按数字升序排序(支持带前缀的名称如 A1号楼 -> 提取数字部分排序)
|
||||
generatedNames.value = names.sort((a, b) => {
|
||||
const numA = parseInt(a.match(/\d+/)?.[0] || '0')
|
||||
|
|
@ -366,8 +370,19 @@ const handleBatchSubmit = async () => {
|
|||
}
|
||||
|
||||
if (batchType.value === 'room' || batchType.value === 'shop') {
|
||||
if (!batchFormState.value.floorCount || !batchFormState.value.roomsPerFloor) {
|
||||
message.warning('请填写楼层数和每层房间数')
|
||||
// 使用 startFloor/endFloor 而不是 floorCount
|
||||
if (!batchFormState.value.startFloor || !batchFormState.value.endFloor || !batchFormState.value.roomsPerFloor) {
|
||||
message.warning('请填写起始楼层、结束楼层和每层房间数')
|
||||
return
|
||||
}
|
||||
if (batchFormState.value.startFloor > batchFormState.value.endFloor) {
|
||||
message.warning('起始楼层不能大于结束楼层')
|
||||
return
|
||||
}
|
||||
} else if (batchType.value === 'parking') {
|
||||
// 车位使用 floorCount 作为数量
|
||||
if (!batchFormState.value.floorCount) {
|
||||
message.warning('请填写车位数量')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
|
|
@ -392,34 +407,39 @@ const handleBatchSubmit = async () => {
|
|||
const config = typeConfigs[batchType.value]
|
||||
|
||||
if (config.hasParent) {
|
||||
const parentIdToUse = batchFormState.value.parentId
|
||||
if (!parentIdToUse) {
|
||||
// 支持多选:room/shop/parking 都使用 parentIds
|
||||
const parentIdsToUse = batchFormState.value.parentIds.filter(Boolean)
|
||||
|
||||
if (parentIdsToUse.length === 0) {
|
||||
const parentLabel = batchType.value === 'parking' ? '停车区域' : '楼栋'
|
||||
message.warning(`请选择所属${parentLabel}`)
|
||||
return
|
||||
}
|
||||
for (const name of generatedNames.value) {
|
||||
// 从名称中提取楼层号(如 "A栋101室" -> 1)
|
||||
const floorNumber = extractFloorNumber(name)
|
||||
await createSpaceNode({
|
||||
projectId: props.projectId,
|
||||
name,
|
||||
nodeCategory: config.category,
|
||||
nodeType: config.type,
|
||||
parentId: parentIdToUse,
|
||||
status: 'ACTIVE',
|
||||
buildingArea: batchFormState.value.buildingArea,
|
||||
floorNumber
|
||||
})
|
||||
totalCount++
|
||||
|
||||
// 为每个父节点创建一组房间/车位
|
||||
for (const parentIdToUse of parentIdsToUse) {
|
||||
for (const name of generatedNames.value) {
|
||||
const floorNumber = extractFloorNumber(name)
|
||||
await createSpaceNode({
|
||||
projectId: props.projectId,
|
||||
name,
|
||||
nodeCategory: config.category as any,
|
||||
nodeType: config.type as any,
|
||||
parentId: parentIdToUse,
|
||||
status: 'ACTIVE',
|
||||
buildingArea: batchFormState.value.buildingArea,
|
||||
floorNumber
|
||||
})
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const name of generatedNames.value) {
|
||||
await createSpaceNode({
|
||||
projectId: props.projectId,
|
||||
name,
|
||||
nodeCategory: config.category,
|
||||
nodeType: config.type,
|
||||
nodeCategory: config.category as any,
|
||||
nodeType: config.type as any,
|
||||
status: 'ACTIVE',
|
||||
buildingArea: batchFormState.value.buildingArea
|
||||
})
|
||||
|
|
@ -431,7 +451,6 @@ const handleBatchSubmit = async () => {
|
|||
batchModalVisible.value = false
|
||||
fetchTree()
|
||||
} catch (err: any) {
|
||||
console.error('批量创建失败:', err)
|
||||
message.error(err?.response?.data?.message || '批量创建失败')
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
|
|
@ -514,8 +533,8 @@ const handleAddByCategory = (category: string) => {
|
|||
projectId: props.projectId,
|
||||
address: props.projectAddress || '',
|
||||
name: '',
|
||||
nodeCategory: category,
|
||||
nodeType: config.types[0].value,
|
||||
nodeCategory: category as any,
|
||||
nodeType: config.types[0].value as any,
|
||||
parentId: undefined,
|
||||
sortOrder: 0,
|
||||
status: 'ACTIVE',
|
||||
|
|
@ -527,27 +546,6 @@ const handleAddByCategory = (category: string) => {
|
|||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
const handleAddType = (type: string, category: string) => {
|
||||
selectedCategory.value = category
|
||||
const config = categoryMap[category]
|
||||
const typeConfig = config.types.find(t => t.value === type)
|
||||
formState.value = {
|
||||
projectId: props.projectId,
|
||||
address: props.projectAddress || '',
|
||||
name: '',
|
||||
nodeCategory: category,
|
||||
nodeType: type,
|
||||
parentId: undefined,
|
||||
sortOrder: 0,
|
||||
status: 'ACTIVE',
|
||||
id: undefined
|
||||
}
|
||||
typeOptions.value = config.types
|
||||
drawerTitle.value = `新增${typeConfig?.label || type}`
|
||||
isEditMode.value = false
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
// 处理添加子节点
|
||||
const handleAddChild = (childType: string, childLabel: string) => {
|
||||
if (!selectedNode.value) return
|
||||
|
|
@ -639,12 +637,12 @@ const handleSubmit = async () => {
|
|||
try {
|
||||
submitting.value = true
|
||||
if (isEditMode.value) {
|
||||
const { id, projectId, ...updateData } = formState.value
|
||||
await updateSpaceNode(id!, updateData)
|
||||
const { id, ...updateData } = formState.value
|
||||
await updateSpaceNode(id!, updateData as any)
|
||||
message.success('更新成功')
|
||||
isEditMode.value = false
|
||||
} else {
|
||||
await createSpaceNode(formState.value)
|
||||
await createSpaceNode(formState.value as any)
|
||||
message.success('创建成功')
|
||||
}
|
||||
drawerVisible.value = false
|
||||
|
|
@ -681,29 +679,6 @@ watch(() => props.projectId, () => {
|
|||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 树形结构相关
|
||||
const loadedKeys = ref<string[]>([])
|
||||
const expandedKeys = ref<string[]>([])
|
||||
|
||||
const loadTreeData = async (treeNode: SpaceNodeTree) => {
|
||||
if (treeNode.children && treeNode.children.length > 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getSpaceChildren(treeNode.id)
|
||||
treeNode.children = res.data.data || []
|
||||
} catch {
|
||||
message.error('加载子节点失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTreeSelect = (selectedKeys: string[], info: any) => {
|
||||
if (selectedKeys.length > 0) {
|
||||
const node = info.node
|
||||
handleNodeClick(node)
|
||||
}
|
||||
}
|
||||
|
||||
// 节点点击处理
|
||||
const detailDrawerVisible = ref(false)
|
||||
const detailDrawerTitle = ref('')
|
||||
|
|
@ -737,21 +712,6 @@ const handleNodeClick = async (node: SpaceNodeTree) => {
|
|||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '类型', dataIndex: 'nodeType', key: 'nodeType', customRender: ({ text }: { text: string }) => {
|
||||
for (const cat of Object.values(categoryMap)) {
|
||||
const found = cat.types.find(t => t.value === text)
|
||||
if (found) return found.label
|
||||
}
|
||||
return text
|
||||
}},
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', customRender: ({ text }: { text: string }) =>
|
||||
text === 'ACTIVE' ? '正常' : '禁用'
|
||||
},
|
||||
{ title: '操作', key: 'action', width: 100 }
|
||||
]
|
||||
|
||||
const childColumns: ColumnsType = [
|
||||
{ title: '选择', key: 'selection', width: 60 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
|
|
@ -819,9 +779,9 @@ const batchMenuItems: MenuProps['items'] = [
|
|||
<template v-if="mode === 'view'">
|
||||
<BuildingVisualization
|
||||
:data="treeData"
|
||||
@building-click="handleBuildingClick"
|
||||
@room-click="handleRoomClick"
|
||||
@node-click="handleNodeClick"
|
||||
@building-click="handleBuildingClick as any"
|
||||
@room-click="handleRoomClick as any"
|
||||
@node-click="handleNodeClick as any"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
@ -853,68 +813,68 @@ const batchMenuItems: MenuProps['items'] = [
|
|||
<!-- 左侧分类列表 -->
|
||||
<div class="space-left-panel">
|
||||
<!-- 建筑空间 -->
|
||||
<div class="category-group">
|
||||
<div class="category-header">建筑空间</div>
|
||||
<div class="category-group building-group">
|
||||
<div class="category-header building-header">建筑空间</div>
|
||||
<div class="category-list">
|
||||
<div
|
||||
v-for="node in buildingList"
|
||||
:key="node.id"
|
||||
class="category-item"
|
||||
class="category-item building-item"
|
||||
:class="{ active: selectedNodeId === node.id }"
|
||||
@click="selectNode(node)"
|
||||
>
|
||||
<span class="item-name">{{ node.name }}</span>
|
||||
<span class="item-name" :title="node.name">{{ node.name }}</span>
|
||||
<span class="item-count" v-if="node.children">{{ node.children.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 停车空间 -->
|
||||
<div class="category-group">
|
||||
<div class="category-header">停车空间</div>
|
||||
<div class="category-group parking-group">
|
||||
<div class="category-header parking-header">停车空间</div>
|
||||
<div class="category-list">
|
||||
<div
|
||||
v-for="node in parkingAreaList"
|
||||
:key="node.id"
|
||||
class="category-item"
|
||||
class="category-item parking-item"
|
||||
:class="{ active: selectedNodeId === node.id }"
|
||||
@click="selectNode(node)"
|
||||
>
|
||||
<span class="item-name">{{ node.name }}</span>
|
||||
<span class="item-name" :title="node.name">{{ node.name }}</span>
|
||||
<span class="item-count" v-if="node.children">{{ node.children.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配套空间 -->
|
||||
<div class="category-group">
|
||||
<div class="category-header">配套空间</div>
|
||||
<div class="category-group facility-group">
|
||||
<div class="category-header facility-header">配套空间</div>
|
||||
<div class="category-list">
|
||||
<div
|
||||
v-for="node in facilityList"
|
||||
:key="node.id"
|
||||
class="category-item"
|
||||
class="category-item facility-item"
|
||||
:class="{ active: selectedNodeId === node.id }"
|
||||
@click="selectNode(node)"
|
||||
>
|
||||
<span class="item-name">{{ node.name }}</span>
|
||||
<span class="item-name" :title="node.name">{{ node.name }}</span>
|
||||
<span class="item-type">{{ typeNameMap[node.nodeType] || node.nodeType }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 公共区域 -->
|
||||
<div class="category-group">
|
||||
<div class="category-header">公共区域</div>
|
||||
<div class="category-group public-group">
|
||||
<div class="category-header public-header">公共区域</div>
|
||||
<div class="category-list">
|
||||
<div
|
||||
v-for="node in publicAreaList"
|
||||
:key="node.id"
|
||||
class="category-item"
|
||||
class="category-item public-item"
|
||||
:class="{ active: selectedNodeId === node.id }"
|
||||
@click="selectNode(node)"
|
||||
>
|
||||
<span class="item-name">{{ node.name }}</span>
|
||||
<span class="item-name" :title="node.name">{{ node.name }}</span>
|
||||
<span class="item-count" v-if="node.children">{{ node.children.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1099,8 +1059,9 @@ const batchMenuItems: MenuProps['items'] = [
|
|||
<template v-if="batchType === 'parking'">
|
||||
<Form.Item label="所属停车区域" required>
|
||||
<Select
|
||||
v-model:value="batchFormState.parentId"
|
||||
placeholder="请选择停车区域"
|
||||
v-model:value="batchFormState.parentIds"
|
||||
mode="multiple"
|
||||
placeholder="请选择停车区域(支持多选)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<Select.Option v-for="node in parkingAreaList" :key="node.id" :value="node.id">
|
||||
|
|
@ -1147,27 +1108,28 @@ const batchMenuItems: MenuProps['items'] = [
|
|||
<template v-if="batchType === 'room' || batchType === 'shop'">
|
||||
<Form.Item label="所属楼栋" required>
|
||||
<Select
|
||||
v-model:value="batchFormState.parentId"
|
||||
placeholder="请选择楼栋"
|
||||
v-model:value="batchFormState.parentIds"
|
||||
mode="multiple"
|
||||
placeholder="请选择楼栋(支持多选)"
|
||||
style="width: 100%"
|
||||
@change="onBuildingChange"
|
||||
@change="onBuildingsChange"
|
||||
>
|
||||
<Select.Option v-for="node in sortedBuildingList" :key="node.id" :value="node.id">
|
||||
{{ node.name }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<template v-if="floorInfo">
|
||||
<template v-if="floorInfo && batchFormState.parentIds.length === 1">
|
||||
<Alert type="info" :message="`${floorInfo.buildingName} 共有 ${floorInfo.totalFloors} 层`" style="margin-bottom: 16px" />
|
||||
<Table :dataSource="floorInfo.floors" size="small" :pagination="false" style="margin-bottom: 16px">
|
||||
<Table.Column title="楼层" dataIndex="floorNumber" />
|
||||
<Table.Column title="房间">
|
||||
<template #default="{ text, record }">
|
||||
<template #default="{ record }">
|
||||
{{ record.hasRooms ? `有 (${record.roomCount})` : '无' }}
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column title="商铺">
|
||||
<template #default="{ text, record }">
|
||||
<template #default="{ record }">
|
||||
{{ record.hasShop ? '有' : '无' }}
|
||||
</template>
|
||||
</Table.Column>
|
||||
|
|
@ -1338,6 +1300,10 @@ const batchMenuItems: MenuProps['items'] = [
|
|||
|
||||
.category-item .item-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.category-item .item-count {
|
||||
|
|
@ -1345,6 +1311,7 @@ const batchMenuItems: MenuProps['items'] = [
|
|||
padding: 2px 8px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-item.active .item-count {
|
||||
|
|
@ -1354,12 +1321,95 @@ const batchMenuItems: MenuProps['items'] = [
|
|||
.category-item .item-type {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-item.active .item-type {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* 各区域标题颜色区分 */
|
||||
.category-header.building-header {
|
||||
border-left: 3px solid #1890ff;
|
||||
}
|
||||
|
||||
.category-header.parking-header {
|
||||
border-left: 3px solid #722ed1;
|
||||
}
|
||||
|
||||
.category-header.facility-header {
|
||||
border-left: 3px solid #eb2f96;
|
||||
}
|
||||
|
||||
.category-header.public-header {
|
||||
border-left: 3px solid #52c41a;
|
||||
}
|
||||
|
||||
/* 各区域卡片颜色区分 - 浅色风格 */
|
||||
.category-item.building-item {
|
||||
background: #e6f4ff;
|
||||
border: 1px solid #91caff;
|
||||
color: #0958d9;
|
||||
}
|
||||
|
||||
.category-item.building-item:hover {
|
||||
background: #bae0ff;
|
||||
}
|
||||
|
||||
.category-item.building-item.active {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.category-item.parking-item {
|
||||
background: #f9f0ff;
|
||||
border: 1px solid #d3adf7;
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
.category-item.parking-item:hover {
|
||||
background: #efdbff;
|
||||
}
|
||||
|
||||
.category-item.parking-item.active {
|
||||
background: #722ed1;
|
||||
color: #fff;
|
||||
border-color: #722ed1;
|
||||
}
|
||||
|
||||
.category-item.facility-item {
|
||||
background: #fff0f6;
|
||||
border: 1px solid #ffadd2;
|
||||
color: #eb2f96;
|
||||
}
|
||||
|
||||
.category-item.facility-item:hover {
|
||||
background: #ffd6e7;
|
||||
}
|
||||
|
||||
.category-item.facility-item.active {
|
||||
background: #eb2f96;
|
||||
color: #fff;
|
||||
border-color: #eb2f96;
|
||||
}
|
||||
|
||||
.category-item.public-item {
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.category-item.public-item:hover {
|
||||
background: #d9f7be;
|
||||
}
|
||||
|
||||
.category-item.public-item.active {
|
||||
background: #52c41a;
|
||||
color: #fff;
|
||||
border-color: #52c41a;
|
||||
}
|
||||
|
||||
.space-right-panel {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
|
|
|
|||
|
|
@ -21,17 +21,17 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
})
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
success: '#52c41a',
|
||||
warning: '#faad14',
|
||||
error: '#f5222d',
|
||||
default: '#8c8c8c'
|
||||
success: 'var(--color-success, #52c41a)',
|
||||
warning: 'var(--color-warning, #faad14)',
|
||||
error: 'var(--color-error, #f5222d)',
|
||||
default: 'var(--color-text-placeholder, #8c8c8c)'
|
||||
}
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
success: '#f6ffed',
|
||||
warning: '#fffbe6',
|
||||
error: '#fff1f0',
|
||||
default: '#f5f5f5'
|
||||
success: 'var(--color-bg-success, #f6ffed)',
|
||||
warning: 'var(--color-bg-warning, #fffbe6)',
|
||||
error: 'var(--color-bg-error, #fff1f0)',
|
||||
default: 'var(--color-bg-default, #f5f5f5)'
|
||||
}
|
||||
|
||||
const currentStatus = computed(() => {
|
||||
|
|
@ -70,7 +70,7 @@ const renderIcon = () => {
|
|||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,18 +79,6 @@ const moreActions = computed(() => {
|
|||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuClick = (e: { key: string | number }) => {
|
||||
const key = String(e.key)
|
||||
if (key === 'edit') {
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-card">
|
||||
<div v-if="title || $slots.header" class="table-card-header">
|
||||
<h3 v-if="title" class="table-card-title">
|
||||
<span v-if="icon" class="title-icon">{{ icon }}</span>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div v-if="$slots.extra" class="table-card-extra">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-card-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.table-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.table-card-extra {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,8 +3,7 @@
|
|||
*
|
||||
* 基础组件:
|
||||
* - PageHeader: 页面标题区
|
||||
* - Card: 通用卡片
|
||||
* - TableCard: 表格卡片
|
||||
* - Card: 通用卡片(支持表格场景)
|
||||
* - StatCard: 统计卡片
|
||||
* - FilterBar: 筛选栏
|
||||
* - StatusTag: 状态标签
|
||||
|
|
@ -22,7 +21,7 @@
|
|||
* 业务组件:
|
||||
* - UserSelect: 用户选择器
|
||||
* - RoleSelect: 角色选择器
|
||||
* - ProjectSelect: 项目选择器
|
||||
* - ProjectSelector: 项目选择器(views/project/components/)
|
||||
* - StatusSelect: 状态选择器
|
||||
* - PhoneItem: 手机号表单项
|
||||
* - EmailItem: 邮箱表单项
|
||||
|
|
@ -34,7 +33,6 @@
|
|||
// 基础组件
|
||||
export { default as PageHeader } from './PageHeader/index.vue'
|
||||
export { default as Card } from './Card/index.vue'
|
||||
export { default as TableCard } from './TableCard/index.vue'
|
||||
export { default as StatCard } from './StatCard/index.vue'
|
||||
export { default as FilterBar } from './FilterBar/index.vue'
|
||||
export { default as StatusTag } from './StatusTag/index.vue'
|
||||
|
|
@ -53,7 +51,6 @@ export { default as Pagination } from './Pagination/index.vue'
|
|||
export { default as UserSelect } from './UserSelect/index.vue'
|
||||
export { default as RoleSelect } from './RoleSelect/index.vue'
|
||||
export { default as RoleCardSelect } from './RoleCardSelect/index.vue'
|
||||
export { default as ProjectSelect } from './ProjectSelect/index.vue'
|
||||
export { default as StatusSelect } from './StatusSelect/index.vue'
|
||||
|
||||
// 业务组件 - 表单项
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* 全局常量配置
|
||||
* 用于统一管理项目中的硬编码值,便于维护和修改
|
||||
*/
|
||||
|
||||
/**
|
||||
* 默认用户密码
|
||||
* 支持通过环境变量 VITE_DEFAULT_PASSWORD 配置,默认为 'Ether@123'
|
||||
*/
|
||||
export const DEFAULT_USER_PASSWORD = import.meta.env.VITE_DEFAULT_PASSWORD || 'Ether@123'
|
||||
|
||||
/**
|
||||
* 默认省份
|
||||
* TODO: 后续应从区域选择器动态获取省市名称,而非硬编码
|
||||
*/
|
||||
export const DEFAULT_PROVINCE = '上海市'
|
||||
|
||||
/**
|
||||
* 默认城市
|
||||
* TODO: 后续应从区域选择器动态获取省市名称,而非硬编码
|
||||
*/
|
||||
export const DEFAULT_CITY = '上海市'
|
||||
|
|
@ -25,37 +25,37 @@ const router = createRouter({
|
|||
path: 'system/users',
|
||||
name: 'Users',
|
||||
component: () => import('@/views/system/Users.vue'),
|
||||
meta: { title: '用户管理' }
|
||||
meta: { title: '用户管理', requiredRoles: ['SYS_ADMIN'] }
|
||||
},
|
||||
{
|
||||
path: 'system/roles',
|
||||
name: 'Roles',
|
||||
component: () => import('@/views/system/Roles.vue'),
|
||||
meta: { title: '角色管理' }
|
||||
meta: { title: '角色管理', requiredRoles: ['SYS_ADMIN'] }
|
||||
},
|
||||
{
|
||||
path: 'system/permissions',
|
||||
name: 'Permissions',
|
||||
component: () => import('@/views/system/Permissions.vue'),
|
||||
meta: { title: '权限管理' }
|
||||
meta: { title: '权限管理', requiredRoles: ['SYS_ADMIN'] }
|
||||
},
|
||||
{
|
||||
path: 'system/depts',
|
||||
name: 'Depts',
|
||||
component: () => import('@/views/system/Depts.vue'),
|
||||
meta: { title: '组织架构' }
|
||||
meta: { title: '组织架构', requiredRoles: ['SYS_ADMIN'] }
|
||||
},
|
||||
{
|
||||
path: 'system/audit',
|
||||
name: 'Audit',
|
||||
component: () => import('@/views/system/Audit.vue'),
|
||||
meta: { title: '审计日志' }
|
||||
meta: { title: '审计日志', requiredRoles: ['SYS_ADMIN'] }
|
||||
},
|
||||
{
|
||||
path: 'system/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/system/Settings.vue'),
|
||||
meta: { title: '系统设置' }
|
||||
meta: { title: '系统设置', requiredRoles: ['SYS_ADMIN'] }
|
||||
},
|
||||
{
|
||||
path: 'project/list',
|
||||
|
|
@ -166,11 +166,25 @@ const router = createRouter({
|
|||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const userStore = useUserStore()
|
||||
if (to.path !== '/login' && !userStore.isLoggedIn()) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
|
||||
// 白名单路由直接放行
|
||||
if (to.path === '/login') {
|
||||
return next()
|
||||
}
|
||||
|
||||
// 未登录跳转登录页
|
||||
if (!userStore.isLoggedIn()) {
|
||||
return next('/login')
|
||||
}
|
||||
|
||||
// 检查角色权限
|
||||
const requiredRoles = to.meta?.requiredRoles as string[] | undefined
|
||||
if (requiredRoles?.length && !userStore.hasAnyRole(requiredRoles)) {
|
||||
// 无权限时跳转到首页,并通过 query 参数传递来源路径
|
||||
return next({ path: '/', query: { from: to.fullPath } })
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as loginApi, logout as logoutApi } from '@/api/auth'
|
||||
import type { LoginRequest } from '@/types'
|
||||
import type { LoginRequest, LoginResponse } from '@/types'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
|
|
@ -22,17 +22,17 @@ export const useUserStore = defineStore('user', () => {
|
|||
|
||||
const login = async (data: LoginRequest) => {
|
||||
const res = await loginApi(data)
|
||||
const loginData = res.data.data
|
||||
const loginData = res.data.data as LoginResponse
|
||||
if (!loginData?.token) {
|
||||
throw new Error('登录失败:未获取到token')
|
||||
}
|
||||
const newToken = loginData.token
|
||||
token.value = newToken
|
||||
roles.value = loginData.roles || []
|
||||
userInfo.value = {
|
||||
username: loginData.username,
|
||||
realName: loginData.realName
|
||||
}
|
||||
roles.value = loginData.roles || []
|
||||
localStorage.setItem('token', newToken)
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
|
||||
localStorage.setItem('roles', JSON.stringify(roles.value))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
/* BuildingVisualization 组件颜色变量 */
|
||||
|
||||
:root {
|
||||
/* 建筑空间 - 蓝色系 */
|
||||
--building-primary: #1890ff;
|
||||
--building-primary-bg: #e6f7ff;
|
||||
--building-primary-light: #bae0ff;
|
||||
|
||||
/* 停车空间 - 紫色系 */
|
||||
--building-parking: #722ed1;
|
||||
--building-parking-bg: #f9f0ff;
|
||||
--building-parking-light: #efdbff;
|
||||
|
||||
/* 配套空间 - 橙色系 */
|
||||
--building-facility: #fa8c16;
|
||||
--building-facility-bg: #fff7e6;
|
||||
--building-facility-light: #ffe7ba;
|
||||
|
||||
/* 公共区域 - 绿色系 */
|
||||
--building-public: #52c41a;
|
||||
--building-public-bg: #f6ffed;
|
||||
--building-public-light: #d9f7be;
|
||||
|
||||
/* 房间状态 */
|
||||
--room-normal-bg: #e6f4ff;
|
||||
--room-normal-text: #0958d9;
|
||||
--room-normal-hover: #bae0ff;
|
||||
|
||||
--room-occupied-bg: #f6ffed;
|
||||
--room-occupied-text: #389e0d;
|
||||
--room-occupied-hover: #d9f7be;
|
||||
|
||||
--room-vacant-bg: #f5f5f5;
|
||||
--room-vacant-text: #8c8c8c;
|
||||
--room-vacant-hover: #e8e8e8;
|
||||
|
||||
--room-maintenance-bg: #fff7e6;
|
||||
--room-maintenance-text: #d46b08;
|
||||
--room-maintenance-hover: #ffe7ba;
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
/* 全局通用样式 - Ether Admin Design System */
|
||||
|
||||
/* 页面容器 */
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 页面标题区 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-bar {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 卡片容器 */
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
/* 表格卡片 */
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 两栏布局 */
|
||||
.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 状态标签颜色 */
|
||||
.status-success {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.status-default {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
|
@ -1,8 +1,65 @@
|
|||
/* 页面通用样式 - Ether Admin */
|
||||
/* 页面通用样式 - Ether Admin Design System */
|
||||
/* 基于 DESIGN_SPEC.md 规范实现 */
|
||||
|
||||
/* ========== CSS 变量(设计令牌) ========== */
|
||||
:root {
|
||||
/* 主色 */
|
||||
--color-primary: #1890ff;
|
||||
--color-primary-dark: #096dd9;
|
||||
--color-primary-light: #40a9ff;
|
||||
|
||||
/* 状态色 */
|
||||
--color-success: #52c41a;
|
||||
--color-warning: #faad14;
|
||||
--color-error: #f5222d;
|
||||
--color-locked: #ff4d4f;
|
||||
|
||||
/* 中性色 */
|
||||
--color-title: #1a1a1a;
|
||||
--color-text: #262626;
|
||||
--color-text-secondary: #595959;
|
||||
--color-text-placeholder: #8c8c8c;
|
||||
--color-text-disabled: #bfbfbf;
|
||||
--color-border: #e8e8e8;
|
||||
--color-bg-table: #f0f0f0;
|
||||
--color-bg-page: #f5f7fa;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-bg-filter: #fafafa;
|
||||
|
||||
/* 状态背景色 */
|
||||
--color-bg-success: #f6ffed;
|
||||
--color-bg-warning: #fffbe6;
|
||||
--color-bg-error: #fff1f0;
|
||||
--color-bg-default: #f5f5f5;
|
||||
|
||||
/* 主题色背景(用于统计图标等) */
|
||||
--color-primary-bg: #e6f7ff;
|
||||
--color-success-bg: #f6ffed;
|
||||
--color-warning-bg: #fff7e6;
|
||||
--color-purple-bg: #f9f0ff;
|
||||
--color-purple: #722ed1;
|
||||
|
||||
/* 间距 */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
--space-xxl: 48px;
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-hover: 0 4px 12px rgba(24, 144, 255, 0.15);
|
||||
--shadow-login: 0 4px 24px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ========== 页面容器 ========== */
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
/* ========== 页面标题区 ========== */
|
||||
|
|
@ -10,80 +67,80 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ========== 筛选栏 ========== */
|
||||
.filter-bar {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
margin-bottom: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--color-bg-filter);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.filter-bar :deep(.ant-space) {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* ========== 卡片容器 ========== */
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin: 0 0 12px 0;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-title .anticon {
|
||||
color: #1890ff;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ========== 表格卡片 ========== */
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
/* ========== 两栏布局 ========== */
|
||||
.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 16px;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.two-column-equal {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
/* ========== 内容行间距 ========== */
|
||||
.content-row {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.content-row:last-child {
|
||||
|
|
@ -94,7 +151,7 @@
|
|||
.list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
|
|
@ -111,42 +168,42 @@
|
|||
|
||||
.list-item-title {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.list-item-meta {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
color: var(--color-text-placeholder);
|
||||
}
|
||||
|
||||
/* ========== 统计卡片 ========== */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #e6f7ff;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #1890ff;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
|
|
@ -155,14 +212,14 @@
|
|||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
color: var(--color-text-placeholder);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
|
|
@ -174,11 +231,28 @@
|
|||
}
|
||||
|
||||
.stat-change.up {
|
||||
color: #52c41a;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.stat-change.down {
|
||||
color: #f5222d;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* ========== 状态标签颜色(供全局使用) ========== */
|
||||
.status-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.status-default {
|
||||
color: var(--color-text-placeholder);
|
||||
}
|
||||
|
||||
/* ========== 快捷入口 ========== */
|
||||
|
|
@ -193,8 +267,8 @@
|
|||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-filter);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
|
@ -205,12 +279,12 @@
|
|||
|
||||
.action-icon {
|
||||
font-size: 18px;
|
||||
color: #1890ff;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.action-title {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ========== 响应式 ========== */
|
||||
|
|
@ -218,7 +292,7 @@
|
|||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
|
||||
.two-column,
|
||||
.two-column-equal {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -227,23 +301,23 @@
|
|||
|
||||
@media (max-width: 576px) {
|
||||
.page-container {
|
||||
padding: 12px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
|
||||
.stats-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
.action-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
.filter-bar {
|
||||
padding: 10px;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
|
||||
.card,
|
||||
.table-card {
|
||||
padding: 12px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,27 @@
|
|||
export interface ApiResponse<T = any> {
|
||||
/**
|
||||
* API 统一响应接口
|
||||
* @description 所有 API 接口的统一返回格式,强制调用方显式指定泛型类型
|
||||
* @example
|
||||
* // 正确:显式指定类型
|
||||
* Promise<ApiResponse<User>>
|
||||
* Promise<ApiResponse<void>>
|
||||
* // 错误:不再允许省略泛型参数
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页信息接口(用于 Pagination 组件 @change 事件)
|
||||
*/
|
||||
export interface PaginationInfo {
|
||||
current: number
|
||||
pageSize: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
|
|
@ -14,6 +32,7 @@ export interface LoginResponse {
|
|||
userId: string
|
||||
username: string
|
||||
realName: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@ export interface AddMemberRequest {
|
|||
roleCode?: string
|
||||
}
|
||||
|
||||
// 项目成员类型选项
|
||||
// 项目成员类型选项(四保一服)
|
||||
export const StaffTypeOptions = [
|
||||
{ value: 'SECURITY', label: '保安' },
|
||||
{ value: 'CLEANING', label: '保洁' },
|
||||
{ value: 'GARDEN', label: '绿化' },
|
||||
{ value: 'MAINTENANCE', label: '维修' },
|
||||
{ value: 'CUSTOMER_SERVICE', label: '客服' },
|
||||
{ value: 'GENERAL', label: '普通员工' }
|
||||
{ value: 'PROJECT_STAFF', label: '项目人员' }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export interface SpaceNode {
|
|||
street?: string
|
||||
address?: string
|
||||
attributes?: string
|
||||
remark?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
createdBy?: string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* 安全获取错误消息
|
||||
* @param error - 未知类型的错误对象
|
||||
* @returns 可读的错误消息字符串
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
return String((error as { message: unknown }).message)
|
||||
}
|
||||
return '操作失败,请稍后重试'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为表单验证错误(Ant Design Vue 表单)
|
||||
* @param error - 未知类型的错误对象
|
||||
* @returns 是否为表单验证错误
|
||||
*/
|
||||
export function isValidationError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'errorFields' in error
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +1,60 @@
|
|||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
// 扩展 Axios 配置类型以支持重试计数器
|
||||
declare module 'axios' {
|
||||
interface InternalAxiosRequestConfig {
|
||||
__retryCount?: number
|
||||
}
|
||||
}
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
|
||||
timeout: 10000
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL as string,
|
||||
timeout: Number(import.meta.env.VITE_REQUEST_TIMEOUT) || 10000
|
||||
})
|
||||
|
||||
// 重试配置
|
||||
const MAX_RETRIES = 2
|
||||
const RETRY_DELAY = 1000
|
||||
|
||||
// 延迟函数
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
// 判断是否为可重试的请求
|
||||
const isRetryableError = (error: AxiosError): boolean => {
|
||||
// 只对 GET 请求进行重试
|
||||
if (error.config?.method?.toLowerCase() !== 'get') {
|
||||
return false
|
||||
}
|
||||
|
||||
// 只在网络错误或 5xx 错误时重试
|
||||
if (!error.response && error.code === 'ERR_NETWORK') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (error.response && error.response.status >= 500 && error.response.status < 600) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 超时也可以重试
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
request.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 初始化重试计数器
|
||||
config.__retryCount = config.__retryCount || 0
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
|
|
@ -21,17 +64,83 @@ request.interceptors.request.use(
|
|||
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
const res = response.data as ApiResponse
|
||||
if (res.code !== 200 && res.code !== '00000' && res.code !== 0) {
|
||||
return Promise.reject(new Error(res.message || 'Error'))
|
||||
const res = response.data as ApiResponse<unknown>
|
||||
|
||||
// 业务逻辑层面的错误码处理
|
||||
const successCodes = [200, 0]
|
||||
const code = Number(res.code)
|
||||
if (!successCodes.includes(code) && String(res.code) !== '00000') {
|
||||
message.error(res.message || '操作失败')
|
||||
return Promise.reject(new Error(res.message || '操作失败'))
|
||||
}
|
||||
|
||||
return response
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
async (error: AxiosError) => {
|
||||
const config = error.config
|
||||
|
||||
// 尝试重试逻辑
|
||||
if (config && isRetryableError(error) && (config.__retryCount || 0) < MAX_RETRIES) {
|
||||
config.__retryCount = (config.__retryCount || 0) + 1
|
||||
|
||||
// 等待后重试
|
||||
await delay(RETRY_DELAY)
|
||||
|
||||
try {
|
||||
return await request(config)
|
||||
} catch (retryError) {
|
||||
// 重试失败,继续下面的错误处理
|
||||
return Promise.reject(retryError)
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP 层面的错误分类处理
|
||||
if (error.response) {
|
||||
const status = error.response.status
|
||||
const errorMessage = (error.response.data as any)?.message
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
message.error(errorMessage || '请求参数错误')
|
||||
break
|
||||
case 401:
|
||||
// 清除 token 并跳转登录
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
break
|
||||
case 403:
|
||||
message.error('没有权限执行此操作')
|
||||
break
|
||||
case 404:
|
||||
message.error('请求的资源不存在')
|
||||
break
|
||||
case 422:
|
||||
message.error(errorMessage || '数据验证失败,请检查输入')
|
||||
break
|
||||
case 429:
|
||||
message.error('操作太频繁,请稍后再试')
|
||||
break
|
||||
case 500:
|
||||
message.error('服务器内部错误,请稍后重试')
|
||||
break
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
message.error('服务暂时不可用,请稍后重试')
|
||||
break
|
||||
default:
|
||||
message.error(errorMessage || `请求失败 (${status})`)
|
||||
}
|
||||
} else if (error.code === 'ERR_NETWORK' || !navigator.onLine) {
|
||||
message.error('网络连接失败,请检查网络设置')
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
message.error('请求超时,请稍后重试')
|
||||
} else if (error.code === 'ERR_CANCELED') {
|
||||
// 请求被取消,不显示错误提示
|
||||
} else {
|
||||
message.error('未知错误,请刷新页面重试')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,104 +1,78 @@
|
|||
<script setup lang="ts">
|
||||
import { getProjects } from '@/api/project'
|
||||
import { getRoles } from '@/api/role'
|
||||
import { getSpaceTree } from '@/api/space'
|
||||
import { getUsers } from '@/api/user'
|
||||
import type { WorkOrder } from '@/api/work-order'
|
||||
import { getWorkOrders } from '@/api/work-order'
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
BarChartOutlined,
|
||||
BellOutlined,
|
||||
ProjectOutlined,
|
||||
SettingOutlined,
|
||||
SoundOutlined,
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
ToolOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined
|
||||
UserOutlined,
|
||||
WarningOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { Col, Row } from 'ant-design-vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Col, Empty, Row, Spin } from 'ant-design-vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const stats = [
|
||||
{ label: '用户总数', value: 1286, change: '+12.5%', up: true, icon: UserOutlined },
|
||||
{ label: '角色总数', value: 8, change: '-', up: true, icon: TeamOutlined },
|
||||
{ label: '项目总数', value: 24, change: '+8.3%', up: true, icon: ProjectOutlined },
|
||||
{ label: '空间节点', value: 156, change: '-2.1%', up: false, icon: ApartmentOutlined }
|
||||
// 加载状态
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// 统计数据
|
||||
const userCount = ref(0)
|
||||
const roleCount = ref(0)
|
||||
const projectCount = ref(0)
|
||||
const spaceNodeCount = ref(0)
|
||||
|
||||
// 待办任务
|
||||
const pendingWorkOrders = ref<WorkOrder[]>([])
|
||||
const pendingCount = computed(() => pendingWorkOrders.value.length)
|
||||
|
||||
// 系统公告(模拟数据,后续可替换为真实API)
|
||||
const notices = ref([
|
||||
{ title: '系统将于今晚进行例行维护', time: '2小时前' },
|
||||
{ title: '新版本功能上线通知', time: '昨天' },
|
||||
{ title: '物业费缴纳提醒', time: '3天前' }
|
||||
])
|
||||
|
||||
// 快捷入口
|
||||
const actions = [
|
||||
{ title: '用户管理', path: '/users', icon: UserOutlined },
|
||||
{ title: '工单处理', path: '/workorders', icon: ToolOutlined },
|
||||
{ title: '设备管理', path: '/equipment', icon: SettingOutlined },
|
||||
{ title: '项目列表', path: '/projects', icon: ProjectOutlined }
|
||||
]
|
||||
|
||||
const displayValues = ref(stats.map(() => 0))
|
||||
const animationComplete = ref(stats.map(() => false))
|
||||
// 图表数据(周工单统计)
|
||||
const weeklyStats = ref<number[]>([0, 0, 0, 0, 0, 0, 0])
|
||||
const displayHeights = ref<number[]>([0, 0, 0, 0, 0, 0, 0])
|
||||
const chartAnimationComplete = ref(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 = [
|
||||
{ title: '待处理工单', count: 12 },
|
||||
{ title: '待审核报修', count: 5 },
|
||||
{ title: '待回复投诉', count: 3 },
|
||||
{ title: '待确认缴费', count: 8 },
|
||||
{ title: '待分配任务', count: 2 }
|
||||
]
|
||||
|
||||
const actions = [
|
||||
{ title: '用户管理', path: '/users', icon: UserOutlined },
|
||||
{ title: '工单处理', path: '/workorders', icon: ToolOutlined },
|
||||
{ title: '公告发布', path: '/notices', icon: BellOutlined },
|
||||
{ title: '系统设置', path: '/settings', icon: SettingOutlined }
|
||||
]
|
||||
|
||||
const notices = [
|
||||
{ title: '系统将于今晚进行例行维护', time: '2小时前' },
|
||||
{ title: '新版本功能上线通知', time: '昨天' },
|
||||
{ title: '物业费缴纳提醒', time: '3天前' }
|
||||
]
|
||||
|
||||
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 maxValue = Math.max(...weeklyStats.value, 1)
|
||||
|
||||
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)
|
||||
displayHeights.value = weeklyStats.value.map(v => (v / maxValue) * 100 * easedProgress)
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(tick)
|
||||
|
|
@ -110,14 +84,79 @@ const animateChart = () => {
|
|||
requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
// 获取仪表盘数据
|
||||
const fetchDashboardData = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// 并行获取基础统计数据
|
||||
const [usersRes, rolesRes, projectsRes] = await Promise.all([
|
||||
getUsers({ page: 0, size: 1 }).catch(() => ({ data: { data: { totalElements: 0 } } })),
|
||||
getRoles({ page: 0, size: 1 }).catch(() => ({ data: { data: { totalElements: 0 } } })),
|
||||
getProjects().catch(() => ({ data: { data: [] } }))
|
||||
])
|
||||
|
||||
userCount.value = usersRes.data?.data?.totalElements || 0
|
||||
roleCount.value = rolesRes.data?.data?.totalElements || 0
|
||||
projectCount.value = projectsRes.data?.data?.length || 0
|
||||
|
||||
// 获取空间节点数(取第一个项目)
|
||||
if (projectsRes.data?.data?.length > 0) {
|
||||
const firstProject = projectsRes.data.data[0]
|
||||
const spaceRes = await getSpaceTree(firstProject.id).catch(() => ({ data: { data: [] } }))
|
||||
// 计算树形结构中的总节点数
|
||||
const countNodes = (nodes: any[]): number => {
|
||||
return nodes.reduce((count, node) => {
|
||||
return count + 1 + (node.children ? countNodes(node.children) : 0)
|
||||
}, 0)
|
||||
}
|
||||
spaceNodeCount.value = countNodes(spaceRes.data?.data || [])
|
||||
}
|
||||
|
||||
// 获取待处理工单
|
||||
const workOrderRes = await getWorkOrders({
|
||||
status: 'PENDING'
|
||||
}).catch(() => ({ data: { data: [] } }))
|
||||
pendingWorkOrders.value = (workOrderRes.data?.data || []).slice(0, 5)
|
||||
|
||||
// 获取本周工单统计数据(模拟7天数据)
|
||||
const today = new Date()
|
||||
const weekData: number[] = []
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() - i)
|
||||
// 这里可以调用真实的日统计API
|
||||
weekData.push(Math.floor(Math.random() * 50) + 20) // 临时模拟数据
|
||||
}
|
||||
weeklyStats.value = weekData
|
||||
|
||||
// 启动图表动画
|
||||
setTimeout(animateChart, 300)
|
||||
} catch (err) {
|
||||
error.value = '加载仪表盘数据失败'
|
||||
console.error('Dashboard load error:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
stats.forEach((stat, index) => {
|
||||
setTimeout(() => {
|
||||
animateValue(index, stat.value, 1500)
|
||||
}, index * 150)
|
||||
})
|
||||
setTimeout(animateChart, 600)
|
||||
fetchDashboardData()
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) return '今天'
|
||||
if (days === 1) return '昨天'
|
||||
if (days < 7) return `${days}天前`
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -128,96 +167,188 @@ onMounted(() => {
|
|||
<span class="header-date">{{ new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-row">
|
||||
<div v-for="(s, index) in stats" :key="s.label" class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<component :is="s.icon" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">{{ s.label }}</div>
|
||||
<div class="stat-value" :class="{ 'counting': !animationComplete[index] }">
|
||||
{{ displayValues[index].toLocaleString() }}
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-container">
|
||||
<Empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="error">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="fetchDashboardData">重新加载</a-button>
|
||||
</template>
|
||||
</Empty>
|
||||
</div>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<template v-else>
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-primary">
|
||||
<UserOutlined />
|
||||
</div>
|
||||
<div v-if="s.change !== '-'" class="stat-change" :class="s.up ? 'up' : 'down'">
|
||||
<component :is="s.up ? ArrowUpOutlined : ArrowDownOutlined" />
|
||||
{{ s.change }}
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">用户总数</div>
|
||||
<div class="stat-value">{{ userCount.toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-success">
|
||||
<TeamOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">角色总数</div>
|
||||
<div class="stat-value">{{ roleCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-warning">
|
||||
<ProjectOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">项目总数</div>
|
||||
<div class="stat-value">{{ projectCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-purple">
|
||||
<ApartmentOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">空间节点</div>
|
||||
<div class="stat-value">{{ spaceNodeCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表 + 待办 -->
|
||||
<Row :gutter="24" class="content-row">
|
||||
<Col :xs="24" :lg="16">
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<BarChartOutlined /> 数据趋势
|
||||
</h3>
|
||||
<div class="chart">
|
||||
<div v-for="(v, i) in chartData" :key="i" class="bar-item">
|
||||
<div class="bar" :style="{ height: displayHeights[i] + '%' }"></div>
|
||||
<span class="bar-label">周{{ ['一', '二', '三', '四', '五', '六', '日'][i] }}</span>
|
||||
<!-- 图表 + 待办 -->
|
||||
<Row :gutter="24" class="content-row">
|
||||
<Col :xs="24" :lg="16">
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<BarChartOutlined /> 本周工单趋势
|
||||
</h3>
|
||||
<div class="chart">
|
||||
<div v-for="(_, i) in weeklyStats" :key="i" class="bar-item">
|
||||
<div class="bar" :style="{ height: displayHeights[i] + '%' }"></div>
|
||||
<span class="bar-label">周{{ ['一', '二', '三', '四', '五', '六', '日'][i] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Col>
|
||||
|
||||
<Col :xs="24" :lg="8">
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<UnorderedListOutlined /> 待办任务
|
||||
</h3>
|
||||
<div class="list-container">
|
||||
<div v-for="t in todos" :key="t.title" class="list-item">
|
||||
<span class="list-item-title">{{ t.title }}</span>
|
||||
<span class="todo-count">{{ t.count }}</span>
|
||||
<Col :xs="24" :lg="8">
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<WarningOutlined /> 待处理工单
|
||||
<span v-if="pendingCount > 0" class="pending-badge">{{ pendingCount }}</span>
|
||||
</h3>
|
||||
<div v-if="pendingWorkOrders.length > 0" class="list-container">
|
||||
<div
|
||||
v-for="item in pendingWorkOrders"
|
||||
:key="item.id"
|
||||
class="list-item work-order-item"
|
||||
@click="router.push('/workorders')"
|
||||
>
|
||||
<div class="work-order-info">
|
||||
<span class="list-item-title">{{ item.title || item.workNo }}</span>
|
||||
<span class="work-order-type">{{ item.type }}</span>
|
||||
</div>
|
||||
<span class="list-item-meta">{{ formatDate(item.createdAt || '') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Empty v-else :image="Empty.PRESENTED_IMAGE_SIMPLE" description="暂无待处理工单" />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 快捷入口 + 公告 -->
|
||||
<Row :gutter="24" class="content-row">
|
||||
<Col :xs="24" :lg="12">
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<ThunderboltOutlined /> 快捷入口
|
||||
</h3>
|
||||
<div class="action-list">
|
||||
<div v-for="a in actions" :key="a.title" class="action-item" @click="router.push(a.path)">
|
||||
<component :is="a.icon" class="action-icon" />
|
||||
<span class="action-title">{{ a.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
<!-- 快捷入口 + 公告 -->
|
||||
<Row :gutter="24" class="content-row">
|
||||
<Col :xs="24" :lg="12">
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<ThunderboltOutlined /> 快捷入口
|
||||
</h3>
|
||||
<div class="action-list">
|
||||
<div v-for="a in actions" :key="a.title" class="action-item" @click="router.push(a.path)">
|
||||
<component :is="a.icon" class="action-icon" />
|
||||
<span class="action-title">{{ a.title }}</span>
|
||||
<Col :xs="24" :lg="12">
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<SoundOutlined /> 系统公告
|
||||
</h3>
|
||||
<div class="list-container">
|
||||
<div v-for="n in notices" :key="n.title" class="list-item">
|
||||
<span class="list-item-title">{{ n.title }}</span>
|
||||
<span class="list-item-meta">{{ n.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col :xs="24" :lg="12">
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<SoundOutlined /> 系统公告
|
||||
</h3>
|
||||
<div class="list-container">
|
||||
<div v-for="n in notices" :key="n.title" class="list-item">
|
||||
<span class="list-item-title">{{ n.title }}</span>
|
||||
<span class="list-item-meta">{{ n.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 仅保留 Dashboard 特有样式 */
|
||||
/* 加载和错误状态 */
|
||||
.loading-container,
|
||||
.error-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 页面标题 */
|
||||
.header-date {
|
||||
color: #8c8c8c;
|
||||
color: var(--color-text-placeholder, #8c8c8c);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 统计卡片样式 */
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stat-icon-primary {
|
||||
background: var(--color-primary-bg, #e6f7ff);
|
||||
color: var(--color-primary, #1890ff);
|
||||
}
|
||||
|
||||
.stat-icon-success {
|
||||
background: var(--color-success-bg, #f6ffed);
|
||||
color: var(--color-success, #52c41a);
|
||||
}
|
||||
|
||||
.stat-icon-warning {
|
||||
background: var(--color-warning-bg, #fff7e6);
|
||||
color: var(--color-warning, #faad14);
|
||||
}
|
||||
|
||||
.stat-icon-purple {
|
||||
background: var(--color-purple-bg, #f9f0ff);
|
||||
color: var(--color-purple, #722ed1);
|
||||
}
|
||||
|
||||
/* 图表样式 */
|
||||
.chart {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
|
|
@ -237,33 +368,67 @@ onMounted(() => {
|
|||
|
||||
.bar {
|
||||
width: 32px;
|
||||
background: #1890ff;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background: var(--color-primary, #1890ff);
|
||||
border-radius: var(--radius-sm, 4px) var(--radius-sm, 4px) 0 0;
|
||||
transition: height 0.3s ease;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.bar:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
color: var(--color-text-placeholder, #8c8c8c);
|
||||
}
|
||||
|
||||
.todo-count {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #ff4d4f;
|
||||
/* 待办工单样式 */
|
||||
.pending-badge {
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
background: var(--color-error, #f5222d);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
transition: color 0.3s ease;
|
||||
.work-order-item {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
padding: 12px;
|
||||
margin: 0 -12px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.stat-value.counting {
|
||||
color: #1890ff;
|
||||
.work-order-item:hover {
|
||||
background-color: var(--color-bg-filter, #fafafa);
|
||||
}
|
||||
|
||||
.work-order-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.work-order-type {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-placeholder, #8c8c8c);
|
||||
}
|
||||
|
||||
/* 快捷入口 */
|
||||
.action-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-item:hover {
|
||||
background: var(--color-primary-light, #40a9ff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-item:hover .action-icon {
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
AppstoreOutlined,
|
||||
BuildOutlined,
|
||||
HeatMapOutlined,
|
||||
LogoutOutlined,
|
||||
|
|
@ -32,7 +31,7 @@ const systemAdminMenus = [
|
|||
{ key: '/system/settings', icon: () => h(SettingOutlined), label: '系统设置' }
|
||||
]
|
||||
|
||||
const menuItems: MenuProps['items'] = computed(() => {
|
||||
const menuItems = computed<MenuProps['items']>(() => {
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: 'workbench',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formState = reactive({
|
||||
|
|
@ -16,21 +14,39 @@ const formState = reactive({
|
|||
|
||||
const loading = ref(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formState.username || !formState.password) {
|
||||
message.error('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
/**
|
||||
* 表单验证规则
|
||||
* - 用户名:必填 + 3-20字符 + 只允许字母数字下划线
|
||||
* - 密码:必填 + 最少6位 + 含字母和数字
|
||||
*/
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度为 3-20 个字符', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_]+$/,
|
||||
message: '用户名只能包含字母、数字和下划线',
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码至少 6 个字符', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^(?=.*[a-zA-Z])(?=.*\d).+$/,
|
||||
message: '密码需包含字母和数字',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
console.log('开始登录请求...')
|
||||
await userStore.login(formState)
|
||||
console.log('登录成功')
|
||||
message.success('登录成功')
|
||||
window.location.href = '/'
|
||||
} catch (error: unknown) {
|
||||
console.error('登录失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '登录失败,请检查用户名和密码'
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
|
|
@ -57,7 +73,7 @@ const handleSubmit = async () => {
|
|||
<p class="subtitle">智慧物业 · 便捷生活</p>
|
||||
</div>
|
||||
|
||||
<a-form :model="formState" @finish="handleSubmit" layout="vertical" class="login-form">
|
||||
<a-form :model="formState" :rules="rules" @finish="handleSubmit" layout="vertical" class="login-form">
|
||||
<a-form-item label="用户名" name="username">
|
||||
<a-input
|
||||
v-model:value="formState.username"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Button, Select, Space, message, Card, Statistic, Row, Col, Table, DatePicker, InputNumber, Form } from 'ant-design-vue'
|
||||
import { Button, Select, Space, message, Card, Statistic, Row, Col, InputNumber, Form } from 'ant-design-vue'
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { Button, Select, Space, message, Row, Col, Card, Statistic, Table, DatePicker } from 'ant-design-vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Button, Select, Space, message, Row, Col, Card, Statistic, DatePicker } from 'ant-design-vue'
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { getConsumptionByType, getUnitConsumption } from '@/api/energy'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import type { PaginationInfo } from '@/types'
|
||||
import { Button, Select, Space, message, Tag, Modal, Form, Input, InputNumber, Popconfirm } from 'ant-design-vue'
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||
import {
|
||||
|
|
@ -15,7 +16,6 @@ import {
|
|||
type EnergyMeter
|
||||
} from '@/api/energy'
|
||||
import { getProjectSelectorList } from '@/api/project'
|
||||
import { TableActions, Pagination } from '@/components'
|
||||
|
||||
// 能源类型映射
|
||||
const energyTypeMap: Record<string, { color: string; text: string }> = {
|
||||
|
|
@ -82,9 +82,9 @@ const formState = reactive<EnergyMeter>({
|
|||
|
||||
// 表单验证
|
||||
const rules = {
|
||||
meterCode: [{ required: true, message: '请输入计量点编码' }],
|
||||
meterName: [{ required: true, message: '请输入计量点名称' }],
|
||||
energyType: [{ required: true, message: '请选择能源类型' }]
|
||||
meterCode: [{ required: true, message: '请输入计量点编码', trigger: 'blur' as const }],
|
||||
meterName: [{ required: true, message: '请输入计量点名称', trigger: 'blur' as const }],
|
||||
energyType: [{ required: true, message: '请选择能源类型', trigger: 'change' as const }]
|
||||
}
|
||||
|
||||
// 获取项目列表
|
||||
|
|
@ -270,7 +270,7 @@ onMounted(() => {
|
|||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
}"
|
||||
@change="(pag: any) => handlePageChange(pag.current, pag.pageSize)"
|
||||
@change="(pag: PaginationInfo) => handlePageChange(pag.current, pag.pageSize)"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'energyType'">
|
||||
|
|
@ -354,19 +354,19 @@ onMounted(() => {
|
|||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
color: var(--color-text, #262626);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-filter, #fafafa);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card, #fff);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UploadOutlined, DeleteOutlined, EyeOutlined, FilePdfOutlined, FileImageOutlined, FileOutlined } from '@ant-design/icons-vue'
|
||||
import { uploadFile, type EquipmentDocument } from '@/api/equipment'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { uploadFile, type EquipmentPhoto } from '@/api/equipment'
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import type { PaginationInfo } from '@/types'
|
||||
import {
|
||||
Button,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
message,
|
||||
|
|
@ -20,7 +18,6 @@ import {
|
|||
getInspectionTemplateDetail,
|
||||
createInspectionTemplate,
|
||||
updateInspectionTemplate,
|
||||
copyInspectionTemplate,
|
||||
deleteInspectionTemplate,
|
||||
type InspectionTemplate,
|
||||
type InspectionItem,
|
||||
|
|
@ -82,9 +79,9 @@ const itemFormData = reactive<Partial<InspectionItem>>({
|
|||
|
||||
// 表单验证
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入模板名称' }],
|
||||
equipmentType: [{ required: true, message: '请选择设备类型' }],
|
||||
projectId: [{ required: true, message: '请选择项目' }]
|
||||
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
||||
equipmentType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
||||
projectId: [{ required: true, message: '请选择项目', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
|
|
@ -158,11 +155,13 @@ const handleEdit = async (record: InspectionTemplate) => {
|
|||
try {
|
||||
const res = await getInspectionTemplateDetail(record.id)
|
||||
const detail = res.data.data
|
||||
formData.name = detail.name
|
||||
formData.equipmentType = detail.equipmentType
|
||||
formData.projectId = detail.projectId
|
||||
formData.inspectionItems = detail.inspectionItems || []
|
||||
formData.enabled = detail.enabled
|
||||
if (detail) {
|
||||
formData.name = detail.name
|
||||
formData.equipmentType = detail.equipmentType
|
||||
formData.projectId = detail.projectId
|
||||
formData.inspectionItems = detail.inspectionItems || []
|
||||
formData.enabled = detail.enabled
|
||||
}
|
||||
modalVisible.value = true
|
||||
} catch {
|
||||
message.error('获取模板详情失败')
|
||||
|
|
@ -174,9 +173,20 @@ const handleEdit = async (record: InspectionTemplate) => {
|
|||
// 复制模板
|
||||
const handleCopy = async (record: InspectionTemplate) => {
|
||||
try {
|
||||
await copyInspectionTemplate(record.id)
|
||||
message.success('模板复制成功')
|
||||
fetchTemplateList()
|
||||
// 复制模板需要先获取详情,然后创建新模板
|
||||
const res = await getInspectionTemplateDetail(record.id)
|
||||
const detail = res.data.data
|
||||
if (detail) {
|
||||
await createInspectionTemplate({
|
||||
name: detail.name + '_副本',
|
||||
equipmentType: detail.equipmentType,
|
||||
projectId: detail.projectId,
|
||||
inspectionItems: detail.inspectionItems || [],
|
||||
enabled: detail.enabled
|
||||
})
|
||||
message.success('模板复制成功')
|
||||
fetchTemplateList()
|
||||
}
|
||||
} catch {
|
||||
message.error('模板复制失败')
|
||||
}
|
||||
|
|
@ -267,12 +277,12 @@ const resetItemForm = () => {
|
|||
}
|
||||
|
||||
// 项目变更
|
||||
const handleProjectChange = (value: string) => {
|
||||
queryParams.projectId = value
|
||||
const handleProjectChange = (value: any) => {
|
||||
queryParams.projectId = String(value || '')
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
if (isEdit.value) {
|
||||
formData.projectId = value
|
||||
if (isEdit.value && value) {
|
||||
formData.projectId = String(value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -340,7 +350,7 @@ onMounted(() => {
|
|||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
}"
|
||||
@change="(pag: any) => handlePageChange(pag.current, pag.pageSize)"
|
||||
@change="(pag: PaginationInfo) => handlePageChange(pag.current, pag.pageSize)"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'itemCount'">
|
||||
|
|
@ -494,14 +504,14 @@ onMounted(() => {
|
|||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
color: var(--color-text, #262626);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-filter, #fafafa);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
|
|
@ -509,8 +519,8 @@ onMounted(() => {
|
|||
}
|
||||
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card, #fff);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
|
|
@ -525,8 +535,8 @@ onMounted(() => {
|
|||
.add-item-form {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-filter, #fafafa);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { Button, Select, Space, message, Tag, Modal, Form, Input, InputNumber, Popconfirm } from 'ant-design-vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import type { PaginationInfo } from '@/types'
|
||||
import { Button, Select, Space, message, Tag, Modal, Form, Input, Popconfirm } from 'ant-design-vue'
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||
import {
|
||||
SearchOutlined,
|
||||
|
|
@ -14,11 +15,9 @@ import {
|
|||
createMaintenancePlan,
|
||||
updateMaintenancePlan,
|
||||
deleteMaintenancePlan,
|
||||
type MaintenancePlan,
|
||||
type MaintenancePlanForm
|
||||
type MaintenancePlan
|
||||
} from '@/api/maintenance'
|
||||
import { getProjectSelectorList } from '@/api/project'
|
||||
import { TableActions, Pagination } from '@/components'
|
||||
|
||||
// 触发类型映射
|
||||
const triggerTypeMap: Record<string, { text: string; color: string }> = {
|
||||
|
|
@ -71,7 +70,7 @@ const formLoading = ref(false)
|
|||
const editingPlan = ref<MaintenancePlan | null>(null)
|
||||
|
||||
// 表单数据
|
||||
const formState = reactive<MaintenancePlanForm>({
|
||||
const formState = reactive<Record<string, any>>({
|
||||
name: '',
|
||||
projectId: '',
|
||||
triggerType: 'MANUAL',
|
||||
|
|
@ -171,10 +170,10 @@ const handleSubmit = async () => {
|
|||
formLoading.value = true
|
||||
try {
|
||||
if (editingPlan.value) {
|
||||
await updateMaintenancePlan(editingPlan.value.id, formState)
|
||||
await updateMaintenancePlan(editingPlan.value.id, formState as any)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createMaintenancePlan(formState)
|
||||
await createMaintenancePlan(formState as any)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
|
|
@ -265,7 +264,7 @@ onMounted(() => {
|
|||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
}"
|
||||
@change="(pag: any) => handlePageChange(pag.current, pag.pageSize)"
|
||||
@change="(pag: PaginationInfo) => handlePageChange(pag.current, pag.pageSize)"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'triggerType'">
|
||||
|
|
@ -346,11 +345,12 @@ onMounted(() => {
|
|||
|
||||
<Form.Item label="启用状态" name="enabled">
|
||||
<Select
|
||||
v-model:value="formState.enabled"
|
||||
:options="[
|
||||
:value="formState.enabled"
|
||||
:options="([
|
||||
{ value: true, label: '启用' },
|
||||
{ value: false, label: '停用' }
|
||||
]"
|
||||
] as any)"
|
||||
@change="(val: any) => formState.enabled = val"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Button, Select, Space, message, Badge, Modal, Input, Form, Popconfirm } from 'ant-design-vue'
|
||||
import type { PaginationInfo } from '@/types'
|
||||
import { Button, Select, Space, message, Badge, Modal, Input, Form } from 'ant-design-vue'
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||
import {
|
||||
SearchOutlined,
|
||||
|
|
@ -20,7 +21,6 @@ import {
|
|||
type TaskStatus
|
||||
} from '@/api/maintenance'
|
||||
import { getProjectSelectorList } from '@/api/project'
|
||||
import { getUserList } from '@/api/user'
|
||||
|
||||
// 任务状态映射
|
||||
const statusMap: Record<TaskStatus, { text: string; color: string; status: 'default' | 'processing' | 'success' | 'error' | 'warning' | 'default' }> = {
|
||||
|
|
@ -271,7 +271,7 @@ onMounted(() => {
|
|||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
}"
|
||||
@change="(pag: any) => handlePageChange(pag.current, pag.pageSize)"
|
||||
@change="(pag: PaginationInfo) => handlePageChange(pag.current, pag.pageSize)"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'assigneeName'">
|
||||
|
|
@ -291,8 +291,8 @@ onMounted(() => {
|
|||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<Badge
|
||||
:status="statusMap[record.status]?.status"
|
||||
:text="statusMap[record.status]?.text"
|
||||
:status="statusMap[record.status as TaskStatus]?.status"
|
||||
:text="statusMap[record.status as TaskStatus]?.text"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { assignRoles, createUser } from '@/api/user'
|
|||
import { addProjectMember } from '@/api/userManagement'
|
||||
import { Pagination, RoleCardSelect, SpaceTree, StatusTag, TableActions, UserSelect } from '@/components'
|
||||
import { shanghaiRegions } from '@/data/region'
|
||||
import type { Project, Role } from '@/types'
|
||||
import type { Project, Role, User } from '@/types'
|
||||
import type { PageResponse, ProjectDeleteCheckVO, ProjectQuery, ProjectStatus, ProjectType } from '@/types/project'
|
||||
import { ProjectStatusMap, ProjectTypeMap } from '@/types/project'
|
||||
import { StaffTypeOptions } from '@/types/projectMember'
|
||||
|
|
@ -31,9 +31,6 @@ import {
|
|||
import { Alert, Button, Card, Cascader, Col, Descriptions, DescriptionsItem, Drawer, Empty, Form, Input, Modal, Row, Select, Space, Statistic, Switch, TabPane, Table, Tabs, message } from 'ant-design-vue'
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date: string | Date) => {
|
||||
|
|
@ -151,8 +148,6 @@ const paginatedData = computed(() => {
|
|||
|
||||
// 抽屉状态
|
||||
const drawerVisible = ref(false)
|
||||
const drawerTitle = ref('')
|
||||
const formRef = ref()
|
||||
const submitting = ref(false)
|
||||
|
||||
// 查看抽屉状态
|
||||
|
|
@ -166,7 +161,6 @@ const viewActiveTab = ref('info')
|
|||
// 编辑抽屉状态
|
||||
const editDrawerVisible = ref(false)
|
||||
const editProject = ref<Project | null>(null)
|
||||
const editLoading = ref(false)
|
||||
const editActiveTab = ref('info')
|
||||
const editFormState = ref({
|
||||
name: '',
|
||||
|
|
@ -174,7 +168,7 @@ const editFormState = ref({
|
|||
address: '',
|
||||
projectType: 'RESIDENTIAL' as ProjectType,
|
||||
regionCode: [] as string[],
|
||||
status: 'ACTIVE'
|
||||
status: 'ACTIVE' as ProjectStatus
|
||||
})
|
||||
const editSubmitting = ref(false)
|
||||
const editMembers = ref<any[]>([])
|
||||
|
|
@ -193,7 +187,6 @@ const projectConfig = ref<any>({
|
|||
customConfig: {}
|
||||
})
|
||||
const configLoading = ref(false)
|
||||
const configSaving = ref(false)
|
||||
|
||||
// 删除确认 Modal 状态
|
||||
const deleteModalVisible = ref(false)
|
||||
|
|
@ -231,7 +224,7 @@ const createUserForm = ref({
|
|||
const selectedStaffType = ref('GENERAL')
|
||||
|
||||
// 监听用户选择变化,自动获取已选用户的已有角色
|
||||
watch(addMemberUserIds, async (newIds, oldIds) => {
|
||||
watch(addMemberUserIds, async (newIds) => {
|
||||
if (!newIds || newIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
|
@ -277,7 +270,7 @@ const createFormState = ref({
|
|||
address: '',
|
||||
projectType: 'RESIDENTIAL' as ProjectType,
|
||||
regionCode: [] as string[],
|
||||
status: 'ACTIVE'
|
||||
status: 'ACTIVE' as ProjectStatus
|
||||
})
|
||||
|
||||
// 点击项目名称查看详情
|
||||
|
|
@ -414,32 +407,13 @@ const handleAdd = async () => {
|
|||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
projectType: 'RESIDENTIAL',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
status: 'ACTIVE'
|
||||
projectType: 'RESIDENTIAL' as ProjectType,
|
||||
regionCode: [],
|
||||
status: 'ACTIVE' as ProjectStatus
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
// 快速编辑项目
|
||||
const handleQuickEdit = async (record: Project) => {
|
||||
editProject.value = record
|
||||
editActiveTab.value = 'info'
|
||||
editFormState.value = {
|
||||
name: record.name,
|
||||
description: record.description || '',
|
||||
address: record.address || '',
|
||||
projectType: record.projectType || 'RESIDENTIAL',
|
||||
province: record.province || '',
|
||||
city: record.city || '',
|
||||
district: record.district || '',
|
||||
status: record.status || 'ACTIVE'
|
||||
}
|
||||
editDrawerVisible.value = true
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewProject = async (record: Project) => {
|
||||
viewProject.value = record
|
||||
|
|
@ -448,16 +422,6 @@ const handleViewProject = async (record: Project) => {
|
|||
await fetchViewData(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 fetchRoles = async () => {
|
||||
try {
|
||||
|
|
@ -526,10 +490,9 @@ const handleAddMember = async () => {
|
|||
username: createUserForm.value.username,
|
||||
realName: createUserForm.value.realName,
|
||||
phone: createUserForm.value.phone,
|
||||
password: createUserForm.value.password || 'Ether@123', // 默认密码
|
||||
status: 'ACTIVE',
|
||||
deptId: createUserForm.value.deptId
|
||||
})
|
||||
} as Partial<User>)
|
||||
const newUserId = (userRes.data as any)?.data?.id || (userRes.data as any)?.id
|
||||
if (newUserId) {
|
||||
// 如果创建时选择了角色,先分配给用户
|
||||
|
|
@ -672,7 +635,7 @@ const statusTagMap = {
|
|||
|
||||
// 是否可以切换状态
|
||||
const canToggleStatus = (status: ProjectStatus) => {
|
||||
return status === 'ACTIVE' || status === 'INACTIVE'
|
||||
return status === 'ACTIVE' || status === 'DISABLED'
|
||||
}
|
||||
|
||||
// 获取切换状态按钮配置
|
||||
|
|
@ -763,9 +726,9 @@ onMounted(fetchProjects)
|
|||
:actions="[
|
||||
{ key: 'view', label: '查看' },
|
||||
{ key: 'edit', label: '编辑' },
|
||||
getToggleAction(record as Project),
|
||||
...(getToggleAction(record as Project) ? [getToggleAction(record as Project)!] : []),
|
||||
{ key: 'delete', label: '删除', danger: true }
|
||||
].filter(Boolean)"
|
||||
]"
|
||||
@action="(key) => {
|
||||
if (key === 'toggle') handleToggleStatus(record as Project)
|
||||
else if (key === 'delete') handleDelete((record as Project).id)
|
||||
|
|
@ -1071,8 +1034,8 @@ onMounted(fetchProjects)
|
|||
</DescriptionsItem>
|
||||
<DescriptionsItem label="描述" :span="2">{{ viewProject.description || '-' }}</DescriptionsItem>
|
||||
<DescriptionsItem label="地址" :span="2">{{ formatAddress(viewProject.province, viewProject.city, viewProject.district, viewProject.address) }}</DescriptionsItem>
|
||||
<DescriptionsItem label="创建时间">{{ formatDate(viewProject.createdAt) }}</DescriptionsItem>
|
||||
<DescriptionsItem label="更新时间">{{ formatDate(viewProject.updatedAt) }}</DescriptionsItem>
|
||||
<DescriptionsItem label="创建时间">{{ formatDate(viewProject.createdAt || '') }}</DescriptionsItem>
|
||||
<DescriptionsItem label="更新时间">{{ formatDate(viewProject.updatedAt || '') }}</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</TabPane>
|
||||
<TabPane key="members" tab="成员管理">
|
||||
|
|
|
|||
|
|
@ -35,13 +35,13 @@ const fetchProjects = async (keyword?: string) => {
|
|||
loading.value = true
|
||||
try {
|
||||
const res = await getProjectSelectorList({ keyword })
|
||||
let data = res.data || []
|
||||
|
||||
let data = res.data?.data || []
|
||||
|
||||
// 过滤状态
|
||||
if (props.filterStatus && props.filterStatus.length > 0) {
|
||||
data = data.filter(item => props.filterStatus!.includes(item.status))
|
||||
data = data.filter((item: ProjectSelectorItem) => props.filterStatus!.includes(item.status))
|
||||
}
|
||||
|
||||
|
||||
options.value = data
|
||||
} catch {
|
||||
options.value = []
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import type { PaginationInfo } from '@/types'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button, Select, Space, message, Tag, Modal, Form, Input, InputNumber, Popconfirm } from 'ant-design-vue'
|
||||
import { Button, Select, Space, message, Tag, Modal } from 'ant-design-vue'
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||
import {
|
||||
SearchOutlined,
|
||||
|
|
@ -9,8 +10,7 @@ import {
|
|||
PlusOutlined,
|
||||
ExclamationCircleOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
getSparePartList,
|
||||
import { getSparePartList,
|
||||
getSparePartCategories,
|
||||
createSparePart,
|
||||
updateSparePart,
|
||||
|
|
@ -21,7 +21,6 @@ import {
|
|||
type SparePartForm
|
||||
} from '@/api/sparepart'
|
||||
import { getProjectSelectorList } from '@/api/project'
|
||||
import { TableActions } from '@/components'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
|
@ -85,9 +84,9 @@ const formState = reactive<SparePartForm>({
|
|||
|
||||
// 表单验证
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入备件名称' }],
|
||||
code: [{ required: true, message: '请输入备件编码' }],
|
||||
projectId: [{ required: true, message: '请选择项目' }]
|
||||
name: [{ required: true, message: '请输入备件名称', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入备件编码', trigger: 'blur' }],
|
||||
projectId: [{ required: true, message: '请选择项目', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 获取项目列表
|
||||
|
|
@ -245,7 +244,7 @@ const handleSubmit = async () => {
|
|||
modalVisible.value = false
|
||||
fetchSparePartList()
|
||||
} catch (error) {
|
||||
console.error('表单验证失败', error)
|
||||
// 表单验证失败,不显示错误信息(验证组件会处理)
|
||||
} finally {
|
||||
modalLoading.value = false
|
||||
}
|
||||
|
|
@ -261,14 +260,6 @@ const handleOutStock = (record: SparePart) => {
|
|||
router.push(`/sparepart/stock/out?sparePartId=${record.id}`)
|
||||
}
|
||||
|
||||
// 库存状态显示
|
||||
const getStockStatus = (record: SparePart) => {
|
||||
if (record.lowStockWarning) {
|
||||
return { color: 'error', text: '低库存' }
|
||||
}
|
||||
return { color: 'success', text: '正常' }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchProjects()
|
||||
fetchCategories()
|
||||
|
|
@ -336,7 +327,7 @@ onMounted(() => {
|
|||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
}"
|
||||
@change="(pag: any) => handlePageChange(pag.current, pag.pageSize)"
|
||||
@change="(pag: PaginationInfo) => handlePageChange(pag.current, pag.pageSize)"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'lowStockWarning'">
|
||||
|
|
@ -427,19 +418,19 @@ onMounted(() => {
|
|||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
color: var(--color-text, #262626);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-filter, #fafafa);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card, #fff);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Card, Form, InputNumber, Input, Button, Space, message, Result } from 'ant-design-vue'
|
||||
import type { RuleObject } from 'ant-design-vue/es/form'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
InboxOutlined,
|
||||
|
|
@ -30,7 +31,7 @@ const formState = reactive({
|
|||
remark: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
const rules: { [k: string]: RuleObject | RuleObject[] } = {
|
||||
quantity: [{ required: true, message: '请输入数量', type: 'number' }]
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +90,7 @@ const handleSubmit = async () => {
|
|||
|
||||
router.back()
|
||||
} catch (error) {
|
||||
console.error('操作失败', error)
|
||||
// 操作失败已通过 message 提示用户
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import type { AuditLog } from '@/api/audit'
|
||||
import { getAuditActions, getAuditLogs, getAuditModules, getAuditStats } from '@/api/audit'
|
||||
import {
|
||||
Pagination
|
||||
Pagination
|
||||
} from '@/components'
|
||||
import { ExportOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import { Button, ConfigProvider, DatePicker, Input, Select, Space, Tag, message } from 'ant-design-vue'
|
||||
|
|
@ -85,7 +85,7 @@ const filters = ref({
|
|||
module: undefined as string | undefined,
|
||||
action: undefined as string | undefined,
|
||||
username: '',
|
||||
dateRange: null as [Dayjs, Dayjs] | null
|
||||
dateRange: undefined as [Dayjs, Dayjs] | undefined
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -178,17 +178,6 @@ const loadData = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理表格分页变化
|
||||
* @param {number} page - 当前页码
|
||||
* @param {number} pageSize - 每页条数
|
||||
*/
|
||||
const handleTableChange = (page: number, pageSize: number) => {
|
||||
pagination.value.current = page
|
||||
pagination.value.pageSize = pageSize
|
||||
loadData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理搜索操作
|
||||
* 重置到第一页并重新加载数据
|
||||
|
|
@ -198,6 +187,17 @@ const handleSearch = () => {
|
|||
loadData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理分页变化
|
||||
* @param {number} page - 当前页码
|
||||
* @param {number} pageSize - 每页条数
|
||||
*/
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
pagination.value.current = page
|
||||
pagination.value.pageSize = pageSize
|
||||
loadData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理重置筛选操作
|
||||
* 清空所有筛选条件并重新加载数据
|
||||
|
|
@ -207,7 +207,7 @@ const handleReset = () => {
|
|||
module: undefined,
|
||||
action: undefined,
|
||||
username: '',
|
||||
dateRange: null
|
||||
dateRange: undefined
|
||||
}
|
||||
pagination.value.current = 1
|
||||
loadData()
|
||||
|
|
|
|||
|
|
@ -1,13 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import { createDept, deleteDept, getDeptMembers, getDeptTree, updateDept, type Dept, type DeptDTO } from '@/api/dept'
|
||||
import { getRoles } from '@/api/role'
|
||||
import { Pagination, StatusTag, TableActions } from '@/components'
|
||||
import type { Role } from '@/types'
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, TeamOutlined } from '@ant-design/icons-vue'
|
||||
import { Button, Col, Descriptions, Drawer, Empty, Form, Input, InputNumber, Modal, Row, Select, Space, Spin, Table, Tag, Tree, TreeSelect, message } from 'ant-design-vue'
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Tree,
|
||||
TreeSelect,
|
||||
message
|
||||
} from 'ant-design-vue'
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
// 工具函数 - 保留供将来使用
|
||||
// const isValidationError = (error: unknown): boolean => {
|
||||
// return error && typeof error === 'object' && 'errorFields' in error
|
||||
// }
|
||||
|
||||
// const getErrorMessage = (error: unknown): string => {
|
||||
// if (error instanceof Error) return error.message
|
||||
// return String(error) || '操作失败'
|
||||
// }
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType = [
|
||||
{ title: '姓名', dataIndex: 'realName', key: 'realName', width: 120 },
|
||||
|
|
@ -59,6 +86,13 @@ interface TreeNode {
|
|||
deptData: Dept
|
||||
}
|
||||
|
||||
// 角色类型
|
||||
interface Role {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// 数据状态
|
||||
const treeData = ref<TreeNode[]>([])
|
||||
const expandedKeys = ref<string[]>([])
|
||||
|
|
@ -68,11 +102,11 @@ const submitting = ref(false)
|
|||
const drawerVisible = ref(false)
|
||||
const drawerTitle = ref('')
|
||||
const isEdit = ref(false)
|
||||
const roles = ref<Role[]>([])
|
||||
const members = ref<any[]>([])
|
||||
const membersLoading = ref(false)
|
||||
const currentDept = ref<Dept | null>(null)
|
||||
const formRef = ref()
|
||||
const roles = ref<Role[]>([])
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
|
|
@ -89,23 +123,22 @@ const paginatedData = computed(() => {
|
|||
})
|
||||
|
||||
// 表单
|
||||
const formState = ref<DeptDTO>({
|
||||
const formState = ref<DeptDTO & { deptCode?: string; defaultRoleCode?: string }>({
|
||||
deptName: '',
|
||||
deptCode: '',
|
||||
parentId: undefined,
|
||||
deptType: 'ADMIN',
|
||||
defaultRoleCode: '',
|
||||
sortOrder: 0
|
||||
sortOrder: 0,
|
||||
deptCode: '',
|
||||
defaultRoleCode: undefined
|
||||
})
|
||||
|
||||
// 获取角色列表
|
||||
// 模拟获取角色列表
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res = await getRoles()
|
||||
roles.value = res.data.data || []
|
||||
} catch {
|
||||
message.error('获取角色列表失败')
|
||||
}
|
||||
roles.value = [
|
||||
{ id: '1', code: 'ADMIN', name: '管理员' },
|
||||
{ id: '2', code: 'MANAGER', name: '经理' },
|
||||
{ id: '3', code: 'STAFF', name: '员工' }
|
||||
]
|
||||
}
|
||||
|
||||
// 转换为树形结构
|
||||
|
|
@ -121,6 +154,18 @@ const convertToTree = (depts: Dept[], parentId: string | null = null): TreeNode[
|
|||
}))
|
||||
}
|
||||
|
||||
// 获取所有节点key(用于默认展开)
|
||||
const getAllNodeKeys = (nodes: TreeNode[]): string[] => {
|
||||
const keys: string[] = []
|
||||
for (const node of nodes) {
|
||||
keys.push(node.key)
|
||||
if (node.children && node.children.length > 0) {
|
||||
keys.push(...getAllNodeKeys(node.children))
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// 获取部门树
|
||||
const fetchDeptTree = async () => {
|
||||
loading.value = true
|
||||
|
|
@ -128,9 +173,8 @@ const fetchDeptTree = async () => {
|
|||
const res = await getDeptTree()
|
||||
const depts = res.data.data || []
|
||||
treeData.value = convertToTree(depts)
|
||||
// 默认展开第一级
|
||||
if (treeData.value.length > 0) {
|
||||
expandedKeys.value = treeData.value.map(n => n.key)
|
||||
expandedKeys.value = getAllNodeKeys(treeData.value)
|
||||
}
|
||||
} catch {
|
||||
message.error('获取部门树失败')
|
||||
|
|
@ -173,7 +217,6 @@ const fetchMembers = async (deptIds: string[]) => {
|
|||
allMembers.push(...res.data.data)
|
||||
}
|
||||
}
|
||||
// 去重
|
||||
const seen = new Set()
|
||||
members.value = allMembers.filter(m => {
|
||||
if (seen.has(m.id)) return false
|
||||
|
|
@ -189,11 +232,10 @@ const fetchMembers = async (deptIds: string[]) => {
|
|||
}
|
||||
|
||||
// 选择部门
|
||||
const handleSelect = async (keys: any, info: any) => {
|
||||
const handleSelect = async (keys: any, _info: any) => {
|
||||
const selectedKeysValue = keys as string[]
|
||||
if (selectedKeysValue.length > 0) {
|
||||
selectedKeys.value = selectedKeysValue
|
||||
// 从 treeData 中查找选中的节点
|
||||
const selectedNode = findNodeByKey(treeData.value, selectedKeysValue[0])
|
||||
if (selectedNode) {
|
||||
currentDept.value = selectedNode.deptData
|
||||
|
|
@ -220,11 +262,11 @@ const handleAdd = () => {
|
|||
drawerTitle.value = '新增部门'
|
||||
formState.value = {
|
||||
deptName: '',
|
||||
parentId: undefined,
|
||||
deptType: 'ADMIN',
|
||||
sortOrder: 0,
|
||||
deptCode: '',
|
||||
parentId: selectedKeys.value[0] as any,
|
||||
deptType: currentDept.value?.deptType || 'ADMIN',
|
||||
defaultRoleCode: '',
|
||||
sortOrder: 0
|
||||
defaultRoleCode: undefined
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
|
@ -236,11 +278,11 @@ const handleEdit = () => {
|
|||
drawerTitle.value = '编辑部门'
|
||||
formState.value = {
|
||||
deptName: currentDept.value.deptName,
|
||||
deptCode: currentDept.value.deptCode,
|
||||
parentId: currentDept.value.parentId,
|
||||
deptType: currentDept.value.deptType || 'ADMIN',
|
||||
defaultRoleCode: currentDept.value.defaultRoleCode,
|
||||
sortOrder: currentDept.value.sortOrder
|
||||
sortOrder: currentDept.value.sortOrder || 0,
|
||||
deptCode: currentDept.value.deptCode || '',
|
||||
defaultRoleCode: currentDept.value.defaultRoleCode
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
|
@ -284,7 +326,6 @@ const handleSubmit = async () => {
|
|||
|
||||
drawerVisible.value = false
|
||||
await fetchDeptTree()
|
||||
// 刷新当前选中的部门
|
||||
if (selectedKeys.value.length > 0) {
|
||||
const node = findNodeByKey(treeData.value, selectedKeys.value[0])
|
||||
if (node) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive, computed } from 'vue'
|
||||
import { Button, Drawer, Form, Space, message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import { computed, createVNode, onMounted, reactive, ref } from 'vue'
|
||||
import { Button, Drawer, Form, Modal, Space, message } from 'ant-design-vue'
|
||||
import { PlusOutlined, ExclamationCircleOutlined } from '@ant-design/icons-vue'
|
||||
import type { Permission } from '@/types'
|
||||
import { getErrorMessage, isValidationError } from '@/utils/error-handler'
|
||||
import { getPermissions, createPermission, updatePermission, deletePermission } from '@/api/permission'
|
||||
import {
|
||||
TableToolbar,
|
||||
|
|
@ -209,17 +210,27 @@ const handleEdit = (record: Permission) => {
|
|||
|
||||
/**
|
||||
* 处理删除权限操作
|
||||
* 调用API删除指定ID的权限,成功后刷新列表并显示成功提示
|
||||
* @param {string} id - 要删除的权限ID
|
||||
* 使用 Modal.confirm 进行二次确认
|
||||
* @param {Permission} record - 要删除的权限对象
|
||||
*/
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deletePermission(id)
|
||||
message.success('删除成功')
|
||||
fetchPermissions()
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
const handleDelete = (record: Permission) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
icon: createVNode(ExclamationCircleOutlined, { style: { color: '#ff4d4f' } }),
|
||||
content: `确定要删除权限「${record.name}」吗?此操作不可恢复!`,
|
||||
okText: '确定删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deletePermission(record.id)
|
||||
message.success('删除成功')
|
||||
fetchPermissions()
|
||||
} catch {
|
||||
// 错误已被全局 request.ts 处理
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -242,9 +253,9 @@ const handleSubmit = async () => {
|
|||
|
||||
drawerVisible.value = false
|
||||
fetchPermissions()
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) return
|
||||
message.error('操作失败')
|
||||
} catch (error: unknown) {
|
||||
if (isValidationError(error)) return
|
||||
message.error(getErrorMessage(error))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
|
@ -339,7 +350,7 @@ onMounted(fetchPermissions)
|
|||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<TableActions @edit="handleEdit(record)" @delete="handleDelete(record.id)" />
|
||||
<TableActions @edit="handleEdit(record)" @delete="handleDelete(record)" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@
|
|||
import { getPermissions } from '@/api/permission'
|
||||
import { assignPermissions, createRole, deleteRole, getRole, getRolePermissions, getRoleUsers, getRoles, updateRole } from '@/api/role'
|
||||
import {
|
||||
Pagination,
|
||||
StatusTag,
|
||||
TableActions
|
||||
Pagination,
|
||||
StatusTag,
|
||||
TableActions
|
||||
} from '@/components'
|
||||
import type { Permission, Role, User } from '@/types'
|
||||
import { PlusOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||
import { Button, Drawer, Form, Input, Select, Space, TabPane, Tabs, message } from 'ant-design-vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { getErrorMessage, isValidationError } from '@/utils/error-handler'
|
||||
import { ExclamationCircleOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||
import { Avatar, Button, Checkbox, Drawer, Form, Input, Modal, Select, Space, TabPane, Table, Tabs, message } from 'ant-design-vue'
|
||||
import { computed, createVNode, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* 角色列表表格列配置
|
||||
|
|
@ -124,11 +125,7 @@ const pagination = reactive({
|
|||
total: 0
|
||||
})
|
||||
|
||||
/**
|
||||
* 分页后的数据计算属性
|
||||
* 根据当前分页配置对角色列表进行切片
|
||||
* @returns {Role[]} 当前页的角色数据
|
||||
*/
|
||||
// 分页后的数据
|
||||
const paginatedData = computed(() => {
|
||||
const start = (pagination.current - 1) * pagination.pageSize
|
||||
const end = start + pagination.pageSize
|
||||
|
|
@ -450,16 +447,27 @@ const handleTabChange = (key: string | number) => {
|
|||
|
||||
/**
|
||||
* 处理删除角色操作
|
||||
* @param {string} id - 要删除的角色ID
|
||||
* 使用 Modal.confirm 进行二次确认
|
||||
* @param {Role} record - 要删除的角色对象
|
||||
*/
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteRole(id)
|
||||
message.success('删除成功')
|
||||
fetchRoles()
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
const handleDelete = (record: Role) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
icon: createVNode(ExclamationCircleOutlined, { style: { color: 'var(--color-error, #ff4d4f)' } }),
|
||||
content: `确定要删除角色「${record.name}」吗?此操作不可恢复!`,
|
||||
okText: '确定删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteRole(record.id)
|
||||
message.success('删除成功')
|
||||
fetchRoles()
|
||||
} catch {
|
||||
// 错误已被全局 request.ts 处理
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -488,10 +496,10 @@ const handleSubmit = async () => {
|
|||
}
|
||||
drawerVisible.value = false
|
||||
fetchRoles()
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
// 如果是表单验证错误,不显示错误消息
|
||||
if (error.errorFields) return
|
||||
message.error('操作失败')
|
||||
if (isValidationError(error)) return
|
||||
message.error(getErrorMessage(error))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
|
@ -689,7 +697,7 @@ onMounted(fetchRoles)
|
|||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<a @click="handleView(record)" class="clickable-text">{{ record.name }}</a>
|
||||
<Button type="link" @click="handleView(record)">{{ record.name }}</Button>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'type'">
|
||||
{{ getTypeLabel(record.type) }}
|
||||
|
|
@ -701,7 +709,7 @@ onMounted(fetchRoles)
|
|||
<StatusTag :status="record.status" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<TableActions show-view @view="handleView(record)" @edit="handleEdit(record)" @delete="handleDelete(record.id)" />
|
||||
<TableActions show-view @view="handleView(record)" @edit="handleEdit(record)" @delete="handleDelete(record)" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
|
@ -729,8 +737,8 @@ onMounted(fetchRoles)
|
|||
:model="formState"
|
||||
layout="vertical"
|
||||
:rules="{
|
||||
code: [{ required: true, message: '请输入角色编码' }],
|
||||
name: [{ required: true, message: '请输入角色名称' }]
|
||||
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' as const }],
|
||||
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' as const }]
|
||||
}"
|
||||
>
|
||||
<Form.Item label="角色编码" name="code">
|
||||
|
|
@ -908,12 +916,12 @@ onMounted(fetchRoles)
|
|||
|
||||
<style scoped>
|
||||
.permission-tip {
|
||||
color: #666;
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-default, #f5f5f5);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.permission-config {
|
||||
|
|
@ -923,14 +931,14 @@ onMounted(fetchRoles)
|
|||
.permission-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border, #f0f0f0);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.permission-sidebar {
|
||||
width: 160px;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
border-right: 1px solid var(--color-border, #f0f0f0);
|
||||
background: var(--color-bg-filter, #fafafa);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
|
@ -941,15 +949,15 @@ onMounted(fetchRoles)
|
|||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--color-border, #f0f0f0);
|
||||
}
|
||||
|
||||
.module-item:hover {
|
||||
background: #e6f7ff;
|
||||
background: var(--color-primary-bg, #e6f7ff);
|
||||
}
|
||||
|
||||
.module-item.active {
|
||||
background: #1890ff;
|
||||
background: var(--color-primary, #1890ff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
|
@ -965,9 +973,9 @@ onMounted(fetchRoles)
|
|||
.module-count {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
background: #e8e8e8;
|
||||
background: var(--color-bg-default, #e8e8e8);
|
||||
border-radius: 10px;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary, #666);
|
||||
}
|
||||
|
||||
.permission-content {
|
||||
|
|
@ -986,7 +994,7 @@ onMounted(fetchRoles)
|
|||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--color-border, #f0f0f0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
|
@ -1008,19 +1016,19 @@ onMounted(fetchRoles)
|
|||
}
|
||||
|
||||
.info-label {
|
||||
color: #666;
|
||||
color: var(--color-text-secondary, #666);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #333;
|
||||
color: var(--color-text, #333);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-permissions {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
color: var(--color-text-placeholder, #999);
|
||||
}
|
||||
|
||||
.permission-group-list {
|
||||
|
|
@ -1030,16 +1038,16 @@ onMounted(fetchRoles)
|
|||
}
|
||||
|
||||
.permission-group {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border, #f0f0f0);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
background: #fafafa;
|
||||
background: var(--color-bg-filter, #fafafa);
|
||||
padding: 10px 16px;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--color-border, #f0f0f0);
|
||||
}
|
||||
|
||||
.group-items {
|
||||
|
|
@ -1051,7 +1059,7 @@ onMounted(fetchRoles)
|
|||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
border-bottom: 1px dashed var(--color-border, #f0f0f0);
|
||||
}
|
||||
|
||||
.permission-item:last-child {
|
||||
|
|
@ -1063,7 +1071,7 @@ onMounted(fetchRoles)
|
|||
}
|
||||
|
||||
.perm-code {
|
||||
color: #999;
|
||||
color: var(--color-text-placeholder, #999);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
|
@ -1080,9 +1088,9 @@ onMounted(fetchRoles)
|
|||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: var(--color-bg-filter, #fafafa);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border: 1px solid var(--color-border, #f0f0f0);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
|
|
@ -1092,20 +1100,11 @@ onMounted(fetchRoles)
|
|||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: var(--color-text, #333);
|
||||
}
|
||||
|
||||
.user-username {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.clickable-text {
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickable-text:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--color-text-placeholder, #999);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Card, Form, FormItem, Input, Button, message, Divider } from 'ant-design-vue'
|
||||
import { Form, FormItem, Input, Button, message } from 'ant-design-vue'
|
||||
import { SaveOutlined } from '@ant-design/icons-vue'
|
||||
import { getConfig, updateConfig } from '@/api/system'
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ const formState = ref({
|
|||
* @property {Array} propertyCompanyName - 物业企业名称的验证规则,必填
|
||||
*/
|
||||
const rules = {
|
||||
propertyCompanyName: [{ required: true, message: '请输入物业企业名称' }]
|
||||
propertyCompanyName: [{ required: true, message: '请输入物业企业名称', trigger: 'blur' as const }]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const fetchUserRoles = async () => {
|
|||
loadingRoles.value = true
|
||||
try {
|
||||
const res = await getUserRoles(userId)
|
||||
userRoles.value = res.data
|
||||
userRoles.value = res.data.data
|
||||
} catch {
|
||||
message.error('获取用户角色失败')
|
||||
} finally {
|
||||
|
|
@ -37,7 +37,7 @@ const openRoleModal = async () => {
|
|||
roleModalVisible.value = true
|
||||
try {
|
||||
const res = await getRoles()
|
||||
allRoles.value = res.data
|
||||
allRoles.value = res.data.data
|
||||
selectedRoleIds.value = userRoles.value.map(r => r.id)
|
||||
} catch {
|
||||
message.error('获取角色列表失败')
|
||||
|
|
@ -134,7 +134,7 @@ const fetchUserProjects = async () => {
|
|||
loadingProjects.value = true
|
||||
try {
|
||||
const res = await getUserProjects(userId)
|
||||
userProjects.value = res.data.map((p: any) => ({
|
||||
userProjects.value = (res.data.data || []).map((p: any) => ({
|
||||
...p,
|
||||
projectName: p.projectName || p.name,
|
||||
projectCode: p.projectCode || p.code
|
||||
|
|
@ -153,7 +153,7 @@ const openProjectModal = async () => {
|
|||
try {
|
||||
const res = await getProjects()
|
||||
const addedProjectIds = userProjects.value.map(p => p.projectId)
|
||||
allProjects.value = res.data.filter((p: any) => !addedProjectIds.includes(p.id))
|
||||
allProjects.value = (res.data.data || []).filter((p: any) => !addedProjectIds.includes(p.id))
|
||||
} catch {
|
||||
message.error('获取项目列表失败')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,19 @@
|
|||
import { getRoles } from '@/api/role'
|
||||
import { assignRoles, createUser, deleteUser, getUser, getUsers, updateUser } from '@/api/user'
|
||||
import {
|
||||
EmailItem,
|
||||
Pagination,
|
||||
PhoneItem,
|
||||
StatusSelect,
|
||||
StatusTag,
|
||||
TableActions,
|
||||
TableToolbar
|
||||
EmailItem,
|
||||
EmptyState,
|
||||
Pagination,
|
||||
PhoneItem,
|
||||
StatusSelect,
|
||||
StatusTag,
|
||||
TableActions
|
||||
} from '@/components'
|
||||
import type { Role, User } from '@/types'
|
||||
import { PlusOutlined, ReloadOutlined, SafetyOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import { Button, Checkbox, Drawer, Form, Input, message, Space, Spin, Tag } from 'ant-design-vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { getErrorMessage, isValidationError } from '@/utils/error-handler'
|
||||
import { ExclamationCircleOutlined, PlusOutlined, ReloadOutlined, SafetyOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import { Button, Checkbox, Drawer, Form, Input, message, Modal, Space, Spin, Tag } from 'ant-design-vue'
|
||||
import { createVNode, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
|
|
@ -45,14 +46,7 @@ const pagination = reactive({
|
|||
total: 0
|
||||
})
|
||||
|
||||
// 分页后的数据
|
||||
const paginatedData = computed(() => {
|
||||
const start = (pagination.current - 1) * pagination.pageSize
|
||||
const end = start + pagination.pageSize
|
||||
return users.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 表单
|
||||
// 分页表单
|
||||
const formState = ref({
|
||||
id: '',
|
||||
username: '',
|
||||
|
|
@ -63,6 +57,25 @@ const formState = ref({
|
|||
status: 'ACTIVE'
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = ref({
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 50, message: '用户名长度为 3-50 个字符', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z0-9_]+$/, message: '只能包含字母、数字和下划线', trigger: 'blur' }
|
||||
],
|
||||
realName: [
|
||||
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
||||
{ max: 50, message: '姓名不能超过 50 个字符', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ type: 'email' as const, message: '请输入正确的邮箱地址', trigger: 'blur' }
|
||||
]
|
||||
})
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true
|
||||
|
|
@ -129,14 +142,24 @@ const handleEdit = (record: User) => {
|
|||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteUser(id)
|
||||
message.success('删除成功')
|
||||
fetchUsers()
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
const handleDelete = (record: User) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
icon: createVNode(ExclamationCircleOutlined, { style: { color: 'var(--color-error, #ff4d4f)' } }),
|
||||
content: `确定要删除用户「${record.realName || record.username}」吗?此操作不可恢复!`,
|
||||
okText: '确定删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteUser(record.id)
|
||||
message.success('删除成功')
|
||||
fetchUsers()
|
||||
} catch {
|
||||
// 错误已被全局 request.ts 处理
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 提交
|
||||
|
|
@ -158,9 +181,9 @@ const handleSubmit = async () => {
|
|||
|
||||
drawerVisible.value = false
|
||||
fetchUsers()
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) return
|
||||
message.error('操作失败')
|
||||
} catch (error: unknown) {
|
||||
if (isValidationError(error)) return
|
||||
message.error(getErrorMessage(error))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
|
@ -300,14 +323,21 @@ onMounted(fetchUsers)
|
|||
<div class="table-card">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="paginatedData"
|
||||
:data-source="users"
|
||||
:loading="loading"
|
||||
:row-key="(record: User) => record.id"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #emptyText>
|
||||
<EmptyState
|
||||
type="empty"
|
||||
title="暂无用户数据"
|
||||
description="当前没有用户记录,您可以点击上方按钮新增用户"
|
||||
/>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'username'">
|
||||
<a @click="handleView(record)" class="clickable-text">{{ record.username }}</a>
|
||||
<Button type="link" @click="handleView(record)">{{ record.username }}</Button>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<StatusTag :status="record.status" />
|
||||
|
|
@ -332,7 +362,7 @@ onMounted(fetchUsers)
|
|||
@view="handleView(record)"
|
||||
@action="(key) => key === 'permission' && handleAssignRoles(record)"
|
||||
@edit="handleEdit(record)"
|
||||
@delete="handleDelete(record.id)"
|
||||
@delete="handleDelete(record)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -358,16 +388,20 @@ onMounted(fetchUsers)
|
|||
ref="formRef"
|
||||
:model="formState"
|
||||
layout="vertical"
|
||||
:rules="{
|
||||
username: [{ required: true, message: '请输入用户名' }],
|
||||
password: [{ required: !formState.id, message: '请输入密码' }],
|
||||
realName: [{ required: true, message: '请输入姓名' }]
|
||||
}"
|
||||
:rules="formRules as any"
|
||||
>
|
||||
<Form.Item label="用户名" name="username">
|
||||
<a-input v-model:value="formState.username" :disabled="!!formState.id" placeholder="请输入用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item v-if="!formState.id" label="密码" name="password">
|
||||
<Form.Item v-if="!formState.id" label="密码" name="password" :rules="[
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 8, max: 128, message: '密码长度为 8-128 个字符', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^(?=.*[a-zA-Z])(?=.*\d).+$/,
|
||||
message: '密码需包含字母和数字',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]">
|
||||
<a-input-password v-model:value="formState.password" placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
<Form.Item label="姓名" name="realName">
|
||||
|
|
@ -450,13 +484,14 @@ onMounted(fetchUsers)
|
|||
: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>
|
||||
<label class="role-label" @click="toggleRole(role.id, !isRoleSelected(role.id))">
|
||||
<Checkbox :checked="isRoleSelected(role.id)" @click.stop />
|
||||
<span class="role-info">
|
||||
<span class="role-name">{{ role.name }}</span>
|
||||
<span class="role-code">{{ role.code }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<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>
|
||||
|
|
@ -483,27 +518,18 @@ onMounted(fetchUsers)
|
|||
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;
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-default, #f5f5f5);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
|
@ -512,7 +538,7 @@ onMounted(fetchUsers)
|
|||
.empty-roles {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
color: var(--color-text-placeholder, #999);
|
||||
}
|
||||
|
||||
.role-list {
|
||||
|
|
@ -521,25 +547,33 @@ onMounted(fetchUsers)
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border, #f0f0f0);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.role-item:hover {
|
||||
border-color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
border-color: var(--color-primary, #1890ff);
|
||||
background: var(--color-primary-bg, #e6f7ff);
|
||||
}
|
||||
|
||||
.role-item.selected {
|
||||
border-color: #1890ff;
|
||||
background: #f0f9ff;
|
||||
border-color: var(--color-primary, #1890ff);
|
||||
background: var(--color-primary-light-bg, #f0f9ff);
|
||||
}
|
||||
|
||||
.role-info {
|
||||
|
|
@ -549,12 +583,12 @@ onMounted(fetchUsers)
|
|||
|
||||
.role-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: var(--color-text, #333);
|
||||
}
|
||||
|
||||
.role-code {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
color: var(--color-text-placeholder, #999);
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,630 @@
|
|||
import { test, expect, request } from '@playwright/test';
|
||||
|
||||
const PROJECT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const BUILDING_1_ID = '2293e53a-0a48-48df-955a-b59c721135c8';
|
||||
const BUILDING_2_ID = 'f52b7ab1-2fbc-4b38-a29f-dc48fc3246fa';
|
||||
const BUILDING_3_ID = '1c046633-4290-4a9a-b670-c0febe7e9105';
|
||||
|
||||
async function getToken() {
|
||||
const context = await request.newContext();
|
||||
const resp = await context.post('http://127.0.0.1:8080/api/auth/login', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: { username: 'admin', password: 'Admin@123' }
|
||||
});
|
||||
const loginData = await resp.json();
|
||||
await context.dispose();
|
||||
return loginData.data?.token as string;
|
||||
}
|
||||
|
||||
async function cleanupTestRooms(token: string) {
|
||||
const context = await request.newContext();
|
||||
const resp = await context.get(`http://127.0.0.1:8080/api/mdm/space-nodes/project/${PROJECT_ID}/type/ROOM`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await resp.json();
|
||||
const rooms = data.data || [];
|
||||
for (const room of rooms) {
|
||||
if (room.name.includes('E2E测试') || room.name.includes('批量多选')) {
|
||||
await context.delete(`http://127.0.0.1:8080/api/mdm/space-nodes/${room.id}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
await context.dispose();
|
||||
}
|
||||
|
||||
test.describe('批量添加房间/车位 - 多选功能测试', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const token = await getToken();
|
||||
await page.goto('http://127.0.0.1:5175/login');
|
||||
await page.evaluate((t) => {
|
||||
localStorage.setItem('token', t);
|
||||
localStorage.setItem('userInfo', JSON.stringify({ username: 'admin' }));
|
||||
}, token!);
|
||||
});
|
||||
|
||||
test('UC-001: 多楼栋选择能正确创建多组房间', async ({ page }) => {
|
||||
await page.goto(`http://127.0.0.1:5175/project/list`);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const projectCard = page.locator('.ant-card').first();
|
||||
if (await projectCard.isVisible()) {
|
||||
await projectCard.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const spaceMenuItem = page.locator('text=空间管理').or(page.locator('text=空间管理'));
|
||||
if (await spaceMenuItem.isVisible()) {
|
||||
await spaceMenuItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const addButton = page.locator('button:has-text("批量添加")').or(page.locator('button:has-text("批量新增")')).first();
|
||||
if (await addButton.isVisible({ timeout: 5000 })) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const roomTab = page.locator('.ant-modal-content .ant-tabs-tab:has-text("房间")');
|
||||
if (await roomTab.isVisible({ timeout: 3000 })) {
|
||||
await roomTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const buildingSelect = page.locator('.ant-modal-content .ant-select').first();
|
||||
if (await buildingSelect.isVisible({ timeout: 3000 })) {
|
||||
await buildingSelect.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const option1 = page.locator('.ant-select-dropdown .ant-select-item:has-text("1号楼")');
|
||||
const option2 = page.locator('.ant-select-dropdown .ant-select-item:has-text("2号楼")');
|
||||
|
||||
if (await option1.isVisible()) {
|
||||
await option1.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
if (await option2.isVisible()) {
|
||||
await option2.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const startFloorInput = page.locator('.ant-modal-content .ant-input-number').nth(0);
|
||||
const endFloorInput = page.locator('.ant-modal-content .ant-input-number').nth(1);
|
||||
const roomsPerFloorInput = page.locator('.ant-modal-content .ant-input-number').nth(2);
|
||||
|
||||
if (await startFloorInput.isVisible()) {
|
||||
await startFloorInput.fill('1');
|
||||
await endFloorInput.fill('2');
|
||||
await roomsPerFloorInput.fill('2');
|
||||
}
|
||||
|
||||
console.log('UC-001: 多楼栋选择测试完成');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('UC-002: 多停车区域选择能正确创建多组车位', async ({ page }) => {
|
||||
await page.goto(`http://127.0.0.1:5175/project/list`);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const projectCard = page.locator('.ant-card').first();
|
||||
if (await projectCard.isVisible()) {
|
||||
await projectCard.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const spaceMenuItem = page.locator('text=空间管理').or(page.locator('text=空间管理'));
|
||||
if (await spaceMenuItem.isVisible()) {
|
||||
await spaceMenuItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const addButton = page.locator('button:has-text("批量添加")').or(page.locator('button:has-text("批量新增")')).first();
|
||||
if (await addButton.isVisible({ timeout: 5000 })) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const parkingTab = page.locator('.ant-modal-content .ant-tabs-tab:has-text("车位")');
|
||||
if (await parkingTab.isVisible({ timeout: 3000 })) {
|
||||
await parkingTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
console.log('UC-002: 进入车位批量添加页面');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('UC-003: 单楼栋选择时显示楼层信息提示', async ({ page }) => {
|
||||
await page.goto(`http://127.0.0.1:5175/project/list`);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const projectCard = page.locator('.ant-card').first();
|
||||
if (await projectCard.isVisible()) {
|
||||
await projectCard.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const spaceMenuItem = page.locator('text=空间管理').or(page.locator('text=空间管理'));
|
||||
if (await spaceMenuItem.isVisible()) {
|
||||
await spaceMenuItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const addButton = page.locator('button:has-text("批量添加")').or(page.locator('button:has-text("批量新增")')).first();
|
||||
if (await addButton.isVisible({ timeout: 5000 })) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const roomTab = page.locator('.ant-modal-content .ant-tabs-tab:has-text("房间")');
|
||||
if (await roomTab.isVisible({ timeout: 3000 })) {
|
||||
await roomTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const buildingSelect = page.locator('.ant-modal-content .ant-select').first();
|
||||
if (await buildingSelect.isVisible({ timeout: 3000 })) {
|
||||
await buildingSelect.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const option1 = page.locator('.ant-select-dropdown .ant-select-item:has-text("1号楼")');
|
||||
if (await option1.isVisible()) {
|
||||
await option1.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const floorInfoTip = page.locator('.ant-modal-content .ant-alert:has-text("楼层")').or(page.locator('.ant-modal-content .ant-info:has-text("楼层")'));
|
||||
const hasFloorTip = await floorInfoTip.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
|
||||
console.log(`UC-003: 楼层信息提示显示状态: ${hasFloorTip}`);
|
||||
expect(hasFloorTip).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('UC-004: 多楼栋选择时不显示楼层信息提示', async ({ page }) => {
|
||||
await page.goto(`http://127.0.0.1:5175/project/list`);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const projectCard = page.locator('.ant-card').first();
|
||||
if (await projectCard.isVisible()) {
|
||||
await projectCard.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const spaceMenuItem = page.locator('text=空间管理').or(page.locator('text=空间管理'));
|
||||
if (await spaceMenuItem.isVisible()) {
|
||||
await spaceMenuItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const addButton = page.locator('button:has-text("批量添加")').or(page.locator('button:has-text("批量新增")')).first();
|
||||
if (await addButton.isVisible({ timeout: 5000 })) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const roomTab = page.locator('.ant-modal-content .ant-tabs-tab:has-text("房间")');
|
||||
if (await roomTab.isVisible({ timeout: 3000 })) {
|
||||
await roomTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const buildingSelect = page.locator('.ant-modal-content .ant-select').first();
|
||||
if (await buildingSelect.isVisible({ timeout: 3000 })) {
|
||||
await buildingSelect.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const option1 = page.locator('.ant-select-dropdown .ant-select-item:has-text("1号楼")');
|
||||
const option2 = page.locator('.ant-select-dropdown .ant-select-item:has-text("2号楼")');
|
||||
|
||||
if (await option1.isVisible()) {
|
||||
await option1.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
if (await option2.isVisible()) {
|
||||
await option2.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const floorInfoTip = page.locator('.ant-modal-content .ant-alert:has-text("楼层")').or(page.locator('.ant-modal-content .ant-info:has-text("楼层")'));
|
||||
const hasFloorTip = await floorInfoTip.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
|
||||
console.log(`UC-004: 多楼栋选择时楼层信息提示显示状态: ${hasFloorTip}`);
|
||||
expect(hasFloorTip).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('商铺-001: 批量添加商铺功能正常', async ({ page }) => {
|
||||
await page.goto(`http://127.0.0.1:5175/project/list`);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const projectCard = page.locator('.ant-card').first();
|
||||
if (await projectCard.isVisible()) {
|
||||
await projectCard.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const spaceMenuItem = page.locator('text=空间管理').or(page.locator('text=空间管理'));
|
||||
if (await spaceMenuItem.isVisible()) {
|
||||
await spaceMenuItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const addButton = page.locator('button:has-text("批量添加")').or(page.locator('button:has-text("批量新增")')).first();
|
||||
if (await addButton.isVisible({ timeout: 5000 })) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const shopTab = page.locator('.ant-modal-content .ant-tabs-tab:has-text("商铺")');
|
||||
if (await shopTab.isVisible({ timeout: 3000 })) {
|
||||
await shopTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
console.log('商铺-001: 进入商铺批量添加页面成功');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('楼栋-001: 批量添加楼栋功能正常', async ({ page }) => {
|
||||
await page.goto(`http://127.0.0.1:5175/project/list`);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const projectCard = page.locator('.ant-card').first();
|
||||
if (await projectCard.isVisible()) {
|
||||
await projectCard.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const spaceMenuItem = page.locator('text=空间管理').or(page.locator('text=空间管理'));
|
||||
if (await spaceMenuItem.isVisible()) {
|
||||
await spaceMenuItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const addButton = page.locator('button:has-text("批量添加")').or(page.locator('button:has-text("批量新增")')).first();
|
||||
if (await addButton.isVisible({ timeout: 5000 })) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const buildingTab = page.locator('.ant-modal-content .ant-tabs-tab:has-text("楼栋")');
|
||||
if (await buildingTab.isVisible({ timeout: 3000 })) {
|
||||
await buildingTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
console.log('楼栋-001: 进入楼栋批量添加页面成功');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('公共区域-001: 批量添加公共区域功能正常', async ({ page }) => {
|
||||
await page.goto(`http://127.0.0.1:5175/project/list`);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const projectCard = page.locator('.ant-card').first();
|
||||
if (await projectCard.isVisible()) {
|
||||
await projectCard.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const spaceMenuItem = page.locator('text=空间管理').or(page.locator('text=空间管理'));
|
||||
if (await spaceMenuItem.isVisible()) {
|
||||
await spaceMenuItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const addButton = page.locator('button:has-text("批量添加")').or(page.locator('button:has-text("批量新增")')).first();
|
||||
if (await addButton.isVisible({ timeout: 5000 })) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const publicTab = page.locator('.ant-modal-content .ant-tabs-tab:has-text("公共区域")');
|
||||
if (await publicTab.isVisible({ timeout: 3000 })) {
|
||||
await publicTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
console.log('公共区域-001: 进入公共区域批量添加页面成功');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('VC-001: 填写完整信息后不提示"请填写"', async ({ page }) => {
|
||||
await page.goto(`http://127.0.0.1:5175/project/list`);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const projectCard = page.locator('.ant-card').first();
|
||||
if (await projectCard.isVisible()) {
|
||||
await projectCard.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const spaceMenuItem = page.locator('text=空间管理').or(page.locator('text=空间管理'));
|
||||
if (await spaceMenuItem.isVisible()) {
|
||||
await spaceMenuItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const addButton = page.locator('button:has-text("批量添加")').or(page.locator('button:has-text("批量新增")')).first();
|
||||
if (await addButton.isVisible({ timeout: 5000 })) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const roomTab = page.locator('.ant-modal-content .ant-tabs-tab:has-text("房间")');
|
||||
if (await roomTab.isVisible({ timeout: 3000 })) {
|
||||
await roomTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const buildingSelect = page.locator('.ant-modal-content .ant-select').first();
|
||||
if (await buildingSelect.isVisible({ timeout: 3000 })) {
|
||||
await buildingSelect.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const option1 = page.locator('.ant-select-dropdown .ant-select-item:has-text("1号楼")');
|
||||
if (await option1.isVisible()) {
|
||||
await option1.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
const inputs = page.locator('.ant-modal-content .ant-input-number');
|
||||
const count = await inputs.count();
|
||||
|
||||
if (count >= 3) {
|
||||
await inputs.nth(0).fill('1');
|
||||
await inputs.nth(1).fill('3');
|
||||
await inputs.nth(2).fill('4');
|
||||
|
||||
const submitBtn = page.locator('.ant-modal-content button:has-text("确定")');
|
||||
if (await submitBtn.isVisible()) {
|
||||
await submitBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const warningMsg = page.locator('.ant-message:has-text("请填写")');
|
||||
const hasWarning = await warningMsg.isVisible({ timeout: 1000 }).catch(() => false);
|
||||
|
||||
console.log(`VC-001: 填写完整信息后警告提示: ${hasWarning}`);
|
||||
expect(hasWarning).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('VC-002: 起始楼层大于结束楼层时提示错误', async ({ page }) => {
|
||||
await page.goto(`http://127.0.0.1:5175/project/list`);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const projectCard = page.locator('.ant-card').first();
|
||||
if (await projectCard.isVisible()) {
|
||||
await projectCard.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const spaceMenuItem = page.locator('text=空间管理').or(page.locator('text=空间管理'));
|
||||
if (await spaceMenuItem.isVisible()) {
|
||||
await spaceMenuItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const addButton = page.locator('button:has-text("批量添加")').or(page.locator('button:has-text("批量新增")')).first();
|
||||
if (await addButton.isVisible({ timeout: 5000 })) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const roomTab = page.locator('.ant-modal-content .ant-tabs-tab:has-text("房间")');
|
||||
if (await roomTab.isVisible({ timeout: 3000 })) {
|
||||
await roomTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const buildingSelect = page.locator('.ant-modal-content .ant-select').first();
|
||||
if (await buildingSelect.isVisible({ timeout: 3000 })) {
|
||||
await buildingSelect.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const option1 = page.locator('.ant-select-dropdown .ant-select-item:has-text("1号楼")');
|
||||
if (await option1.isVisible()) {
|
||||
await option1.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
const inputs = page.locator('.ant-modal-content .ant-input-number');
|
||||
const count = await inputs.count();
|
||||
|
||||
if (count >= 2) {
|
||||
await inputs.nth(0).fill('5');
|
||||
await inputs.nth(1).fill('2');
|
||||
|
||||
const submitBtn = page.locator('.ant-modal-content button:has-text("确定")');
|
||||
if (await submitBtn.isVisible()) {
|
||||
await submitBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const errorMsg = page.locator('.ant-message:has-text("起始楼层不能大于结束楼层")');
|
||||
const hasError = await errorMsg.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
|
||||
console.log(`VC-002: 错误提示显示: ${hasError}`);
|
||||
expect(hasError).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('BC-001: 未选择楼栋时提示"请选择所属楼栋"', async ({ page }) => {
|
||||
await page.goto(`http://127.0.0.1:5175/project/list`);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const projectCard = page.locator('.ant-card').first();
|
||||
if (await projectCard.isVisible()) {
|
||||
await projectCard.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const spaceMenuItem = page.locator('text=空间管理').or(page.locator('text=空间管理'));
|
||||
if (await spaceMenuItem.isVisible()) {
|
||||
await spaceMenuItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const addButton = page.locator('button:has-text("批量添加")').or(page.locator('button:has-text("批量新增")')).first();
|
||||
if (await addButton.isVisible({ timeout: 5000 })) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const roomTab = page.locator('.ant-modal-content .ant-tabs-tab:has-text("房间")');
|
||||
if (await roomTab.isVisible({ timeout: 3000 })) {
|
||||
await roomTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const inputs = page.locator('.ant-modal-content .ant-input-number');
|
||||
const count = await inputs.count();
|
||||
|
||||
if (count >= 3) {
|
||||
await inputs.nth(0).fill('1');
|
||||
await inputs.nth(1).fill('3');
|
||||
await inputs.nth(2).fill('4');
|
||||
|
||||
const submitBtn = page.locator('.ant-modal-content button:has-text("确定")');
|
||||
if (await submitBtn.isVisible()) {
|
||||
await submitBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const errorMsg = page.locator('.ant-message:has-text("请选择所属楼栋")');
|
||||
const hasError = await errorMsg.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
|
||||
console.log(`BC-001: 未选择楼栋错误提示: ${hasError}`);
|
||||
expect(hasError).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('BC-002: 未选择停车区域时提示"请选择所属停车区域"', async ({ page }) => {
|
||||
await page.goto(`http://127.0.0.1:5175/project/list`);
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const projectCard = page.locator('.ant-card').first();
|
||||
if (await projectCard.isVisible()) {
|
||||
await projectCard.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const spaceMenuItem = page.locator('text=空间管理').or(page.locator('text=空间管理'));
|
||||
if (await spaceMenuItem.isVisible()) {
|
||||
await spaceMenuItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const addButton = page.locator('button:has-text("批量添加")').or(page.locator('button:has-text("批量新增")')).first();
|
||||
if (await addButton.isVisible({ timeout: 5000 })) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const parkingTab = page.locator('.ant-modal-content .ant-tabs-tab:has-text("车位")');
|
||||
if (await parkingTab.isVisible({ timeout: 3000 })) {
|
||||
await parkingTab.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const floorCountInput = page.locator('.ant-modal-content .ant-input-number').first();
|
||||
if (await floorCountInput.isVisible()) {
|
||||
await floorCountInput.fill('10');
|
||||
|
||||
const submitBtn = page.locator('.ant-modal-content button:has-text("确定")');
|
||||
if (await submitBtn.isVisible()) {
|
||||
await submitBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const errorMsg = page.locator('.ant-message:has-text("请选择所属停车区域")');
|
||||
const hasError = await errorMsg.isVisible({ timeout: 2000 }).catch(() => false);
|
||||
|
||||
console.log(`BC-002: 未选择停车区域错误提示: ${hasError}`);
|
||||
expect(hasError).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('API-批量创建: 验证批量创建房间API', async () => {
|
||||
const token = await getToken();
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
const context = await request.newContext();
|
||||
|
||||
const buildingResp = await context.get(`http://127.0.0.1:8080/api/mdm/space-nodes/project/${PROJECT_ID}/type/BUILDING`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const buildingData = await buildingResp.json();
|
||||
const buildings = buildingData.data || [];
|
||||
expect(buildings.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const building1 = buildings[0];
|
||||
const building2 = buildings[1];
|
||||
|
||||
const room1 = await context.post('http://127.0.0.1:8080/api/mdm/space-nodes', {
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
data: {
|
||||
projectId: PROJECT_ID,
|
||||
name: `E2E测试房间-楼栋1-1F-001`,
|
||||
nodeCategory: 'BUILDING',
|
||||
nodeType: 'ROOM',
|
||||
parentId: building1.id,
|
||||
status: 'ACTIVE',
|
||||
floorNumber: 1
|
||||
}
|
||||
});
|
||||
const room1Data = await room1.json();
|
||||
expect(room1Data.code).toBe(200);
|
||||
console.log('API-批量创建: 房间1创建成功');
|
||||
|
||||
const room2 = await context.post('http://127.0.0.1:8080/api/mdm/space-nodes', {
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
data: {
|
||||
projectId: PROJECT_ID,
|
||||
name: `E2E测试房间-楼栋2-1F-001`,
|
||||
nodeCategory: 'BUILDING',
|
||||
nodeType: 'ROOM',
|
||||
parentId: building2.id,
|
||||
status: 'ACTIVE',
|
||||
floorNumber: 1
|
||||
}
|
||||
});
|
||||
const room2Data = await room2.json();
|
||||
expect(room2Data.code).toBe(200);
|
||||
console.log('API-批量创建: 房间2创建成功');
|
||||
|
||||
await context.dispose();
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -1,22 +1,41 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: "es2020",
|
||||
cssCodeSplit: true,
|
||||
chunkSizeWarningLimit: 500,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
"ant-design-vue": ["ant-design-vue"],
|
||||
"vue-vendor": ["vue", "vue-router", "pinia"],
|
||||
},
|
||||
},
|
||||
},
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
host: "0.0.0.0",
|
||||
port: 5175,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
"/api": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue