feat:新增设备分类、设备台账、设备关键件

master
黄伟杰 3 weeks ago
parent 3f496dea47
commit 61e06971f1

@ -0,0 +1,48 @@
import request from '@/utils/request'
export function getCriticalComponentPage(params) {
return request({
url: '/admin-api/mes/critical-component/page',
method: 'get',
params
})
}
export function getCriticalComponent(id) {
return request({
url: '/admin-api/mes/critical-component/get',
method: 'get',
params: { id }
})
}
export function createCriticalComponent(data) {
return request({
url: '/admin-api/mes/critical-component/create',
method: 'post',
data
})
}
export function updateCriticalComponent(data) {
return request({
url: '/admin-api/mes/critical-component/update',
method: 'put',
data
})
}
export function deleteCriticalComponent(ids) {
return request({
url: '/admin-api/mes/critical-component/delete',
method: 'delete',
params: { ids }
})
}
export function getCriticalComponentList() {
return request({
url: '/admin-api/mes/critical-component/list',
method: 'get'
})
}

@ -0,0 +1,48 @@
import request from '@/utils/request'
export function getDeviceLedgerPage(params) {
return request({
url: '/admin-api/mes/device-ledger/page',
method: 'get',
params
})
}
export function getDeviceLedger(id) {
return request({
url: '/admin-api/mes/device-ledger/get',
method: 'get',
params: { id }
})
}
export function createDeviceLedger(data) {
return request({
url: '/admin-api/mes/device-ledger/create',
method: 'post',
data
})
}
export function updateDeviceLedger(data) {
return request({
url: '/admin-api/mes/device-ledger/update',
method: 'put',
data
})
}
export function deleteDeviceLedger(ids) {
return request({
url: '/admin-api/mes/device-ledger/delete',
method: 'delete',
params: { ids }
})
}
export function getDeviceLedgerList() {
return request({
url: '/admin-api/mes/device-ledger/list',
method: 'get'
})
}

@ -0,0 +1,49 @@
import request from '@/utils/request'
export function getDeviceTypeTree(params) {
return request({
url: '/admin-api/mes/device-type/tree',
method: 'get',
params
})
}
export function getDeviceTypeList(params) {
return request({
url: '/admin-api/mes/device-type/page',
method: 'get',
params
})
}
export function getDeviceType(id) {
return request({
url: '/admin-api/mes/device-type/get',
method: 'get',
params: { id }
})
}
export function createDeviceType(data) {
return request({
url: '/admin-api/mes/device-type/create',
method: 'post',
data
})
}
export function updateDeviceType(data) {
return request({
url: '/admin-api/mes/device-type/update',
method: 'put',
data
})
}
export function deleteDeviceType(id) {
return request({
url: '/admin-api/mes/device-type/delete',
method: 'delete',
params: { id }
})
}

@ -159,7 +159,9 @@ export default {
uploading: 'Uploading',
uploadImageFailed: 'Image upload failed',
yes: 'Yes',
no: 'No'
no: 'No',
noMore: 'No more data',
confirmTitle: 'Confirm'
},
moldGet: {
moduleName: 'Mold Stock-out',
@ -654,5 +656,127 @@ export default {
detailLossRate: 'Loss Rate',
detailRemark: 'Remark',
detailEmpty: 'No BOM detail data'
},
equipmentCategory: {
moduleName: 'Equipment Category',
subTitle: 'Equipment category management',
detailTitle: 'Category Detail',
basicInfo: 'Basic Info',
code: 'Category Code',
name: 'Category Name',
parentName: 'Parent Category',
rootCategory: 'None (Top Level)',
sort: 'Sort',
remark: 'Remark',
createTime: 'Created At',
searchPlaceholder: 'Enter category code/name',
createTitle: 'Create Category',
editTitle: 'Edit Category',
empty: 'No category data',
loadEditFailed: 'Failed to load edit data',
noId: 'Missing category ID',
loadFailed: 'Failed to load category detail',
placeholderCode: 'Enter category code',
placeholderName: 'Enter category name',
placeholderParent: 'Select parent category',
placeholderSort: 'Enter sort order',
placeholderRemark: 'Enter remark',
validatorCodeRequired: 'Category code is required',
validatorNameRequired: 'Category name is required',
confirmDeleteContent: 'Confirm delete category [{name}]?'
},
equipmentLedger: {
moduleName: 'Equipment Ledger',
subTitle: 'Equipment ledger management',
detailTitle: 'Equipment Detail',
basicInfo: 'Basic Info',
deviceCode: 'Device Code',
deviceName: 'Device Name',
deviceType: 'Device Type',
deviceStatus: 'Device Status',
deviceSpec: 'Device Spec',
isScheduled: 'Scheduled',
ratedCapacity: 'Rated Capacity',
dailyAverageValue: 'Daily Avg Value',
dataCollectionCapacity: 'Data Collection Capacity',
productionDate: 'Production Date',
factoryEntryDate: 'Factory Entry Date',
deviceLocation: 'Device Location',
deviceManagerName: 'Device Manager',
workshop: 'Workshop',
remark: 'Remark',
creatorName: 'Creator',
createTime: 'Created At',
autoCode: 'Auto Generate',
yes: 'Yes',
no: 'No',
searchPlaceholder: 'Enter device code/name',
createTitle: 'Create Equipment',
editTitle: 'Edit Equipment',
empty: 'No equipment data',
loadEditFailed: 'Failed to load edit data',
noId: 'Missing equipment ID',
loadFailed: 'Failed to load equipment detail',
placeholderDeviceCode: 'Enter device code',
placeholderDeviceName: 'Enter device name',
placeholderDeviceType: 'Select device type',
placeholderDeviceSpec: 'Enter device spec',
placeholderRatedCapacity: 'Enter rated capacity',
placeholderDailyAverageValue: 'Enter daily average value',
placeholderDataCollectionCapacity: 'Enter data collection capacity',
placeholderProductionDate: 'Select production date',
placeholderFactoryEntryDate: 'Select factory entry date',
placeholderDeviceLocation: 'Enter device location',
placeholderRemark: 'Enter remark',
validatorDeviceCodeRequired: 'Device code is required',
validatorDeviceNameRequired: 'Device name is required',
validatorDeviceTypeRequired: 'Device type is required',
validatorProductionDateRequired: 'Production date is required',
validatorFactoryEntryDateRequired: 'Factory entry date is required',
validatorRatedCapacityRequired: 'Rated capacity is required',
validatorDailyAverageValueRequired: 'Daily average value is required',
validatorDataCollectionCapacityRequired: 'Data collection capacity is required',
confirmDeleteContent: 'Confirm delete equipment [{name}]?',
checkHistory: 'Inspection',
maintainHistory: 'Maintenance',
repairHistory: 'Repair',
operator: 'Operator',
noHistoryData: 'No history data',
resultPass: 'Pass',
resultFail: 'Fail',
repairPending: 'Pending',
repairProcessing: 'Processing',
repairCompleted: 'Completed'
},
criticalComponent: {
moduleName: 'Critical Component',
subTitle: 'Equipment critical component management',
detailTitle: 'Component Detail',
basicInfo: 'Basic Info',
code: 'Component Code',
name: 'Component Name',
deviceSpec: 'Specification',
description: 'Description',
count: 'Count',
remark: 'Remark',
creatorName: 'Creator',
createTime: 'Created At',
autoCode: 'Auto Generate',
searchPlaceholder: 'Enter code/name',
createTitle: 'Create Component',
editTitle: 'Edit Component',
empty: 'No component data',
loadEditFailed: 'Failed to load edit data',
noId: 'Missing component ID',
loadFailed: 'Failed to load component detail',
placeholderCode: 'Enter component code',
placeholderName: 'Enter component name',
placeholderDeviceSpec: 'Enter specification',
placeholderDescription: 'Enter description',
placeholderCount: 'Enter count',
placeholderRemark: 'Enter remark',
validatorCodeRequired: 'Component code is required',
validatorNameRequired: 'Component name is required',
confirmDeleteContent: 'Confirm delete component [{name}]?'
}
}

@ -159,7 +159,9 @@ export default {
uploading: '上传中',
uploadImageFailed: '图片上传失败',
yes: '是',
no: '否'
no: '否',
noMore: '没有更多数据了',
confirmTitle: '提示'
},
moldGet: {
moduleName: '模具出库',
@ -654,5 +656,127 @@ export default {
detailLossRate: '损耗率',
detailRemark: '备注',
detailEmpty: '暂无BOM明细数据'
},
equipmentCategory: {
moduleName: '设备分类',
subTitle: '设备分类管理',
detailTitle: '设备分类详情',
basicInfo: '基础信息',
code: '分类编码',
name: '分类名称',
parentName: '上级分类',
rootCategory: '无(顶级分类)',
sort: '排序',
remark: '备注',
createTime: '创建时间',
searchPlaceholder: '请输入分类编码/名称',
createTitle: '新增设备分类',
editTitle: '编辑设备分类',
empty: '暂无设备分类数据',
loadEditFailed: '加载编辑数据失败',
noId: '缺少分类ID',
loadFailed: '加载分类详情失败',
placeholderCode: '请输入分类编码',
placeholderName: '请输入分类名称',
placeholderParent: '请选择上级分类',
placeholderSort: '请输入排序',
placeholderRemark: '请输入备注',
validatorCodeRequired: '分类编码不能为空',
validatorNameRequired: '分类名称不能为空',
confirmDeleteContent: '确认删除分类【{name}】吗?'
},
equipmentLedger: {
moduleName: '设备台账',
subTitle: '设备台账管理',
detailTitle: '设备台账详情',
basicInfo: '基础信息',
deviceCode: '设备编码',
deviceName: '设备名称',
deviceType: '设备类型',
deviceStatus: '设备状态',
deviceSpec: '设备规格',
isScheduled: '是否排产',
ratedCapacity: '额定产能',
dailyAverageValue: '每日报工平均值',
dataCollectionCapacity: '数据采集产能',
productionDate: '生产日期',
factoryEntryDate: '入厂日期',
deviceLocation: '设备位置',
deviceManagerName: '设备负责人',
workshop: '所属车间',
remark: '备注',
creatorName: '创建人',
createTime: '创建时间',
autoCode: '自动生成',
yes: '是',
no: '否',
searchPlaceholder: '请输入设备编码/名称',
createTitle: '新增设备台账',
editTitle: '编辑设备台账',
empty: '暂无设备台账数据',
loadEditFailed: '加载编辑数据失败',
noId: '缺少设备ID',
loadFailed: '加载设备详情失败',
placeholderDeviceCode: '请输入设备编码',
placeholderDeviceName: '请输入设备名称',
placeholderDeviceType: '请选择设备类型',
placeholderDeviceSpec: '请输入设备规格',
placeholderRatedCapacity: '请输入额定产能',
placeholderDailyAverageValue: '请输入每日报工平均值',
placeholderDataCollectionCapacity: '请输入数据采集产能',
placeholderProductionDate: '请选择生产日期',
placeholderFactoryEntryDate: '请选择入厂日期',
placeholderDeviceLocation: '请输入设备位置',
placeholderRemark: '请输入备注',
validatorDeviceCodeRequired: '设备编码不能为空',
validatorDeviceNameRequired: '设备名称不能为空',
validatorDeviceTypeRequired: '设备类型不能为空',
validatorProductionDateRequired: '生产日期不能为空',
validatorFactoryEntryDateRequired: '入厂日期不能为空',
validatorRatedCapacityRequired: '额定产能不能为空',
validatorDailyAverageValueRequired: '每日报工平均值不能为空',
validatorDataCollectionCapacityRequired: '数据采集产能不能为空',
confirmDeleteContent: '确认删除设备【{name}】吗?',
checkHistory: '点检记录',
maintainHistory: '保养记录',
repairHistory: '报修记录',
operator: '操作人',
noHistoryData: '暂无历史记录',
resultPass: '合格',
resultFail: '不合格',
repairPending: '待处理',
repairProcessing: '处理中',
repairCompleted: '已完成'
},
criticalComponent: {
moduleName: '设备关键件',
subTitle: '设备关键件管理',
detailTitle: '设备关键件详情',
basicInfo: '基础信息',
code: '关键件编码',
name: '关键件名称',
deviceSpec: '规格',
description: '描述',
count: '数量',
remark: '备注',
creatorName: '创建人',
createTime: '创建时间',
autoCode: '自动生成',
searchPlaceholder: '请输入编码/名称',
createTitle: '新增设备关键件',
editTitle: '编辑设备关键件',
empty: '暂无设备关键件数据',
loadEditFailed: '加载编辑数据失败',
noId: '缺少关键件ID',
loadFailed: '加载关键件详情失败',
placeholderCode: '请输入关键件编码',
placeholderName: '请输入关键件名称',
placeholderDeviceSpec: '请输入规格',
placeholderDescription: '请输入描述',
placeholderCount: '请输入数量',
placeholderRemark: '请输入备注',
validatorCodeRequired: '关键件编码不能为空',
validatorNameRequired: '关键件名称不能为空',
confirmDeleteContent: '确认删除关键件【{name}】吗?'
}
}

@ -428,6 +428,13 @@
"navigationStyle": "custom"
}
},
{
"path": "equipmentCategory/detail",
"style": {
"navigationBarTitleText": "设备分类详情",
"navigationStyle": "custom"
}
},
{
"path": "equipmentLedger/index",
"style": {
@ -436,12 +443,26 @@
}
},
{
"path": "equipmentKeypart/index",
"path": "equipmentLedger/detail",
"style": {
"navigationBarTitleText": "设备台账详情",
"navigationStyle": "custom"
}
},
{
"path": "criticalComponent/index",
"style": {
"navigationBarTitleText": "设备关键件",
"navigationStyle": "custom"
}
},
{
"path": "criticalComponent/detail",
"style": {
"navigationBarTitleText": "设备关键件详情",
"navigationStyle": "custom"
}
},
{
"path": "moldType/index",
"style": {

@ -173,7 +173,7 @@
<view class="function-item" @click="handleClick('点检任务')">
<view class="function-icon" style="background: rgba(255, 140, 0, 0.1);">
<text class="icon-inner"></text>
</view>
</view>
<text class="function-name">点检任务</text>
</view>
<view class="function-item" @click="handleClick('点检记录')">
@ -373,7 +373,7 @@ function handleClick(name) {
'产品BOM': '/pages_function/pages/productBom/index',
'设备分类': '/pages_function/pages/equipmentCategory/index',
'设备台账': '/pages_function/pages/equipmentLedger/index',
'设备关键件': '/pages_function/pages/equipmentKeypart/index',
'设备关键件': '/pages_function/pages/criticalComponent/index',
'模具类型': '/pages_function/pages/moldType/index',
'模具台账': '/pages_function/pages/moldLedger/index',
moldGet: '/pages_function/pages/moldget/index',

@ -0,0 +1,140 @@
<template>
<view class="page-container">
<view class="fixed-header">
<AppTitleHeader :title="t('criticalComponent.detailTitle')" />
</view>
<scroll-view scroll-y class="detail-scroll">
<view class="content-section">
<!-- 基础信息 -->
<view class="info-card">
<view class="card-title">{{ t('criticalComponent.basicInfo') }}</view>
<view class="info-list">
<view class="info-row">
<text class="info-label">{{ t('criticalComponent.code') }}</text>
<text class="info-value">{{ fieldValue('code') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('criticalComponent.name') }}</text>
<text class="info-value">{{ fieldValue('name') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('criticalComponent.deviceSpec') }}</text>
<text class="info-value">{{ fieldValue('deviceSpec') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('criticalComponent.description') }}</text>
<text class="info-value">{{ fieldValue('description') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('criticalComponent.count') }}</text>
<text class="info-value">{{ fieldValue('count') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('criticalComponent.creatorName') }}</text>
<text class="info-value">{{ fieldValue('creatorName') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('criticalComponent.createTime') }}</text>
<text class="info-value">{{ formatDateTime(detailData?.createTime) }}</text>
</view>
<view class="info-row remark-row">
<text class="info-label">{{ t('criticalComponent.remark') }}</text>
<text class="info-value remark-value">{{ fieldValue('remark') }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import AppTitleHeader from '@/components/common/AppTitleHeader.vue'
import { getCriticalComponent } from '@/api/mes/criticalComponent'
const { t } = useI18n()
const detailId = ref(undefined)
const detailData = ref(null)
onLoad(async (query) => {
const id = query?.id ? decodeURIComponent(String(query.id)) : ''
detailId.value = id || undefined
await fetchDetail()
})
async function fetchDetail() {
if (!detailId.value) {
uni.showToast({ title: t('criticalComponent.noId'), icon: 'none' })
return
}
try {
const res = await getCriticalComponent(detailId.value)
detailData.value = normalizeDetail(res)
} catch (e) {
uni.showToast({ title: t('criticalComponent.loadFailed'), icon: 'none' })
}
}
function normalizeDetail(res) {
const root = res && res.data !== undefined ? res.data : res
if (root?.data && typeof root.data === 'object') return root.data
if (root && typeof root === 'object') return root
return {}
}
function fieldValue(field) {
return textValue(detailData.value ? detailData.value[field] : undefined)
}
function textValue(value) {
if (value === 0) return '0'
if (value === false) return t('functionCommon.no')
if (value === true) return t('functionCommon.yes')
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function formatDateTime(value) {
if (!value) return '-'
if (Array.isArray(value) && value.length >= 3) {
const [y, m, d, hh = 0, mm = 0, ss = 0] = value
const pad = (n) => String(n).padStart(2, '0')
return `${y}-${pad(m)}-${pad(d)} ${pad(hh)}:${pad(mm)}:${pad(ss)}`
}
const text = String(value).trim()
if (!text) return '-'
const numeric = Number(text)
if (Number.isFinite(numeric)) {
const timestamp = text.length === 10 ? numeric * 1000 : numeric
const date = new Date(timestamp)
if (!Number.isNaN(date.getTime())) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
}
const date = new Date(text)
if (!Number.isNaN(date.getTime())) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
return text
}
</script>
<style lang="scss" scoped>
.page-container { min-height: 100vh; background-color: #f0f2f5; }
.fixed-header { position: sticky; top: 0; z-index: 10; }
.detail-scroll { height: calc(100vh - 120rpx); }
.content-section { padding: 0 24rpx 24rpx; }
.info-card { margin-top: 20rpx; background: #ffffff; border-radius: 20rpx; padding: 28rpx; box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); }
.card-title { font-size: 32rpx; color: #1a3a5c; font-weight: 700; margin-bottom: 18rpx; }
.info-list { background: #ffffff; }
.info-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 18rpx 0; border-bottom: 1rpx solid #edf0f3; }
.info-label { font-size: 27rpx; color: #8a9099; width: 220rpx; }
.info-value { flex: 1; text-align: right; font-size: 28rpx; color: #30363d; line-height: 1.45; }
.remark-row { border-bottom: none; }
.remark-value { white-space: pre-wrap; }
</style>

@ -0,0 +1,422 @@
<template>
<view class="page-container">
<AppTitleHeader :title="t('criticalComponent.moduleName')" :subTitle="t('criticalComponent.subTitle')" :showSubTitle="true" />
<!-- 搜索区域 -->
<view class="search-card">
<view class="search-row">
<view class="search-input-wrap">
<text class="iconfont icon-search search-icon"></text>
<input v-model="searchKeyword" class="search-input" :placeholder="t('criticalComponent.searchPlaceholder')" @confirm="handleSearch" />
</view>
<view class="search-btn" @click="handleSearch">{{ t('functionCommon.search') }}</view>
</view>
</view>
<!-- 列表区域 -->
<scroll-view scroll-y class="list-scroll" :scroll-top="scrollTop" @scroll="onScroll" @scrolltolower="loadMore" :lower-threshold="80">
<view class="list-wrap">
<view v-for="item in list" :key="item.id" class="type-card" @click="openDetail(item)">
<view class="card-header">
<view class="header-left">
<text class="type-name">{{ textValue(item.name) }}</text>
<text class="type-code">{{ t('criticalComponent.code') }}: {{ textValue(item.code) }}</text>
</view>
</view>
<view class="card-body">
<view class="row">
<text class="label">{{ t('criticalComponent.deviceSpec') }}</text>
<text class="value">{{ textValue(item.deviceSpec) }}</text>
</view>
<view class="row">
<text class="label">{{ t('criticalComponent.description') }}</text>
<text class="value">{{ textValue(item.description) }}</text>
</view>
<view class="row">
<text class="label">{{ t('criticalComponent.count') }}</text>
<text class="value">{{ textValue(item.count) }}</text>
</view>
</view>
<view class="card-actions">
<view class="action-btn edit-btn" @click.stop="openEdit(item)">
<uni-icons type="compose" size="18" color="#ffffff"></uni-icons>
</view>
<view class="action-btn delete-btn" @click.stop="confirmDelete(item)">
<uni-icons type="trash" size="18" color="#ffffff"></uni-icons>
</view>
</view>
</view>
<view v-if="loading && pageNo === 1" class="loading-text">{{ t('functionCommon.loading') }}</view>
<view v-else-if="!list.length" class="empty-text">{{ t('criticalComponent.empty') }}</view>
<view v-else-if="loadingMore" class="loading-text">{{ t('functionCommon.loadingMore') }}</view>
<view v-else-if="finished" class="finished-text">{{ t('functionCommon.noMoreData') }}</view>
</view>
</scroll-view>
<!-- 新增悬浮按钮 -->
<view class="add-btn" @click="openCreate">
<text class="add-icon">+</text>
</view>
<!-- 返回顶部 -->
<view v-if="showGoTop" class="go-top-btn" @click="goTop">
<text class="go-top-icon"></text>
</view>
<!-- 新增/编辑弹框 -->
<uni-popup ref="formPopupRef" type="center" background-color="#fff">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">{{ formMode === 'create' ? t('criticalComponent.createTitle') : t('criticalComponent.editTitle') }}</text>
<view class="popup-close" @click="closeForm">
<text class="close-icon">×</text>
</view>
</view>
<scroll-view scroll-y class="form-scroll">
<view class="form-content">
<!-- 编码 -->
<view class="form-item">
<text class="form-label">{{ t('criticalComponent.code') }} <text class="required-star">*</text></text>
<view class="code-row">
<input v-model="formData.code" class="form-input code-input" type="text" :placeholder="t('criticalComponent.placeholderCode')" :disabled="formData.isCode || formMode === 'update'" />
<view class="auto-code-wrap" @click="toggleAutoCode">
<text class="auto-code-label">{{ t('criticalComponent.autoCode') }}</text>
<view :class="['auto-code-switch', formData.isCode ? 'active' : '']">
<text class="switch-dot"></text>
</view>
</view>
</view>
</view>
<!-- 名称 -->
<view class="form-item">
<text class="form-label">{{ t('criticalComponent.name') }} <text class="required-star">*</text></text>
<input v-model="formData.name" class="form-input" type="text" :placeholder="t('criticalComponent.placeholderName')" />
</view>
<!-- 规格 -->
<view class="form-item">
<text class="form-label">{{ t('criticalComponent.deviceSpec') }}</text>
<input v-model="formData.deviceSpec" class="form-input" type="text" :placeholder="t('criticalComponent.placeholderDeviceSpec')" />
</view>
<!-- 描述 -->
<view class="form-item">
<text class="form-label">{{ t('criticalComponent.description') }}</text>
<textarea v-model="formData.description" class="form-textarea" :placeholder="t('criticalComponent.placeholderDescription')" :maxlength="500" />
</view>
<!-- 数量 -->
<view class="form-item">
<text class="form-label">{{ t('criticalComponent.count') }}</text>
<input v-model="formData.count" class="form-input" type="digit" :placeholder="t('criticalComponent.placeholderCount')" />
</view>
<!-- 备注 -->
<view class="form-item">
<text class="form-label">{{ t('criticalComponent.remark') }}</text>
<textarea v-model="formData.remark" class="form-textarea" :placeholder="t('criticalComponent.placeholderRemark')" :maxlength="200" />
</view>
</view>
</scroll-view>
<view class="form-footer">
<view class="footer-btn cancel-btn" @click="closeForm">
<text class="btn-text">{{ t('functionCommon.cancel') }}</text>
</view>
<view class="footer-btn confirm-btn" @click="submitForm">
<text class="btn-text">{{ t('functionCommon.save') }}</text>
</view>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import AppTitleHeader from '@/components/common/AppTitleHeader.vue'
import { getCriticalComponentPage, getCriticalComponent, createCriticalComponent, updateCriticalComponent, deleteCriticalComponent } from '@/api/mes/criticalComponent'
const { t } = useI18n()
const formPopupRef = ref(null)
const searchKeyword = ref('')
const list = ref([])
const loading = ref(false)
const loadingMore = ref(false)
const finished = ref(false)
const pageNo = ref(1)
const pageSize = ref(10)
const total = ref(0)
const scrollTop = ref(0)
const showGoTop = ref(false)
const formMode = ref('create')
const formData = reactive({
id: undefined,
code: '',
isCode: true,
name: '',
deviceSpec: '',
description: '',
count: '',
remark: ''
})
onLoad(async () => {
await fetchList(true)
})
async function fetchList(reset) {
if (reset) {
pageNo.value = 1
finished.value = false
}
if (pageNo.value === 1) {
loading.value = true
} else {
loadingMore.value = true
}
try {
const params = {
pageNo: pageNo.value,
pageSize: pageSize.value,
code: searchKeyword.value.trim() || undefined,
name: searchKeyword.value.trim() || undefined
}
const res = await getCriticalComponentPage(params)
const page = normalizePageData(res)
total.value = page.total
if (reset) {
list.value = page.list
} else {
list.value = [...list.value, ...page.list]
}
const loadedCount = list.value.length
finished.value = loadedCount >= total.value || page.list.length < pageSize.value
} catch (e) {
if (!reset) pageNo.value = Math.max(1, pageNo.value - 1)
uni.showToast({ title: t('functionCommon.loadFailed'), icon: 'none' })
} finally {
loading.value = false
loadingMore.value = false
}
}
function normalizePageData(res) {
const root = res && res.data !== undefined ? res.data : res
const candidateList = root?.list || root?.rows || root?.records || root?.data?.list || root?.data?.rows || root?.data?.records || []
const candidateTotal = root?.total ?? root?.data?.total ?? (Array.isArray(candidateList) ? candidateList.length : 0)
return { list: Array.isArray(candidateList) ? candidateList : [], total: Number(candidateTotal || 0) }
}
async function handleSearch() {
await fetchList(true)
}
function onScroll(e) {
const top = e?.detail?.scrollTop || 0
showGoTop.value = top > 600
}
function goTop() {
scrollTop.value = 0
}
async function loadMore() {
if (loading.value || loadingMore.value || finished.value) return
pageNo.value += 1
await fetchList(false)
}
function openCreate() {
formMode.value = 'create'
resetForm()
formPopupRef.value?.open()
}
async function openEdit(item) {
const id = item?.id
if (id === undefined || id === null) {
uni.showToast({ title: t('criticalComponent.noId'), icon: 'none' })
return
}
try {
const res = await getCriticalComponent(id)
const detail = normalizeDetail(res)
formMode.value = 'update'
formData.id = detail?.id
formData.code = textValueForInput(detail?.code)
formData.isCode = Boolean(detail?.isCode)
formData.name = textValueForInput(detail?.name)
formData.deviceSpec = textValueForInput(detail?.deviceSpec)
formData.description = textValueForInput(detail?.description)
formData.count = textValueForInput(detail?.count)
formData.remark = textValueForInput(detail?.remark)
formPopupRef.value?.open()
} catch (e) {
uni.showToast({ title: t('criticalComponent.loadEditFailed'), icon: 'none' })
}
}
function confirmDelete(item) {
const id = item?.id
if (id === undefined || id === null) return
uni.showModal({
title: t('functionCommon.confirmTitle'),
content: t('criticalComponent.confirmDeleteContent', { name: textValue(item?.name) }),
success: async (res) => {
if (!res.confirm) return
try {
await deleteCriticalComponent(String(id))
uni.showToast({ title: t('functionCommon.deleteSuccess'), icon: 'success' })
await fetchList(true)
} catch (e) {
uni.showToast({ title: t('functionCommon.deleteFailed'), icon: 'none' })
}
}
})
}
function closeForm() {
formPopupRef.value?.close()
}
function toggleAutoCode() {
if (formMode.value === 'update') return
formData.isCode = !formData.isCode
if (formData.isCode) formData.code = ''
}
async function submitForm() {
if (!formData.isCode && !formData.code.trim()) {
uni.showToast({ title: t('criticalComponent.validatorCodeRequired'), icon: 'none' })
return
}
if (!formData.name.trim()) {
uni.showToast({ title: t('criticalComponent.validatorNameRequired'), icon: 'none' })
return
}
const payload = {
id: formMode.value === 'update' ? formData.id : undefined,
code: formData.isCode ? undefined : formData.code.trim(),
isCode: formData.isCode,
name: formData.name.trim(),
deviceSpec: formData.deviceSpec.trim() || undefined,
description: formData.description.trim() || undefined,
count: toNumberOrUndefined(formData.count),
remark: formData.remark.trim() || undefined
}
try {
if (formMode.value === 'create') {
await createCriticalComponent(payload)
uni.showToast({ title: t('functionCommon.createSuccess'), icon: 'success' })
} else {
await updateCriticalComponent(payload)
uni.showToast({ title: t('functionCommon.updateSuccess'), icon: 'success' })
}
closeForm()
await fetchList(true)
} catch (e) {
uni.showToast({ title: t('functionCommon.saveFailed'), icon: 'none' })
}
}
function resetForm() {
formData.id = undefined
formData.code = ''
formData.isCode = true
formData.name = ''
formData.deviceSpec = ''
formData.description = ''
formData.count = ''
formData.remark = ''
}
function openDetail(item) {
const id = item?.id
if (!id && id !== 0) return
uni.navigateTo({
url: `/pages_function/pages/criticalComponent/detail?id=${encodeURIComponent(String(id))}`
})
}
function normalizeDetail(res) {
const root = res && res.data !== undefined ? res.data : res
if (root?.data && typeof root.data === 'object') return root.data
if (root && typeof root === 'object') return root
return {}
}
function textValue(value) {
if (value === 0) return '0'
if (value === false) return t('functionCommon.no')
if (value === true) return t('functionCommon.yes')
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function textValueForInput(value) {
if (value === null || value === undefined) return ''
return String(value)
}
function toNumberOrUndefined(value) {
if (value === null || value === undefined || String(value).trim() === '') return undefined
const num = Number(value)
return Number.isFinite(num) ? num : undefined
}
</script>
<style lang="scss" scoped>
.page-container { min-height: 100vh; background-color: #f0f2f5; }
.search-card { background: #ffffff; margin: 20rpx 24rpx; border-radius: 18rpx; padding: 20rpx; box-shadow: 0 4rpx 18rpx rgba(0, 0, 0, 0.04); }
.search-row { display: flex; align-items: center; gap: 16rpx; }
.search-input-wrap { flex: 1; display: flex; align-items: center; background: #f5f7fa; border-radius: 44rpx; padding: 0 20rpx; }
.search-icon { margin-right: 12rpx; font-size: 30rpx; color: #999; }
.search-input { flex: 1; height: 72rpx; font-size: 28rpx; color: #333; background: transparent; }
.search-btn { min-width: 120rpx; height: 72rpx; background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%); border-radius: 14rpx; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 28rpx; font-weight: 600; }
.list-scroll { height: calc(100vh - 360rpx); }
.list-wrap { padding: 0 24rpx 30rpx; }
.type-card { background: #ffffff; border-radius: 18rpx; padding: 24rpx; margin-bottom: 18rpx; box-shadow: 0 4rpx 18rpx rgba(0, 0, 0, 0.05); }
.card-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 18rpx; border-bottom: 1rpx solid #edf0f3; }
.header-left { display: flex; flex-direction: column; }
.type-name { font-size: 32rpx; font-weight: 600; color: #1a3a5c; margin-bottom: 8rpx; }
.type-code { font-size: 24rpx; color: #8a9099; }
.card-body { padding-top: 16rpx; }
.row { display: flex; justify-content: space-between; align-items: center; margin-top: 12rpx; }
.label { font-size: 26rpx; color: #8a9099; }
.value { font-size: 27rpx; color: #30363d; max-width: 62%; text-align: right; }
.card-actions { margin-top: 24rpx; display: flex; justify-content: flex-end; gap: 14rpx; }
.action-btn { width: 60rpx; height: 60rpx; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; }
.edit-btn { background: #1a3a5c; }
.delete-btn { background: #ff4d4f; }
.add-btn { position: fixed; right: 28rpx; bottom: 140rpx; width: 96rpx; height: 96rpx; border-radius: 48rpx; background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%); display: flex; align-items: center; justify-content: center; box-shadow: 0 8rpx 24rpx rgba(26, 58, 92, 0.32); z-index: 99; }
.add-icon { color: #ffffff; font-size: 56rpx; line-height: 1; }
.go-top-btn { position: fixed; right: 28rpx; bottom: 254rpx; width: 88rpx; height: 88rpx; border-radius: 44rpx; background: rgba(26, 58, 92, 0.9); display: flex; align-items: center; justify-content: center; z-index: 99; box-shadow: 0 6rpx 16rpx rgba(26, 58, 92, 0.25); }
.go-top-icon { color: #ffffff; font-size: 36rpx; font-weight: 700; }
.popup-content { width: 680rpx; max-height: 80vh; border-radius: 20rpx; overflow: hidden; }
.popup-header { padding: 24rpx; display: flex; align-items: center; justify-content: space-between; border-bottom: 1rpx solid #edf0f3; }
.popup-title { font-size: 32rpx; color: #1a3a5c; font-weight: 700; }
.close-icon { font-size: 38rpx; color: #8e95a0; }
.form-scroll { max-height: 56vh; }
.form-content { padding: 24rpx; }
.form-item { margin-bottom: 18rpx; }
.form-label { display: block; margin-bottom: 8rpx; font-size: 26rpx; color: #8a9099; }
.required-star { color: #ff4d4f; }
.form-input { height: 76rpx; padding: 0 20rpx; border-radius: 12rpx; background: #f5f7fa; font-size: 28rpx; color: #30363d; }
.code-row { display: flex; align-items: center; gap: 16rpx; }
.code-input { flex: 1; }
.auto-code-wrap { display: flex; align-items: center; gap: 8rpx; }
.auto-code-label { font-size: 24rpx; color: #8a9099; white-space: nowrap; }
.auto-code-switch { width: 64rpx; height: 36rpx; border-radius: 18rpx; background: #dcdfe6; position: relative; transition: background 0.3s; }
.auto-code-switch.active { background: #1a3a5c; }
.switch-dot { position: absolute; top: 4rpx; left: 4rpx; width: 28rpx; height: 28rpx; border-radius: 14rpx; background: #fff; transition: transform 0.3s; }
.auto-code-switch.active .switch-dot { transform: translateX(28rpx); }
.form-textarea { width: 100%; min-height: 130rpx; padding: 18rpx 20rpx; border-radius: 12rpx; background: #f5f7fa; font-size: 28rpx; color: #30363d; }
.form-footer { display: flex; gap: 14rpx; padding: 20rpx 24rpx 24rpx; }
.footer-btn { flex: 1; height: 76rpx; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; }
.cancel-btn { background: #edf0f4; }
.confirm-btn { background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%); }
.btn-text { font-size: 28rpx; font-weight: 600; color: #ffffff; }
.cancel-btn .btn-text { color: #586070; }
.loading-text, .empty-text, .finished-text { text-align: center; padding: 28rpx 0; color: #99a1aa; font-size: 26rpx; }
</style>

@ -0,0 +1,162 @@
<template>
<view class="page-container">
<view class="fixed-header">
<AppTitleHeader :title="t('equipmentCategory.detailTitle')" />
</view>
<scroll-view scroll-y class="detail-scroll">
<view class="content-section">
<!-- 基础信息 -->
<view class="info-card">
<view class="card-title">{{ t('equipmentCategory.basicInfo') }}</view>
<view class="info-list">
<view class="info-row">
<text class="info-label">{{ t('equipmentCategory.code') }}</text>
<text class="info-value">{{ fieldValue('code') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentCategory.name') }}</text>
<text class="info-value">{{ fieldValue('name') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentCategory.parentName') }}</text>
<text class="info-value">{{ parentNameText }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentCategory.sort') }}</text>
<text class="info-value">{{ fieldValue('sort') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentCategory.createTime') }}</text>
<text class="info-value">{{ formatDateTime(detailData?.createTime) }}</text>
</view>
<view class="info-row remark-row">
<text class="info-label">{{ t('equipmentCategory.remark') }}</text>
<text class="info-value remark-value">{{ fieldValue('remark') }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import AppTitleHeader from '@/components/common/AppTitleHeader.vue'
import { getDeviceType, getDeviceTypeTree } from '@/api/mes/deviceType'
const { t } = useI18n()
const detailId = ref(undefined)
const detailData = ref(null)
const allCategories = ref([])
const parentNameText = computed(() => {
const parentId = detailData.value?.parentId
if (!parentId || parentId === 0) return t('equipmentCategory.rootCategory')
const parent = allCategories.value.find(c => c.id === parentId)
return parent ? parent.name : t('equipmentCategory.rootCategory')
})
onLoad(async (query) => {
const id = query?.id ? decodeURIComponent(String(query.id)) : ''
detailId.value = id || undefined
await fetchCategoryList()
await fetchDetail()
})
async function fetchCategoryList() {
try {
const res = await getDeviceTypeTree({})
const root = res && res.data !== undefined ? res.data : res
const treeData = Array.isArray(root) ? root : (Array.isArray(root?.data) ? root.data : [])
allCategories.value = flattenTree(treeData)
} catch (e) {}
}
function flattenTree(nodes) {
const result = []
nodes.forEach(node => {
result.push(node)
if (node.children && node.children.length) {
result.push(...flattenTree(node.children))
}
})
return result
}
async function fetchDetail() {
if (!detailId.value) {
uni.showToast({ title: t('equipmentCategory.noId'), icon: 'none' })
return
}
try {
const res = await getDeviceType(detailId.value)
detailData.value = normalizeDetail(res)
} catch (e) {
uni.showToast({ title: t('equipmentCategory.loadFailed'), icon: 'none' })
}
}
function normalizeDetail(res) {
const root = res && res.data !== undefined ? res.data : res
if (root?.data && typeof root.data === 'object') return root.data
if (root && typeof root === 'object') return root
return {}
}
function fieldValue(field) {
return textValue(detailData.value ? detailData.value[field] : undefined)
}
function textValue(value) {
if (value === 0) return '0'
if (value === false) return t('functionCommon.no')
if (value === true) return t('functionCommon.yes')
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function formatDateTime(value) {
if (!value) return '-'
if (Array.isArray(value) && value.length >= 3) {
const [y, m, d, hh = 0, mm = 0, ss = 0] = value
const pad = (n) => String(n).padStart(2, '0')
return `${y}-${pad(m)}-${pad(d)} ${pad(hh)}:${pad(mm)}:${pad(ss)}`
}
const text = String(value).trim()
if (!text) return '-'
const numeric = Number(text)
if (Number.isFinite(numeric)) {
const timestamp = text.length === 10 ? numeric * 1000 : numeric
const date = new Date(timestamp)
if (!Number.isNaN(date.getTime())) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
}
const date = new Date(text)
if (!Number.isNaN(date.getTime())) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
return text
}
</script>
<style lang="scss" scoped>
.page-container { min-height: 100vh; background-color: #f0f2f5; }
.fixed-header { position: sticky; top: 0; z-index: 10; }
.detail-scroll { height: calc(100vh - 120rpx); }
.content-section { padding: 0 24rpx 24rpx; }
.info-card { margin-top: 20rpx; background: #ffffff; border-radius: 20rpx; padding: 28rpx; box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); }
.card-title { font-size: 32rpx; color: #1a3a5c; font-weight: 700; margin-bottom: 18rpx; }
.info-list { background: #ffffff; }
.info-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 18rpx 0; border-bottom: 1rpx solid #edf0f3; }
.info-row:last-child { border-bottom: none; }
.info-label { font-size: 27rpx; color: #8a9099; width: 220rpx; }
.info-value { flex: 1; text-align: right; font-size: 28rpx; color: #30363d; line-height: 1.45; }
.remark-row { border-bottom: none; }
.remark-value { white-space: pre-wrap; }
</style>

@ -1,123 +1,112 @@
<template>
<view class="page-container">
<view class="header-section">
<view class="back-btn" @click="goBack">
<text class="back-icon"></text>
<text class="back-text">返回</text>
</view>
<text class="header-title">设备分类</text>
</view>
<view class="search-section">
<view class="search-wrapper">
<view class="search-icon">
<text class="iconfont icon-search"></text>
</view>
<input
v-model="searchKeyword"
class="search-input"
type="text"
placeholder="请输入分类编码或名称"
placeholder-class="input-placeholder"
@input="handleSearch"
/>
<view v-if="searchKeyword" class="clear-btn" @click="clearSearch">
<text class="clear-icon">×</text>
<AppTitleHeader :title="t('equipmentCategory.moduleName')" :subTitle="t('equipmentCategory.subTitle')" :showSubTitle="true" />
<!-- 搜索区域 -->
<view class="search-card">
<view class="search-row">
<view class="search-input-wrap">
<text class="iconfont icon-search search-icon"></text>
<input v-model="searchKeyword" class="search-input" :placeholder="t('equipmentCategory.searchPlaceholder')" @confirm="handleSearch" />
</view>
<view class="search-btn" @click="handleSearch">{{ t('functionCommon.search') }}</view>
</view>
</view>
<scroll-view scroll-y class="content-scroll">
<view class="category-list">
<view
v-for="(item, index) in categoryList"
:key="index"
class="category-card"
>
<!-- 列表区域 -->
<scroll-view scroll-y class="list-scroll" :scroll-top="scrollTop" @scroll="onScroll" @scrolltolower="loadMore" :lower-threshold="80">
<view class="list-wrap">
<view v-for="item in filteredList" :key="item.id" class="type-card" @click="openDetail(item)">
<view class="card-header">
<view class="header-left">
<text class="category-name">{{ item.name }}</text>
<text class="category-code">编码: {{ item.code }}</text>
</view>
<view class="card-actions">
<view class="action-btn edit-btn" @click.stop="handleEdit(item)">
<text class="action-icon"></text>
</view>
<view class="action-btn delete-btn" @click.stop="handleDelete(item)">
<text class="action-icon">🗑</text>
</view>
<text class="type-name">{{ textValue(item.name) }}</text>
<text class="type-code">{{ t('equipmentCategory.code') }}: {{ textValue(item.code) }}</text>
</view>
</view>
<view class="card-body">
<view class="card-row">
<text class="card-label">排序</text>
<text class="card-value">{{ item.sort }}</text>
<view class="row">
<text class="label">{{ t('equipmentCategory.parentName') }}</text>
<text class="value">{{ getParentName(item.parentId) }}</text>
</view>
<view class="row">
<text class="label">{{ t('equipmentCategory.sort') }}</text>
<text class="value">{{ textValue(item.sort) }}</text>
</view>
<view class="card-row">
<text class="card-label">备注</text>
<text class="card-value">{{ item.remark || '-' }}</text>
<view class="row">
<text class="label">{{ t('equipmentCategory.remark') }}</text>
<text class="value">{{ textValue(item.remark) }}</text>
</view>
</view>
<view class="card-actions">
<view class="action-btn edit-btn" @click.stop="openEdit(item)">
<uni-icons type="compose" size="18" color="#ffffff"></uni-icons>
</view>
<view class="action-btn delete-btn" @click.stop="confirmDelete(item)">
<uni-icons type="trash" size="18" color="#ffffff"></uni-icons>
</view>
</view>
</view>
<view v-if="loading" class="loading-text">{{ t('functionCommon.loading') }}</view>
<view v-else-if="!filteredList.length" class="empty-text">{{ t('equipmentCategory.empty') }}</view>
<view v-else-if="finished" class="finished-text">{{ t('functionCommon.noMore') }}</view>
</view>
</scroll-view>
<view class="add-btn" @click="showAddForm">
<!-- 新增悬浮按钮 -->
<view class="add-btn" @click="openCreate">
<text class="add-icon">+</text>
</view>
<uni-popup ref="addPopup" type="center" background-color="#fff">
<!-- 返回顶部 -->
<view v-if="showGoTop" class="go-top-btn" @click="goTop">
<text class="go-top-icon"></text>
</view>
<!-- 新增/编辑弹框 -->
<uni-popup ref="formPopupRef" type="center" background-color="#fff">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">{{ isEdit ? '编辑分类' : '新增分类' }}</text>
<view class="popup-close" @click="hideAddForm">
<text class="popup-title">{{ formMode === 'create' ? t('equipmentCategory.createTitle') : t('equipmentCategory.editTitle') }}</text>
<view class="popup-close" @click="closeForm">
<text class="close-icon">×</text>
</view>
</view>
<view class="form-content">
<view class="form-item">
<text class="form-label">分类编码 <text class="required">*</text></text>
<input
v-model="formData.code"
class="form-input"
type="text"
placeholder="请输入分类编码"
/>
</view>
<view class="form-item">
<text class="form-label">分类名称 <text class="required">*</text></text>
<input
v-model="formData.name"
class="form-input"
type="text"
placeholder="请输入分类名称"
/>
</view>
<view class="form-item">
<text class="form-label">排序</text>
<input
v-model="formData.sort"
class="form-input"
type="number"
placeholder="请输入排序号"
/>
</view>
<view class="form-item">
<text class="form-label">备注</text>
<textarea
v-model="formData.remark"
class="form-textarea"
placeholder="请输入备注"
:maxlength="200"
/>
<scroll-view scroll-y class="form-scroll">
<view class="form-content">
<view class="form-item">
<text class="form-label">{{ t('equipmentCategory.code') }} <text class="required-star">*</text></text>
<input v-model="formData.code" class="form-input" type="text" :placeholder="t('equipmentCategory.placeholderCode')" :disabled="formMode === 'update'" />
</view>
<view class="form-item">
<text class="form-label">{{ t('equipmentCategory.name') }} <text class="required-star">*</text></text>
<input v-model="formData.name" class="form-input" type="text" :placeholder="t('equipmentCategory.placeholderName')" />
</view>
<view class="form-item">
<text class="form-label">{{ t('equipmentCategory.parentName') }}</text>
<picker :range="parentCategoryLabels" @change="onParentChange">
<view class="form-picker">
<text :class="['picker-text', formData.parentId === undefined || formData.parentId === 0 ? 'placeholder' : '']">{{ selectedParentLabel }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">{{ t('equipmentCategory.sort') }}</text>
<input v-model="formData.sort" class="form-input" type="number" :placeholder="t('equipmentCategory.placeholderSort')" />
</view>
<view class="form-item">
<text class="form-label">{{ t('equipmentCategory.remark') }}</text>
<textarea v-model="formData.remark" class="form-textarea" :placeholder="t('equipmentCategory.placeholderRemark')" :maxlength="200" />
</view>
</view>
</view>
</scroll-view>
<view class="form-footer">
<view class="footer-btn cancel-btn" @click="hideAddForm">
<text class="btn-text">取消</text>
<view class="footer-btn cancel-btn" @click="closeForm">
<text class="btn-text">{{ t('functionCommon.cancel') }}</text>
</view>
<view class="footer-btn confirm-btn" @click="handleSave">
<text class="btn-text">保存</text>
<view class="footer-btn confirm-btn" @click="submitForm">
<text class="btn-text">{{ t('functionCommon.save') }}</text>
</view>
</view>
</view>
@ -126,448 +115,304 @@
</template>
<script setup>
import { ref, reactive } from 'vue';
const addPopup = ref(null);
const searchKeyword = ref('');
const isEdit = ref(false);
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import AppTitleHeader from '@/components/common/AppTitleHeader.vue'
import { getDeviceTypeTree, getDeviceType, createDeviceType, updateDeviceType, deleteDeviceType } from '@/api/mes/deviceType'
const { t } = useI18n()
const formPopupRef = ref(null)
const searchKeyword = ref('')
const list = ref([])
const allCategories = ref([])
const loading = ref(false)
const finished = ref(false)
const scrollTop = ref(0)
const showGoTop = ref(false)
const formMode = ref('create')
const formData = reactive({
id: undefined,
code: '',
name: '',
parentId: 0,
sort: '',
remark: ''
});
const categoryList = reactive([
{ code: 'EQ001', name: '生产设备', sort: 1, remark: '生产线主要设备' },
{ code: 'EQ002', name: '检测设备', sort: 2, remark: '质量检测用设备' },
{ code: 'EQ003', name: '辅助设备', sort: 3, remark: '辅助生产设备' },
{ code: 'EQ004', name: '动力设备', sort: 4, remark: '电力、空压等设备' },
{ code: 'EQ005', name: '运输设备', sort: 5, remark: '物料运输设备' },
{ code: 'EQ006', name: '办公设备', sort: 6, remark: '办公用设备' }
]);
function goBack() {
uni.navigateBack();
}
function handleSearch() {
console.log('搜索:', searchKeyword.value);
}
function clearSearch() {
searchKeyword.value = '';
}
function handleEdit(item) {
isEdit.value = true;
formData.code = item.code;
formData.name = item.name;
formData.sort = item.sort;
formData.remark = item.remark;
addPopup.value.open();
}
function handleDelete(item) {
uni.showModal({
title: '确认删除',
content: `确定要删除"${item.name}"吗?`,
success: () => {
uni.showToast({
title: '删除成功',
icon: 'success'
});
}
});
}
function showAddForm() {
isEdit.value = false;
resetForm();
addPopup.value.open();
}
function hideAddForm() {
addPopup.value.close();
resetForm();
}
function resetForm() {
formData.code = '';
formData.name = '';
formData.sort = '';
formData.remark = '';
}
function handleSave() {
if (!formData.code || !formData.name) {
uni.showToast({
title: '请填写必填项',
icon: 'none'
});
return;
})
onLoad(async () => {
await fetchList()
})
async function fetchList() {
loading.value = true
try {
const res = await getDeviceTypeTree({
code: searchKeyword.value.trim() || undefined,
name: searchKeyword.value.trim() || undefined
})
const root = res && res.data !== undefined ? res.data : res
const treeData = Array.isArray(root) ? root : (Array.isArray(root?.data) ? root.data : [])
allCategories.value = flattenTree(buildTree(treeData))
list.value = allCategories.value
finished.value = true
} catch (e) {
uni.showToast({ title: t('functionCommon.loadFailed'), icon: 'none' })
} finally {
loading.value = false
}
uni.showToast({
title: '保存成功',
icon: 'success'
});
hideAddForm();
resetForm();
}
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background-color: #f0f2f5;
}
.header-section {
background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%);
padding: 60rpx 40rpx 40rpx 30rpx;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.back-btn {
display: flex;
align-items: center;
position: absolute;
left: 30rpx;
top: 50%;
transform: translateY(-50%);
&:active {
opacity: 0.7;
}
.back-icon {
font-size: 48rpx;
color: #ffffff;
margin-right: 8rpx;
}
.back-text {
font-size: 28rpx;
color: #ffffff;
}
function buildTree(data) {
const map = {}
const roots = []
data.forEach(item => { map[item.id] = { ...item, children: [] } })
data.forEach(item => {
if (item.parentId && map[item.parentId]) {
map[item.parentId].children.push(map[item.id])
} else {
roots.push(map[item.id])
}
})
return roots
}
.header-title {
font-size: 34rpx;
font-weight: bold;
color: #ffffff;
function flattenTree(nodes, depth = 0) {
const result = []
nodes.forEach(node => {
result.push({ ...node, _depth: depth })
if (node.children && node.children.length) {
result.push(...flattenTree(node.children, depth + 1))
}
})
return result
}
.search-section {
background: #ffffff;
padding: 24rpx 30rpx;
margin-bottom: 20rpx;
}
const filteredList = computed(() => {
if (!searchKeyword.value.trim()) return list.value
const kw = searchKeyword.value.trim().toLowerCase()
return list.value.filter(item =>
(item.name && item.name.toLowerCase().includes(kw)) ||
(item.code && item.code.toLowerCase().includes(kw))
)
})
.search-wrapper {
display: flex;
align-items: center;
background: #f5f7fa;
border-radius: 48rpx;
padding: 0 24rpx;
function getParentName(parentId) {
if (!parentId || parentId === 0) return t('equipmentCategory.rootCategory')
const parent = allCategories.value.find(c => c.id === parentId)
return parent ? parent.name : t('equipmentCategory.rootCategory')
}
.search-icon {
margin-right: 20rpx;
.iconfont {
font-size: 36rpx;
color: #666666;
}
}
const parentCategoryOptions = computed(() => {
const root = [{ label: t('equipmentCategory.rootCategory'), value: 0 }]
allCategories.value.forEach(item => {
root.push({ label: item.name || '', value: item.id })
})
return root
})
.search-input {
flex: 1;
height: 72rpx;
font-size: 28rpx;
color: #333333;
background: transparent;
}
const parentCategoryLabels = computed(() => parentCategoryOptions.value.map(o => o.label))
.input-placeholder {
color: #999999;
}
const selectedParentLabel = computed(() => {
const opt = parentCategoryOptions.value.find(o => o.value === formData.parentId)
return opt ? opt.label : t('equipmentCategory.placeholderParent')
})
.clear-btn {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.7;
}
.clear-icon {
font-size: 36rpx;
color: #999999;
}
}
.content-scroll {
flex: 1;
height: calc(100vh - 260rpx);
function onParentChange(e) {
const idx = e.detail.value
formData.parentId = parentCategoryOptions.value[idx]?.value ?? 0
}
.category-list {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 24rpx;
padding-bottom: 120rpx;
async function handleSearch() {
await fetchList()
}
.category-card {
width: 100%;
background: #ffffff;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 20rpx;
box-sizing: border-box;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
function onScroll(e) {
const top = e?.detail?.scrollTop || 0
showGoTop.value = top > 600
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 2rpx solid #f0f2f5;
function goTop() {
scrollTop.value = 0
}
.header-left {
display: flex;
flex-direction: column;
}
function loadMore() {}
.category-name {
font-size: 32rpx;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 8rpx;
function openCreate() {
formMode.value = 'create'
resetForm()
formPopupRef.value?.open()
}
.category-code {
font-size: 24rpx;
color: #999999;
}
.card-actions {
display: flex;
gap: 16rpx;
}
.action-btn {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.7;
transform: scale(0.95);
async function openEdit(item) {
const id = item?.id
if (id === undefined || id === null) {
uni.showToast({ title: t('equipmentCategory.noId'), icon: 'none' })
return
}
.action-icon {
font-size: 32rpx;
color: #ffffff;
}
}
.edit-btn {
background: #1a3a5c;
}
.delete-btn {
background: #ff4d4f;
}
.card-body {
display: flex;
flex-direction: column;
}
.card-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
try {
const res = await getDeviceType(id)
const detail = normalizeDetail(res)
formMode.value = 'update'
formData.id = detail?.id
formData.code = textValueForInput(detail?.code)
formData.name = textValueForInput(detail?.name)
formData.parentId = detail?.parentId ?? 0
formData.sort = textValueForInput(detail?.sort)
formData.remark = textValueForInput(detail?.remark)
formPopupRef.value?.open()
} catch (e) {
uni.showToast({ title: t('equipmentCategory.loadEditFailed'), icon: 'none' })
}
}
.card-label {
font-size: 26rpx;
color: #999999;
function confirmDelete(item) {
const id = item?.id
if (id === undefined || id === null) return
uni.showModal({
title: t('functionCommon.confirmTitle'),
content: t('equipmentCategory.confirmDeleteContent', { name: textValue(item?.name) }),
success: async (res) => {
if (!res.confirm) return
try {
await deleteDeviceType(id)
uni.showToast({ title: t('functionCommon.deleteSuccess'), icon: 'success' })
await fetchList()
} catch (e) {
uni.showToast({ title: t('functionCommon.deleteFailed'), icon: 'none' })
}
}
})
}
.card-value {
font-size: 28rpx;
color: #333333;
flex: 1;
text-align: right;
function closeForm() {
formPopupRef.value?.close()
}
.add-btn {
position: fixed;
bottom: 120rpx;
right: 30rpx;
width: 100rpx;
height: 100rpx;
background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(26, 58, 92, 0.3);
z-index: 999;
&:active {
transform: scale(0.95);
async function submitForm() {
if (!formData.code.trim()) {
uni.showToast({ title: t('equipmentCategory.validatorCodeRequired'), icon: 'none' })
return
}
.add-icon {
font-size: 60rpx;
color: #ffffff;
font-weight: bold;
if (!formData.name.trim()) {
uni.showToast({ title: t('equipmentCategory.validatorNameRequired'), icon: 'none' })
return
}
}
.popup-content {
width: 600rpx;
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%);
}
.popup-title {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
.popup-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.7;
const payload = {
id: formMode.value === 'update' ? formData.id : undefined,
code: formData.code.trim(),
name: formData.name.trim(),
parentId: formData.parentId || 0,
sort: toNumberOrUndefined(formData.sort),
remark: formData.remark.trim() || undefined
}
.close-icon {
font-size: 36rpx;
color: #ffffff;
try {
if (formMode.value === 'create') {
await createDeviceType(payload)
uni.showToast({ title: t('functionCommon.createSuccess'), icon: 'success' })
} else {
await updateDeviceType(payload)
uni.showToast({ title: t('functionCommon.updateSuccess'), icon: 'success' })
}
closeForm()
await fetchList()
} catch (e) {
uni.showToast({ title: t('functionCommon.saveFailed'), icon: 'none' })
}
}
.form-content {
padding: 32rpx;
}
.form-item {
margin-bottom: 24rpx;
}
.form-label {
display: block;
font-size: 28rpx;
color: #333333;
margin-bottom: 12rpx;
}
.required {
color: #ff4d4f;
margin-left: 4rpx;
function resetForm() {
formData.id = undefined
formData.code = ''
formData.name = ''
formData.parentId = 0
formData.sort = ''
formData.remark = ''
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 24rpx;
font-size: 28rpx;
background: #f5f7fa;
border-radius: 12rpx;
border: 2rpx solid #e8eaed;
box-sizing: border-box;
function openDetail(item) {
const id = item?.id
if (!id && id !== 0) return
uni.navigateTo({
url: `/pages_function/pages/equipmentCategory/detail?id=${encodeURIComponent(String(id))}`
})
}
.form-textarea {
width: 100%;
min-height: 120rpx;
padding: 16rpx 24rpx;
font-size: 28rpx;
background: #f5f7fa;
border-radius: 12rpx;
border: 2rpx solid #e8eaed;
box-sizing: border-box;
resize: none;
function normalizeDetail(res) {
const root = res && res.data !== undefined ? res.data : res
if (root?.data && typeof root.data === 'object') return root.data
if (root && typeof root === 'object') return root
return {}
}
.form-footer {
display: flex;
gap: 16rpx;
margin-top: 32rpx;
function textValue(value) {
if (value === 0) return '0'
if (value === false) return t('functionCommon.no')
if (value === true) return t('functionCommon.yes')
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
.footer-btn {
flex: 1;
height: 88rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.8;
}
.btn-text {
font-size: 28rpx;
font-weight: 500;
}
function textValueForInput(value) {
if (value === null || value === undefined) return ''
return String(value)
}
.cancel-btn {
background: #f5f7fa;
.btn-text {
color: #666666;
}
function toNumberOrUndefined(value) {
if (value === null || value === undefined || String(value).trim() === '') return undefined
const num = Number(value)
return Number.isFinite(num) ? num : undefined
}
</script>
.confirm-btn {
background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%);
.btn-text {
color: #ffffff;
}
}
<style lang="scss" scoped>
.page-container { min-height: 100vh; background-color: #f0f2f5; }
.search-card { background: #ffffff; margin: 20rpx 24rpx; border-radius: 18rpx; padding: 20rpx; box-shadow: 0 4rpx 18rpx rgba(0, 0, 0, 0.04); }
.search-row { display: flex; align-items: center; gap: 16rpx; }
.search-input-wrap { flex: 1; display: flex; align-items: center; background: #f5f7fa; border-radius: 44rpx; padding: 0 20rpx; }
.search-icon { margin-right: 12rpx; font-size: 30rpx; color: #999; }
.search-input { flex: 1; height: 72rpx; font-size: 28rpx; color: #333; background: transparent; }
.search-btn { min-width: 120rpx; height: 72rpx; background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%); border-radius: 14rpx; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 28rpx; font-weight: 600; }
.list-scroll { height: calc(100vh - 360rpx); }
.list-wrap { padding: 0 24rpx 30rpx; }
.type-card { background: #ffffff; border-radius: 18rpx; padding: 24rpx; margin-bottom: 18rpx; box-shadow: 0 4rpx 18rpx rgba(0, 0, 0, 0.05); }
.card-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 18rpx; border-bottom: 1rpx solid #edf0f3; }
.header-left { display: flex; flex-direction: column; }
.type-name { font-size: 32rpx; font-weight: 600; color: #1a3a5c; margin-bottom: 8rpx; }
.type-code { font-size: 24rpx; color: #8a9099; }
.card-body { padding-top: 16rpx; }
.row { display: flex; justify-content: space-between; align-items: center; margin-top: 12rpx; }
.label { font-size: 26rpx; color: #8a9099; }
.value { font-size: 27rpx; color: #30363d; max-width: 62%; text-align: right; }
.card-actions { margin-top: 24rpx; display: flex; justify-content: flex-end; gap: 14rpx; }
.action-btn { width: 60rpx; height: 60rpx; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; }
.edit-btn { background: #1a3a5c; }
.delete-btn { background: #ff4d4f; }
.add-btn { position: fixed; right: 28rpx; bottom: 140rpx; width: 96rpx; height: 96rpx; border-radius: 48rpx; background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%); display: flex; align-items: center; justify-content: center; box-shadow: 0 8rpx 24rpx rgba(26, 58, 92, 0.32); z-index: 99; }
.add-icon { color: #ffffff; font-size: 56rpx; line-height: 1; }
.go-top-btn { position: fixed; right: 28rpx; bottom: 254rpx; width: 88rpx; height: 88rpx; border-radius: 44rpx; background: rgba(26, 58, 92, 0.9); display: flex; align-items: center; justify-content: center; z-index: 99; box-shadow: 0 6rpx 16rpx rgba(26, 58, 92, 0.25); }
.go-top-icon { color: #ffffff; font-size: 36rpx; font-weight: 700; }
.popup-content { width: 680rpx; max-height: 80vh; border-radius: 20rpx; overflow: hidden; }
.popup-header { padding: 24rpx; display: flex; align-items: center; justify-content: space-between; border-bottom: 1rpx solid #edf0f3; }
.popup-title { font-size: 32rpx; color: #1a3a5c; font-weight: 700; }
.close-icon { font-size: 38rpx; color: #8e95a0; }
.form-scroll { max-height: 56vh; }
.form-content { padding: 24rpx; }
.form-item { margin-bottom: 18rpx; }
.form-label { display: block; margin-bottom: 8rpx; font-size: 26rpx; color: #8a9099; }
.required-star { color: #ff4d4f; }
.form-input { height: 76rpx; padding: 0 20rpx; border-radius: 12rpx; background: #f5f7fa; font-size: 28rpx; color: #30363d; }
.form-picker { height: 76rpx; padding: 0 20rpx; border-radius: 12rpx; background: #f5f7fa; display: flex; align-items: center; justify-content: space-between; }
.picker-text { font-size: 28rpx; color: #30363d; }
.picker-text.placeholder { color: #999; }
.picker-arrow { font-size: 30rpx; color: #999; }
.form-textarea { width: 100%; min-height: 130rpx; padding: 18rpx 20rpx; border-radius: 12rpx; background: #f5f7fa; font-size: 28rpx; color: #30363d; }
.form-footer { display: flex; gap: 14rpx; padding: 20rpx 24rpx 24rpx; }
.footer-btn { flex: 1; height: 76rpx; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; }
.cancel-btn { background: #edf0f4; }
.confirm-btn { background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%); }
.btn-text { font-size: 28rpx; font-weight: 600; color: #ffffff; }
.cancel-btn .btn-text { color: #586070; }
.loading-text, .empty-text, .finished-text { text-align: center; padding: 28rpx 0; color: #99a1aa; font-size: 26rpx; }
</style>

@ -1,589 +0,0 @@
<template>
<view class="page-container">
<view class="header-section">
<view class="back-btn" @click="goBack">
<text class="back-icon"></text>
<text class="back-text">返回</text>
</view>
<text class="header-title">设备关键件</text>
</view>
<view class="search-section">
<view class="search-wrapper">
<view class="search-icon">
<text class="iconfont icon-search"></text>
</view>
<input
v-model="searchKeyword"
class="search-input"
type="text"
placeholder="请输入关键件编码或名称"
placeholder-class="input-placeholder"
@input="handleSearch"
/>
<view v-if="searchKeyword" class="clear-btn" @click="clearSearch">
<text class="clear-icon">×</text>
</view>
</view>
</view>
<scroll-view scroll-y class="content-scroll">
<view class="keypart-list">
<view
v-for="(item, index) in keypartList"
:key="index"
class="keypart-card"
>
<view class="card-header">
<view class="header-left">
<text class="keypart-name">{{ item.name }}</text>
<text class="keypart-code">编码: {{ item.code }}</text>
</view>
<view class="card-actions">
<view class="action-btn edit-btn" @click.stop="handleEdit(item)">
<text class="action-icon"></text>
</view>
<view class="action-btn delete-btn" @click.stop="handleDelete(item)">
<text class="action-icon">🗑</text>
</view>
</view>
</view>
<view class="card-body">
<view class="card-row">
<text class="card-label">描述</text>
<text class="card-value">{{ item.description || '-' }}</text>
</view>
<view class="card-row">
<text class="card-label">数量</text>
<text class="card-value">{{ item.count }}</text>
</view>
<view class="card-row">
<text class="card-label">备注</text>
<text class="card-value">{{ item.remark || '-' }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="add-btn" @click="showAddForm">
<text class="add-icon">+</text>
</view>
<uni-popup ref="addPopup" type="center" background-color="#fff">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">{{ isEdit ? '编辑关键件' : '新增关键件' }}</text>
<view class="popup-close" @click="hideAddForm">
<text class="close-icon">×</text>
</view>
</view>
<view class="form-content">
<view class="form-item">
<text class="form-label">关键件编码 <text class="required">*</text></text>
<input
v-model="formData.code"
class="form-input"
type="text"
placeholder="请输入关键件编码"
/>
</view>
<view class="form-item">
<text class="form-label">关键件名称 <text class="required">*</text></text>
<input
v-model="formData.name"
class="form-input"
type="text"
placeholder="请输入关键件名称"
/>
</view>
<view class="form-item">
<text class="form-label">描述</text>
<textarea
v-model="formData.description"
class="form-textarea"
placeholder="请输入描述"
:maxlength="200"
/>
</view>
<view class="form-item">
<text class="form-label">数量</text>
<input
v-model="formData.count"
class="form-input"
type="number"
placeholder="请输入数量"
/>
</view>
<view class="form-item">
<text class="form-label">备注</text>
<textarea
v-model="formData.remark"
class="form-textarea"
placeholder="请输入备注"
:maxlength="200"
/>
</view>
</view>
<view class="form-footer">
<view class="footer-btn cancel-btn" @click="hideAddForm">
<text class="btn-text">取消</text>
</view>
<view class="footer-btn confirm-btn" @click="handleSave">
<text class="btn-text">保存</text>
</view>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { ref, reactive } from 'vue';
const addPopup = ref(null);
const searchKeyword = ref('');
const isEdit = ref(false);
const formData = reactive({
code: '',
name: '',
description: '',
count: '',
remark: ''
});
const keypartList = reactive([
{ code: 'KP001', name: '电机', description: 'Y2系列三相异步电机', count: 2, remark: '设备核心动力部件' },
{ code: 'KP002', name: '变频器', description: '37kW变频器', count: 1, remark: '电机调速控制' },
{ code: 'KP003', name: 'PLC控制器', description: '西门子S7-1200', count: 1, remark: '设备控制系统' },
{ code: 'KP004', name: '伺服驱动器', description: '2kW伺服驱动器', count: 2, remark: '精确位置控制' },
{ code: 'KP005', name: '滚珠丝杠', description: 'F4004滚珠丝杠', count: 2, remark: '线性传动部件' },
{ code: 'KP006', name: '直线导轨', description: 'HGR20直线导轨', count: 4, remark: '滑动支撑部件' }
]);
function goBack() {
uni.navigateBack();
}
function handleSearch() {
console.log('搜索:', searchKeyword.value);
}
function clearSearch() {
searchKeyword.value = '';
}
function handleEdit(item) {
isEdit.value = true;
formData.code = item.code;
formData.name = item.name;
formData.description = item.description;
formData.count = item.count;
formData.remark = item.remark;
addPopup.value.open();
}
function handleDelete(item) {
uni.showModal({
title: '确认删除',
content: `确定要删除"${item.name}"吗?`,
success: () => {
uni.showToast({
title: '删除成功',
icon: 'success'
});
}
});
}
function showAddForm() {
isEdit.value = false;
resetForm();
addPopup.value.open();
}
function hideAddForm() {
addPopup.value.close();
resetForm();
}
function resetForm() {
formData.code = '';
formData.name = '';
formData.description = '';
formData.count = '';
formData.remark = '';
}
function handleSave() {
if (!formData.code || !formData.name) {
uni.showToast({
title: '请填写必填项',
icon: 'none'
});
return;
}
uni.showToast({
title: '保存成功',
icon: 'success'
});
hideAddForm();
resetForm();
}
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background-color: #f0f2f5;
}
.header-section {
background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%);
padding: 60rpx 40rpx 40rpx 30rpx;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.back-btn {
display: flex;
align-items: center;
position: absolute;
left: 30rpx;
top: 50%;
transform: translateY(-50%);
&:active {
opacity: 0.7;
}
.back-icon {
font-size: 48rpx;
color: #ffffff;
margin-right: 8rpx;
}
.back-text {
font-size: 28rpx;
color: #ffffff;
}
}
.header-title {
font-size: 34rpx;
font-weight: bold;
color: #ffffff;
}
.search-section {
background: #ffffff;
padding: 24rpx 30rpx;
margin-bottom: 20rpx;
}
.search-wrapper {
display: flex;
align-items: center;
background: #f5f7fa;
border-radius: 48rpx;
padding: 0 24rpx;
}
.search-icon {
margin-right: 20rpx;
.iconfont {
font-size: 36rpx;
color: #666666;
}
}
.search-input {
flex: 1;
height: 72rpx;
font-size: 28rpx;
color: #333333;
background: transparent;
}
.input-placeholder {
color: #999999;
}
.clear-btn {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.7;
}
.clear-icon {
font-size: 36rpx;
color: #999999;
}
}
.content-scroll {
flex: 1;
height: calc(100vh - 260rpx);
}
.keypart-list {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 24rpx;
padding-bottom: 120rpx;
}
.keypart-card {
width: 100%;
background: #ffffff;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 20rpx;
box-sizing: border-box;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 2rpx solid #f0f2f5;
}
.header-left {
display: flex;
flex-direction: column;
}
.keypart-name {
font-size: 32rpx;
font-weight: 600;
color: #1a3a5c;
margin-bottom: 8rpx;
}
.keypart-code {
font-size: 24rpx;
color: #999999;
}
.card-actions {
display: flex;
gap: 16rpx;
}
.action-btn {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.7;
transform: scale(0.95);
}
.action-icon {
font-size: 32rpx;
color: #ffffff;
}
}
.edit-btn {
background: #1a3a5c;
}
.delete-btn {
background: #ff4d4f;
}
.card-body {
display: flex;
flex-direction: column;
}
.card-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
}
.card-label {
font-size: 26rpx;
color: #999999;
}
.card-value {
font-size: 28rpx;
color: #333333;
flex: 1;
text-align: right;
}
.add-btn {
position: fixed;
bottom: 120rpx;
right: 30rpx;
width: 100rpx;
height: 100rpx;
background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(26, 58, 92, 0.3);
z-index: 999;
&:active {
transform: scale(0.95);
}
.add-icon {
font-size: 60rpx;
color: #ffffff;
font-weight: bold;
}
}
.popup-content {
width: 600rpx;
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%);
}
.popup-title {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
.popup-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.7;
}
.close-icon {
font-size: 36rpx;
color: #ffffff;
}
}
.form-content {
padding: 32rpx;
}
.form-item {
margin-bottom: 24rpx;
}
.form-label {
display: block;
font-size: 28rpx;
color: #333333;
margin-bottom: 12rpx;
}
.required {
color: #ff4d4f;
margin-left: 4rpx;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 24rpx;
font-size: 28rpx;
background: #f5f7fa;
border-radius: 12rpx;
border: 2rpx solid #e8eaed;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
min-height: 120rpx;
padding: 16rpx 24rpx;
font-size: 28rpx;
background: #f5f7fa;
border-radius: 12rpx;
border: 2rpx solid #e8eaed;
box-sizing: border-box;
resize: none;
}
.form-footer {
display: flex;
gap: 16rpx;
margin-top: 32rpx;
}
.footer-btn {
flex: 1;
height: 88rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.8;
}
.btn-text {
font-size: 28rpx;
font-weight: 500;
}
}
.cancel-btn {
background: #f5f7fa;
.btn-text {
color: #666666;
}
}
.confirm-btn {
background: linear-gradient(135deg, #1a3a5c 0%, #2d5a87 100%);
.btn-text {
color: #ffffff;
}
}
</style>

@ -0,0 +1,380 @@
<template>
<view class="page-container">
<view class="fixed-header">
<AppTitleHeader :title="t('equipmentLedger.detailTitle')" />
</view>
<scroll-view scroll-y class="detail-scroll">
<view class="content-section">
<!-- 基础信息 -->
<view class="info-card">
<view class="card-title">{{ t('equipmentLedger.basicInfo') }}</view>
<view class="info-list">
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.deviceCode') }}</text>
<text class="info-value">{{ fieldValue('deviceCode') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.deviceName') }}</text>
<text class="info-value">{{ fieldValue('deviceName') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.deviceStatus') }}</text>
<text :class="['info-value', getStatusClass(detailData?.deviceStatus)]">{{ getStatusText(detailData?.deviceStatus) }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.deviceType') }}</text>
<text class="info-value">{{ fieldValue('deviceTypeName') || getDeviceTypeNameById(detailData?.deviceType) }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.deviceSpec') }}</text>
<text class="info-value">{{ fieldValue('deviceSpec') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.isScheduled') }}</text>
<text class="info-value">{{ scheduledText }}</text>
</view>
<view v-if="detailData?.isScheduled === 1 || detailData?.isSchedueld === 1" class="info-row">
<text class="info-label">{{ t('equipmentLedger.ratedCapacity') }}</text>
<text class="info-value">{{ fieldValue('ratedCapacity') }}</text>
</view>
<view v-if="detailData?.isScheduled === 1 || detailData?.isSchedueld === 1" class="info-row">
<text class="info-label">{{ t('equipmentLedger.dailyAverageValue') }}</text>
<text class="info-value">{{ fieldValue('dailyAverageValue') }}</text>
</view>
<view v-if="detailData?.isScheduled === 1 || detailData?.isSchedueld === 1" class="info-row">
<text class="info-label">{{ t('equipmentLedger.dataCollectionCapacity') }}</text>
<text class="info-value">{{ fieldValue('dataCollectionCapacity') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.productionDate') }}</text>
<text class="info-value">{{ formatDateValue(detailData?.productionDate) }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.factoryEntryDate') }}</text>
<text class="info-value">{{ formatDateValue(detailData?.factoryEntryDate) }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.deviceLocation') }}</text>
<text class="info-value">{{ fieldValue('deviceLocation') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.deviceManagerName') }}</text>
<text class="info-value">{{ fieldValue('deviceManagerName') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.workshop') }}</text>
<text class="info-value">{{ fieldValue('workshopName') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.creatorName') }}</text>
<text class="info-value">{{ fieldValue('creatorName') }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ t('equipmentLedger.createTime') }}</text>
<text class="info-value">{{ formatDateTime(detailData?.createTime) }}</text>
</view>
<view class="info-row remark-row">
<text class="info-label">{{ t('equipmentLedger.remark') }}</text>
<text class="info-value remark-value">{{ fieldValue('remark') }}</text>
</view>
</view>
</view>
<!-- 历史记录Tabs -->
<view class="info-card">
<view class="tab-bar">
<view
v-for="tab in tabs"
:key="tab.key"
:class="['tab-item', activeTab === tab.key ? 'active' : '']"
@click="switchTab(tab.key)"
>
<text class="tab-text">{{ tab.label }}</text>
</view>
</view>
<!-- 点检历史 -->
<view v-if="activeTab === 'check'" class="tab-content">
<view v-if="!inspectionList.length" class="empty-tip">{{ t('equipmentLedger.noHistoryData') }}</view>
<view v-for="(item, idx) in inspectionList" :key="idx" class="history-item">
<view class="history-header">
<text class="history-time">{{ formatDateTime(item.inspectionTime) }}</text>
<text :class="['history-result', getResultClass(item.inspectionResult)]">{{ getResultText(item.inspectionResult) }}</text>
</view>
<view class="history-body">
<text class="history-name">{{ item.inspectionItemName || item.name || '-' }}</text>
<text class="history-operator">{{ t('equipmentLedger.operator') }}: {{ item.operatorName || item.inspectorName || '-' }}</text>
</view>
</view>
</view>
<!-- 保养历史 -->
<view v-if="activeTab === 'maintain'" class="tab-content">
<view v-if="!maintainList.length" class="empty-tip">{{ t('equipmentLedger.noHistoryData') }}</view>
<view v-for="(item, idx) in maintainList" :key="idx" class="history-item">
<view class="history-header">
<text class="history-time">{{ formatDateTime(item.maintainTime || item.inspectionTime) }}</text>
<text :class="['history-result', getResultClass(item.maintainResult || item.inspectionResult)]">{{ getResultText(item.maintainResult || item.inspectionResult) }}</text>
</view>
<view class="history-body">
<text class="history-name">{{ item.maintainItemName || item.inspectionItemName || item.name || '-' }}</text>
<text class="history-operator">{{ t('equipmentLedger.operator') }}: {{ item.operatorName || item.inspectorName || '-' }}</text>
</view>
</view>
</view>
<!-- 报修历史 -->
<view v-if="activeTab === 'repair'" class="tab-content">
<view v-if="!repairList.length" class="empty-tip">{{ t('equipmentLedger.noHistoryData') }}</view>
<view v-for="(item, idx) in repairList" :key="idx" class="history-item">
<view class="history-header">
<text class="history-time">{{ formatDateTime(item.createTime) }}</text>
<text :class="['history-result', getRepairStatusClass(item.status)]">{{ getRepairStatusText(item.status) }}</text>
</view>
<view class="history-body">
<text class="history-name">{{ item.repairNo || item.description || '-' }}</text>
<text class="history-operator">{{ t('equipmentLedger.operator') }}: {{ item.creatorName || '-' }}</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import AppTitleHeader from '@/components/common/AppTitleHeader.vue'
import { getDeviceLedger } from '@/api/mes/deviceLedger'
import { getDeviceTypeTree } from '@/api/mes/deviceType'
import { DICT_TYPE, getDictLabel, initAllDict } from '@/utils/dict'
import request from '@/utils/request'
const { t } = useI18n()
const detailId = ref(undefined)
const detailData = ref(null)
const deviceTypeList = ref([])
const activeTab = ref('check')
const inspectionList = ref([])
const maintainList = ref([])
const repairList = ref([])
const tabs = computed(() => [
{ key: 'check', label: t('equipmentLedger.checkHistory') },
{ key: 'maintain', label: t('equipmentLedger.maintainHistory') },
{ key: 'repair', label: t('equipmentLedger.repairHistory') }
])
const scheduledText = computed(() => {
const val = detailData.value?.isSchedueld ?? detailData.value?.isScheduled
return Number(val) === 1 ? t('equipmentLedger.yes') : t('equipmentLedger.no')
})
onLoad(async (query) => {
const id = query?.id ? decodeURIComponent(String(query.id)) : ''
detailId.value = id || undefined
await initAllDict()
await fetchDeviceTypeList()
await fetchDetail()
})
async function fetchDeviceTypeList() {
try {
const res = await getDeviceTypeTree({})
const root = res && res.data !== undefined ? res.data : res
const treeData = Array.isArray(root) ? root : (Array.isArray(root?.data) ? root.data : [])
deviceTypeList.value = flattenTree(treeData)
} catch (e) {}
}
function flattenTree(nodes) {
const result = []
nodes.forEach(node => {
result.push(node)
if (node.children && node.children.length) {
result.push(...flattenTree(node.children))
}
})
return result
}
function getDeviceTypeNameById(id) {
if (!id) return '-'
const found = deviceTypeList.value.find(item => String(item.id) === String(id))
return found ? found.name : String(id)
}
async function fetchDetail() {
if (!detailId.value) {
uni.showToast({ title: t('equipmentLedger.noId'), icon: 'none' })
return
}
try {
const res = await getDeviceLedger(detailId.value)
detailData.value = normalizeDetail(res)
await fetchInspectionHistory()
await fetchMaintainHistory()
await fetchRepairHistory()
} catch (e) {
uni.showToast({ title: t('equipmentLedger.loadFailed'), icon: 'none' })
}
}
function normalizeDetail(res) {
const root = res && res.data !== undefined ? res.data : res
if (root?.data && typeof root.data === 'object') return root.data
if (root && typeof root === 'object') return root
return {}
}
async function fetchInspectionHistory() {
try {
const res = await request({ url: '/admin-api/mes/ticket-management/getInspectionByDeviceId', method: 'get', params: { deviceId: detailId.value } })
const data = res && res.data !== undefined ? res.data : res
inspectionList.value = Array.isArray(data) ? data : []
} catch (e) { inspectionList.value = [] }
}
async function fetchMaintainHistory() {
try {
const res = await request({ url: '/admin-api/mes/ticket-management/getMaintenanceByDeviceId', method: 'get', params: { deviceId: detailId.value } })
const data = res && res.data !== undefined ? res.data : res
maintainList.value = Array.isArray(data) ? data : []
} catch (e) { maintainList.value = [] }
}
async function fetchRepairHistory() {
try {
const res = await request({ url: '/admin-api/mes/dv-repair/getRepairListByDeviceId', method: 'get', params: { deviceId: detailId.value } })
const data = res && res.data !== undefined ? res.data : res
repairList.value = Array.isArray(data) ? data : []
} catch (e) { repairList.value = [] }
}
function switchTab(key) {
activeTab.value = key
}
function getStatusText(status) {
return getDictLabel(DICT_TYPE.MES_TZ_STATUS, status, textValue(status))
}
function getStatusClass(status) {
const s = String(status)
if (s === '0' || s === '1') return 'text-success'
return 'text-danger'
}
function getResultText(result) {
if (result === 'PASS' || result === 'pass' || result === 'OK' || result === 'ok' || result === 1 || result === '1') return t('equipmentLedger.resultPass')
if (result === 'FAIL' || result === 'fail' || result === 'NG' || result === 'ng' || result === 0 || result === '0') return t('equipmentLedger.resultFail')
return textValue(result)
}
function getResultClass(result) {
if (result === 'PASS' || result === 'pass' || result === 'OK' || result === 'ok' || result === 1 || result === '1') return 'text-success'
if (result === 'FAIL' || result === 'fail' || result === 'NG' || result === 'ng' || result === 0 || result === '0') return 'text-danger'
return ''
}
function getRepairStatusText(status) {
if (status === 0 || status === '0') return t('equipmentLedger.repairPending')
if (status === 1 || status === '1') return t('equipmentLedger.repairProcessing')
if (status === 2 || status === '2') return t('equipmentLedger.repairCompleted')
return textValue(status)
}
function getRepairStatusClass(status) {
if (status === 0 || status === '0') return 'text-warning'
if (status === 1 || status === '1') return 'text-primary'
if (status === 2 || status === '2') return 'text-success'
return ''
}
function fieldValue(field) {
return textValue(detailData.value ? detailData.value[field] : undefined)
}
function textValue(value) {
if (value === 0) return '0'
if (value === false) return t('functionCommon.no')
if (value === true) return t('functionCommon.yes')
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function formatDateValue(value) {
if (!value) return '-'
if (Array.isArray(value) && value.length >= 3) {
const [y, m, d] = value
const pad = (n) => String(n).padStart(2, '0')
return `${y}-${pad(m)}-${pad(d)}`
}
const text = String(value).trim()
if (!text) return '-'
return text.split(' ')[0]
}
function formatDateTime(value) {
if (!value) return '-'
if (Array.isArray(value) && value.length >= 3) {
const [y, m, d, hh = 0, mm = 0, ss = 0] = value
const pad = (n) => String(n).padStart(2, '0')
return `${y}-${pad(m)}-${pad(d)} ${pad(hh)}:${pad(mm)}:${pad(ss)}`
}
const text = String(value).trim()
if (!text) return '-'
const numeric = Number(text)
if (Number.isFinite(numeric)) {
const timestamp = text.length === 10 ? numeric * 1000 : numeric
const date = new Date(timestamp)
if (!Number.isNaN(date.getTime())) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
}
const date = new Date(text)
if (!Number.isNaN(date.getTime())) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
return text
}
</script>
<style lang="scss" scoped>
.page-container { min-height: 100vh; background-color: #f0f2f5; }
.fixed-header { position: sticky; top: 0; z-index: 10; }
.detail-scroll { height: calc(100vh - 120rpx); }
.content-section { padding: 0 24rpx 24rpx; }
.info-card { margin-top: 20rpx; background: #ffffff; border-radius: 20rpx; padding: 28rpx; box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); }
.card-title { font-size: 32rpx; color: #1a3a5c; font-weight: 700; margin-bottom: 18rpx; }
.info-list { background: #ffffff; }
.info-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 18rpx 0; border-bottom: 1rpx solid #edf0f3; }
.info-label { font-size: 27rpx; color: #8a9099; width: 220rpx; }
.info-value { flex: 1; text-align: right; font-size: 28rpx; color: #30363d; line-height: 1.45; }
.text-success { color: #52c41a; }
.text-danger { color: #ff4d4f; }
.text-warning { color: #faad14; }
.text-primary { color: #1a3a5c; }
.remark-row { border-bottom: none; }
.remark-value { white-space: pre-wrap; }
.tab-bar { display: flex; border-bottom: 2rpx solid #edf0f3; margin-bottom: 20rpx; }
.tab-item { flex: 1; text-align: center; padding: 16rpx 0; position: relative; }
.tab-text { font-size: 28rpx; color: #8a9099; }
.tab-item.active .tab-text { color: #1a3a5c; font-weight: 700; }
.tab-item.active::after { content: ''; position: absolute; bottom: -2rpx; left: 30%; right: 30%; height: 4rpx; background: #1a3a5c; border-radius: 2rpx; }
.tab-content { min-height: 200rpx; }
.empty-tip { text-align: center; padding: 40rpx 0; color: #99a1aa; font-size: 26rpx; }
.history-item { padding: 20rpx 0; border-bottom: 1rpx solid #f0f2f5; }
.history-item:last-child { border-bottom: none; }
.history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10rpx; }
.history-time { font-size: 24rpx; color: #8a9099; }
.history-result { font-size: 26rpx; font-weight: 600; }
.history-body { display: flex; justify-content: space-between; align-items: center; }
.history-name { font-size: 28rpx; color: #30363d; }
.history-operator { font-size: 24rpx; color: #8a9099; }
</style>

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save