This commit is contained in:
ygx
2026-03-17 23:31:21 +08:00
parent 2835695d78
commit ba2494f5b3
23 changed files with 5032 additions and 0 deletions

50
src/api/ops/alertLevel.ts Normal file
View File

@@ -0,0 +1,50 @@
import { request } from "@/api/request";
/** 获取告警级别列表(分页) */
export const fetchAlertLevelList = (data?: {
page?: number;
page_size?: number;
keyword?: string;
enabled?: string;
}) => {
return request.get("/Alert/v1/severity/list", data || {});
};
/** 获取告警级别详情 */
export const fetchAlertLevelDetail = (id: number) => {
return request.get(`/Alert/v1/severity/get/${id}`);
};
/** 创建告警级别 */
export const createAlertLevel = (data: {
name: string;
code: string;
description?: string;
color: string;
icon?: string;
priority?: number;
enabled?: boolean;
config?: string;
}) => {
return request.post("/Alert/v1/severity/create", data);
};
/** 更新告警级别 */
export const updateAlertLevel = (data: {
id: number;
name?: string;
code?: string;
description?: string;
color?: string;
icon?: string;
priority?: number;
enabled?: boolean;
config?: string;
}) => {
return request.post("/Alert/v1/severity/update", data);
};
/** 删除告警级别 */
export const deleteAlertLevel = (id: number) => {
return request.delete(`/Alert/v1/severity/delete/${id}`);
};

View File

@@ -0,0 +1,58 @@
import { request } from "@/api/request";
/** 获取通知渠道列表(分页) */
export const fetchNoticeChannelList = (data?: {
page?: number;
page_size?: number;
keyword?: string;
type?: string;
}) => {
return request.get("/Alert/v1/channel/list", { params: data || {} });
};
/** 获取通知渠道详情 */
export const fetchNoticeChannelDetail = (id: number) => {
return request.get(`/Alert/v1/channel/get/${id}`);
};
/** 创建通知渠道 */
export const createNoticeChannel = (data: {
name: string;
type: string;
config: string;
message_template?: string;
rate_limit?: number;
rate_limit_window?: number;
severity_filter?: string;
quiet_hours?: string;
retry_times?: number;
retry_interval?: number;
enabled?: boolean;
description?: string;
}) => {
return request.post("/Alert/v1/channel/create", data);
};
/** 更新通知渠道 */
export const updateNoticeChannel = (data: {
id: number;
name?: string;
type?: string;
config?: string;
message_template?: string;
rate_limit?: number;
rate_limit_window?: number;
severity_filter?: string;
quiet_hours?: string;
retry_times?: number;
retry_interval?: number;
enabled?: boolean;
description?: string;
}) => {
return request.post("/Alert/v1/channel/update", data);
};
/** 删除通知渠道 */
export const deleteNoticeChannel = (id: number) => {
return request.delete(`/Alert/v1/channel/delete/${id}`);
};

51
src/api/ops/rack.ts Normal file
View File

@@ -0,0 +1,51 @@
import { request } from "@/api/request";
/** 获取机柜列表(分页) */
export const fetchRackList = (data?: {
page?: number;
page_size?: number;
keyword?: string;
datacenter_id?: number;
floor_id?: number;
rack_type?: string;
status?: string;
sort?: string;
order?: string;
}) => {
return request.post("/Assets/v1/rack/list", data || {});
};
/** 获取机柜详情 */
export const fetchRackDetail = (id: number) => {
return request.get(`/Assets/v1/rack/detail/${id}`);
};
/** 创建机柜 */
export const createRack = (data: any) => {
return request.post("/Assets/v1/rack/create", data);
};
/** 更新机柜 */
export const updateRack = (data: any) => {
return request.put("/Assets/v1/rack/update", data);
};
/** 删除机柜 */
export const deleteRack = (id: number) => {
return request.delete(`/Assets/v1/rack/delete/${id}`);
};
/** 获取供应商列表(用于下拉选择) */
export const fetchSupplierList = () => {
return request.get("/Assets/v1/supplier/all");
};
/** 获取数据中心列表(用于下拉选择) */
export const fetchDatacenterList = () => {
return request.get("/Assets/v1/datacenter/all");
};
/** 获取楼层列表(用于下拉选择) */
export const fetchFloorListForSelect = (datacenterId?: number) => {
return request.get("/Assets/v1/floor/all", { params: { datacenter_id: datacenterId } });
};

64
src/api/ops/unit.ts Normal file
View File

@@ -0,0 +1,64 @@
import { request } from "@/api/request";
/** 获取机柜U位状态 */
export const fetchRackUnits = (rackId: number) => {
return request.get(`/Assets/v1/unit/rack/${rackId}`);
};
/** 获取U位列表别名 */
export const fetchUnitList = fetchRackUnits;
/** 分配U位 */
export const allocateUnit = (data: {
rack_id: number;
start_unit: number;
occupied_units: number;
asset_id?: number;
asset_code?: string;
asset_name: string;
asset_type: string;
power_consumption?: number;
description?: string;
}) => {
return request.post("/Assets/v1/unit/allocate", data);
};
/** 释放U位 */
export const releaseUnit = (data: {
rack_id: number;
start_unit: number;
end_unit: number;
}) => {
return request.post("/Assets/v1/unit/release", data);
};
/** 预留U位 */
export const reserveUnit = (data: {
rack_id: number;
start_unit: number;
occupied_units: number;
reserved_for: string;
reserved_until: string;
description?: string;
}) => {
return request.post("/Assets/v1/unit/reserve", data);
};
/** 取消预留 */
export const cancelReservation = (data: {
rack_id: number;
start_unit: number;
end_unit: number;
}) => {
return request.post("/Assets/v1/unit/cancel_reservation", data);
};
/** 更新U位状态 */
export const updateUnitStatus = (data: {
rack_id: number;
start_unit: number;
end_unit: number;
status: "available" | "disabled";
}) => {
return request.put("/Assets/v1/unit/status", data);
};

View File

@@ -0,0 +1,147 @@
<template>
<a-modal
:visible="visible"
title="告警级别详情"
width="600px"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:footer="false"
>
<a-spin :loading="loading" style="width: 100%">
<a-descriptions :column="2" bordered v-if="levelDetail">
<a-descriptions-item label="级别名称" :span="2">
{{ levelDetail.name }}
</a-descriptions-item>
<a-descriptions-item label="级别代码">
{{ levelDetail.code }}
</a-descriptions-item>
<a-descriptions-item label="优先级">
{{ levelDetail.priority }}
</a-descriptions-item>
<a-descriptions-item label="颜色">
<div style="display: flex; align-items: center; gap: 8px;">
<div
:style="{
width: '24px',
height: '24px',
backgroundColor: levelDetail.color,
border: '1px solid var(--color-border-2)',
borderRadius: '4px'
}"
></div>
<span>{{ levelDetail.color }}</span>
</div>
</a-descriptions-item>
<a-descriptions-item label="图标">
{{ levelDetail.icon || '-' }}
</a-descriptions-item>
<a-descriptions-item label="状态" :span="2">
<a-tag :color="levelDetail.enabled ? 'green' : 'red'">
{{ levelDetail.enabled ? '启用' : '禁用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="系统级别" :span="2">
<a-tag :color="levelDetail.is_default ? 'blue' : 'gray'">
{{ levelDetail.is_default ? '是' : '否' }}
</a-tag>
<span v-if="levelDetail.is_default" style="color: var(--color-text-3); font-size: 12px; margin-left: 8px;">
系统预置的默认级别
</span>
</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">
{{ levelDetail.description || '-' }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatDate(levelDetail.created_at) }}
</a-descriptions-item>
<a-descriptions-item label="更新时间">
{{ formatDate(levelDetail.updated_at) }}
</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchAlertLevelDetail } from '@/api/ops/alertLevel'
interface Props {
visible: boolean
levelId?: number
}
interface Emits {
(e: 'update:visible', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const loading = ref(false)
const levelDetail = ref<any>(null)
// 格式化日期
const formatDate = (dateStr: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
// 加载告警级别详情
const loadLevelDetail = async () => {
if (!props.levelId) return
loading.value = true
try {
const res: any = await fetchAlertLevelDetail(props.levelId)
if (res.code === 0) {
levelDetail.value = res.details
} else {
Message.error(res.message || '获取告警级别详情失败')
}
} catch (error) {
console.error('获取告警级别详情失败:', error)
Message.error('获取告警级别详情失败')
} finally {
loading.value = false
}
}
// 监听对话框显示状态
watch(
() => props.visible,
(newVal) => {
if (newVal && props.levelId) {
loadLevelDetail()
}
}
)
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
</script>
<script lang="ts">
export default {
name: 'LevelDetailDialog',
}
</script>
<style scoped lang="less">
// 样式可以根据需要添加
</style>

View File

@@ -0,0 +1,248 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑告警级别' : '新建告警级别'"
width="600px"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:confirm-loading="submitting"
>
<a-form :model="form" layout="vertical" ref="formRef">
<a-form-item
label="级别名称"
field="name"
:rules="[{ required: true, message: '请输入级别名称' }]"
>
<a-input
v-model="form.name"
placeholder="请输入级别名称,如:紧急、严重、警告、提示"
:max-length="50"
/>
</a-form-item>
<a-form-item
label="级别代码(英文)"
field="code"
:rules="[
{ required: true, message: '请输入级别代码' },
{ validator: validateCode }
]"
>
<a-input
v-model="form.code"
placeholder="请输入级别代码critical、warning、info"
:max-length="50"
:disabled="isEdit && level?.is_default"
/>
<template v-if="isEdit && level?.is_default">
<div style="color: var(--color-text-3); font-size: 12px; margin-top: 4px;">
默认级别的代码不可修改
</div>
</template>
</a-form-item>
<a-form-item
label="颜色"
field="color"
:rules="[{ required: true, message: '请选择颜色' }]"
>
<div style="display: flex; align-items: center; gap: 12px;">
<input
type="color"
v-model="form.color"
style="width: 60px; height: 32px; cursor: pointer; border: 1px solid var(--color-border-2); border-radius: 4px;"
/>
<a-input
v-model="form.color"
placeholder="请输入颜色值,如:#FF5722"
style="flex: 1;"
/>
</div>
</a-form-item>
<a-form-item
label="图标"
field="icon"
>
<a-input
v-model="form.icon"
placeholder="请输入图标名称或图标类名"
:max-length="100"
/>
</a-form-item>
<a-form-item
label="是否启用"
field="enabled"
>
<a-switch v-model="form.enabled">
<template #checked>启用</template>
<template #unchecked>禁用</template>
</a-switch>
</a-form-item>
<a-form-item
label="描述"
field="description"
>
<a-textarea
v-model="form.description"
placeholder="请输入级别描述"
:auto-size="{ minRows: 4, maxRows: 8 }"
:max-length="500"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { createAlertLevel, updateAlertLevel } from '@/api/ops/alertLevel'
interface AlertLevel {
id?: number
name?: string
code?: string
description?: string
color?: string
icon?: string
priority?: number
enabled?: boolean
is_default?: boolean
config?: string
}
interface Props {
visible: boolean
level: AlertLevel | null
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref()
const submitting = ref(false)
// 验证器函数:验证级别代码格式
const validateCode = (value: string, callback: (error?: string) => void) => {
if (!/^[a-z0-9_-]+$/.test(value)) {
callback('级别代码只能包含小写字母、数字、下划线和短横线')
} else {
callback()
}
}
// 表单数据
const form = ref({
name: '',
code: '',
description: '',
color: '#FF5722',
icon: '',
enabled: true,
priority: 0,
})
// 是否为编辑模式
const isEdit = computed(() => !!props.level?.id)
// 监听对话框显示状态
watch(
() => props.visible,
(newVal) => {
if (newVal) {
if (props.level && isEdit.value) {
// 编辑模式:填充表单
form.value = {
name: props.level.name || '',
code: props.level.code || '',
description: props.level.description || '',
color: props.level.color || '#FF5722',
icon: props.level.icon || '',
enabled: props.level.enabled !== undefined ? props.level.enabled : true,
priority: props.level.priority || 0,
}
} else {
// 新建模式:重置表单
form.value = {
name: '',
code: '',
description: '',
color: '#FF5722',
icon: '',
enabled: true,
priority: 0,
}
}
}
}
)
// 确认提交
const handleOk = async () => {
const valid = await formRef.value?.validate()
if (valid) return
submitting.value = true
try {
const data: any = {
name: form.value.name,
code: form.value.code,
description: form.value.description,
color: form.value.color,
icon: form.value.icon,
enabled: form.value.enabled,
}
let res
if (isEdit.value && props.level?.id) {
// 编辑告警级别
data.id = props.level.id
res = await updateAlertLevel(data)
} else {
// 新建告警级别
res = await createAlertLevel(data)
}
if (res.code === 0) {
Message.success(isEdit.value ? '编辑成功' : '创建成功')
emit('success')
emit('update:visible', false)
} else {
Message.error(res.message || (isEdit.value ? '编辑失败' : '创建失败'))
}
} catch (error) {
Message.error(isEdit.value ? '编辑失败' : '创建失败')
console.error(error)
} finally {
submitting.value = false
}
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
</script>
<script lang="ts">
export default {
name: 'LevelFormDialog',
}
</script>
<style scoped lang="less">
</style>

View File

@@ -0,0 +1,47 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
export const columns: TableColumnData[] = [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '级别名称',
dataIndex: 'name',
width: 150,
},
{
title: '图标',
dataIndex: 'icon',
slotName: 'icon',
width: 100,
},
{
title: '颜色标识',
dataIndex: 'color',
slotName: 'color',
width: 120,
},
{
title: '描述',
dataIndex: 'description',
ellipsis: true,
tooltip: true,
width: 200,
},
{
title: '状态',
dataIndex: 'enabled',
slotName: 'enabled',
width: 120,
},
{
title: '操作',
dataIndex: 'actions',
slotName: 'actions',
width: 240,
fixed: 'right' as const,
},
]

View File

@@ -0,0 +1,20 @@
import type { FormItem } from '@/components/search-form/types'
export const searchFormConfig: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入级别名称、代码或描述',
},
{
field: 'enabled',
label: '状态',
type: 'select',
placeholder: '请选择状态',
options: [
{ label: '启用', value: 'true' },
{ label: '禁用', value: 'false' },
],
},
]

View File

@@ -0,0 +1,262 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="告警级别管理"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #toolbar-left>
<a-button type="primary" @click="handleCreate">
<template #icon>
<icon-plus />
</template>
新建级别
</a-button>
</template>
<!-- 序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 颜色标识 -->
<template #color="{ record }">
<div style="display: flex; align-items: center; gap: 8px;">
<div
:style="{
width: '20px',
height: '20px',
backgroundColor: record.color,
border: '1px solid var(--color-border-2)',
borderRadius: '4px'
}"
></div>
<span>{{ record.color }}</span>
</div>
</template>
<!-- 状态 -->
<template #enabled="{ record }">
<a-tag :color="record.enabled ? 'green' : 'red'">
{{ record.enabled ? '启用' : '禁用' }}
</a-tag>
</template>
<!-- 操作 -->
<template #actions="{ record }">
<a-button type="text" size="small" @click="handleDetail(record)">
详情
</a-button>
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button
type="text"
size="small"
status="danger"
@click="handleDelete(record)"
:disabled="record.is_default"
>
删除
</a-button>
</template>
</search-table>
<!-- 告警级别表单对话框新建/编辑 -->
<level-form-dialog
v-model:visible="formVisible"
:level="editingLevel"
@success="handleFormSuccess"
/>
<!-- 告警级别详情对话框 -->
<level-detail-dialog
v-model:visible="detailVisible"
:level-id="currentLevelId"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import { columns as columnsConfig } from './config/columns'
import {
fetchAlertLevelList,
deleteAlertLevel,
} from '@/api/ops/alertLevel'
import LevelDetailDialog from './components/LevelDetailDialog.vue'
import LevelFormDialog from './components/LevelFormDialog.vue'
// 状态管理
const loading = ref(false)
const tableData = ref<any[]>([])
const formModel = ref({
keyword: '',
enabled: '',
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => searchFormConfig)
// 表格列配置
const columns = computed(() => columnsConfig)
// 当前选中的级别
const currentLevelId = ref<number | undefined>(undefined)
const editingLevel = ref<any>(null)
// 对话框可见性
const formVisible = ref(false)
const detailVisible = ref(false)
// 获取告警级别列表
const fetchLevels = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
page_size: pagination.pageSize,
keyword: formModel.value.keyword || undefined,
enabled: formModel.value.enabled || undefined,
}
const res = await fetchAlertLevelList(params)
tableData.value = res.details?.data || []
pagination.total = res.details?.total || 0
} catch (error) {
console.error('获取告警级别列表失败:', error)
Message.error('获取告警级别列表失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchLevels()
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = value
}
// 重置
const handleReset = () => {
formModel.value = {
keyword: '',
enabled: '',
}
pagination.current = 1
fetchLevels()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchLevels()
}
// 刷新
const handleRefresh = () => {
fetchLevels()
Message.success('数据已刷新')
}
// 新建级别
const handleCreate = () => {
editingLevel.value = null
formVisible.value = true
}
// 编辑级别
const handleEdit = (record: any) => {
console.log('编辑告警级别:', record)
editingLevel.value = record
formVisible.value = true
}
// 详情
const handleDetail = (record: any) => {
console.log('查看详情:', record)
currentLevelId.value = record.id
detailVisible.value = true
}
// 删除级别
const handleDelete = async (record: any) => {
console.log('删除告警级别:', record)
// 系统默认级别不允许删除
if (record.is_default) {
Message.warning('系统默认级别不允许删除')
return
}
try {
Modal.confirm({
title: '确认删除',
content: `确认删除告警级别 ${record.name} 吗?`,
onOk: async () => {
const res = await deleteAlertLevel(record.id)
if (res.code === 0) {
Message.success('删除成功')
fetchLevels()
} else {
Message.error(res.message || '删除失败')
}
},
})
} catch (error) {
console.error('删除告警级别失败:', error)
}
}
// 表单成功回调
const handleFormSuccess = () => {
formVisible.value = false
fetchLevels()
}
// 初始化加载数据
fetchLevels()
</script>
<script lang="ts">
export default {
name: 'AlertLevelManagement',
}
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<a-modal
:visible="visible"
title="通知渠道详情"
width="800px"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:footer="false"
>
<a-descriptions
v-if="channelDetail"
:column="2"
bordered
size="large"
>
<a-descriptions-item label="渠道名称">
{{ channelDetail.name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="渠道类型">
<a-tag :color="getChannelTypeColor(channelDetail.type)">
{{ getChannelTypeName(channelDetail.type) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态" :span="2">
<a-tag :color="channelDetail.enabled ? 'green' : 'red'">
{{ channelDetail.enabled ? '开启' : '关闭' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="消息模板" :span="2">
<div class="template-content">
{{ channelDetail.message_template || '-' }}
</div>
</a-descriptions-item>
<a-descriptions-item label="限流次数">
{{ channelDetail.rate_limit === 0 ? '不限制' : channelDetail.rate_limit || '-' }}
</a-descriptions-item>
<a-descriptions-item label="限流窗口(秒)">
{{ channelDetail.rate_limit_window || '-' }}
</a-descriptions-item>
<a-descriptions-item label="告警级别过滤" :span="2">
<a-space wrap>
<a-tag
v-for="(level, index) in getSeverityLevels(channelDetail.severity_filter)"
:key="index"
>
{{ level }}
</a-tag>
<span v-if="!channelDetail.severity_filter">-</span>
</a-space>
</a-descriptions-item>
<a-descriptions-item label="静默时间段" :span="2">
<div v-if="getQuietHoursDisplay()">
<a-tag color="blue">已启用</a-tag>
<span style="margin-left: 8px">
{{ getQuietHoursDisplay() }}
</span>
</div>
<a-tag v-else color="gray">未启用</a-tag>
</a-descriptions-item>
<a-descriptions-item label="重试次数">
{{ channelDetail.retry_times || '-' }}
</a-descriptions-item>
<a-descriptions-item label="重试间隔(秒)">
{{ channelDetail.retry_interval || '-' }}
</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">
<div class="description-content">
{{ channelDetail.description || '-' }}
</div>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatTime(channelDetail.created_at) || '-' }}
</a-descriptions-item>
<a-descriptions-item label="更新时间">
{{ formatTime(channelDetail.updated_at) || '-' }}
</a-descriptions-item>
</a-descriptions>
<a-spin v-else :loading="loading" style="display: block; padding: 60px 0;">
<a-empty description="加载中..." />
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchNoticeChannelDetail } from '@/api/ops/noticeChannel'
interface NoticeChannel {
id?: number
name?: string
type?: string
config?: string
message_template?: string
rate_limit?: number
rate_limit_window?: number
severity_filter?: string
quiet_hours?: string
retry_times?: number
retry_interval?: number
enabled?: boolean
description?: string
created_at?: string
updated_at?: string
}
interface Props {
visible: boolean
channelId: number | null
}
interface Emits {
(e: 'update:visible', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const channelDetail = ref<NoticeChannel | null>(null)
const loading = ref(false)
// 渠道类型映射
const channelTypeMap: Record<string, { name: string; color: string }> = {
email: { name: '邮件', color: 'blue' },
sms: { name: '短信', color: 'cyan' },
webhook: { name: 'Webhook', color: 'purple' },
dingtalk: { name: '钉钉', color: 'red' },
wechat: { name: '企业微信', color: 'green' },
feishu: { name: '飞书', color: 'orange' },
slack: { name: 'Slack', color: 'arcoblue' },
}
// 获取渠道类型名称
const getChannelTypeName = (type?: string) => {
return channelTypeMap[type || '']?.name || type || '-'
}
// 获取渠道类型颜色
const getChannelTypeColor = (type?: string) => {
return channelTypeMap[type || '']?.color || 'gray'
}
// 获取告警级别列表
const getSeverityLevels = (filter?: string) => {
if (!filter) return []
return filter.split(',').filter((v) => v.trim())
}
// 获取静默时间段显示
const getQuietHoursDisplay = () => {
if (!channelDetail.value?.quiet_hours) return ''
try {
const quietHoursData = JSON.parse(channelDetail.value.quiet_hours)
if (quietHoursData.enabled) {
return `${quietHoursData.start || '--:--'}${quietHoursData.end || '--:--'} (${quietHoursData.timezone || 'Asia/Shanghai'})`
}
} catch (error) {
console.error('解析静默时间段失败:', error)
}
return ''
}
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return ''
try {
const date = new Date(time)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} catch (error) {
return time
}
}
// 获取渠道详情
const fetchDetail = async () => {
if (!props.channelId) {
channelDetail.value = null
return
}
loading.value = true
try {
const res = await fetchNoticeChannelDetail(props.channelId)
if (res.code === 0) {
channelDetail.value = res.details || res.data
} else {
Message.error(res.message || '获取详情失败')
channelDetail.value = null
}
} catch (error) {
Message.error('获取详情失败')
console.error(error)
channelDetail.value = null
} finally {
loading.value = false
}
}
// 监听对话框显示状态
watch(
() => props.visible,
(newVal) => {
if (newVal && props.channelId) {
fetchDetail()
} else {
channelDetail.value = null
}
}
)
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
</script>
<script lang="ts">
export default {
name: 'ChannelDetailDialog',
}
</script>
<style scoped lang="less">
.template-content,
.description-content {
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,423 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑通知渠道' : '新建通知渠道'"
width="800px"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:confirm-loading="submitting"
>
<a-form :model="form" layout="vertical" ref="formRef">
<a-form-item
label="渠道名称"
field="name"
:rules="[{ required: true, message: '请输入渠道名称' }]"
>
<a-input
v-model="form.name"
placeholder="请输入渠道名称"
:max-length="100"
/>
</a-form-item>
<a-form-item
label="渠道类型"
field="type"
:rules="[{ required: true, message: '请选择渠道类型' }]"
>
<a-select
v-model="form.type"
placeholder="请选择渠道类型"
>
<a-option value="email">邮件</a-option>
<a-option value="sms">短信</a-option>
<a-option value="webhook">Webhook</a-option>
<a-option value="dingtalk">钉钉</a-option>
<a-option value="wechat">企业微信</a-option>
<a-option value="feishu">飞书</a-option>
<a-option value="slack">Slack</a-option>
</a-select>
</a-form-item>
<a-form-item
label="消息模板"
field="message_template"
>
<a-textarea
v-model="form.message_template"
placeholder="请输入消息模板,支持变量:{{.Severity}}、{{.RuleName}}、{{.Summary}}、{{.FiredAt}}等"
:auto-size="{ minRows: 3, maxRows: 6 }"
:max-length="1000"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="限流次数"
field="rate_limit"
>
<a-input-number
v-model="form.rate_limit"
placeholder="0表示不限制"
:min="0"
:max="10000"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="限流窗口(秒)"
field="rate_limit_window"
>
<a-input-number
v-model="form.rate_limit_window"
placeholder="限流时间窗口"
:min="1"
:max="86400"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item
label="告警级别过滤"
field="severity_filter"
>
<a-select
v-model="form.severity_filter"
placeholder="请选择告警级别(可多选)"
multiple
allow-clear
>
<a-option
v-for="level in severityLevels"
:key="level.code"
:value="level.code"
>
{{ level.name }}
</a-option>
</a-select>
</a-form-item>
<a-form-item
label="静默时间段"
field="quiet_hours"
>
<a-space direction="vertical" style="width: 100%">
<a-switch
v-model="quietHoursEnabled"
@change="handleQuietHoursChange"
>
<template #checked>启用静默</template>
<template #unchecked>禁用静默</template>
</a-switch>
<a-time-picker
v-if="quietHoursEnabled"
v-model="quietHoursStart"
placeholder="开始时间"
format="HH:mm"
value-format="HH:mm"
style="width: 180px"
@change="handleQuietHoursChange"
/>
<span v-if="quietHoursEnabled"></span>
<a-time-picker
v-if="quietHoursEnabled"
v-model="quietHoursEnd"
placeholder="结束时间"
format="HH:mm"
value-format="HH:mm"
style="width: 180px"
@change="handleQuietHoursChange"
/>
</a-space>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="重试次数"
field="retry_times"
>
<a-input-number
v-model="form.retry_times"
placeholder="发送失败重试次数"
:min="0"
:max="10"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="重试间隔(秒)"
field="retry_interval"
>
<a-input-number
v-model="form.retry_interval"
placeholder="重试间隔时间"
:min="1"
:max="3600"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item
label="是否开启"
field="enabled"
>
<a-switch v-model="form.enabled">
<template #checked>开启</template>
<template #unchecked>关闭</template>
</a-switch>
</a-form-item>
<a-form-item
label="描述"
field="description"
>
<a-textarea
v-model="form.description"
placeholder="请输入渠道描述"
:auto-size="{ minRows: 3, maxRows: 6 }"
:max-length="500"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { createNoticeChannel, updateNoticeChannel } from '@/api/ops/noticeChannel'
import { fetchAlertLevelList } from '@/api/ops/alertLevel'
interface NoticeChannel {
id?: number
name?: string
type?: string
config?: string
message_template?: string
rate_limit?: number
rate_limit_window?: number
severity_filter?: string
quiet_hours?: string
retry_times?: number
retry_interval?: number
enabled?: boolean
description?: string
}
interface Props {
visible: boolean
channel: NoticeChannel | null
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref()
const submitting = ref(false)
const severityLevels = ref<any[]>([])
// 静默时间段相关状态
const quietHoursEnabled = ref(false)
const quietHoursStart = ref('22:00')
const quietHoursEnd = ref('08:00')
// 表单数据
const form = ref({
name: '',
type: 'email',
config: '{}',
message_template: '',
rate_limit: 0,
rate_limit_window: 60,
severity_filter: [] as string[],
quiet_hours: '',
retry_times: 3,
retry_interval: 60,
enabled: true,
description: '',
})
// 是否为编辑模式
const isEdit = computed(() => !!props.channel?.id)
// 获取告警级别列表
const fetchSeverityLevels = async () => {
try {
const res = await fetchAlertLevelList({
page: 1,
page_size: 100,
enabled: 'true',
})
if (res.code === 0 && res.details?.data) {
severityLevels.value = res.details.data
}
} catch (error) {
console.error('获取告警级别列表失败:', error)
}
}
// 处理静默时间段变化
const handleQuietHoursChange = () => {
if (quietHoursEnabled.value) {
const quietHoursData = {
enabled: true,
start: quietHoursStart.value,
end: quietHoursEnd.value,
timezone: 'Asia/Shanghai',
}
form.value.quiet_hours = JSON.stringify(quietHoursData)
} else {
form.value.quiet_hours = ''
}
}
// 监听对话框显示状态
watch(
() => props.visible,
(newVal) => {
if (newVal) {
if (props.channel && isEdit.value) {
// 编辑模式:填充表单
form.value = {
name: props.channel.name || '',
type: props.channel.type || 'email',
config: props.channel.config || '{}',
message_template: props.channel.message_template || '',
rate_limit: props.channel.rate_limit ?? 0,
rate_limit_window: props.channel.rate_limit_window ?? 60,
severity_filter: props.channel.severity_filter
? props.channel.severity_filter.split(',').filter((v: string) => v)
: [],
quiet_hours: props.channel.quiet_hours || '',
retry_times: props.channel.retry_times ?? 3,
retry_interval: props.channel.retry_interval ?? 60,
enabled: props.channel.enabled !== undefined ? props.channel.enabled : true,
description: props.channel.description || '',
}
// 解析静默时间段
if (props.channel.quiet_hours) {
try {
const quietHoursData = JSON.parse(props.channel.quiet_hours)
quietHoursEnabled.value = quietHoursData.enabled
quietHoursStart.value = quietHoursData.start || '22:00'
quietHoursEnd.value = quietHoursData.end || '08:00'
} catch (error) {
quietHoursEnabled.value = false
}
} else {
quietHoursEnabled.value = false
}
} else {
// 新建模式:重置表单
form.value = {
name: '',
type: 'email',
config: '{}',
message_template: '',
rate_limit: 0,
rate_limit_window: 60,
severity_filter: [],
quiet_hours: '',
retry_times: 3,
retry_interval: 60,
enabled: true,
description: '',
}
quietHoursEnabled.value = false
quietHoursStart.value = '22:00'
quietHoursEnd.value = '08:00'
}
}
}
)
// 确认提交
const handleOk = async () => {
const valid = await formRef.value?.validate()
if (valid) return
submitting.value = true
try {
const data: any = {
name: form.value.name,
type: form.value.type,
config: form.value.config,
message_template: form.value.message_template,
rate_limit: form.value.rate_limit,
rate_limit_window: form.value.rate_limit_window,
severity_filter: Array.isArray(form.value.severity_filter)
? form.value.severity_filter.join(',')
: form.value.severity_filter,
quiet_hours: form.value.quiet_hours,
retry_times: form.value.retry_times,
retry_interval: form.value.retry_interval,
enabled: form.value.enabled,
description: form.value.description,
}
let res
if (isEdit.value && props.channel?.id) {
// 编辑通知渠道
data.id = props.channel.id
res = await updateNoticeChannel(data)
} else {
// 新建通知渠道
res = await createNoticeChannel(data)
}
if (res.code === 0) {
Message.success(isEdit.value ? '编辑成功' : '创建成功')
emit('success')
emit('update:visible', false)
} else {
Message.error(res.message || (isEdit.value ? '编辑失败' : '创建失败'))
}
} catch (error) {
Message.error(isEdit.value ? '编辑失败' : '创建失败')
console.error(error)
} finally {
submitting.value = false
}
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
// 初始化加载告警级别
onMounted(() => {
fetchSeverityLevels()
})
</script>
<script lang="ts">
export default {
name: 'ChannelFormDialog',
}
</script>
<style scoped lang="less">
</style>

View File

@@ -0,0 +1,38 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
export const columns: TableColumnData[] = [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '渠道名称',
dataIndex: 'name',
width: 180,
},
{
title: '渠道类型',
dataIndex: 'type',
slotName: 'type',
width: 120,
},
{
title: '限流次数',
dataIndex: 'rate_limit',
width: 120,
},
{
title: '限流窗口(秒)',
dataIndex: 'rate_limit_window',
width: 140,
},
{
title: '操作',
dataIndex: 'actions',
slotName: 'actions',
width: 240,
fixed: 'right' as const,
},
]

View File

@@ -0,0 +1,25 @@
import type { FormItem } from '@/components/search-form/types'
export const searchFormConfig: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入渠道名称或描述',
},
{
field: 'type',
label: '渠道类型',
type: 'select',
placeholder: '请选择渠道类型',
options: [
{ label: '邮件', value: 'email' },
{ label: '短信', value: 'sms' },
{ label: 'Webhook', value: 'webhook' },
{ label: '钉钉', value: 'dingtalk' },
{ label: '企业微信', value: 'wechat' },
{ label: '飞书', value: 'feishu' },
{ label: 'Slack', value: 'slack' },
],
},
]

View File

@@ -0,0 +1,298 @@
<template>
<div class="notice-channel-container">
<SearchTable
v-model:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="通知渠道管理"
search-button-text="查询"
reset-button-text="重置"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<!-- 工具栏左侧按钮 -->
<template #toolbar-left>
<a-button type="primary" @click="handleCreate">
<template #icon>
<icon-plus />
</template>
新建渠道
</a-button>
</template>
<!-- 表格自定义列序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 表格自定义列渠道类型 -->
<template #type="{ record }">
<a-tag :color="getChannelTypeColor(record.type)">
{{ getChannelTypeName(record.type) }}
</a-tag>
</template>
<!-- 表格自定义列操作 -->
<template #actions="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleDetail(record)">
详情
</a-button>
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-popconfirm
content="确认删除该通知渠道吗?"
@ok="handleDelete(record)"
>
<a-button type="text" size="small" status="danger">
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</SearchTable>
<!-- 表单对话框 -->
<channel-form-dialog
v-model:visible="formDialogVisible"
:channel="currentChannel"
@success="fetchList"
/>
<!-- 详情对话框 -->
<channel-detail-dialog
v-model:visible="detailDialogVisible"
:channel-id="currentChannelId"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import SearchTable from '@/components/search-table/index.vue'
import ChannelFormDialog from './components/ChannelFormDialog.vue'
import ChannelDetailDialog from './components/ChannelDetailDialog.vue'
import type { FormItem } from '@/components/search-form/types'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import { fetchNoticeChannelList, deleteNoticeChannel } from '@/api/ops/noticeChannel'
// 表格数据
const tableData = ref<any[]>([])
const loading = ref(false)
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 搜索表单数据
const formModel = ref<Record<string, any>>({
name: '',
type: '',
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'name',
label: '渠道名称',
type: 'input',
placeholder: '请输入渠道名称',
},
{
field: 'type',
label: '渠道类型',
type: 'select',
placeholder: '请选择渠道类型',
options: [
{ label: '邮件', value: 'email' },
{ label: '短信', value: 'sms' },
{ label: 'Webhook', value: 'webhook' },
{ label: '钉钉', value: 'dingtalk' },
{ label: '企业微信', value: 'wechat' },
{ label: '飞书', value: 'feishu' },
{ label: 'Slack', value: 'slack' },
],
},
])
// 表格列配置
const columns = computed<TableColumnData[]>(() => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '渠道名称',
dataIndex: 'name',
width: 150,
},
{
title: '渠道类型',
dataIndex: 'type',
slotName: 'type',
width: 120,
},
{
title: '限流次数',
dataIndex: 'rate_limit',
width: 100,
},
{
title: '限流窗口',
dataIndex: 'rate_limit_window',
width: 100,
},
{
title: '操作',
dataIndex: 'actions',
slotName: 'actions',
width: 200,
fixed: 'right',
},
])
// 表单对话框
const formDialogVisible = ref(false)
const currentChannel = ref<any>(null)
// 详情对话框
const detailDialogVisible = ref(false)
const currentChannelId = ref<number | null>(null)
// 渠道类型映射
const channelTypeMap: Record<string, { name: string; color: string }> = {
email: { name: '邮件', color: 'blue' },
sms: { name: '短信', color: 'cyan' },
webhook: { name: 'Webhook', color: 'purple' },
dingtalk: { name: '钉钉', color: 'red' },
wechat: { name: '企业微信', color: 'green' },
feishu: { name: '飞书', color: 'orange' },
slack: { name: 'Slack', color: 'arcoblue' },
}
// 获取渠道类型名称
const getChannelTypeName = (type?: string) => {
return channelTypeMap[type || '']?.name || type || '-'
}
// 获取渠道类型颜色
const getChannelTypeColor = (type?: string) => {
return channelTypeMap[type || '']?.color || 'gray'
}
// 获取列表
const fetchList = async () => {
loading.value = true
try {
const params = {
page: pagination.current,
page_size: pagination.pageSize,
...formModel.value,
}
const res = await fetchNoticeChannelList(params)
if (res.code === 0) {
tableData.value = res.details?.data || []
pagination.total = res.details?.total || 0
} else {
Message.error(res.message || '获取列表失败')
}
} catch (error) {
Message.error('获取列表失败')
console.error(error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchList()
}
// 重置
const handleReset = () => {
formModel.value = {
name: '',
type: '',
}
pagination.current = 1
fetchList()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchList()
}
// 刷新
const handleRefresh = () => {
fetchList()
}
// 新建
const handleCreate = () => {
currentChannel.value = null
formDialogVisible.value = true
}
// 编辑
const handleEdit = (record: any) => {
currentChannel.value = { ...record }
formDialogVisible.value = true
}
// 详情
const handleDetail = (record: any) => {
currentChannelId.value = record.id
detailDialogVisible.value = true
}
// 删除
const handleDelete = async (record: any) => {
try {
const res = await deleteNoticeChannel(record.id)
if (res.code === 0) {
Message.success('删除成功')
fetchList()
} else {
Message.error(res.message || '删除失败')
}
} catch (error) {
Message.error('删除失败')
console.error(error)
}
}
// 初始化加载
onMounted(() => {
fetchList()
})
</script>
<script lang="ts">
export default {
name: 'NoticeChannel',
}
</script>
<style scoped lang="less">
.notice-channel-container {
padding: 0 20px 20px 20px;
}
</style>

View File

@@ -0,0 +1,386 @@
<template>
<a-modal
:visible="visible"
title="机柜详情"
width="900px"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:footer="false"
>
<a-spin :loading="loading" style="width: 100%">
<div v-if="rackDetail">
<!-- 基础信息 -->
<a-descriptions title="基础信息" :column="2" bordered>
<a-descriptions-item label="机柜名称">{{
rackDetail.name
}}</a-descriptions-item>
<a-descriptions-item label="机柜编码">{{
rackDetail.code
}}</a-descriptions-item>
<a-descriptions-item label="所属中心">{{
rackDetail.datacenter?.name || '-'
}}</a-descriptions-item>
<a-descriptions-item label="所属楼层">{{
rackDetail.floor?.name || '-'
}}</a-descriptions-item>
</a-descriptions>
<!-- 规格参数 -->
<a-descriptions title="规格参数" :column="2" bordered>
<a-descriptions-item label="高度(U)">{{
rackDetail.height || '-'
}}</a-descriptions-item>
<a-descriptions-item label="宽度(mm)">{{
rackDetail.width || '-'
}}</a-descriptions-item>
<a-descriptions-item label="深度(mm)">{{
rackDetail.depth || '-'
}}</a-descriptions-item>
<a-descriptions-item label="重量(kg)">{{
rackDetail.weight || '-'
}}</a-descriptions-item>
<a-descriptions-item label="最大承重(kg)">{{
rackDetail.max_load || '-'
}}</a-descriptions-item>
<a-descriptions-item label="电力容量(KW)">{{
rackDetail.power_capacity || '-'
}}</a-descriptions-item>
<a-descriptions-item label="电源相位">{{
rackDetail.power_phase || '-'
}}</a-descriptions-item>
<a-descriptions-item label="PDU数量">{{
rackDetail.pdu_count || '-'
}}</a-descriptions-item>
<a-descriptions-item label="网络接入方式">{{
rackDetail.network_access || '-'
}}</a-descriptions-item>
<a-descriptions-item label="交换机端口数">{{
rackDetail.switch_ports || '-'
}}</a-descriptions-item>
<a-descriptions-item label="制冷方式">{{
rackDetail.cooling_type || '-'
}}</a-descriptions-item>
<a-descriptions-item label="已用电力(KW)">{{
rackDetail.used_power || '-'
}}</a-descriptions-item>
</a-descriptions>
<!-- U位使用情况 -->
<a-descriptions title="U位使用情况" :column="2" bordered>
<a-descriptions-item label="总U位数">{{
rackDetail.total_units || '-'
}}</a-descriptions-item>
<a-descriptions-item label="已用U位数">{{
rackDetail.used_units || '-'
}}</a-descriptions-item>
<a-descriptions-item label="预留U位数">{{
rackDetail.reserved_units || '-'
}}</a-descriptions-item>
<a-descriptions-item label="可用U位数">{{
rackDetail.available_units || '-'
}}</a-descriptions-item>
<a-descriptions-item label="使用率">
<a-progress
:percent="(rackDetail.utilization_rate || 0)"
:color="
(rackDetail.utilization_rate || 0) > 80
? '#f53f3f'
: (rackDetail.utilization_rate || 0) > 50
? '#ff7d00'
: '#00b42a'
"
/>
</a-descriptions-item>
</a-descriptions>
<!-- 状态信息 -->
<a-descriptions title="状态信息" :column="2" bordered>
<a-descriptions-item label="机柜类型">
<a-tag :color="getRackTypeColor(rackDetail.rack_type)">
{{ getRackTypeText(rackDetail.rack_type) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getStatusColor(rackDetail.status)">
{{ getStatusText(rackDetail.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="供应商">{{
rackDetail.supplier?.name || '-'
}}</a-descriptions-item>
<a-descriptions-item label="启用状态">
<a-tag :color="rackDetail.enabled ? 'green' : 'red'">
{{ rackDetail.enabled ? '已启用' : '已禁用' }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
<!-- 制造商信息 -->
<a-descriptions title="制造商信息" :column="2" bordered>
<a-descriptions-item label="制造商">{{
rackDetail.manufacturer || '-'
}}</a-descriptions-item>
<a-descriptions-item label="型号">{{
rackDetail.model || '-'
}}</a-descriptions-item>
<a-descriptions-item label="序列号">{{
rackDetail.serial_number || '-'
}}</a-descriptions-item>
<a-descriptions-item label="价格(元)">{{
rackDetail.price || '-'
}}</a-descriptions-item>
<a-descriptions-item label="采购日期">{{
rackDetail.purchase_date || '-'
}}</a-descriptions-item>
</a-descriptions>
<!-- 责任人信息 -->
<a-descriptions title="责任人信息" :column="2" bordered>
<a-descriptions-item label="负责人">{{
rackDetail.owner || '-'
}}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{
rackDetail.contact_phone || '-'
}}</a-descriptions-item>
<a-descriptions-item label="使用部门">{{
rackDetail.department || '-'
}}</a-descriptions-item>
</a-descriptions>
<!-- 位置信息 -->
<a-descriptions title="位置信息" :column="2" bordered>
<a-descriptions-item label="行号">{{
rackDetail.row || '-'
}}</a-descriptions-item>
<a-descriptions-item label="列号">{{
rackDetail.column || '-'
}}</a-descriptions-item>
<a-descriptions-item label="X坐标">{{
rackDetail.position_x || '-'
}}</a-descriptions-item>
<a-descriptions-item label="Y坐标">{{
rackDetail.position_y || '-'
}}</a-descriptions-item>
<a-descriptions-item label="颜色标识">
<span
v-if="rackDetail.color"
:style="{ backgroundColor: rackDetail.color }"
class="color-preview"
>
{{ rackDetail.color }}
</span>
<span v-else>-</span>
</a-descriptions-item>
</a-descriptions>
<!-- 其他信息 -->
<a-descriptions title="其他信息" :column="2" bordered>
<a-descriptions-item label="描述" :span="2">
{{ rackDetail.description || '-' }}
</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">
{{ rackDetail.remarks || '-' }}
</a-descriptions-item>
<a-descriptions-item label="创建人">{{
rackDetail.created_by || '-'
}}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{
rackDetail.created_at || '-'
}}</a-descriptions-item>
<a-descriptions-item label="更新人">{{
rackDetail.updated_by || '-'
}}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{
rackDetail.updated_at || '-'
}}</a-descriptions-item>
</a-descriptions>
</div>
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { fetchRackDetail } from '@/api/ops/rack'
import { Message } from '@arco-design/web-vue'
interface RackDetail {
id?: number
name?: string
code?: string
datacenter_id?: number
datacenter?: { id?: number; name?: string }
floor_id?: number
floor?: { id?: number; name?: string }
height?: number
width?: number
depth?: number
weight?: number
max_load?: number
power_capacity?: number
used_power?: number
power_phase?: string
pdu_count?: number
network_access?: string
switch_ports?: number
cooling_type?: string
rack_type?: string
status?: string
supplier_id?: number
supplier?: { id?: number; name?: string }
manufacturer?: string
model?: string
serial_number?: string
purchase_date?: string
price?: number
owner?: string
contact_phone?: string
department?: string
row?: number
column?: number
position_x?: number
position_y?: number
color?: string
enabled?: boolean
description?: string
remarks?: string
total_units?: number
used_units?: number
reserved_units?: number
available_units?: number
utilization_rate?: number
created_by?: string
created_at?: string
updated_by?: string
updated_at?: string
}
interface Props {
visible: boolean
rackId: number | null
}
interface Emits {
(e: 'update:visible', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const loading = ref(false)
const rackDetail = ref<RackDetail | null>(null)
// 获取机柜类型颜色
const getRackTypeColor = (type?: string) => {
const colorMap: Record<string, string> = {
standard: 'blue',
blade: 'cyan',
network: 'purple',
storage: 'orange',
custom: 'gray',
}
return colorMap[type || ''] || 'gray'
}
// 获取机柜类型文本
const getRackTypeText = (type?: string) => {
const textMap: Record<string, string> = {
standard: '标准机柜',
blade: '刀片机柜',
network: '网络机柜',
storage: '存储机柜',
custom: '定制机柜',
}
return textMap[type || ''] || '-'
}
// 获取状态颜色
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
idle: 'green',
in_use: 'blue',
reserved: 'orange',
maintenance: 'red',
offline: 'gray',
}
return colorMap[status || ''] || 'gray'
}
// 获取状态文本
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
idle: '空闲',
in_use: '使用中',
reserved: '已预留',
maintenance: '维护中',
offline: '已下线',
}
return textMap[status || ''] || '-'
}
// 加载机柜详情
const loadRackDetail = async () => {
if (!props.rackId) return
loading.value = true
try {
const res: any = await fetchRackDetail(props.rackId)
if (res.code === 0) {
rackDetail.value = res.details
} else {
Message.error(res.message || '获取机柜详情失败')
}
} catch (error) {
console.error('获取机柜详情失败:', error)
Message.error('获取机柜详情失败')
} finally {
loading.value = false
}
}
// 监听对话框显示状态
watch(
() => props.visible,
(newVal) => {
if (newVal && props.rackId) {
loadRackDetail()
}
}
)
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
</script>
<script lang="ts">
export default {
name: 'RackDetailDialog',
}
</script>
<style scoped lang="less">
.color-preview {
display: inline-block;
padding: 2px 8px;
border-radius: 2px;
color: #fff;
font-size: 12px;
}
:deep(.arco-descriptions) {
margin-bottom: 16px;
}
:deep(.arco-descriptions-title) {
font-size: 16px;
font-weight: 500;
color: var(--color-text-1);
}
</style>

View File

@@ -0,0 +1,779 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑机柜' : '新建机柜'"
width="800px"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:confirm-loading="submitting"
>
<a-form :model="form" layout="vertical" ref="formRef">
<!-- 基础信息 -->
<a-divider orientation="left">基础信息</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="机柜名称"
field="name"
:rules="[{ required: true, message: '请输入机柜名称' }]"
>
<a-input
v-model="form.name"
placeholder="请输入机柜名称"
:max-length="200"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="机柜编码"
field="code"
:rules="[{ required: true, message: '请输入机柜编码' }]"
>
<a-input
v-model="form.code"
placeholder="请输入机柜编码"
:max-length="100"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="所属中心"
field="datacenter_id"
:rules="[{ required: true, message: '请选择所属中心' }]"
>
<a-select
v-model="form.datacenter_id"
placeholder="请选择所属中心"
:loading="loadingDatacenters"
allow-search
@change="handleDatacenterChange"
>
<a-option
v-for="item in datacenterList"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="所属楼层"
field="floor_id"
:rules="[{ required: true, message: '请选择所属楼层' }]"
>
<a-select
v-model="form.floor_id"
placeholder="请选择所属楼层"
:loading="loadingFloors"
allow-search
>
<a-option
v-for="item in floorList"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<!-- 规格参数 -->
<a-divider orientation="left">规格参数</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="高度(U)" field="height">
<a-input-number
v-model="form.height"
placeholder="默认42"
:min="1"
:max="100"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="宽度(mm)" field="width">
<a-input-number
v-model="form.width"
placeholder="请输入宽度"
:min="0"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="深度(mm)" field="depth">
<a-input-number
v-model="form.depth"
placeholder="请输入深度"
:min="0"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="重量(kg)" field="weight">
<a-input-number
v-model="form.weight"
placeholder="请输入重量"
:min="0"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="最大承重(kg)" field="max_load">
<a-input-number
v-model="form.max_load"
placeholder="请输入最大承重"
:min="0"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="电力容量(KW)" field="power_capacity">
<a-input-number
v-model="form.power_capacity"
placeholder="请输入电力容量"
:min="0"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="电源相位" field="power_phase">
<a-select v-model="form.power_phase" placeholder="请选择电源相位">
<a-option value="单相">单相</a-option>
<a-option value="三相">三相</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="PDU数量" field="pdu_count">
<a-input-number
v-model="form.pdu_count"
placeholder="请输入PDU数量"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="网络接入方式" field="network_access">
<a-input
v-model="form.network_access"
placeholder="请输入网络接入方式"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="交换机端口数" field="switch_ports">
<a-input-number
v-model="form.switch_ports"
placeholder="请输入交换机端口数"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="制冷方式" field="cooling_type">
<a-input
v-model="form.cooling_type"
placeholder="请输入制冷方式"
/>
</a-form-item>
<!-- 状态信息 -->
<a-divider orientation="left">状态信息</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="机柜类型" field="rack_type">
<a-select v-model="form.rack_type" placeholder="请选择机柜类型">
<a-option value="standard">标准机柜</a-option>
<a-option value="blade">刀片机柜</a-option>
<a-option value="network">网络机柜</a-option>
<a-option value="storage">存储机柜</a-option>
<a-option value="custom">定制机柜</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="状态" field="status">
<a-select v-model="form.status" placeholder="请选择状态">
<a-option value="idle">空闲</a-option>
<a-option value="in_use">使用中</a-option>
<a-option value="reserved">已预留</a-option>
<a-option value="maintenance">维护中</a-option>
<a-option value="offline">已下线</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item
label="供应商"
field="supplier_id"
>
<a-select
v-model="form.supplier_id"
placeholder="请选择供应商"
:loading="loadingSuppliers"
allow-search
>
<a-option
v-for="item in supplierList"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
<!-- 制造商信息 -->
<a-divider orientation="left">制造商信息</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="制造商" field="manufacturer">
<a-input
v-model="form.manufacturer"
placeholder="请输入制造商"
:max-length="100"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="型号" field="model">
<a-input
v-model="form.model"
placeholder="请输入型号"
:max-length="100"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="序列号" field="serial_number">
<a-input
v-model="form.serial_number"
placeholder="请输入序列号"
:max-length="100"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="价格(元)" field="price">
<a-input-number
v-model="form.price"
placeholder="请输入价格"
:min="0"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="采购日期" field="purchase_date">
<a-date-picker
v-model="form.purchase_date"
placeholder="请选择采购日期"
style="width: 100%"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
</a-form-item>
<!-- 责任人信息 -->
<a-divider orientation="left">责任人信息</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="负责人" field="owner">
<a-input
v-model="form.owner"
placeholder="请输入负责人"
:max-length="50"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="联系电话" field="contact_phone">
<a-input
v-model="form.contact_phone"
placeholder="请输入联系电话"
:max-length="20"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="使用部门" field="department">
<a-input
v-model="form.department"
placeholder="请输入使用部门"
:max-length="100"
/>
</a-form-item>
</a-col>
</a-row>
<!-- 位置信息 -->
<a-divider orientation="left">位置信息</a-divider>
<a-row :gutter="16">
<a-col :span="6">
<a-form-item label="行号" field="row">
<a-input-number
v-model="form.row"
placeholder="请输入行号"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="列号" field="column">
<a-input-number
v-model="form.column"
placeholder="请输入列号"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="X坐标" field="position_x">
<a-input-number
v-model="form.position_x"
placeholder="请输入X坐标"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="Y坐标" field="position_y">
<a-input-number
v-model="form.position_y"
placeholder="请输入Y坐标"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<!-- 其他 -->
<a-divider orientation="left">其他</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="颜色标识" field="color">
<a-input
v-model="form.color"
placeholder="请输入颜色值,如#FF0000"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="启用" field="enabled">
<a-switch v-model="form.enabled" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="描述" field="description">
<a-textarea
v-model="form.description"
placeholder="请输入描述"
:auto-size="{ minRows: 3, maxRows: 6 }"
:max-length="500"
/>
</a-form-item>
<a-form-item label="备注" field="remarks">
<a-textarea
v-model="form.remarks"
placeholder="请输入备注"
:auto-size="{ minRows: 3, maxRows: 6 }"
:max-length="500"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch, onMounted } from 'vue'
import { computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { createRack, updateRack } from '@/api/ops/rack'
import {
fetchDatacenterList,
fetchFloorListForSelect,
fetchSupplierList,
} from '@/api/ops/rack'
interface Rack {
id?: number
name?: string
code?: string
datacenter_id?: number
floor_id?: number
height?: number
width?: number
depth?: number
weight?: number
max_load?: number
power_capacity?: number
power_phase?: string
pdu_count?: number
network_access?: string
switch_ports?: number
cooling_type?: string
rack_type?: string
status?: string
supplier_id?: number
manufacturer?: string
model?: string
serial_number?: string
purchase_date?: string
price?: number
owner?: string
contact_phone?: string
department?: string
row?: number
column?: number
position_x?: number
position_y?: number
color?: string
enabled?: boolean
description?: string
remarks?: string
}
interface Props {
visible: boolean
rack: Rack | null
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref()
const loadingDatacenters = ref(false)
const loadingFloors = ref(false)
const loadingSuppliers = ref(false)
const submitting = ref(false)
const datacenterList = ref<any[]>([])
const floorList = ref<any[]>([])
const supplierList = ref<any[]>([])
// 表单数据
const form = ref({
name: '',
code: '',
datacenter_id: undefined as number | undefined,
floor_id: undefined as number | undefined,
height: 42,
width: undefined as number | undefined,
depth: undefined as number | undefined,
weight: undefined as number | undefined,
max_load: undefined as number | undefined,
power_capacity: undefined as number | undefined,
power_phase: '',
pdu_count: 0,
network_access: '',
switch_ports: 0,
cooling_type: '',
rack_type: 'standard',
status: 'idle',
supplier_id: undefined as number | undefined,
manufacturer: '',
model: '',
serial_number: '',
purchase_date: '',
price: undefined as number | undefined,
owner: '',
contact_phone: '',
department: '',
row: 0,
column: 0,
position_x: undefined as number | undefined,
position_y: undefined as number | undefined,
color: '',
enabled: true,
description: '',
remarks: '',
})
// 是否为编辑模式
const isEdit = computed(() => !!props.rack?.id)
// 加载数据中心列表
const loadDatacenterList = async () => {
loadingDatacenters.value = true
try {
const res: any = await fetchDatacenterList()
if (res.code === 0) {
datacenterList.value = res.details || []
}
} catch (error) {
console.error('获取数据中心列表失败:', error)
} finally {
loadingDatacenters.value = false
}
}
// 加载楼层列表
const loadFloorList = async (datacenterId?: number) => {
loadingFloors.value = true
try {
const res: any = await fetchFloorListForSelect(datacenterId)
if (res.code === 0) {
floorList.value = res.details || []
}
} catch (error) {
console.error('获取楼层列表失败:', error)
} finally {
loadingFloors.value = false
}
}
// 加载供应商列表
const loadSupplierList = async () => {
loadingSuppliers.value = true
try {
const res: any = await fetchSupplierList()
if (res.code === 0) {
supplierList.value = res.details || []
}
} catch (error) {
console.error('获取供应商列表失败:', error)
} finally {
loadingSuppliers.value = false
}
}
// 数据中心变化时重新加载楼层列表
const handleDatacenterChange = async (value: number) => {
form.value.floor_id = undefined
await loadFloorList(value)
}
// 监听对话框显示状态
watch(
() => props.visible,
(newVal) => {
if (newVal) {
if (props.rack && isEdit.value) {
// 编辑模式:填充表单
form.value = {
name: props.rack.name || '',
code: props.rack.code || '',
datacenter_id: props.rack.datacenter_id,
floor_id: props.rack.floor_id,
height: props.rack.height || 42,
width: props.rack.width,
depth: props.rack.depth,
weight: props.rack.weight,
max_load: props.rack.max_load,
power_capacity: props.rack.power_capacity,
power_phase: props.rack.power_phase || '',
pdu_count: props.rack.pdu_count || 0,
network_access: props.rack.network_access || '',
switch_ports: props.rack.switch_ports || 0,
cooling_type: props.rack.cooling_type || '',
rack_type: props.rack.rack_type || 'standard',
status: props.rack.status || 'idle',
supplier_id: props.rack.supplier_id,
manufacturer: props.rack.manufacturer || '',
model: props.rack.model || '',
serial_number: props.rack.serial_number || '',
purchase_date: props.rack.purchase_date || '',
price: props.rack.price,
owner: props.rack.owner || '',
contact_phone: props.rack.contact_phone || '',
department: props.rack.department || '',
row: props.rack.row || 0,
column: props.rack.column || 0,
position_x: props.rack.position_x,
position_y: props.rack.position_y,
color: props.rack.color || '',
enabled: props.rack.enabled !== undefined ? props.rack.enabled : true,
description: props.rack.description || '',
remarks: props.rack.remarks || '',
}
// 加载对应数据中心的楼层列表
if (props.rack.datacenter_id) {
loadFloorList(props.rack.datacenter_id)
}
} else {
// 新建模式:重置表单
form.value = {
name: '',
code: '',
datacenter_id: undefined,
floor_id: undefined,
height: 42,
width: undefined,
depth: undefined,
weight: undefined,
max_load: undefined,
power_capacity: undefined,
power_phase: '',
pdu_count: 0,
network_access: '',
switch_ports: 0,
cooling_type: '',
rack_type: 'standard',
status: 'idle',
supplier_id: undefined,
manufacturer: '',
model: '',
serial_number: '',
purchase_date: '',
price: undefined,
owner: '',
contact_phone: '',
department: '',
row: 0,
column: 0,
position_x: undefined,
position_y: undefined,
color: '',
enabled: true,
description: '',
remarks: '',
}
}
}
}
)
// 确认提交
const handleOk = async () => {
const valid = await formRef.value?.validate()
if (valid) return
submitting.value = true
try {
const data: any = {
name: form.value.name,
code: form.value.code,
datacenter_id: form.value.datacenter_id,
floor_id: form.value.floor_id,
height: form.value.height,
width: form.value.width,
depth: form.value.depth,
weight: form.value.weight,
max_load: form.value.max_load,
power_capacity: form.value.power_capacity,
power_phase: form.value.power_phase,
pdu_count: form.value.pdu_count,
network_access: form.value.network_access,
switch_ports: form.value.switch_ports,
cooling_type: form.value.cooling_type,
rack_type: form.value.rack_type,
status: form.value.status,
supplier_id: form.value.supplier_id,
manufacturer: form.value.manufacturer,
model: form.value.model,
serial_number: form.value.serial_number,
purchase_date: form.value.purchase_date,
price: form.value.price,
owner: form.value.owner,
contact_phone: form.value.contact_phone,
department: form.value.department,
row: form.value.row,
column: form.value.column,
position_x: form.value.position_x,
position_y: form.value.position_y,
color: form.value.color,
enabled: form.value.enabled,
description: form.value.description,
remarks: form.value.remarks,
}
let res
if (isEdit.value && props.rack?.id) {
// 编辑机柜
data.id = props.rack.id
res = await updateRack(data)
} else {
// 新建机柜
res = await createRack(data)
}
if (res.code === 0) {
Message.success(isEdit.value ? '编辑成功' : '创建成功')
emit('success')
emit('update:visible', false)
} else {
Message.error(res.message || (isEdit.value ? '编辑失败' : '创建失败'))
}
} catch (error) {
Message.error(isEdit.value ? '编辑失败' : '创建失败')
console.error(error)
} finally {
submitting.value = false
}
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
// 组件挂载时加载数据
onMounted(() => {
loadDatacenterList()
loadSupplierList()
})
</script>
<script lang="ts">
export default {
name: 'RackFormDialog',
}
</script>
<style scoped lang="less">
</style>

View File

@@ -0,0 +1,77 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
export const columns: TableColumnData[] = [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '机柜名称',
dataIndex: 'name',
width: 150,
},
{
title: '机柜编码',
dataIndex: 'code',
width: 200,
},
{
title: '所属中心',
dataIndex: 'datacenter',
slotName: 'datacenter',
width: 180,
},
{
title: '所属楼层',
dataIndex: 'floor',
slotName: 'floor',
width: 120,
},
{
title: '机柜类型',
dataIndex: 'rack_type',
slotName: 'rack_type',
width: 120,
},
{
title: '高度(U)',
dataIndex: 'height',
width: 100,
},
{
title: '总U位',
dataIndex: 'total_units',
width: 100,
},
{
title: '已用U位',
dataIndex: 'used_units',
width: 100,
},
{
title: '可用U位',
dataIndex: 'available_units',
width: 100,
},
{
title: '使用率',
dataIndex: 'utilization_rate',
slotName: 'utilization_rate',
width: 100,
},
{
title: '状态',
dataIndex: 'status',
slotName: 'status',
width: 120,
},
{
title: '操作',
dataIndex: 'actions',
slotName: 'actions',
width: 320,
fixed: 'right' as const,
},
]

View File

@@ -0,0 +1,55 @@
import type { FormItem } from '@/components/search-form/types'
export const searchFormConfig: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入机柜名称或编码',
span: 6,
},
{
field: 'datacenter_id',
label: '数据中心',
type: 'select',
placeholder: '请选择数据中心',
options: [], // 需要动态加载
span: 6,
},
{
field: 'floor_id',
label: '楼层',
type: 'select',
placeholder: '请选择楼层',
options: [], // 需要动态加载
span: 6,
},
{
field: 'rack_type',
label: '机柜类型',
type: 'select',
placeholder: '请选择机柜类型',
options: [
{ label: '标准机柜', value: 'standard' },
{ label: '刀片机柜', value: 'blade' },
{ label: '网络机柜', value: 'network' },
{ label: '存储机柜', value: 'storage' },
{ label: '定制机柜', value: 'custom' },
],
span: 6,
},
{
field: 'status',
label: '状态',
type: 'select',
placeholder: '请选择状态',
options: [
{ label: '空闲', value: 'idle' },
{ label: '使用中', value: 'in_use' },
{ label: '已预留', value: 'reserved' },
{ label: '维护中', value: 'maintenance' },
{ label: '已下线', value: 'offline' },
],
span: 6,
},
]

View File

@@ -0,0 +1,333 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="机柜管理"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #toolbar-left>
<a-button type="primary" @click="handleAdd">
<template #icon>
<icon-plus />
</template>
新增机柜
</a-button>
</template>
<!-- 序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 所属中心 -->
<template #datacenter="{ record }">
{{ record.datacenter?.name || '-' }}
</template>
<!-- 所属楼层 -->
<template #floor="{ record }">
{{ record.floor?.name || '-' }}
</template>
<!-- 机柜类型 -->
<template #rack_type="{ record }">
<a-tag :color="getRackTypeColor(record.rack_type)">
{{ getRackTypeText(record.rack_type) }}
</a-tag>
</template>
<!-- 状态 -->
<template #status="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<!-- 使用率 -->
<template #utilization_rate="{ record }">
<a-progress
:percent="(record.utilization_rate || 0)"
:size="'small'"
:color="
(record.utilization_rate || 0) > 80
? '#f53f3f'
: (record.utilization_rate || 0) > 50
? '#ff7d00'
: '#00b42a'
"
/>
</template>
<!-- 操作 -->
<template #actions="{ record }">
<a-button type="text" size="small" @click="handleView(record)">
查看
</a-button>
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button type="text" size="small" @click="handleUnitManagement(record)">
U位管理
</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
删除
</a-button>
</template>
</search-table>
<!-- 机柜表单对话框新建/编辑 -->
<rack-form-dialog
v-model:visible="formVisible"
:rack="editingRack"
@success="handleFormSuccess"
/>
<!-- 机柜详情对话框 -->
<rack-detail-dialog
v-model:visible="detailVisible"
:rack-id="currentRackId"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import { useRouter } from 'vue-router'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import { columns as columnsConfig } from './config/columns'
import {
fetchRackList,
deleteRack,
} from '@/api/ops/rack'
import RackDetailDialog from './components/RackDetailDialog.vue'
import RackFormDialog from './components/RackFormDialog.vue'
const router = useRouter()
// 状态管理
const loading = ref(false)
const tableData = ref<any[]>([])
const formModel = ref({
keyword: '',
datacenter_id: undefined,
floor_id: undefined,
rack_type: undefined,
status: undefined,
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => searchFormConfig)
// 表格列配置
const columns = computed(() => columnsConfig)
// 当前选中的机柜
const currentRackId = ref<number | null>(null)
const editingRack = ref<any>(null)
// 对话框可见性
const formVisible = ref(false)
const detailVisible = ref(false)
// 获取机柜类型颜色
const getRackTypeColor = (type?: string) => {
const colorMap: Record<string, string> = {
standard: 'blue',
blade: 'cyan',
network: 'purple',
storage: 'orange',
custom: 'gray',
}
return colorMap[type || ''] || 'gray'
}
// 获取机柜类型文本
const getRackTypeText = (type?: string) => {
const textMap: Record<string, string> = {
standard: '标准机柜',
blade: '刀片机柜',
network: '网络机柜',
storage: '存储机柜',
custom: '定制机柜',
}
return textMap[type || ''] || '-'
}
// 获取状态颜色
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
idle: 'green',
in_use: 'blue',
reserved: 'orange',
maintenance: 'red',
offline: 'gray',
}
return colorMap[status || ''] || 'gray'
}
// 获取状态文本
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
idle: '空闲',
in_use: '使用中',
reserved: '已预留',
maintenance: '维护中',
offline: '已下线',
}
return textMap[status || ''] || '-'
}
// 获取机柜列表
const fetchRacks = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
page_size: pagination.pageSize,
keyword: formModel.value.keyword || undefined,
datacenter_id: formModel.value.datacenter_id || undefined,
floor_id: formModel.value.floor_id || undefined,
rack_type: formModel.value.rack_type || undefined,
status: formModel.value.status || undefined,
}
const res = await fetchRackList(params)
tableData.value = res.data?.data || []
pagination.total = res.data?.total || 0
} catch (error) {
console.error('获取机柜列表失败:', error)
Message.error('获取机柜列表失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchRacks()
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = value
}
// 重置
const handleReset = () => {
formModel.value = {
keyword: '',
datacenter_id: undefined,
floor_id: undefined,
rack_type: undefined,
status: undefined,
}
pagination.current = 1
fetchRacks()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchRacks()
}
// 刷新
const handleRefresh = () => {
fetchRacks()
Message.success('数据已刷新')
}
// 新增机柜
const handleAdd = () => {
editingRack.value = null
formVisible.value = true
}
// 编辑机柜
const handleEdit = (record: any) => {
editingRack.value = record
formVisible.value = true
}
// 查看详情
const handleView = (record: any) => {
currentRackId.value = record.id
detailVisible.value = true
}
// 删除机柜
const handleDelete = async (record: any) => {
try {
Modal.confirm({
title: '确认删除',
content: `确认删除机柜 ${record.name} 吗?`,
onOk: async () => {
const res = await deleteRack(record.id)
if (res.code === 0) {
Message.success('删除成功')
fetchRacks()
} else {
Message.error(res.message || '删除失败')
}
},
})
} catch (error) {
console.error('删除机柜失败:', error)
}
}
// U位管理
const handleUnitManagement = (record: any) => {
router.push({
path: '/ops/datacenter/u-position',
query: { rack_id: record.id, rack_name: record.name },
})
}
// 表单成功回调
const handleFormSuccess = () => {
formVisible.value = false
fetchRacks()
}
// 初始化加载数据
fetchRacks()
</script>
<script lang="ts">
export default {
name: 'DataCenterRack',
}
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<a-modal
v-model:visible="dialogVisible"
title="分配U位"
:width="600"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-form-item field="start_unit" label="起始U位" required>
<a-input-number
v-model="formData.start_unit"
:min="1"
:max="rackHeight"
placeholder="请输入起始U位"
style="width: 100%"
/>
</a-form-item>
<a-form-item field="occupied_units" label="占用U位数量" required>
<a-input-number
v-model="formData.occupied_units"
:min="1"
:max="rackHeight"
placeholder="请输入占用U位数量"
style="width: 100%"
/>
</a-form-item>
<a-form-item field="asset_id" label="选择设备">
<a-select
v-model="formData.asset_id"
placeholder="请选择设备"
allow-search
@change="handleAssetChange"
>
<a-option
v-for="asset in assetList"
:key="asset.id"
:value="asset.id"
:label="asset.asset_name"
/>
</a-select>
</a-form-item>
<a-form-item field="asset_type" label="设备类型" required>
<a-input
v-model="formData.asset_type"
placeholder="设备类型(选择设备后自动填入)"
disabled
/>
</a-form-item>
<a-form-item field="power_consumption" label="功耗 (W)">
<a-input-number
v-model="formData.power_consumption"
:min="0"
placeholder="请输入功耗"
style="width: 100%"
/>
</a-form-item>
<a-form-item field="description" label="描述">
<a-textarea
v-model="formData.description"
placeholder="请输入描述信息"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'AllocateUnitDialog',
})
</script>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
interface Asset {
id: number
asset_code: string
asset_name: string
asset_type: string
power_consumption?: number
}
interface Props {
visible: boolean
rackId: number
rackHeight: number
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value),
})
const formRef = ref()
const formData = reactive({
start_unit: 1,
occupied_units: 1,
asset_id: undefined as number | undefined,
asset_code: '',
asset_name: '',
asset_type: '',
power_consumption: undefined as number | undefined,
description: '',
})
const assetList = ref<Asset[]>([
// 模拟设备列表实际应从API获取
{ id: 1, asset_code: 'SRV001', asset_name: '服务器-001', asset_type: '服务器设备', power_consumption: 500 },
{ id: 2, asset_code: 'SRV002', asset_name: '服务器-002', asset_type: '服务器设备', power_consumption: 600 },
{ id: 3, asset_code: 'NET001', asset_name: '交换机-001', asset_type: '网络设备', power_consumption: 200 },
{ id: 4, asset_code: 'STO001', asset_name: '存储-001', asset_type: '存储设备', power_consumption: 400 },
])
const rules = {
start_unit: [{ required: true, message: '请输入起始U位' }],
occupied_units: [{ required: true, message: '请输入占用U位数量' }],
asset_type: [{ required: true, message: '设备类型不能为空' }],
}
const handleAssetChange = (value: number) => {
const asset = assetList.value.find((a) => a.id === value)
if (asset) {
formData.asset_code = asset.asset_code
formData.asset_name = asset.asset_name
formData.asset_type = asset.asset_type
formData.power_consumption = asset.power_consumption
}
}
const handleOk = async () => {
try {
const valid = await formRef.value?.validate()
if (!valid) {
// 发送分配请求
const { allocateUnit } = await import('@/api/ops/unit')
const params = {
rack_id: props.rackId,
start_unit: formData.start_unit,
occupied_units: formData.occupied_units,
asset_id: formData.asset_id,
asset_code: formData.asset_code,
asset_name: formData.asset_name || `未命名设备-${formData.start_unit}`,
asset_type: formData.asset_type,
power_consumption: formData.power_consumption,
description: formData.description,
}
const res = await allocateUnit(params)
if (res.code === 0) {
Message.success('分配成功')
emit('success')
handleCancel()
} else {
Message.error(res.message || '分配失败')
}
}
} catch (error) {
console.error('分配失败:', error)
}
}
const handleCancel = () => {
dialogVisible.value = false
resetForm()
}
const resetForm = () => {
formData.start_unit = 1
formData.occupied_units = 1
formData.asset_id = undefined
formData.asset_code = ''
formData.asset_name = ''
formData.asset_type = ''
formData.power_consumption = undefined
formData.description = ''
formRef.value?.resetFields()
}
watch(
() => props.visible,
(val) => {
if (!val) {
resetForm()
}
}
)
</script>

View File

@@ -0,0 +1,163 @@
<template>
<a-modal
v-model:visible="dialogVisible"
title="预留U位"
:width="600"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-form-item field="start_unit" label="起始U位" required>
<a-input-number
v-model="formData.start_unit"
:min="1"
:max="rackHeight"
placeholder="请输入起始U位"
style="width: 100%"
/>
</a-form-item>
<a-form-item field="occupied_units" label="占用U位数量" required>
<a-input-number
v-model="formData.occupied_units"
:min="1"
:max="rackHeight"
placeholder="请输入占用U位数量"
style="width: 100%"
/>
</a-form-item>
<a-form-item field="reserved_for" label="预留对象" required>
<a-input
v-model="formData.reserved_for"
placeholder="请输入预留对象(部门或项目)"
/>
</a-form-item>
<a-form-item field="reserved_until" label="预留截止时间" required>
<a-date-picker
v-model="formData.reserved_until"
show-time
format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择预留截止时间"
style="width: 100%"
/>
</a-form-item>
<a-form-item field="description" label="预留信息">
<a-textarea
v-model="formData.description"
placeholder="请输入预留信息"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'ReserveUnitDialog',
})
</script>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
interface Props {
visible: boolean
rackId: number
rackHeight: number
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value),
})
const formRef = ref()
const formData = reactive({
start_unit: 1,
occupied_units: 1,
reserved_for: '',
reserved_until: '',
description: '',
})
const rules = {
start_unit: [{ required: true, message: '请输入起始U位' }],
occupied_units: [{ required: true, message: '请输入占用U位数量' }],
reserved_for: [{ required: true, message: '请输入预留对象' }],
reserved_until: [{ required: true, message: '请选择预留截止时间' }],
}
const handleOk = async () => {
try {
const valid = await formRef.value?.validate()
if (!valid) {
// 发送预留请求
const { reserveUnit } = await import('@/api/ops/unit')
// 将日期转换为ISO格式
let reservedUntil = ''
if (formData.reserved_until) {
const date = new Date(formData.reserved_until as any)
reservedUntil = date.toISOString()
}
const params = {
rack_id: props.rackId,
start_unit: formData.start_unit,
occupied_units: formData.occupied_units,
reserved_for: formData.reserved_for,
reserved_until: reservedUntil,
description: formData.description,
}
const res = await reserveUnit(params)
if (res.code === 0) {
Message.success('预留成功')
emit('success')
handleCancel()
} else {
Message.error(res.message || '预留失败')
}
}
} catch (error) {
console.error('预留失败:', error)
}
}
const handleCancel = () => {
dialogVisible.value = false
resetForm()
}
const resetForm = () => {
formData.start_unit = 1
formData.occupied_units = 1
formData.reserved_for = ''
formData.reserved_until = ''
formData.description = ''
formRef.value?.resetFields()
}
watch(
() => props.visible,
(val) => {
if (!val) {
resetForm()
}
}
)
</script>

View File

@@ -0,0 +1,557 @@
<template>
<div class="container">
<!-- 页面头部 -->
<div class="page-header">
<a-button type="text" @click="handleBack">
<template #icon>
<icon-left />
</template>
返回列表
</a-button>
<h2 class="page-title">{{ pageTitle }}</h2>
</div>
<!-- 机柜信息卡片 -->
<a-card class="info-card" :loading="loading">
<template #title>
<icon-storage /> 机柜信息
</template>
<a-row :gutter="24">
<a-col :span="6">
<div class="info-item">
<div class="info-label">机柜编码</div>
<div class="info-value">{{ rackInfo.code || '-' }}</div>
</div>
</a-col>
<a-col :span="6">
<div class="info-item">
<div class="info-label">机柜总U位</div>
<div class="info-value">{{ rackInfo.height || 0 }}</div>
</div>
</a-col>
<a-col :span="6">
<div class="info-item">
<div class="info-label">已使用U位</div>
<div class="info-value">{{ usedUnits }}</div>
</div>
</a-col>
<a-col :span="6">
<div class="info-item">
<div class="info-label">使用率</div>
<div class="info-value">
<a-progress
:percent="usagePercentage"
:size="'small'"
:color="
usagePercentage > 80
? '#f53f3f'
: usagePercentage > 50
? '#ff7d00'
: '#00b42a'
"
/>
</div>
</div>
</a-col>
<a-col :span="6">
<div class="info-item">
<div class="info-label">空余U位</div>
<div class="info-value">{{ availableUnits }}</div>
</div>
</a-col>
<a-col :span="6">
<div class="info-item">
<div class="info-label">空余率</div>
<div class="info-value">
<a-progress
:percent="availablePercentage"
:size="'small'"
:color="'#00b42a'"
/>
</div>
</div>
</a-col>
<a-col :span="6">
<div class="info-item">
<div class="info-label">总功耗</div>
<div class="info-value">{{ totalPower }}W</div>
</div>
</a-col>
<a-col :span="6">
<div class="info-item">
<div class="info-label">状态</div>
<div class="info-value">
<a-tag :color="getRackStatusColor(rackInfo.status)">
{{ getRackStatusText(rackInfo.status) }}
</a-tag>
</div>
</div>
</a-col>
</a-row>
</a-card>
<!-- U位管理卡片 -->
<a-card class="u-position-card" :loading="loading">
<template #title>
<icon-apps /> U位列表
</template>
<template #extra>
<a-space>
<a-button type="primary" @click="handleAllocate">
<template #icon>
<icon-plus />
</template>
分配U位
</a-button>
<a-button type="outline" @click="handleReserve">
<template #icon>
<icon-lock />
</template>
预留U位
</a-button>
<a-button type="outline" @click="handleRefresh">
<template #icon>
<icon-refresh />
</template>
刷新
</a-button>
</a-space>
</template>
<a-table
:data="unitList"
:pagination="false"
:bordered="{ cell: true }"
:scroll="{ x: 1400 }"
:loading="loading"
size="small"
>
<template #columns>
<a-table-column title="序号" :width="80">
<template #cell="{ rowIndex }">
{{ rowIndex + 1 }}
</template>
</a-table-column>
<a-table-column title="U位编号" data-index="unit_number" :width="100" />
<a-table-column title="设备名称" data-index="asset_name" :width="150" />
<a-table-column title="设备编号" data-index="asset_code" :width="150" />
<a-table-column title="设备类型" data-index="asset_type" :width="120" />
<a-table-column title="占用U位" data-index="occupied_units" :width="100" />
<a-table-column title="预留信息" data-index="reserved_for" :width="150" />
<a-table-column title="功耗(W)" data-index="power_consumption" :width="100" />
<a-table-column title="状态" :width="100">
<template #cell="{ record }">
<a-tag :color="getUnitStatusColor(record.status)">
{{ getUnitStatusText(record.status) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" :width="200" fixed="right">
<template #cell="{ record }">
<a-space size="small">
<!-- 禁用/启用按钮所有状态都显示 -->
<a-button
v-if="record.status !== 'disabled'"
type="text"
size="small"
@click="handleDisable(record)"
>
禁用
</a-button>
<a-button
v-else
type="text"
size="small"
@click="handleEnable(record)"
>
启用
</a-button>
<!-- 已占用状态显示释放按钮 -->
<a-button
v-if="record.status === 'occupied'"
type="text"
size="small"
status="danger"
@click="handleRelease(record)"
>
释放
</a-button>
<!-- 已预留状态显示取消预留按钮 -->
<a-button
v-if="record.status === 'reserved'"
type="text"
size="small"
status="danger"
@click="handleCancelReservation(record)"
>
取消预留
</a-button>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<!-- 分配U位对话框 -->
<allocate-unit-dialog
v-model:visible="allocateVisible"
:rack-id="rackId"
:rack-height="rackInfo.height"
@success="handleRefresh"
/>
<!-- 预留U位对话框 -->
<reserve-unit-dialog
v-model:visible="reserveVisible"
:rack-id="rackId"
:rack-height="rackInfo.height"
@success="handleRefresh"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Message, Modal } from '@arco-design/web-vue'
import { IconLeft, IconStorage, IconApps, IconPlus, IconLock, IconRefresh } from '@arco-design/web-vue/es/icon'
import {
fetchUnitList,
allocateUnit,
reserveUnit,
cancelReservation,
releaseUnit,
updateUnitStatus,
} from '@/api/ops/unit'
import AllocateUnitDialog from './components/AllocateUnitDialog.vue'
import ReserveUnitDialog from './components/ReserveUnitDialog.vue'
const route = useRoute()
const router = useRouter()
// 状态管理
const loading = ref(false)
const rackId = ref<number>(0)
const rackName = ref<string>('')
const rackInfo = ref<any>({})
const unitList = ref<any[]>([])
// 对话框可见性
const allocateVisible = ref(false)
const reserveVisible = ref(false)
// 页面标题
const pageTitle = computed(() => {
return rackName.value ? `${rackName.value} - U位详情` : 'U位详情'
})
// 已使用U位
const usedUnits = computed(() => {
return unitList.value.filter(
(unit) => unit.status === 'occupied' || unit.status === 'reserved'
).length
})
// 空余U位
const availableUnits = computed(() => {
return rackInfo.value.height - usedUnits.value
})
// 使用率
const usagePercentage = computed(() => {
if (!rackInfo.value.height) return 0
return Math.round((usedUnits.value / rackInfo.value.height) * 100)
})
// 空余率
const availablePercentage = computed(() => {
if (!rackInfo.value.height) return 0
return Math.round((availableUnits.value / rackInfo.value.height) * 100)
})
// 总功耗
const totalPower = computed(() => {
return unitList.value.reduce((total, unit) => {
return total + (unit.power_consumption || 0)
}, 0)
})
// 获取机柜状态颜色
const getRackStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
idle: 'green',
in_use: 'blue',
reserved: 'orange',
maintenance: 'red',
offline: 'gray',
}
return colorMap[status || ''] || 'gray'
}
// 获取机柜状态文本
const getRackStatusText = (status?: string) => {
const textMap: Record<string, string> = {
idle: '空闲',
in_use: '使用中',
reserved: '已预留',
maintenance: '维护中',
offline: '已下线',
}
return textMap[status || ''] || '-'
}
// 获取U位状态颜色
const getUnitStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
available: 'green',
occupied: 'blue',
reserved: 'orange',
disabled: 'gray',
}
return colorMap[status || ''] || 'gray'
}
// 获取U位状态文本
const getUnitStatusText = (status?: string) => {
const textMap: Record<string, string> = {
available: '可用',
occupied: '已占用',
reserved: '已预留',
disabled: '已禁用',
}
return textMap[status || ''] || '-'
}
// 获取U位列表
const fetchUnits = async () => {
loading.value = true
try {
const res = await fetchUnitList(rackId.value)
if (res.code === 0) {
rackInfo.value = res.data?.rack || {}
unitList.value = res.data?.units || []
} else {
Message.error(res.message || '获取U位列表失败')
}
} catch (error) {
console.error('获取U位列表失败:', error)
Message.error('获取U位列表失败')
} finally {
loading.value = false
}
}
// 返回列表
const handleBack = () => {
router.push('/ops/datacenter/u-position')
}
// 刷新
const handleRefresh = () => {
fetchUnits()
Message.success('数据已刷新')
}
// 分配U位
const handleAllocate = () => {
allocateVisible.value = true
}
// 预留U位
const handleReserve = () => {
reserveVisible.value = true
}
// 禁用U位
const handleDisable = async (record: any) => {
try {
Modal.confirm({
title: '确认禁用',
content: `确认禁用 U位 ${record.unit_number} 吗?`,
onOk: async () => {
const res = await updateUnitStatus({
rack_id: rackId.value,
start_unit: record.unit_number,
end_unit: record.unit_number,
status: 'disabled',
})
if (res.code === 0) {
Message.success('禁用成功')
fetchUnits()
} else {
Message.error(res.message || '禁用失败')
}
},
})
} catch (error) {
console.error('禁用U位失败:', error)
}
}
// 启用U位
const handleEnable = async (record: any) => {
try {
Modal.confirm({
title: '确认启用',
content: `确认启用 U位 ${record.unit_number} 吗?`,
onOk: async () => {
const res = await updateUnitStatus({
rack_id: rackId.value,
start_unit: record.unit_number,
end_unit: record.unit_number,
status: 'available',
})
if (res.code === 0) {
Message.success('启用成功')
fetchUnits()
} else {
Message.error(res.message || '启用失败')
}
},
})
} catch (error) {
console.error('启用U位失败:', error)
}
}
// 释放U位
const handleRelease = async (record: any) => {
try {
Modal.confirm({
title: '确认释放',
content: `确认释放 U位 ${record.unit_number} 吗?`,
onOk: async () => {
const endUnit = record.occupied_units > 1
? record.unit_number + record.occupied_units - 1
: record.unit_number
const res = await releaseUnit({
rack_id: rackId.value,
start_unit: record.unit_number,
end_unit: endUnit,
})
if (res.code === 0) {
Message.success('释放成功')
fetchUnits()
} else {
Message.error(res.message || '释放失败')
}
},
})
} catch (error) {
console.error('释放U位失败:', error)
}
}
// 取消预留
const handleCancelReservation = async (record: any) => {
try {
Modal.confirm({
title: '确认取消预留',
content: `确认取消 U位 ${record.unit_number} 的预留吗?`,
onOk: async () => {
const endUnit = record.occupied_units > 1
? record.unit_number + record.occupied_units - 1
: record.unit_number
const res = await cancelReservation({
rack_id: rackId.value,
start_unit: record.unit_number,
end_unit: endUnit,
})
if (res.code === 0) {
Message.success('取消预留成功')
fetchUnits()
} else {
Message.error(res.message || '取消预留失败')
}
},
})
} catch (error) {
console.error('取消预留失败:', error)
}
}
// 初始化
onMounted(() => {
rackId.value = Number(route.query.rack_id)
rackName.value = (route.query.rack_name as string) || ''
if (!rackId.value) {
Message.error('缺少机柜ID')
router.push('/ops/datacenter/u-position')
return
}
fetchUnits()
})
</script>
<script lang="ts">
export default {
name: 'UPositionDetail',
}
</script>
<style scoped lang="less">
.container {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.page-header {
display: flex;
align-items: center;
margin-bottom: 20px;
background: #fff;
padding: 16px 20px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.page-title {
flex: 1;
margin: 0;
margin-left: 16px;
font-size: 20px;
font-weight: 600;
color: #1d2129;
}
}
.info-card {
margin-bottom: 20px;
.info-item {
margin-bottom: 16px;
.info-label {
font-size: 13px;
color: #86909c;
margin-bottom: 8px;
}
.info-value {
font-size: 16px;
font-weight: 500;
color: #1d2129;
}
}
}
.u-position-card {
:deep(.arco-card-body) {
padding: 0;
}
}
</style>

View File

@@ -0,0 +1,490 @@
<template>
<div class="container">
<!-- 机柜选择卡片 -->
<a-card class="rack-select-card">
<template #title>
<icon-storage /> 选择机柜
</template>
<a-row :gutter="16">
<a-col :span="8">
<a-select
v-model="selectedRackId"
placeholder="请选择机柜"
:loading="rackListLoading"
@change="handleRackChange"
style="width: 100%"
>
<a-option
v-for="rack in rackList"
:key="rack.id"
:value="rack.id"
>
{{ rack.name }} ({{ rack.code }})
</a-option>
</a-select>
</a-col>
<a-col :span="16">
<a-space>
<a-tag v-if="rackInfo.height">
总U位: {{ rackInfo.height }}
</a-tag>
<a-tag v-if="usedUnits" color="blue">
已使用: {{ usedUnits }}
</a-tag>
<a-tag v-if="availableUnits" color="green">
空余: {{ availableUnits }}
</a-tag>
<a-tag v-if="usagePercentage" :color="usagePercentage > 80 ? 'red' : 'orange'">
使用率: {{ usagePercentage }}%
</a-tag>
</a-space>
</a-col>
</a-row>
</a-card>
<!-- 操作按钮 -->
<a-card class="action-card">
<a-space>
<a-button type="primary" @click="handleAllocate" :disabled="!selectedRackId">
<template #icon>
<icon-plus />
</template>
分配U位
</a-button>
<a-button type="outline" @click="handleReserve" :disabled="!selectedRackId">
<template #icon>
<icon-lock />
</template>
预留U位
</a-button>
<a-button type="outline" @click="handleRefresh" :disabled="!selectedRackId">
<template #icon>
<icon-refresh />
</template>
刷新
</a-button>
</a-space>
</a-card>
<!-- U位列表 -->
<a-card class="u-position-card" :loading="loading">
<template #title>
<icon-apps /> U位列表
</template>
<a-table
:data="unitList"
:pagination="false"
:bordered="{ cell: true }"
:scroll="{ x: 1400 }"
:loading="loading"
size="small"
>
<template #columns>
<a-table-column title="序号" :width="80">
<template #cell="{ rowIndex }">
{{ rowIndex + 1 }}
</template>
</a-table-column>
<a-table-column title="U位编号" data-index="unit_number" :width="100" />
<a-table-column title="设备名称" data-index="asset_name" :width="150" />
<a-table-column title="设备编号" data-index="asset_code" :width="150" />
<a-table-column title="设备类型" data-index="asset_type" :width="120" />
<a-table-column title="占用U位" data-index="occupied_units" :width="100" />
<a-table-column title="预留信息" data-index="reserved_for" :width="150" />
<a-table-column title="功耗(W)" data-index="power_consumption" :width="100" />
<a-table-column title="状态" :width="100">
<template #cell="{ record }">
<a-tag :color="getUnitStatusColor(record.status)">
{{ getUnitStatusText(record.status) }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" :width="200" fixed="right">
<template #cell="{ record }">
<a-space size="small">
<!-- 禁用/启用按钮所有状态都显示 -->
<a-button
v-if="record.status !== 'disabled'"
type="text"
size="small"
@click="handleDisable(record)"
>
禁用
</a-button>
<a-button
v-else
type="text"
size="small"
@click="handleEnable(record)"
>
启用
</a-button>
<!-- 已占用状态显示释放按钮 -->
<a-button
v-if="record.status === 'occupied'"
type="text"
size="small"
status="danger"
@click="handleRelease(record)"
>
释放
</a-button>
<!-- 已预留状态显示取消预留按钮 -->
<a-button
v-if="record.status === 'reserved'"
type="text"
size="small"
status="danger"
@click="handleCancelReservation(record)"
>
取消预留
</a-button>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<!-- 分配U位对话框 -->
<allocate-unit-dialog
v-model:visible="allocateVisible"
:rack-id="selectedRackId"
:rack-height="rackInfo.height"
@success="handleRefresh"
/>
<!-- 预留U位对话框 -->
<reserve-unit-dialog
v-model:visible="reserveVisible"
:rack-id="selectedRackId"
:rack-height="rackInfo.height"
@success="handleRefresh"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconStorage, IconApps, IconPlus, IconLock, IconRefresh } from '@arco-design/web-vue/es/icon'
import {
fetchUnitList,
allocateUnit,
reserveUnit,
cancelReservation,
releaseUnit,
updateUnitStatus,
} from '@/api/ops/unit'
import { fetchRackList } from '@/api/ops/rack'
import AllocateUnitDialog from './components/AllocateUnitDialog.vue'
import ReserveUnitDialog from './components/ReserveUnitDialog.vue'
// 状态管理
const loading = ref(false)
const rackListLoading = ref(false)
const selectedRackId = ref<number | undefined>(undefined)
const rackInfo = ref<any>({})
const rackList = ref<any[]>([])
const unitList = ref<any[]>([])
// 对话框可见性
const allocateVisible = ref(false)
const reserveVisible = ref(false)
// 已使用U位
const usedUnits = computed(() => {
return unitList.value.filter(
(unit) => unit.status === 'occupied' || unit.status === 'reserved'
).length
})
// 空余U位
const availableUnits = computed(() => {
return rackInfo.value.height - usedUnits.value
})
// 使用率
const usagePercentage = computed(() => {
if (!rackInfo.value.height) return 0
return Math.round((usedUnits.value / rackInfo.value.height) * 100)
})
// 获取U位状态颜色
const getUnitStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
available: 'green',
occupied: 'blue',
reserved: 'orange',
disabled: 'gray',
}
return colorMap[status || ''] || 'gray'
}
// 获取U位状态文本
const getUnitStatusText = (status?: string) => {
const textMap: Record<string, string> = {
available: '可用',
occupied: '已占用',
reserved: '已预留',
disabled: '已禁用',
}
return textMap[status || ''] || '-'
}
// 获取机柜列表
const fetchRacks = async () => {
rackListLoading.value = true
try {
const res = await fetchRackList({
page: 1,
page_size: 1000,
status: 'in_use', // 只获取使用中的机柜
})
rackList.value = res.details?.data || []
} catch (error) {
console.error('获取机柜列表失败:', error)
Message.error('获取机柜列表失败')
rackList.value = []
} finally {
rackListLoading.value = false
}
}
// 机柜变化
const handleRackChange = (rackId: number) => {
if (rackId) {
fetchUnits(rackId)
} else {
unitList.value = []
rackInfo.value = {}
}
}
// 获取U位列表
const fetchUnits = async (rackId?: number) => {
const targetRackId = rackId || selectedRackId.value
if (!targetRackId) return
loading.value = true
try {
const res = await fetchUnitList(targetRackId)
if (res.code === 0) {
rackInfo.value = res.data?.rack || {}
unitList.value = res.data?.units || []
} else {
Message.error(res.message || '获取U位列表失败')
}
} catch (error) {
console.error('获取U位列表失败:', error)
Message.error('获取U位列表失败')
} finally {
loading.value = false
}
}
// 刷新
const handleRefresh = () => {
if (selectedRackId.value) {
fetchUnits(selectedRackId.value)
Message.success('数据已刷新')
}
}
// 分配U位
const handleAllocate = () => {
if (!selectedRackId.value) {
Message.warning('请先选择机柜')
return
}
allocateVisible.value = true
}
// 预留U位
const handleReserve = () => {
if (!selectedRackId.value) {
Message.warning('请先选择机柜')
return
}
reserveVisible.value = true
}
// 禁用U位
const handleDisable = async (record: any) => {
try {
Modal.confirm({
title: '确认禁用',
content: `确认禁用 U位 ${record.unit_number} 吗?`,
onOk: async () => {
if (!selectedRackId.value) {
Message.warning('请先选择机柜')
return
}
const res = await updateUnitStatus({
rack_id: selectedRackId.value,
start_unit: record.unit_number,
end_unit: record.unit_number,
status: 'disabled',
})
if (res.code === 0) {
Message.success('禁用成功')
fetchUnits()
} else {
Message.error(res.message || '禁用失败')
}
},
})
} catch (error) {
console.error('禁用U位失败:', error)
}
}
// 启用U位
const handleEnable = async (record: any) => {
try {
Modal.confirm({
title: '确认启用',
content: `确认启用 U位 ${record.unit_number} 吗?`,
onOk: async () => {
if (!selectedRackId.value) {
Message.warning('请先选择机柜')
return
}
const res = await updateUnitStatus({
rack_id: selectedRackId.value,
start_unit: record.unit_number,
end_unit: record.unit_number,
status: 'available',
})
if (res.code === 0) {
Message.success('启用成功')
fetchUnits()
} else {
Message.error(res.message || '启用失败')
}
},
})
} catch (error) {
console.error('启用U位失败:', error)
}
}
// 释放U位
const handleRelease = async (record: any) => {
try {
Modal.confirm({
title: '确认释放',
content: `确认释放 U位 ${record.unit_number} 吗?`,
onOk: async () => {
const endUnit = record.occupied_units > 1
? record.unit_number + record.occupied_units - 1
: record.unit_number
if (!selectedRackId.value) {
Message.warning('请先选择机柜')
return
}
const res = await releaseUnit({
rack_id: selectedRackId.value,
start_unit: record.unit_number,
end_unit: endUnit,
})
if (res.code === 0) {
Message.success('释放成功')
fetchUnits()
} else {
Message.error(res.message || '释放失败')
}
},
})
} catch (error) {
console.error('释放U位失败:', error)
}
}
// 取消预留
const handleCancelReservation = async (record: any) => {
try {
Modal.confirm({
title: '确认取消预留',
content: `确认取消 U位 ${record.unit_number} 的预留吗?`,
onOk: async () => {
const endUnit = record.occupied_units > 1
? record.unit_number + record.occupied_units - 1
: record.unit_number
if (!selectedRackId.value) {
Message.warning('请先选择机柜')
return
}
const res = await cancelReservation({
rack_id: selectedRackId.value,
start_unit: record.unit_number,
end_unit: endUnit,
})
if (res.code === 0) {
Message.success('取消预留成功')
fetchUnits()
} else {
Message.error(res.message || '取消预留失败')
}
},
})
} catch (error) {
console.error('取消预留失败:', error)
}
}
// 初始化
onMounted(() => {
fetchRacks()
})
</script>
<script lang="ts">
export default {
name: 'UPositionManagement',
}
</script>
<style scoped lang="less">
.container {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.rack-select-card {
margin-bottom: 16px;
}
.action-card {
margin-bottom: 16px;
:deep(.arco-card-body) {
padding: 12px 20px;
}
}
.u-position-card {
:deep(.arco-card-body) {
padding: 0;
}
}
</style>