# Conflicts:
#	dist/index.html
This commit is contained in:
“hsc”
2026-08-28 15:29:53 +08:00
11 changed files with 1395 additions and 151 deletions
+15 -14
View File
@@ -1,18 +1,19 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>设备管理系统</title>
<script type="module" crossorigin src="/assets/index-RVBj8t6x.js"></script>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>设备管理系统</title>
<script type="module" crossorigin src="/assets/index-D7ykTT7f.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-DZ3WZ9y9.js">
<link rel="modulepreload" crossorigin href="/assets/message-ddUl4JIs.js">
<link rel="stylesheet" crossorigin href="/assets/index-BpBnxyAz.css">
</head>
<body>
<div id="app"></div>
<!-- Vite 入口,指向 src/main.js,不要删除 -->
</body>
</html>
</head>
<body>
<div id="app"></div>
<!-- Vite 入口,指向 src/main.js,不要删除 -->
</body>
</html>
+1 -1
View File
@@ -2,7 +2,7 @@
"hash": "a6f0f194",
"configHash": "147f2811",
"lockfileHash": "77198382",
"browserHash": "0cd29ca6",
"browserHash": "ee71f75c",
"optimized": {
"@element-plus/icons-vue": {
"src": "../../@element-plus/icons-vue/dist/index.js",
+70 -7
View File
@@ -1,16 +1,79 @@
import request from '@/utils/request'
// 设备列表
export const getEquipmentList = (params) => request.get('/equipment/list', { params })
// 后端路由前缀(EquipmentController: [Route("api/asset/equipment")]
const base = '/asset/equipment'
// ============ 枚举常量(与后端 Model.Entity.Asset 枚举一一对应) ============
// 设备状态(EquipmentStatusEnum
export const EQUIPMENT_STATUS = {
1: { label: '待验收', tag: 'info' },
2: { label: '使用中', tag: 'success' },
3: { label: '已借出', tag: 'warning' },
4: { label: '闲置', tag: 'info' },
5: { label: '维修中', tag: 'danger' },
6: { label: '已停用', tag: 'info' },
7: { label: '待处置', tag: 'warning' },
8: { label: '已处置', tag: 'info' },
9: { label: '入库', tag: 'primary' }
}
export const statusOptions = Object.entries(EQUIPMENT_STATUS).map(([value, v]) => ({ value: Number(value), label: v.label }))
export const statusLabel = (s) => EQUIPMENT_STATUS[s]?.label || '未知'
export const statusTag = (s) => EQUIPMENT_STATUS[s]?.tag || 'info'
// 合规状态(ComplianceStatusEnum
export const COMPLIANCE_STATUS = {
1: { label: '正常', tag: 'success' },
2: { label: '超期', tag: 'danger' }
}
export const complianceLabel = (s) => COMPLIANCE_STATUS[s]?.label || '未知'
export const complianceTag = (s) => COMPLIANCE_STATUS[s]?.tag || 'info'
// 附件文件类型(AttachmentTypeEnum
export const ATTACHMENT_TYPES = {
1: '使用说明书',
2: '合格证明',
3: '校准证书',
4: '保养卡',
5: '合同扫描件',
6: '验收照片',
99: '其他'
}
export const attachmentTypeOptions = Object.entries(ATTACHMENT_TYPES).map(([value, label]) => ({ value: Number(value), label }))
// ============ 设备台账接口 ============
// 设备列表(服务端分页;结果数组上附带 total 总记录数,来自响应头 X-Total-Count
export const getEquipmentList = (params) => request.get(`${base}/list`, { params })
// 全部设备(不分页,供分类/统计/生命周期等视图使用)
export const getAllEquipment = () => request.get(`${base}/all`)
// 设备详情
export const getEquipmentDetail = (id) => request.get(`/equipment/${id}`)
export const getEquipmentDetail = (id) => request.get(`${base}/${id}`)
// 新增设备
export const addEquipment = (data) => request.post('/equipment', data)
export const addEquipment = (data) => request.post(base, data)
// 修改设备
export const updateEquipment = (id, data) => request.put(`/equipment/${id}`, data)
export const updateEquipment = (id, data) => request.put(`${base}/${id}`, data)
// 删除设备
export const deleteEquipment = (id) => request.delete(`/equipment/${id}`)
// 删除设备(软删除)
export const deleteEquipment = (id) => request.delete(`${base}/${id}`)
// 变更设备状态:{ Status, Operator, Remark }
export const changeEquipmentStatus = (id, data) => request.post(`${base}/${id}/status`, data)
// 状态变更历史记录
export const getStatusRecords = (id) => request.get(`${base}/${id}/status-records`)
// ============ 设备附件接口 ============
// 附件列表
export const getAttachments = (equipmentId) => request.get(`${base}/${equipmentId}/attachments`)
// 附件上传地址(el-upload 的 action,绕过拦截器直传后端)
export const uploadAttachmentAction = (equipmentId) => `/api${base}/${equipmentId}/attachments`
// 删除附件
export const deleteAttachment = (attachmentId) => request.delete(`${base}/attachments/${attachmentId}`)
+7 -1
View File
@@ -25,7 +25,13 @@ request.interceptors.response.use(
return Promise.reject(new Error(res.Msg || res.message))
}
// 直接返回 Data,页面拿到的就是干净数据,不用每次再 .data
return res.Data ?? res.data
const data = res.Data ?? res.data
// 分页列表:透传服务端响应头 X-Total-Count(总记录数),以不可枚举属性挂在数组上,不影响遍历与序列化
const totalCount = response.headers['x-total-count']
if (Array.isArray(data) && totalCount !== undefined) {
Object.defineProperty(data, 'total', { value: Number(totalCount), enumerable: false })
}
return data
},
error => {
ElMessage.error(error.message || '网络错误')
+277 -3
View File
@@ -2,13 +2,287 @@
<div class="page-container">
<el-card>
<template #header>
<span>分类管理视图</span>
<div class="card-header">
<span>设备分类与状态管理</span>
</div>
</template>
<el-empty description="分类管理视图 - 功能开发中" />
<el-tabs v-model="activeTab">
<!-- ============ 设备分类管理视图 ============ -->
<el-tab-pane label="分类管理视图" name="category">
<div class="category-layout">
<div class="category-left">
<el-radio-group v-model="dimension" size="small" @change="buildTree">
<el-radio-button value="Type">设备类型</el-radio-button>
<el-radio-button value="Department">使用部门</el-radio-button>
<el-radio-button value="Location">存放位置</el-radio-button>
</el-radio-group>
<el-input v-model="treeFilter" placeholder="输入关键字过滤" clearable class="tree-filter" />
<el-tree ref="treeRef" :data="treeData" :props="{ label: 'label', children: 'children' }"
node-key="key" highlight-current default-expand-all :filter-node-method="filterNode"
@node-click="onNodeClick">
<template #default="{ data }">
<span class="tree-node">
<span>{{ data.label }}</span>
<el-tag size="small" type="info" round>{{ data.count }}</el-tag>
</span>
</template>
</el-tree>
</div>
<div class="category-right">
<div class="filter-tip">
<el-tag v-if="selectedNode" closable @close="clearSelection">
{{ dimensionLabel }}{{ selectedNode.label }}{{ filteredList.length }}
</el-tag>
<span v-else class="tip-text">点击左侧分类节点进行筛选</span>
</div>
<el-table :data="filteredList" border stripe v-loading="loading" max-height="560">
<el-table-column prop="Code" label="设备编号" width="130" />
<el-table-column prop="Name" label="设备名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="Type" label="设备类型" width="110" />
<el-table-column prop="Department" label="使用部门" width="110" />
<el-table-column prop="Location" label="存放位置" min-width="130" show-overflow-tooltip />
<el-table-column label="状态" width="95">
<template #default="{ row }">
<el-tag :type="statusTag(row.Status)">{{ statusLabel(row.Status) }}</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-tab-pane>
<!-- ============ 设备状态管理视图 ============ -->
<el-tab-pane label="状态管理视图" name="status">
<el-row :gutter="12" class="status-cards">
<el-col :span="8" v-for="item in statusOptions" :key="item.value" class="status-card-col">
<div class="status-card" :class="{ active: statusFilter === item.value }" @click="toggleStatus(item.value)">
<div class="status-count">{{ statusCounts[item.value] || 0 }}</div>
<el-tag :type="statusTag(item.value)">{{ item.label }}</el-tag>
</div>
</el-col>
</el-row>
<el-table :data="statusFilteredList" border stripe v-loading="loading" class="status-table">
<el-table-column prop="Code" label="设备编号" width="130" />
<el-table-column prop="Name" label="设备名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="Department" label="使用部门" width="110" />
<el-table-column prop="Location" label="存放位置" min-width="130" show-overflow-tooltip />
<el-table-column label="当前状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTag(row.Status)">{{ statusLabel(row.Status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="更新时间" width="170">
<template #default="{ row }">{{ fmtDateTime(row.UpdateTime || row.CreateTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openRecords(row)">变更记录</el-button>
<el-button link type="warning" size="small" @click="openStatus(row)">变更状态</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 状态变更对话框 -->
<el-dialog v-model="statusVisible" title="变更设备状态" width="460px">
<el-form label-width="90px">
<el-form-item label="当前状态">
<el-tag :type="statusTag(currentRow?.Status)">{{ statusLabel(currentRow?.Status) }}</el-tag>
</el-form-item>
<el-form-item label="目标状态" required>
<el-select v-model="statusForm.Status" style="width: 100%">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value"
:disabled="item.value === currentRow?.Status" />
</el-select>
</el-form-item>
<el-form-item label="操作人">
<el-input v-model="statusForm.Operator" />
</el-form-item>
<el-form-item label="变更原因">
<el-input v-model="statusForm.Remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="statusVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleStatusChange">确定</el-button>
</template>
</el-dialog>
<!-- 变更记录抽屉 -->
<el-drawer v-model="recordsVisible" :title="`状态变更记录 - ${currentRow?.Name || ''}`" size="45%">
<el-table :data="statusRecords" border stripe v-loading="recordLoading">
<el-table-column label="变更" min-width="180">
<template #default="{ row }">
<el-tag :type="statusTag(row.FromStatus)" size="small">{{ statusLabel(row.FromStatus) }}</el-tag>
<el-icon style="margin: 0 6px"><Right /></el-icon>
<el-tag :type="statusTag(row.ToStatus)" size="small">{{ statusLabel(row.ToStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="165">
<template #default="{ row }">{{ fmtDateTime(row.ChangeTime) }}</template>
</el-table-column>
<el-table-column prop="Operator" label="操作人" width="90" />
<el-table-column prop="Remark" label="原因" min-width="120" show-overflow-tooltip />
</el-table>
</el-drawer>
</div>
</template>
<script setup>
// TODO: 实现分类管理视图功能
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
getAllEquipment, changeEquipmentStatus, getStatusRecords,
statusOptions, statusLabel, statusTag
} from '@/api/equipment'
import { fmtDateTime } from '@/utils/format'
const loading = ref(false)
const activeTab = ref('category')
const allEquipments = ref([])
async function loadAll() {
loading.value = true
try {
allEquipments.value = await getAllEquipment()
buildTree()
} finally {
loading.value = false
}
}
// ===== 分类管理视图 =====
const DIMENSION_LABELS = { Type: '设备类型', Department: '使用部门', Location: '存放位置' }
const dimension = ref('Type')
const dimensionLabel = computed(() => DIMENSION_LABELS[dimension.value])
const treeRef = ref()
const treeFilter = ref('')
const treeData = ref([])
const selectedNode = ref(null)
watch(treeFilter, (val) => treeRef.value?.filter(val))
function buildTree() {
selectedNode.value = null
const groups = {}
for (const item of allEquipments.value) {
const key = item[dimension.value] || '未分类'
groups[key] = (groups[key] || 0) + 1
}
treeData.value = [
{ key: '__all__', label: '全部设备', count: allEquipments.value.length, children: [] },
...Object.entries(groups)
.sort((a, b) => b[1] - a[1])
.map(([label, count], idx) => ({ key: `node-${idx}`, label, count, children: [] }))
]
}
function filterNode(value, data) {
if (!value) return true
return data.label.includes(value)
}
function onNodeClick(data) {
selectedNode.value = data.key === '__all__' ? null : data
}
function clearSelection() { selectedNode.value = null }
const filteredList = computed(() => {
if (!selectedNode.value) return allEquipments.value
return allEquipments.value.filter(item => (item[dimension.value] || '未分类') === selectedNode.value.label)
})
// ===== 状态管理视图 =====
const statusFilter = ref(null)
const statusCounts = computed(() => {
const counts = {}
for (const item of allEquipments.value) {
counts[item.Status] = (counts[item.Status] || 0) + 1
}
return counts
})
const statusFilteredList = computed(() =>
statusFilter.value == null ? allEquipments.value : allEquipments.value.filter(e => e.Status === statusFilter.value)
)
function toggleStatus(value) {
statusFilter.value = statusFilter.value === value ? null : value
}
// ===== 状态变更 =====
const statusVisible = ref(false)
const saving = ref(false)
const currentRow = ref(null)
const statusForm = reactive({ Status: null, Operator: '', Remark: '' })
function openStatus(row) {
currentRow.value = row
statusForm.Status = null
statusForm.Operator = ''
statusForm.Remark = ''
statusVisible.value = true
}
async function handleStatusChange() {
if (!statusForm.Status) {
ElMessage.warning('请选择目标状态')
return
}
saving.value = true
try {
await changeEquipmentStatus(currentRow.value.Id, {
Status: statusForm.Status,
Operator: statusForm.Operator,
Remark: statusForm.Remark
})
ElMessage.success('状态变更成功')
statusVisible.value = false
loadAll()
} finally {
saving.value = false
}
}
// ===== 变更记录 =====
const recordsVisible = ref(false)
const recordLoading = ref(false)
const statusRecords = ref([])
async function openRecords(row) {
currentRow.value = row
recordsVisible.value = true
recordLoading.value = true
try {
statusRecords.value = await getStatusRecords(row.Id)
} finally {
recordLoading.value = false
}
}
onMounted(loadAll)
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.category-layout { display: flex; gap: 16px; }
.category-left { width: 280px; flex-shrink: 0; border-right: 1px solid var(--el-border-color-light); padding-right: 16px; }
.category-right { flex: 1; min-width: 0; }
.tree-filter { margin: 12px 0; }
.tree-node { display: flex; align-items: center; gap: 8px; flex: 1; }
.filter-tip { margin-bottom: 12px; }
.tip-text { color: var(--el-text-color-secondary); font-size: 13px; }
.status-cards { margin-bottom: 16px; }
.status-card-col { margin-bottom: 10px; }
.status-card {
border: 1px solid var(--el-border-color-light); border-radius: 6px; padding: 12px;
text-align: center; cursor: pointer; transition: all 0.2s;
}
.status-card:hover { border-color: var(--el-color-primary); }
.status-card.active { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.status-count { font-size: 22px; font-weight: 600; margin-bottom: 6px; }
.status-table { margin-top: 4px; }
</style>
+148 -3
View File
@@ -2,13 +2,158 @@
<div class="page-container">
<el-card>
<template #header>
<span>合规管理视图</span>
<div class="card-header">
<span>设备合规管理</span>
<div class="header-right">
<el-input v-model="keyword" placeholder="设备编号/名称" clearable style="width: 200px" />
<el-switch v-model="onlyOverdue" active-text="只看超期" />
</div>
</div>
</template>
<el-empty description="合规管理视图 - 功能开发中" />
<!-- 合规概览卡片 -->
<el-row :gutter="16" class="summary-row">
<el-col :span="6">
<el-card shadow="never" class="summary-card">
<div class="summary-count">{{ allEquipments.length }}</div>
<div class="summary-label">设备总数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="summary-card" :class="{ danger: overdueCounts.calibration > 0 }">
<div class="summary-count">{{ overdueCounts.calibration }}</div>
<div class="summary-label">校准超期</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="summary-card" :class="{ danger: overdueCounts.inspection > 0 }">
<div class="summary-count">{{ overdueCounts.inspection }}</div>
<div class="summary-label">检定超期</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="summary-card" :class="{ danger: overdueCounts.maintenance > 0 }">
<div class="summary-count">{{ overdueCounts.maintenance }}</div>
<div class="summary-label">保养超期</div>
</el-card>
</el-col>
</el-row>
<!-- 合规明细表 -->
<el-table :data="filteredList" border stripe v-loading="loading">
<el-table-column prop="Code" label="设备编号" width="130" />
<el-table-column prop="Name" label="设备名称" min-width="140" show-overflow-tooltip />
<el-table-column label="校准状态" width="95">
<template #default="{ row }">
<el-tag :type="calibrationOverdue(row) ? 'danger' : 'success'">
{{ calibrationOverdue(row) ? '超期' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="校准日期" width="110">
<template #default="{ row }">{{ fmtDate(row.LastCalibrationDate) || '-' }}</template>
</el-table-column>
<el-table-column label="校准到期" width="110">
<template #default="{ row }">
<span :class="{ 'overdue-text': isOverdue(row.NextCalibrationDate) }">
{{ fmtDate(row.NextCalibrationDate) || '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="检定日期" width="110">
<template #default="{ row }">{{ fmtDate(row.InspectionDate) || '-' }}</template>
</el-table-column>
<el-table-column label="检定到期" width="110">
<template #default="{ row }">
<span :class="{ 'overdue-text': isOverdue(row.NextInspectionDate) }">
{{ fmtDate(row.NextInspectionDate) || '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="保养状态" width="95">
<template #default="{ row }">
<el-tag :type="maintenanceOverdue(row) ? 'danger' : 'success'">
{{ maintenanceOverdue(row) ? '超期' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="保养日期" width="110">
<template #default="{ row }">{{ fmtDate(row.LastMaintenanceDate) || '-' }}</template>
</el-table-column>
<el-table-column label="保养周期到期" width="120">
<template #default="{ row }">
<span :class="{ 'overdue-text': isOverdue(row.NextMaintenanceDate) }">
{{ fmtDate(row.NextMaintenanceDate) || '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="保修到期" width="110">
<template #default="{ row }">
<span :class="{ 'overdue-text': isOverdue(row.WarrantyExpireDate) }">
{{ fmtDate(row.WarrantyExpireDate) || '-' }}
</span>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
// TODO: 实现合规管理视图功能
import { ref, computed, onMounted } from 'vue'
import { getAllEquipment } from '@/api/equipment'
import { fmtDate, isOverdue } from '@/utils/format'
const loading = ref(false)
const allEquipments = ref([])
const keyword = ref('')
const onlyOverdue = ref(false)
async function loadAll() {
loading.value = true
try {
allEquipments.value = await getAllEquipment()
} finally {
loading.value = false
}
}
// 合规判定:设置了到期日期且早于今天即为超期
const calibrationOverdue = (row) => isOverdue(row.NextCalibrationDate)
const maintenanceOverdue = (row) => isOverdue(row.NextMaintenanceDate)
const anyOverdue = (row) =>
calibrationOverdue(row) || maintenanceOverdue(row) || isOverdue(row.NextInspectionDate) || isOverdue(row.WarrantyExpireDate)
const overdueCounts = computed(() => ({
calibration: allEquipments.value.filter(calibrationOverdue).length,
inspection: allEquipments.value.filter(row => isOverdue(row.NextInspectionDate)).length,
maintenance: allEquipments.value.filter(maintenanceOverdue).length
}))
const filteredList = computed(() => {
let list = allEquipments.value
if (onlyOverdue.value) {
list = list.filter(anyOverdue)
}
if (keyword.value) {
const kw = keyword.value.toLowerCase()
list = list.filter(e =>
(e.Code || '').toLowerCase().includes(kw) || (e.Name || '').toLowerCase().includes(kw)
)
}
return list
})
onMounted(loadAll)
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.header-right { display: flex; align-items: center; gap: 16px; }
.summary-row { margin-bottom: 16px; }
.summary-card { text-align: center; }
.summary-card.danger .summary-count { color: var(--el-color-danger); }
.summary-count { font-size: 26px; font-weight: 600; }
.summary-label { color: var(--el-text-color-secondary); margin-top: 4px; font-size: 13px; }
.overdue-text { color: var(--el-color-danger); font-weight: 600; }
</style>
+210 -3
View File
@@ -2,13 +2,220 @@
<div class="page-container">
<el-card>
<template #header>
<span>设备信息</span>
<div class="card-header">
<div class="header-left">
<span class="title">设备信息</span>
<el-select v-model="equipmentId" filterable placeholder="请选择设备" style="width: 300px; margin-left: 16px"
@change="loadDetail">
<el-option v-for="item in equipmentOptions" :key="item.Id"
:label="`${item.Code} - ${item.Name}`" :value="item.Id" />
</el-select>
</div>
<el-tag v-if="equipment" :type="statusTag(equipment.Status)" size="large">
{{ statusLabel(equipment.Status) }}
</el-tag>
</div>
</template>
<el-empty description="设备信息 - 功能开发中" />
<el-empty v-if="!equipment" description="请选择设备查看详情" />
<el-tabs v-else v-model="activeTab">
<!-- 基础信息卡片 -->
<el-tab-pane label="基础信息" name="info">
<el-descriptions title="基础信息" :column="3" border>
<el-descriptions-item label="设备编号">{{ equipment.Code }}</el-descriptions-item>
<el-descriptions-item label="设备名称">{{ equipment.Name }}</el-descriptions-item>
<el-descriptions-item label="设备类型">{{ equipment.Type || '-' }}</el-descriptions-item>
<el-descriptions-item label="设备品牌">{{ equipment.Brand || '-' }}</el-descriptions-item>
<el-descriptions-item label="设备型号">{{ equipment.Model || '-' }}</el-descriptions-item>
<el-descriptions-item label="规格参数">{{ equipment.Specifications || '-' }}</el-descriptions-item>
<el-descriptions-item label="制造商">{{ equipment.Manufacturer || '-' }}</el-descriptions-item>
<el-descriptions-item label="供应商">{{ equipment.Supplier || '-' }}</el-descriptions-item>
<el-descriptions-item label="二维码编号">{{ equipment.QrCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="设备描述" :span="3">{{ equipment.Description || '-' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="权属与使用" :column="3" border class="mt-16">
<el-descriptions-item label="使用部门">{{ equipment.Department || '-' }}</el-descriptions-item>
<el-descriptions-item label="存放位置">{{ equipment.Location || '-' }}</el-descriptions-item>
<el-descriptions-item label="责任人">{{ equipment.ResponsiblePerson || '-' }}</el-descriptions-item>
<el-descriptions-item label="责任人电话">{{ equipment.ContactPhone || '-' }}</el-descriptions-item>
<el-descriptions-item label="保管人">{{ equipment.Custodian || '-' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="购置与生命周期" :column="3" border class="mt-16">
<el-descriptions-item label="购置日期">{{ fmtDate(equipment.PurchaseDate) || '-' }}</el-descriptions-item>
<el-descriptions-item label="购置价格">{{ equipment.PurchasePrice ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="保修到期">{{ fmtDate(equipment.WarrantyExpireDate) || '-' }}</el-descriptions-item>
<el-descriptions-item label="验收日期">{{ fmtDate(equipment.AcceptanceDate) || '-' }}</el-descriptions-item>
<el-descriptions-item label="启用日期">{{ fmtDate(equipment.EnableDate) || '-' }}</el-descriptions-item>
<el-descriptions-item label="使用年限">{{ equipment.ServiceLifeYears ? `${equipment.ServiceLifeYears}` : '-' }}</el-descriptions-item>
<el-descriptions-item label="报废日期">{{ fmtDate(equipment.ScrapDate) || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ equipment.Remark || '-' }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<!-- 资产附件管理 -->
<el-tab-pane label="附件管理" name="attachment">
<div class="upload-bar">
<el-select v-model="uploadForm.fileType" style="width: 150px">
<el-option v-for="item in attachmentTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-input v-model="uploadForm.uploader" placeholder="上传人" style="width: 120px" />
<el-input v-model="uploadForm.remark" placeholder="备注" style="width: 200px" />
<el-upload :action="uploadAttachmentAction(equipmentId)" name="file" :data="uploadForm" :show-file-list="false"
:on-success="onUploadSuccess" :on-error="onUploadError">
<el-button type="primary">
<el-icon><Upload /></el-icon> 上传附件
</el-button>
</el-upload>
</div>
<el-table :data="attachments" border stripe v-loading="attachmentLoading">
<el-table-column prop="FileName" label="文件名称" min-width="200" show-overflow-tooltip />
<el-table-column label="文件类型" width="120">
<template #default="{ row }">
<el-tag>{{ ATTACHMENT_TYPES[row.FileType] || '其他' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="文件大小" width="110">
<template #default="{ row }">{{ fmtFileSize(row.FileSize) }}</template>
</el-table-column>
<el-table-column prop="Uploader" label="上传人" width="100" />
<el-table-column label="上传时间" width="170">
<template #default="{ row }">{{ fmtDateTime(row.CreateTime) }}</template>
</el-table-column>
<el-table-column prop="Remark" label="备注" min-width="140" show-overflow-tooltip />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="previewAttachment(row)">预览</el-button>
<el-button link type="danger" size="small" @click="handleDeleteAttachment(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 状态变更记录 -->
<el-tab-pane label="状态变更记录" name="records">
<el-table :data="statusRecords" border stripe v-loading="recordLoading">
<el-table-column label="变更前" width="110">
<template #default="{ row }">
<el-tag :type="statusTag(row.FromStatus)">{{ statusLabel(row.FromStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="变更后" width="110">
<template #default="{ row }">
<el-tag :type="statusTag(row.ToStatus)">{{ statusLabel(row.ToStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="变更时间" width="170">
<template #default="{ row }">{{ fmtDateTime(row.ChangeTime) }}</template>
</el-table-column>
<el-table-column prop="Operator" label="操作人" width="110" />
<el-table-column prop="Remark" label="变更原因" min-width="200" show-overflow-tooltip />
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup>
// TODO: 实现设备信息功能
import { ref, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getAllEquipment, getEquipmentDetail, getAttachments, deleteAttachment,
uploadAttachmentAction, getStatusRecords,
statusLabel, statusTag, ATTACHMENT_TYPES, attachmentTypeOptions
} from '@/api/equipment'
import { fmtDate, fmtDateTime, fmtFileSize } from '@/utils/format'
const route = useRoute()
const equipmentId = ref(null)
const equipment = ref(null)
const equipmentOptions = ref([])
const activeTab = ref('info')
// ===== 初始化:加载设备下拉,按路由 query 选中 =====
async function loadOptions() {
equipmentOptions.value = await getAllEquipment()
// 雪花 Id 以字符串传输(防止 JS 精度丢失),这里不做 Number 转换
const queryId = route.query.id || null
if (queryId && equipmentOptions.value.some(e => e.Id === queryId)) {
equipmentId.value = queryId
await loadDetail(queryId)
} else if (equipmentOptions.value.length > 0) {
equipmentId.value = equipmentOptions.value[0].Id
await loadDetail(equipmentId.value)
}
}
async function loadDetail(id) {
if (!id) return
equipment.value = await getEquipmentDetail(id)
loadAttachments()
loadRecords()
}
// ===== 附件管理 =====
const attachments = ref([])
const attachmentLoading = ref(false)
const uploadForm = reactive({ fileType: 1, uploader: '', remark: '' })
async function loadAttachments() {
attachmentLoading.value = true
try {
attachments.value = await getAttachments(equipmentId.value)
} finally {
attachmentLoading.value = false
}
}
function onUploadSuccess(response) {
if (response && response.Code === 0) {
ElMessage.success('上传成功')
loadAttachments()
} else {
ElMessage.error(response?.Msg || '上传失败')
}
}
function onUploadError() {
ElMessage.error('上传失败,请检查网络或文件大小')
}
function previewAttachment(row) {
window.open(row.FileUrl, '_blank')
}
async function handleDeleteAttachment(row) {
await ElMessageBox.confirm(`确定删除附件【${row.FileName}】吗?`, '提示', { type: 'warning' })
await deleteAttachment(row.Id)
ElMessage.success('删除成功')
loadAttachments()
}
// ===== 状态变更记录 =====
const statusRecords = ref([])
const recordLoading = ref(false)
async function loadRecords() {
recordLoading.value = true
try {
statusRecords.value = await getStatusRecords(equipmentId.value)
} finally {
recordLoading.value = false
}
}
onMounted(loadOptions)
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.header-left { display: flex; align-items: center; }
.title { font-size: 16px; font-weight: 600; }
.mt-16 { margin-top: 16px; }
.upload-bar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; }
</style>
+323 -111
View File
@@ -4,41 +4,21 @@
<template #header>
<div class="card-header">
<span>设备台账</span>
<div>
<el-button type="primary" @click="dialogVisible = true">
<el-icon><Plus /></el-icon> 新增设备
</el-button>
<el-button type="success">
<el-icon><Upload /></el-icon> 批量导入
</el-button>
<el-button>
<el-icon><Download /></el-icon> 导出
</el-button>
</div>
<el-button type="primary" @click="openAdd">
<el-icon><Plus /></el-icon> 新增设备
</el-button>
</div>
</template>
<!-- 搜索栏 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="设备编号">
<el-input v-model="searchForm.code" placeholder="请输入设备编号" clearable />
</el-form-item>
<el-form-item label="设备名称">
<el-input v-model="searchForm.name" placeholder="请输入设备名称" clearable />
</el-form-item>
<el-form-item label="所属部门">
<el-select v-model="searchForm.department" placeholder="请选择" clearable>
<el-option label="研发部" value="rd" />
<el-option label="生产部" value="prod" />
<el-option label="质检部" value="qa" />
</el-select>
<el-form-item label="关键字">
<el-input v-model="searchForm.keyword" placeholder="设备编号/名称" clearable style="width: 200px"
@keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="设备状态">
<el-select v-model="searchForm.status" placeholder="请选择" clearable>
<el-option label="在用" value="active" />
<el-option label="闲置" value="idle" />
<el-option label="维修中" value="repairing" />
<el-option label="报废" value="scrapped" />
<el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 140px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
@@ -48,117 +28,349 @@
</el-form>
<!-- 表格 -->
<el-table :data="tableData" border stripe style="width: 100%">
<el-table-column prop="code" label="设备编号" width="120" />
<el-table-column prop="name" label="设备名称" min-width="150" />
<el-table-column prop="model" label="型号规格" width="150" />
<el-table-column prop="department" label="所属部门" width="120" />
<el-table-column prop="location" label="存放位置" width="150" />
<el-table-column prop="purchaseDate" label="购置日期" width="120" />
<el-table-column prop="status" label="状态" width="100">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="Code" label="设备编号" width="130" />
<el-table-column prop="Name" label="设备名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="Type" label="设备类型" width="110" />
<el-table-column prop="Brand" label="品牌" width="100" />
<el-table-column prop="Model" label="型号" width="120" show-overflow-tooltip />
<el-table-column prop="Department" label="使用部门" width="110" />
<el-table-column prop="Location" label="存放位置" min-width="130" show-overflow-tooltip />
<el-table-column prop="ResponsiblePerson" label="责任人" width="90" />
<el-table-column label="购置日期" width="110">
<template #default="{ row }">{{ fmtDate(row.PurchaseDate) }}</template>
</el-table-column>
<el-table-column label="状态" width="95">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ row.status }}</el-tag>
<el-tag :type="statusTag(row.Status)">{{ statusLabel(row.Status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default>
<el-button link type="primary" size="small">查看</el-button>
<el-button link type="primary" size="small">编辑</el-button>
<el-button link type="danger" size="small">删除</el-button>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="goDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="warning" size="small" @click="openStatus(row)">状态</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
/>
<el-pagination v-model:current-page="pageIndex" v-model:page-size="pageSize" :total="total"
:page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData" @current-change="loadData" />
</div>
</el-card>
<!-- 新增设备对话框 -->
<el-dialog v-model="dialogVisible" title="新增设备" width="600px">
<el-form :model="form" label-width="100px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="设备编号"><el-input v-model="form.code" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备名称"><el-input v-model="form.name" /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="型号规格"><el-input v-model="form.model" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="所属部门">
<el-select v-model="form.department" style="width:100%">
<el-option label="研发部" value="rd" />
<el-option label="生产部" value="prod" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="存放位置"><el-input v-model="form.location" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="购置日期"><el-date-picker v-model="form.purchaseDate" type="date" style="width:100%" /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="供应商"><el-input v-model="form.supplier" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="保修期"><el-input v-model="form.warranty" /></el-form-item>
</el-col>
</el-row>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="formVisible" :title="form.Id ? '编辑设备' : '新增设备'" width="780px" destroy-on-close>
<el-form :model="form" :rules="formRules" ref="formRef" label-width="110px">
<el-tabs>
<el-tab-pane label="基础信息">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="设备编号" prop="Code"><el-input v-model="form.Code" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备名称" prop="Name"><el-input v-model="form.Name" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备类型"><el-input v-model="form.Type" placeholder="如:试验设备/计量器具" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备品牌"><el-input v-model="form.Brand" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备型号"><el-input v-model="form.Model" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格参数"><el-input v-model="form.Specifications" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="制造商"><el-input v-model="form.Manufacturer" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="供应商"><el-input v-model="form.Supplier" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="二维码编号"><el-input v-model="form.QrCode" placeholder="留空可后续生成" /></el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="设备描述"><el-input v-model="form.Description" type="textarea" :rows="2" /></el-form-item>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="权属与使用">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="使用部门"><el-input v-model="form.Department" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="存放位置"><el-input v-model="form.Location" placeholder="如:A栋3楼实验室" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="责任人"><el-input v-model="form.ResponsiblePerson" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="责任人电话"><el-input v-model="form.ContactPhone" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="保管人"><el-input v-model="form.Custodian" /></el-form-item>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="购置与财务">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="购置日期">
<el-date-picker v-model="form.PurchaseDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="购置价格">
<el-input-number v-model="form.PurchasePrice" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="保修到期">
<el-date-picker v-model="form.WarrantyExpireDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="生命周期">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="验收日期">
<el-date-picker v-model="form.AcceptanceDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="启用日期">
<el-date-picker v-model="form.EnableDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="使用年限">
<el-input-number v-model="form.ServiceLifeYears" :min="0" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="报废日期">
<el-date-picker v-model="form.ScrapDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="合规管理">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="上次校准">
<el-date-picker v-model="form.LastCalibrationDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="校准到期">
<el-date-picker v-model="form.NextCalibrationDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="检定日期">
<el-date-picker v-model="form.InspectionDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="检定到期">
<el-date-picker v-model="form.NextInspectionDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="上次保养">
<el-date-picker v-model="form.LastMaintenanceDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="保养到期">
<el-date-picker v-model="form.NextMaintenanceDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
<el-form-item label="备注">
<el-input v-model="form.Remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="dialogVisible = false">确定</el-button>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<!-- 状态变更对话框 -->
<el-dialog v-model="statusVisible" title="变更设备状态" width="460px">
<el-descriptions :column="2" size="small" border style="margin-bottom: 16px">
<el-descriptions-item label="设备编号">{{ statusForm.equipment?.Code }}</el-descriptions-item>
<el-descriptions-item label="当前状态">
<el-tag :type="statusTag(statusForm.equipment?.Status)">{{ statusLabel(statusForm.equipment?.Status) }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-form label-width="90px">
<el-form-item label="目标状态" required>
<el-select v-model="statusForm.Status" style="width: 100%">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value"
:disabled="item.value === statusForm.equipment?.Status" />
</el-select>
</el-form-item>
<el-form-item label="操作人">
<el-input v-model="statusForm.Operator" />
</el-form-item>
<el-form-item label="变更原因">
<el-input v-model="statusForm.Remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="statusVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleStatusChange">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getEquipmentList, addEquipment, updateEquipment, deleteEquipment,
changeEquipmentStatus, statusOptions, statusLabel, statusTag
} from '@/api/equipment'
import { fmtDate } from '@/utils/format'
const searchForm = ref({ code: '', name: '', department: '', status: '' })
const currentPage = ref(1)
const router = useRouter()
// ===== 列表与搜索 =====
const loading = ref(false)
const tableData = ref([])
const pageIndex = ref(1)
const pageSize = ref(10)
const total = ref(100)
const dialogVisible = ref(false)
const form = ref({})
const total = ref(0)
const searchForm = reactive({ keyword: '', status: null })
const tableData = ref([
{ code: 'EQ-001', name: '高低温试验箱', model: 'GDS-100', department: '研发部', location: 'A栋3楼实验室', purchaseDate: '2024-01-15', status: '在用' },
{ code: 'EQ-002', name: '充放电测试柜', model: 'CDC-200', department: '生产部', location: 'B栋1楼车间', purchaseDate: '2024-03-20', status: '在用' },
{ code: 'EQ-003', name: '振动试验台', model: 'ZD-500', department: '质检部', location: 'A栋2楼实验室', purchaseDate: '2023-08-10', status: '维修中' },
{ code: 'EQ-004', name: '温湿度传感器', model: 'TH-10', department: '研发部', location: 'A栋3楼实验室', purchaseDate: '2024-06-01', status: '在用' },
{ code: 'EQ-005', name: '数据采集仪', model: 'DAQ-300', department: '生产部', location: 'B栋2楼', purchaseDate: '2023-11-25', status: '闲置' }
])
function statusType(status) {
const map = { '在用': 'success', '闲置': 'info', '维修中': 'warning', '报废': 'danger' }
return map[status] || 'info'
async function loadData() {
loading.value = true
try {
const list = await getEquipmentList({
pageIndex: pageIndex.value,
pageSize: pageSize.value,
keyword: searchForm.keyword || undefined,
status: searchForm.status ?? undefined
})
tableData.value = list
total.value = list.total || 0
} finally {
loading.value = false
}
}
function handleSearch() { /* TODO: 对接API */ }
function resetSearch() { searchForm.value = { code: '', name: '', department: '', status: '' } }
function handleSearch() { pageIndex.value = 1; loadData() }
function resetSearch() {
searchForm.keyword = ''
searchForm.status = null
handleSearch()
}
function goDetail(row) { router.push({ path: '/asset/ledger/equipment', query: { id: row.Id } }) }
// ===== 新增/编辑 =====
const formVisible = ref(false)
const saving = ref(false)
const formRef = ref()
const emptyForm = () => ({
// Id/CategoryId 为字符串(后端 DTO 雪花 Id 均以 string 传输),新增时留空即可
Id: '', Code: '', Name: '', CategoryId: '', Type: '', Brand: '', Model: '', Specifications: '',
Manufacturer: '', Supplier: '', QrCode: '', Description: '',
Department: '', Location: '', ResponsiblePerson: '', ContactPhone: '', Custodian: '',
PurchaseDate: null, PurchasePrice: null, WarrantyExpireDate: null,
AcceptanceDate: null, EnableDate: null, ServiceLifeYears: null, ScrapDate: null,
Status: 1,
LastCalibrationDate: null, NextCalibrationDate: null,
InspectionDate: null, NextInspectionDate: null,
LastMaintenanceDate: null, NextMaintenanceDate: null,
Remark: ''
})
const form = ref(emptyForm())
const formRules = {
Code: [{ required: true, message: '请输入设备编号', trigger: 'blur' }],
Name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }]
}
function openAdd() { form.value = emptyForm(); formVisible.value = true }
function openEdit(row) { form.value = { ...emptyForm(), ...row }; formVisible.value = true }
async function handleSave() {
await formRef.value?.validate()
saving.value = true
try {
if (form.value.Id) {
await updateEquipment(form.value.Id, form.value)
ElMessage.success('修改成功')
} else {
await addEquipment(form.value)
ElMessage.success('新增成功')
}
formVisible.value = false
loadData()
} finally {
saving.value = false
}
}
// ===== 删除 =====
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除设备【${row.Name || row.Code}】吗?`, '提示', { type: 'warning' })
await deleteEquipment(row.Id)
ElMessage.success('删除成功')
loadData()
}
// ===== 状态变更 =====
const statusVisible = ref(false)
const statusForm = reactive({ equipment: null, Status: null, Operator: '', Remark: '' })
function openStatus(row) {
statusForm.equipment = row
statusForm.Status = null
statusForm.Operator = ''
statusForm.Remark = ''
statusVisible.value = true
}
async function handleStatusChange() {
if (!statusForm.Status) {
ElMessage.warning('请选择目标状态')
return
}
saving.value = true
try {
await changeEquipmentStatus(statusForm.equipment.Id, {
Status: statusForm.Status,
Operator: statusForm.Operator,
Remark: statusForm.Remark
})
ElMessage.success('状态变更成功')
statusVisible.value = false
loadData()
} finally {
saving.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.search-form { margin-bottom: 16px; }
.search-form { margin-bottom: 8px; }
.pagination { margin-top: 16px; display: flex; justify-content: flex-end; }
</style>
+143 -3
View File
@@ -2,13 +2,153 @@
<div class="page-container">
<el-card>
<template #header>
<span>全生命周期视图</span>
<div class="card-header">
<div class="header-left">
<span class="title">资产全生命周期</span>
<el-select v-model="equipmentId" filterable placeholder="请选择设备" style="width: 300px; margin-left: 16px"
@change="loadData">
<el-option v-for="item in equipmentOptions" :key="item.Id"
:label="`${item.Code} - ${item.Name}`" :value="item.Id" />
</el-select>
</div>
<el-tag v-if="equipment" :type="statusTag(equipment.Status)" size="large">
{{ statusLabel(equipment.Status) }}
</el-tag>
</div>
</template>
<el-empty description="全生命周期视图 - 功能开发中" />
<el-empty v-if="!equipment" description="请选择设备查看生命周期" />
<el-row v-else :gutter="20">
<!-- 左侧设备卡片 -->
<el-col :span="8">
<el-card shadow="never" class="device-card">
<div class="device-name">{{ equipment.Name }}</div>
<div class="device-code">{{ equipment.Code }}</div>
<el-descriptions :column="1" size="small" class="device-desc">
<el-descriptions-item label="设备类型">{{ equipment.Type || '-' }}</el-descriptions-item>
<el-descriptions-item label="使用部门">{{ equipment.Department || '-' }}</el-descriptions-item>
<el-descriptions-item label="存放位置">{{ equipment.Location || '-' }}</el-descriptions-item>
<el-descriptions-item label="责任人">{{ equipment.ResponsiblePerson || '-' }}</el-descriptions-item>
<el-descriptions-item label="供应商">{{ equipment.Supplier || '-' }}</el-descriptions-item>
<el-descriptions-item label="使用年限">
{{ equipment.ServiceLifeYears ? `${equipment.ServiceLifeYears}` : '-' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<!-- 右侧生命周期时间线 -->
<el-col :span="16">
<el-tabs v-model="activeTab">
<el-tab-pane label="生命周期阶段" name="lifecycle">
<el-timeline>
<el-timeline-item v-for="stage in lifecycleStages" :key="stage.name"
:type="stage.reached ? 'primary' : 'info'" :hollow="!stage.reached" size="large">
<div class="stage-line">
<span class="stage-name" :class="{ unreached: !stage.reached }">{{ stage.name }}</span>
<el-tag v-if="stage.reached" size="small">{{ fmtDate(stage.date) }}</el-tag>
<el-tag v-else size="small" type="info">未发生</el-tag>
<span v-if="stage.extra" class="stage-extra">{{ stage.extra }}</span>
</div>
</el-timeline-item>
</el-timeline>
</el-tab-pane>
<el-tab-pane label="状态流转记录" name="records">
<el-empty v-if="statusRecords.length === 0" description="暂无状态变更记录" :image-size="80" />
<el-timeline v-else>
<el-timeline-item v-for="record in statusRecords" :key="record.Id" type="primary">
<div class="stage-line">
<el-tag :type="statusTag(record.FromStatus)" size="small">{{ statusLabel(record.FromStatus) }}</el-tag>
<el-icon style="margin: 0 8px"><Right /></el-icon>
<el-tag :type="statusTag(record.ToStatus)" size="small">{{ statusLabel(record.ToStatus) }}</el-tag>
</div>
<div class="record-meta">
{{ fmtDateTime(record.ChangeTime) }}
<span v-if="record.Operator"> · 操作人{{ record.Operator }}</span>
</div>
<div v-if="record.Remark" class="record-remark">{{ record.Remark }}</div>
</el-timeline-item>
</el-timeline>
</el-tab-pane>
</el-tabs>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script setup>
// TODO: 实现全生命周期视图功能
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import {
getAllEquipment, getEquipmentDetail, getStatusRecords,
statusLabel, statusTag
} from '@/api/equipment'
import { fmtDate, fmtDateTime } from '@/utils/format'
const route = useRoute()
const equipmentId = ref(null)
const equipment = ref(null)
const equipmentOptions = ref([])
const activeTab = ref('lifecycle')
const statusRecords = ref([])
async function loadOptions() {
equipmentOptions.value = await getAllEquipment()
// 雪花 Id 以字符串传输(防止 JS 精度丢失),这里不做 Number 转换
const queryId = route.query.id || null
if (queryId && equipmentOptions.value.some(e => e.Id === queryId)) {
equipmentId.value = queryId
} else if (equipmentOptions.value.length > 0) {
equipmentId.value = equipmentOptions.value[0].Id
}
if (equipmentId.value) await loadData(equipmentId.value)
}
async function loadData(id) {
if (!id) return
equipment.value = await getEquipmentDetail(id)
statusRecords.value = await getStatusRecords(id)
}
// 生命周期阶段:采购 → 验收 → 启用 → 保养 → 校准 → 检定 → 报废
// 已发生的阶段按时间正序排列在前,未发生的阶段置灰在后
const lifecycleStages = computed(() => {
if (!equipment.value) return []
const e = equipment.value
const defs = [
{ name: '采购', date: e.PurchaseDate, extra: e.Supplier ? `供应商:${e.Supplier}` : '' },
{ name: '验收', date: e.AcceptanceDate },
{ name: '启用', date: e.EnableDate },
{ name: '保养', date: e.LastMaintenanceDate, extra: e.NextMaintenanceDate ? `下次:${fmtDate(e.NextMaintenanceDate)}` : '' },
{ name: '校准', date: e.LastCalibrationDate, extra: e.NextCalibrationDate ? `下次:${fmtDate(e.NextCalibrationDate)}` : '' },
{ name: '检定', date: e.InspectionDate, extra: e.NextInspectionDate ? `下次:${fmtDate(e.NextInspectionDate)}` : '' },
{ name: '报废', date: e.ScrapDate }
]
const reached = defs.filter(s => s.date).sort((a, b) => new Date(a.date) - new Date(b.date))
.map(s => ({ ...s, reached: true }))
const unreached = defs.filter(s => !s.date).map(s => ({ ...s, reached: false }))
return [...reached, ...unreached]
})
onMounted(loadOptions)
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.header-left { display: flex; align-items: center; }
.title { font-size: 16px; font-weight: 600; }
.device-card { position: sticky; top: 0; }
.device-name { font-size: 18px; font-weight: 600; }
.device-code { color: var(--el-text-color-secondary); margin: 4px 0 12px; }
.device-desc { margin-top: 8px; }
.stage-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.stage-name { font-weight: 600; }
.stage-name.unreached { color: var(--el-text-color-secondary); font-weight: normal; }
.stage-extra { color: var(--el-text-color-secondary); font-size: 12px; }
.record-meta { color: var(--el-text-color-secondary); font-size: 12px; margin-top: 4px; }
.record-remark { color: var(--el-text-color-regular); font-size: 13px; margin-top: 2px; }
</style>
+196 -4
View File
@@ -1,14 +1,206 @@
<template>
<div class="page-container">
<el-card>
<el-card v-loading="loading">
<template #header>
<span>分类统计</span>
<div class="card-header">
<span>资产分类统计</span>
<el-button @click="loadAll">
<el-icon><Refresh /></el-icon> 刷新
</el-button>
</div>
</template>
<el-empty description="分类统计 - 功能开发中" />
<!-- 概览指标 -->
<el-row :gutter="16" class="summary-row">
<el-col :span="6">
<el-card shadow="never" class="summary-card">
<div class="summary-count">{{ allEquipments.length }}</div>
<div class="summary-label">设备总数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="summary-card">
<div class="summary-count">{{ totalValue }}</div>
<div class="summary-label">资产总值</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="summary-card">
<div class="summary-count">{{ inUseCount }}</div>
<div class="summary-label">使用中</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="summary-card">
<div class="summary-count">{{ avgYears }}</div>
<div class="summary-label">平均使用年限</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区 -->
<el-row :gutter="16">
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>按状态统计</span></template>
<div ref="statusChartRef" class="chart"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>按设备类型统计</span></template>
<div ref="typeChartRef" class="chart"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="mt-16">
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>按使用部门统计</span></template>
<div ref="departmentChartRef" class="chart"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>按使用年限统计</span></template>
<div ref="yearsChartRef" class="chart"></div>
</el-card>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script setup>
// TODO: 实现分类统计功能
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import * as echarts from 'echarts'
import { getAllEquipment, statusOptions } from '@/api/equipment'
const loading = ref(false)
const allEquipments = ref([])
const statusChartRef = ref()
const typeChartRef = ref()
const departmentChartRef = ref()
const yearsChartRef = ref()
let charts = []
// ===== 概览指标 =====
const totalValue = computed(() =>
allEquipments.value.reduce((sum, e) => sum + (e.PurchasePrice || 0), 0).toLocaleString()
)
const inUseCount = computed(() => allEquipments.value.filter(e => e.Status === 2).length)
const avgYears = computed(() => {
const dated = allEquipments.value.filter(e => e.PurchaseDate)
if (dated.length === 0) return '-'
const now = Date.now()
const years = dated.reduce((sum, e) => sum + (now - new Date(e.PurchaseDate).getTime()) / (365 * 24 * 3600 * 1000), 0)
return (years / dated.length).toFixed(1)
})
// ===== 数据加载与图表渲染 =====
async function loadAll() {
loading.value = true
try {
allEquipments.value = await getAllEquipment()
await nextTick()
renderCharts()
} finally {
loading.value = false
}
}
function countBy(field) {
const groups = {}
for (const item of allEquipments.value) {
const key = item[field] || '未分类'
groups[key] = (groups[key] || 0) + 1
}
return Object.entries(groups).sort((a, b) => b[1] - a[1])
}
function makeChart(domRef) {
let chart = charts.find(c => c.getDom() === domRef)
if (!chart) {
chart = echarts.init(domRef)
charts.push(chart)
}
return chart
}
function renderCharts() {
// 按状态统计(饼图)
const statusData = statusOptions
.map(s => ({ name: s.label, value: allEquipments.value.filter(e => e.Status === s.value).length }))
.filter(s => s.value > 0)
makeChart(statusChartRef.value).setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} 台({d}%' },
legend: { bottom: 0 },
series: [{ type: 'pie', radius: ['35%', '65%'], data: statusData, label: { formatter: '{b}\n{c} 台' } }]
}, true)
// 按设备类型统计(柱状图)
const typeData = countBy('Type')
makeChart(typeChartRef.value).setOption({
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 20, bottom: 60 },
xAxis: { type: 'category', data: typeData.map(d => d[0]), axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value', minInterval: 1 },
series: [{ type: 'bar', data: typeData.map(d => d[1]), barMaxWidth: 40, itemStyle: { color: '#409EFF' } }]
}, true)
// 按使用部门统计(饼图)
const deptData = countBy('Department').map(d => ({ name: d[0], value: d[1] }))
makeChart(departmentChartRef.value).setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} 台({d}%' },
legend: { bottom: 0 },
series: [{ type: 'pie', radius: '60%', data: deptData, label: { formatter: '{b}\n{c} 台' } }]
}, true)
// 按使用年限统计(柱状图)
const buckets = { '1年以内': 0, '1-3年': 0, '3-5年': 0, '5-10年': 0, '10年以上': 0, '无购置日期': 0 }
const now = Date.now()
for (const e of allEquipments.value) {
if (!e.PurchaseDate) { buckets['无购置日期']++; continue }
const years = (now - new Date(e.PurchaseDate).getTime()) / (365 * 24 * 3600 * 1000)
if (years < 1) buckets['1年以内']++
else if (years < 3) buckets['1-3年']++
else if (years < 5) buckets['3-5年']++
else if (years < 10) buckets['5-10年']++
else buckets['10年以上']++
}
makeChart(yearsChartRef.value).setOption({
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 20, bottom: 40 },
xAxis: { type: 'category', data: Object.keys(buckets) },
yAxis: { type: 'value', minInterval: 1 },
series: [{ type: 'bar', data: Object.values(buckets), barMaxWidth: 40, itemStyle: { color: '#67C23A' } }]
}, true)
}
function handleResize() {
charts.forEach(c => c.resize())
}
onMounted(() => {
loadAll()
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
charts.forEach(c => c.dispose())
charts = []
})
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.summary-row { margin-bottom: 16px; }
.summary-card { text-align: center; }
.summary-count { font-size: 26px; font-weight: 600; }
.summary-label { color: var(--el-text-color-secondary); margin-top: 4px; font-size: 13px; }
.chart-card :deep(.el-card__header) { padding: 10px 16px; font-weight: 600; }
.chart { height: 320px; }
.mt-16 { margin-top: 16px; }
</style>
+5 -1
View File
@@ -12,11 +12,15 @@ export default defineConfig({
server: {
port: 3000,
open: true,
// 开发环境代理:/api 请求转发到后端服务,避免跨域
// 开发环境代理:/api 请求转发到后端服务,避免跨域;/uploads 为设备附件静态文件与上传接口
proxy: {
'/api': {
target: 'http://localhost:5287',
changeOrigin: true
},
'/uploads': {
target: 'http://localhost:5287',
changeOrigin: true
}
}
}