Compare commits

...

19 Commits

@ -0,0 +1,130 @@
server {
listen 8088;
server_name localhost;
# 允许上传最大100MB
client_max_body_size 100m;
# =========================
# 后端接口代理DEV
# =========================
location /admin-api/ {
proxy_pass http://besure_server_bit:48081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /api/ {
proxy_pass http://besure_server_bit:48081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /jmreport/ {
proxy_pass http://besure_server_bit:48081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /v3/api-docs/ {
proxy_pass http://besure_server_bit:48081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /doc.html {
proxy_pass http://besure_server_bit:48081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /swagger-ui/ {
proxy_pass http://besure_server_bit:48081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /webjars/ {
proxy_pass http://besure_server_bit:48081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location ~ /v3/api-docs/.*\.json$ {
proxy_pass http://besure_server_bit:48081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# =========================
# 前端静态页面
# =========================
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# =========================
# 错误页
# =========================
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
# =========================
# FRP / 转发代理
# =========================
server {
listen 39002;
server_name localhost;
location /besure/ {
proxy_pass http://ngsk.tech:39001/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_buffering off;
}
}

@ -13,6 +13,7 @@
"ts:check": "vue-tsc --noEmit",
"build:local": "node --max_old_space_size=6144 ./node_modules/vite/bin/vite.js build",
"build:dev": "node --max_old_space_size=6144 ./node_modules/vite/bin/vite.js build --mode dev",
"build:bit": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode dev",
"build:test": "node --max_old_space_size=6144 ./node_modules/vite/bin/vite.js build --mode test",
"build:stage": "node --max_old_space_size=6144 ./node_modules/vite/bin/vite.js build --mode stage",
"build:prod": "node --max_old_space_size=6144 ./node_modules/vite/bin/vite.js build --mode prod",

@ -0,0 +1,76 @@
import request from '@/config/axios'
export interface BitWmsInboundReqVO {
productId: number
warehouseId?: number
areaId?: number
locationId: number
count: number
unitId: number
supplierId?: number
remark?: string
}
export interface BitWmsOutboundReqVO {
productId: number
warehouseId?: number
areaId?: number
locationId: number
count: number
outReason: string
remark?: string
}
export interface BitWmsMoveReqVO {
productId: number
fromLocationId: number
toLocationId: number
fromWarehouseId?: number
fromAreaId?: number
toWarehouseId?: number
toAreaId?: number
count: number
remark?: string
}
export interface BitWmsLabelRespVO {
title?: string
printData: Record<string, any>
templateJson?: Record<string, any>
}
export const BitWmsApi = {
getSamplePage: async (params: any) => {
return await request.get({ url: '/erp/product/sample-page', params })
},
getSampleSimpleList: async (params?: any) => {
return await request.get({ url: '/erp/product/sample-simple-list', params })
},
getLocationStockPage: async (params: any) => {
return await request.get({ url: '/erp/bit-wms/location-stock/page', params })
},
getLocationStockCount: async (productId: number, locationId: number) => {
return await request.get({ url: '/erp/bit-wms/location-stock/count', params: { productId, locationId } })
},
getLocationStockRecordPage: async (params: any) => {
return await request.get({ url: '/erp/bit-wms/location-stock-record/page', params })
},
inbound: async (data: BitWmsInboundReqVO) => {
return await request.post({ url: '/erp/bit-wms/inbound', data })
},
outbound: async (data: BitWmsOutboundReqVO) => {
return await request.post({ url: '/erp/bit-wms/outbound', data })
},
move: async (data: BitWmsMoveReqVO) => {
return await request.post({ url: '/erp/bit-wms/move', data })
},
scan: async (code: string) => {
return await request.get({ url: '/erp/bit-wms/scan', params: { code } })
},
getSampleLabel: async (productId: number) => {
return await request.get({ url: '/erp/bit-wms/sample-label', params: { productId } })
},
getLocationLabel: async (locationId: number) => {
return await request.get({ url: '/erp/bit-wms/location-label', params: { locationId } })
}
}

@ -14,6 +14,11 @@ export interface ProductVO {
unitId: number // 单位编号
unitName?: string // 单位名字
status: number // 产品状态
isSample?: boolean // 是否样品
sampleCategory?: string // 样品分类
bizUnit?: string // 所属事业部
customerId?: number // 客户编号
customerName?: string // 客户名称
standard: string // 产品规格
remark: string // 产品备注
expiryDay: number // 保质期天数

@ -1,5 +1,12 @@
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle" width="92%" :fullscreen="false" top="4vh">
<Dialog
v-model="dialogVisible"
:title="dialogTitle"
width="96%"
:fullscreen="false"
:initial-fullscreen="true"
top="2vh"
>
<div class="hiprint-preview">
<div class="hiprint-toolbar">
<div class="hiprint-paper">
@ -306,6 +313,10 @@ defineExpose({ open })
<style scoped lang="scss">
.hiprint-preview {
width: 100%;
height: calc(100vh - 84px);
display: flex;
flex-direction: column;
min-height: 0;
}
.hiprint-toolbar {
@ -313,6 +324,7 @@ defineExpose({ open })
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
}
.hiprint-paper {
@ -348,7 +360,8 @@ defineExpose({ open })
}
.hiprint-body {
height: 75vh;
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 220px 1fr 300px;
gap: 12px;

@ -202,7 +202,7 @@ const clear = () => {
<template>
<div
:class="prefixCls"
class="fixed right-0 top-[45%] h-40px w-40px cursor-pointer bg-[var(--el-color-primary)] text-center leading-40px"
class="fixed right-0 top-[45%] z-10 h-40px w-40px cursor-pointer bg-[var(--el-color-primary)] text-center leading-40px"
@click="drawer = true"
>
<Icon color="#fff" icon="ep:setting" />

@ -177,6 +177,60 @@
}
}
},
Dashboard3: {
Header: {
title: 'Device Operation Overview',
subTitle: 'IOT Real-Time Monitoring Center',
currentTime: 'Current Time: ',
lastUpdate: 'Last Updated: ',
refreshRate: 'Refresh: ',
refreshRateValue: 'Every {seconds}s'
},
Metrics: {
total: 'Total Devices',
online: 'Online',
offline: 'Offline',
running: 'Running',
standby: 'Idle',
fault: 'Alarms'
},
Status: {
running: 'Running',
standby: 'Idle',
fault: 'Alarm',
offline: 'Offline'
},
AlarmBoard: {
currentDevice: 'Alarm Device',
todayAlarmDuration: 'Today Alarm Duration',
lastReportTime: 'Last Reported',
alarmPrefix: 'Alarm',
empty: 'No alarm devices'
},
Detail: {
title: 'Device Operation Details',
carouselText: '{page} · Auto Carousel',
columns: {
deviceName: 'Device Name',
currentStatus: 'Status',
runningTime: 'Running Time (Today)',
standbyTime: 'Idle Time (Today)',
alarmTime: 'Alarm Time (Today)',
efficiency: 'Efficiency (Today)'
},
empty: 'No device operation data'
},
Charts: {
last24Hours: 'Last 24 Hours Operation Rate',
last7Days: 'Last 7 Days Operation Rate',
averageRate: 'Average: {value}'
},
Time: {
minute: '{minute}m',
hour: '{hour}h',
hourMinute: '{hour}h {minute}m'
}
},
Dashboard8: {
Header: {
title: 'Smart Manufacturing Line Task Overview',
@ -1878,6 +1932,12 @@
cancelSuccess: 'Cancel task successfully',
cancelFail: 'Cancel task failed',
selectAllDecisionError: 'Please select pass or fail for all pending records first',
placeholderInputValue: 'Please input',
imageLimitExceed: 'Maximum 3 images',
basicInfo: 'Basic Info',
cancelReason: 'Cancel Reason',
uploading: 'Uploading',
save: 'Save',
selectDecisionError: 'Please select pass or fail for pending records first',
updateSuccess: 'Update successfully',
updateFail: 'Update failed',

@ -177,6 +177,60 @@
}
}
},
Dashboard3: {
Header: {
title: '设备运行总览大屏标题',
subTitle: 'IOT 实时监控中心副标题',
currentTime: '当前时间:',
lastUpdate: '最后更新:',
refreshRate: '刷新频率:',
refreshRateValue: '{seconds} 秒刷新'
},
Metrics: {
total: '设备总数',
online: '在线',
offline: '离线',
running: '运行',
standby: '空闲',
fault: '报警'
},
Status: {
running: '运行',
standby: '空闲',
fault: '报警',
offline: '离线'
},
AlarmBoard: {
currentDevice: '当前报警设备',
todayAlarmDuration: '今日累计报警时长',
lastReportTime: '最后上报时间',
alarmPrefix: '报警',
empty: '暂无报警设备'
},
Detail: {
title: '设备运行明细',
carouselText: '{page} · 自动轮播',
columns: {
deviceName: '设备名称',
currentStatus: '当前状态',
runningTime: '运行时间(今日)',
standbyTime: '空闲时间(今日)',
alarmTime: '报警时间(今日)',
efficiency: '运行效率(今日)'
},
empty: '暂无设备运行数据'
},
Charts: {
last24Hours: '近 24 小时运行率趋势',
last7Days: '近 7 天运行率趋势',
averageRate: '平均运行率:{value}'
},
Time: {
minute: '{minute}分',
hour: '{hour}小时',
hourMinute: '{hour}小时{minute}分'
}
},
Dashboard8: {
Header: {
title: '智能制造产线任务总览',
@ -1845,6 +1899,7 @@
// 工单管理
WorkOrderManagement: {
basicInfo: '基本信息',
planNo: '单号',
planType: '类型',
planTypeInspect: '点检',
@ -1871,6 +1926,7 @@
remark: '备注',
operate: '操作',
dialogTitleDefault: '检验结果',
cancelReason: '取消原因',
cancelConfirm: '确认取消选中的任务吗?',
cancelSuccess: '取消任务成功',
cancelFail: '取消任务失败',
@ -1878,6 +1934,8 @@
selectDecisionError: '请先为待检测记录选择通过或不通过',
updateSuccess: '更新成功',
updateFail: '更新失败',
placeholderInputValue: '请输入',
uploading: '上传中',
placeholderPlanNo: '请输入单号',
placeholderPlanType: '请选择类型',
placeholderJobStatus: '请选择作业状态',

@ -81,7 +81,8 @@ const whiteList = [
'/register',
'/oauthLogin/gitee',
'/iot/report/dashboardPage/Dashboard8',
'/iot/report/dashboardPage/Dashboard1'
'/iot/report/dashboardPage/Dashboard1',
'/iot/report/dashboardPage/Dashboard3'
]
// 路由加载前

@ -653,6 +653,17 @@ const remainingRouter: AppRouteRecordRaw[] = [
canTo: true
}
},
{
path: '/iot/report/dashboardPage/Dashboard3',
component: () => import('@/views/report/dashboardPage/dashboard1/dashboard3/index.vue'),
name: 'IotReportDashboard3',
meta: {
title: t('Dashboard3.Header.title'),
hidden: true,
noTagsView: true,
canTo: true
}
},
{
path: '/iot',
component: Layout,

@ -244,6 +244,8 @@ export enum DICT_TYPE {
ERP_AUTOCODE_PARTTYPE = "erp_autocode_parttype",
MATERIAL_CLASSIFICATION_TYPE = 'material_classification_type',
SPARE_PARTS_LEVEL = 'spare_parts_level',
BIT_SAMPLE_CATEGORY = 'bit_sample_category',
BIT_SAMPLE_OUT_REASON = 'bit_sample_out_reason',
// ========== MES - 生产管理模块 ==========

@ -0,0 +1,163 @@
<template>
<el-button size="small" type="primary" :loading="loading" @click="openPrint">
<Icon icon="ep:printer" class="mr-4px" />
{{ buttonText }}
</el-button>
<HiprintPreviewDialog ref="printDialogRef" />
</template>
<script setup lang="ts">
import HiprintPreviewDialog from '@/components/HiprintPreviewDialog/index.vue'
import { ConfigApi } from '@/api/mes/printconfig'
import { defaultElementTypeProvider, hiwebSocket, hiprint } from 'vue-plugin-hiprint'
const props = withDefaults(
defineProps<{
title: string
buttonText?: string
businessScene?: string
printData?: Record<string, any>
templateJson?: Record<string, any> | string
loadLabel?: () => Promise<{ printData: Record<string, any>; templateJson?: Record<string, any> | string }>
}>(),
{
buttonText: '打印'
}
)
const message = useMessage()
const printDialogRef = ref()
const loading = ref(false)
let hiprintInited = false
const clientHostStorageKey = 'hiprint-client-host'
const defaultClientHost = 'http://192.168.2.58:17521'
const getHiwebSocket = () => {
return ((hiwebSocket as any) || (window as any).hiwebSocket) as any
}
const ensureHiprintInit = () => {
if (hiprintInited) {
return
}
hiprint.init({
host: localStorage.getItem(clientHostStorageKey) || defaultClientHost,
providers: [defaultElementTypeProvider()]
})
hiprintInited = true
}
const normalizeTemplateJson = (templateJson?: Record<string, any> | string) => {
if (!templateJson) {
return undefined
}
if (typeof templateJson !== 'string') {
return templateJson
}
try {
return JSON.parse(templateJson)
} catch (error) {
message.error('打印模板格式异常')
return undefined
}
}
const getPaperSize = (templateJson?: Record<string, any>) => {
const panel = templateJson?.panels?.[0]
if (panel?.width && panel?.height) {
return {
width: Number(panel.width),
height: Number(panel.height)
}
}
return { width: 80, height: 60 }
}
const ensureClientConnected = async () => {
ensureHiprintInit()
const socket = getHiwebSocket()
if (!socket) {
message.warning('打印客户端未初始化')
return false
}
if (socket.opened) {
return true
}
if (typeof socket.start !== 'function') {
message.warning('打印客户端未提供连接方法')
return false
}
return await new Promise<boolean>((resolve) => {
socket.stop?.()
socket.start((status: boolean) => {
if (!status) {
message.warning('未连接到本地打印客户端,请先确认 electron-hiprint 已启动')
}
resolve(Boolean(status))
})
})
}
const silentPrint = async (printData: Record<string, any>, templateJson?: Record<string, any>) => {
if (!templateJson) {
message.warning('未找到可用打印模板')
return
}
const pageRes = await ConfigApi.getConfigPage({
pageNo: 1,
pageSize: 100,
businessScenario: props.businessScene
})
const configs = (pageRes?.list || []).filter((item: any) => item.isEnabled !== false)
if (!configs.length) {
message.warning('未找到匹配业务场景「' + props.businessScene + '」的打印机配置')
return
}
const printerName = configs[0].systemPrinterName
if (!printerName) {
message.warning('匹配到的打印机配置未设置系统打印机')
return
}
const connected = await ensureClientConnected()
if (!connected) {
return
}
const hiprintTmpl = new hiprint.PrintTemplate({ template: templateJson })
hiprintTmpl.on('printSuccess', () => {
message.success('打印已发送到: ' + printerName)
})
hiprintTmpl.on('printError', (error: any) => {
message.error(error?.msg || error?.message || '打印失败')
})
try {
hiprintTmpl.print2(printData, {
printer: printerName,
title: props.title
})
} catch (error: any) {
message.error(error?.message || '打印失败')
}
}
const openPrint = async () => {
loading.value = true
try {
const label = props.loadLabel ? await props.loadLabel() : undefined
const printData = label?.printData || props.printData || {}
const templateJson = normalizeTemplateJson(label?.templateJson || props.templateJson)
if (props.businessScene) {
await silentPrint(printData, templateJson)
return
}
printDialogRef.value?.open({
title: props.title,
printData,
templateJson,
withDefaultQrcodeLayout: !templateJson,
paperSize: getPaperSize(templateJson)
})
} finally {
loading.value = false
}
}
</script>

@ -0,0 +1,194 @@
<template>
<ContentWrap>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="110px" class="bit-wms-form">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="样品" prop="productId">
<el-select v-model="formData.productId" placeholder="请选择样品" filterable clearable class="!w-full">
<el-option v-for="item in sampleList" :key="item.id" :label="formatSampleLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="库位" prop="locationId">
<el-select
v-model="formData.locationId"
placeholder="请选择库位"
filterable
clearable
class="!w-full"
@change="handleLocationChange"
>
<el-option
v-for="item in filteredLocationList"
:key="item.id"
:label="formatLocationLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="数量" prop="count">
<el-input-number v-model="formData.count" :min="1" :precision="0" class="!w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="单位" prop="unitId">
<el-select v-model="formData.unitId" placeholder="请选择单位" filterable class="!w-full">
<el-option v-for="item in sampleUnitOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitLoading" v-hasPermi="['erp:bit-wms-in:create']" @click="submitForm">
<Icon icon="ep:check" class="mr-5px" />确认入库
</el-button>
<el-button @click="resetForm"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
</el-form-item>
</el-form>
</ContentWrap>
</template>
<script setup lang="ts">
import { BitWmsApi } from '@/api/erp/bitwms'
import { WarehouseApi } from '@/api/erp/stock/warehouse'
import { WarehouseAreaApi } from '@/api/erp/stock/warehousearea'
import { WarehouseLocationApi } from '@/api/erp/stock/warehouselocation'
defineOptions({ name: 'ErpBitWmsIn' })
const DEFAULT_WAREHOUSE_NAME = '样品仓'
const DEFAULT_AREA_NAME = '必硕三楼'
const message = useMessage()
const formRef = ref()
const submitLoading = ref(false)
const sampleList = ref<any[]>([])
const warehouseList = ref<any[]>([])
const areaList = ref<any[]>([])
const locationList = ref<any[]>([])
const formData = reactive<any>({
productId: undefined,
warehouseId: undefined,
areaId: undefined,
locationId: undefined,
count: 1,
unitId: undefined,
remark: undefined
})
const formRules = reactive({
productId: [{ required: true, message: '请选择样品', trigger: 'change' }],
locationId: [{ required: true, message: '请选择库位', trigger: 'change' }],
count: [{ required: true, message: '请输入数量', trigger: 'blur' }],
unitId: [{ required: true, message: '请选择单位', trigger: 'change' }]
})
const selectedSample = computed(() => sampleList.value.find((item) => item.id === formData.productId))
const sampleUnitOptions = computed(() => {
const sample = selectedSample.value
if (!sample?.unitId) return []
return [{ id: sample.unitId, name: sample.unitName || '默认单位' }]
})
const filteredLocationList = computed(() => {
if (!formData.warehouseId || !formData.areaId) return []
return locationList.value.filter((item) => {
const matchedWarehouse = item.warehouseId === formData.warehouseId
const matchedArea = item.areaId === formData.areaId
return matchedWarehouse && matchedArea
})
})
const formatSampleLabel = (item: any) => `${item.barCode || item.code || '-'} / ${item.name || '-'}`
const formatLocationLabel = (item: any) => `${item.code || '-'} / ${item.name || '-'}`
const applyDefaultWarehouseArea = () => {
const warehouse = warehouseList.value.find((item) => item.name === DEFAULT_WAREHOUSE_NAME)
if (!warehouse) {
formData.warehouseId = undefined
formData.areaId = undefined
return
}
formData.warehouseId = warehouse.id
const area = areaList.value.find(
(item) => item.warehouseId === warehouse.id && item.areaName === DEFAULT_AREA_NAME
)
formData.areaId = area?.id
}
const handleLocationChange = (locationId?: number) => {
const location = locationList.value.find((item) => item.id === locationId)
if (!location) return
formData.warehouseId = location.warehouseId
formData.areaId = location.areaId
}
const handleSampleChange = () => {
formData.unitId = selectedSample.value?.unitId
}
const resetInnerFormData = () => {
formData.productId = undefined
formData.locationId = undefined
formData.count = 1
formData.unitId = undefined
formData.remark = undefined
applyDefaultWarehouseArea()
}
const resetForm = () => {
formRef.value?.resetFields()
resetInnerFormData()
}
const buildSubmitData = () => ({
productId: formData.productId,
locationId: formData.locationId,
warehouseId: formData.warehouseId,
areaId: formData.areaId,
count: formData.count,
unitId: formData.unitId,
remark: formData.remark
})
watch(
() => formData.productId,
() => handleSampleChange()
)
watch(
() => [formData.warehouseId, formData.areaId],
() => {
if (
formData.locationId &&
!filteredLocationList.value.some((item) => item.id === formData.locationId)
) {
formData.locationId = undefined
}
}
)
const submitForm = async () => {
await formRef.value.validate()
submitLoading.value = true
try {
await BitWmsApi.inbound(buildSubmitData())
message.success('入库成功')
resetForm()
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
const [samples, warehouses, areas, locations] = await Promise.all([
BitWmsApi.getSampleSimpleList(),
WarehouseApi.getWarehouseSimpleList(),
WarehouseAreaApi.getWarehouseAreaPage({ pageNo: 1, pageSize: 100 }),
WarehouseLocationApi.getWarehouseLocationPage({ pageNo: 1, pageSize: 100 })
])
sampleList.value = samples || []
warehouseList.value = warehouses || []
areaList.value = areas.list || []
locationList.value = locations.list || []
resetInnerFormData()
})
</script>

@ -0,0 +1,170 @@
<template>
<div>
<template v-if="!formVisible">
<ContentWrap>
<el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true" label-width="auto">
<el-form-item label="仓库" prop="warehouseId">
<el-select v-model="queryParams.warehouseId" placeholder="请选择仓库" clearable filterable class="!w-220px" @change="handleWarehouseChange">
<el-option v-for="item in warehouseList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="库区" prop="areaId">
<el-select v-model="queryParams.areaId" placeholder="请选择库区" clearable filterable class="!w-220px">
<el-option v-for="item in filteredAreaList" :key="item.id" :label="item.areaName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="库位码" prop="code">
<el-input v-model="queryParams.code" placeholder="请输入库位码" clearable class="!w-220px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="库位名称" prop="name">
<el-input v-model="queryParams.name" placeholder="请输入库位名称" clearable class="!w-220px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />查询</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
<el-button type="primary" plain v-hasPermi="['erp:bit-wms-location:create']" @click="openForm('create')">
<Icon icon="ep:plus" class="mr-5px" />新增库位
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true" row-key="id">
<el-table-column label="仓库" align="center" min-width="140">
<template #default="{ row }">{{ getWarehouseName(row.warehouseId) }}</template>
</el-table-column>
<el-table-column label="库区" align="center" min-width="120">
<template #default="{ row }">{{ getAreaName(row.areaId) }}</template>
</el-table-column>
<el-table-column label="库位码" align="center" prop="code" min-width="140" />
<el-table-column label="库位名称" align="center" prop="name" min-width="140" />
<el-table-column label="面积" align="center" prop="areaSize" width="100" />
<el-table-column label="最大承重" align="center" prop="maxLoadWeight" width="110" />
<el-table-column label="状态" align="center" prop="status" width="100">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="210" fixed="right">
<template #default="{ row }">
<el-button link type="primary" v-hasPermi="['erp:bit-wms-location:update']" @click="openForm('update', row.id)"></el-button>
<BitWmsPrintButton
v-hasPermi="['erp:bit-wms-location:print']"
title="库位标签"
:load-label="() => loadLocationLabel(row.id)"
/>
</template>
</el-table-column>
</el-table>
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize" @pagination="getList" />
</ContentWrap>
</template>
<WarehouseLocationForm v-else ref="formRef" @success="getList" @closed="formVisible = false" />
</div>
</template>
<script setup lang="ts">
import { DICT_TYPE } from '@/utils/dict'
import { WarehouseLocationApi } from '@/api/erp/stock/warehouselocation'
import { WarehouseApi } from '@/api/erp/stock/warehouse'
import { WarehouseAreaApi } from '@/api/erp/stock/warehousearea'
import { BitWmsApi } from '@/api/erp/bitwms'
import WarehouseLocationForm from '@/views/erp/warehouselocation/WarehouseLocationForm.vue'
import BitWmsPrintButton from '../components/BitWmsPrintButton.vue'
defineOptions({ name: 'ErpBitWmsLocation' })
const loading = ref(true)
const list = ref<any[]>([])
const total = ref(0)
const queryFormRef = ref()
const formRef = ref()
const formVisible = ref(false)
const warehouseList = ref<any[]>([])
const areaList = ref<any[]>([])
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
warehouseId: undefined,
areaId: undefined,
code: undefined,
name: undefined
})
const filteredAreaList = computed(() => {
if (!queryParams.warehouseId) return areaList.value
return areaList.value.filter((item) => item.warehouseId === queryParams.warehouseId)
})
const buildQueryParams = () =>
Object.fromEntries(Object.entries(queryParams).filter(([, value]) => value !== undefined && value !== null && value !== ''))
const getWarehouseName = (warehouseId: number) => warehouseList.value.find((item) => item.id === warehouseId)?.name || '-'
const getAreaName = (areaId: number) => areaList.value.find((item) => item.id === areaId)?.areaName || '-'
const getList = async () => {
loading.value = true
try {
const data = await WarehouseLocationApi.getWarehouseLocationPage(buildQueryParams())
list.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
const loadBaseData = async () => {
warehouseList.value = await WarehouseApi.getWarehouseSimpleList()
const areaData = await WarehouseAreaApi.getWarehouseAreaPage({ pageNo: 1, pageSize: 100 })
areaList.value = areaData.list || []
}
const handleWarehouseChange = () => {
queryParams.areaId = undefined
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
const openForm = async (type: string, id?: number) => {
formVisible.value = true
await nextTick()
formRef.value.open(type, id, type === 'create'
? {
defaultWarehouseName: '样品仓',
defaultAreaName: '必硕三楼',
defaultData: {
isCode: false,
allowProductMix: true,
allowBatchMix: true
},
hideAutoCodeSwitch: true,
hiddenFields: [
'areaSize',
'maxLoadWeight',
'positionX',
'positionY',
'positionZ',
'allowProductMix',
'allowBatchMix'
]
}
: undefined)
}
const loadLocationLabel = async (id: number) => await BitWmsApi.getLocationLabel(id)
onMounted(async () => {
await loadBaseData()
await getList()
})
</script>

@ -0,0 +1,216 @@
<template>
<ContentWrap>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px" class="bit-wms-form">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="样品" prop="productId">
<el-select
v-model="formData.productId"
placeholder="请选择样品"
filterable
clearable
class="!w-full"
@change="loadCurrentStock"
>
<el-option
v-for="item in sampleList"
:key="item.id"
:label="formatSampleLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="来源库存">
<el-input :model-value="currentStockText" disabled />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="来源库位" prop="fromLocationId">
<el-select
v-model="formData.fromLocationId"
placeholder="请选择来源库位"
filterable
clearable
class="!w-full"
@change="handleFromLocationChange"
>
<el-option
v-for="item in filteredLocationList"
:key="item.id"
:label="formatLocationLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标库位" prop="toLocationId">
<el-select
v-model="formData.toLocationId"
placeholder="请选择目标库位"
filterable
clearable
class="!w-full"
@change="handleToLocationChange"
>
<el-option
v-for="item in filteredLocationList"
:key="item.id"
:label="formatLocationLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="数量" prop="count">
<el-input-number v-model="formData.count" :min="1" :precision="0" class="!w-240px" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitLoading" v-hasPermi="['erp:bit-wms-move:create']" @click="submitForm">
<Icon icon="ep:check" class="mr-5px" />确认移库
</el-button>
<el-button @click="resetForm"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
</el-form-item>
</el-form>
</ContentWrap>
</template>
<script setup lang="ts">
import { BitWmsApi } from '@/api/erp/bitwms'
import { WarehouseApi } from '@/api/erp/stock/warehouse'
import { WarehouseAreaApi } from '@/api/erp/stock/warehousearea'
import { WarehouseLocationApi } from '@/api/erp/stock/warehouselocation'
defineOptions({ name: 'ErpBitWmsMove' })
const DEFAULT_WAREHOUSE_NAME = '样品仓'
const DEFAULT_AREA_NAME = '必硕三楼'
const message = useMessage()
const formRef = ref()
const submitLoading = ref(false)
const stockLoading = ref(false)
const currentStock = ref<number | undefined>(undefined)
const sampleList = ref<any[]>([])
const warehouseList = ref<any[]>([])
const areaList = ref<any[]>([])
const locationList = ref<any[]>([])
const formData = reactive<any>({
productId: undefined,
fromLocationId: undefined,
fromWarehouseId: undefined,
fromAreaId: undefined,
toLocationId: undefined,
toWarehouseId: undefined,
toAreaId: undefined,
count: 1,
remark: undefined
})
const formRules = reactive({
productId: [{ required: true, message: '请选择样品', trigger: 'change' }],
fromLocationId: [{ required: true, message: '请选择来源库位', trigger: 'change' }],
toLocationId: [{ required: true, message: '请选择目标库位', trigger: 'change' }],
count: [{ required: true, message: '请输入数量', trigger: 'blur' }]
})
const defaultWarehouseId = computed(
() => warehouseList.value.find((item) => item.name === DEFAULT_WAREHOUSE_NAME)?.id
)
const defaultAreaId = computed(() => {
if (!defaultWarehouseId.value) return undefined
return areaList.value.find(
(item) => item.warehouseId === defaultWarehouseId.value && item.areaName === DEFAULT_AREA_NAME
)?.id
})
const filteredLocationList = computed(() =>
locationList.value.filter((item) => {
if (defaultWarehouseId.value && item.warehouseId !== defaultWarehouseId.value) return false
if (defaultAreaId.value && item.areaId !== defaultAreaId.value) return false
return true
})
)
const currentStockText = computed(() => {
if (stockLoading.value) return '查询中...'
return currentStock.value === undefined ? '-' : `${currentStock.value}`
})
const formatSampleLabel = (item: any) => `${item.barCode || item.code || '-'} / ${item.name || '-'}`
const formatLocationLabel = (item: any) => `${item.code || '-'} / ${item.name || '-'}`
const fillLocationFields = (prefix: 'from' | 'to', locationId?: number) => {
const location = locationList.value.find((item) => item.id === locationId)
formData[`${prefix}WarehouseId`] = location?.warehouseId
formData[`${prefix}AreaId`] = location?.areaId
}
const handleFromLocationChange = (locationId?: number) => {
fillLocationFields('from', locationId)
loadCurrentStock()
}
const handleToLocationChange = (locationId?: number) => {
fillLocationFields('to', locationId)
}
const loadCurrentStock = async () => {
if (!formData.productId || !formData.fromLocationId) {
currentStock.value = undefined
return
}
stockLoading.value = true
try {
currentStock.value = await BitWmsApi.getLocationStockCount(
formData.productId,
formData.fromLocationId
)
} finally {
stockLoading.value = false
}
}
const resetInnerFormData = () => {
formData.productId = undefined
formData.fromLocationId = undefined
formData.fromWarehouseId = undefined
formData.fromAreaId = undefined
formData.toLocationId = undefined
formData.toWarehouseId = undefined
formData.toAreaId = undefined
formData.count = 1
formData.remark = undefined
currentStock.value = undefined
}
const resetForm = () => {
formRef.value?.resetFields()
resetInnerFormData()
}
const submitForm = async () => {
await formRef.value.validate()
if (formData.fromLocationId === formData.toLocationId) {
message.warning('来源库位和目标库位不能相同')
return
}
submitLoading.value = true
try {
await BitWmsApi.move(formData)
message.success('移库成功')
await loadCurrentStock()
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
const [samples, warehouses, areas, locations] = await Promise.all([
BitWmsApi.getSampleSimpleList(),
WarehouseApi.getWarehouseSimpleList(),
WarehouseAreaApi.getWarehouseAreaPage({ pageNo: 1, pageSize: 100 }),
WarehouseLocationApi.getWarehouseLocationPage({ pageNo: 1, pageSize: 100 })
])
sampleList.value = samples || []
warehouseList.value = warehouses || []
areaList.value = areas.list || []
locationList.value = locations.list || []
})
</script>

@ -0,0 +1,184 @@
<template>
<ContentWrap>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="110px" class="bit-wms-form">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="样品" prop="productId">
<el-select v-model="formData.productId" placeholder="请选择样品" filterable clearable class="!w-full" @change="loadCurrentStock">
<el-option v-for="item in sampleList" :key="item.id" :label="formatSampleLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="当前库存">
<el-input :model-value="currentStockText" disabled />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="库位" prop="locationId">
<el-select
v-model="formData.locationId"
placeholder="请选择库位"
filterable
clearable
class="!w-full"
@change="handleLocationChange"
>
<el-option
v-for="item in filteredLocationList"
:key="item.id"
:label="formatLocationLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="数量" prop="count">
<el-input-number v-model="formData.count" :min="1" :precision="0" class="!w-full" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="出库原因" prop="outReason">
<el-select v-model="formData.outReason" placeholder="请选择出库原因" class="!w-full">
<el-option v-for="dict in getStrDictOptions(DICT_TYPE.BIT_SAMPLE_OUT_REASON)" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitLoading" v-hasPermi="['erp:bit-wms-out:create']" @click="submitForm">
<Icon icon="ep:check" class="mr-5px" />确认出库
</el-button>
<el-button @click="resetForm"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
</el-form-item>
</el-form>
</ContentWrap>
</template>
<script setup lang="ts">
import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
import { BitWmsApi } from '@/api/erp/bitwms'
import { WarehouseApi } from '@/api/erp/stock/warehouse'
import { WarehouseAreaApi } from '@/api/erp/stock/warehousearea'
import { WarehouseLocationApi } from '@/api/erp/stock/warehouselocation'
defineOptions({ name: 'ErpBitWmsOut' })
const DEFAULT_WAREHOUSE_NAME = '样品仓'
const DEFAULT_AREA_NAME = '必硕三楼'
const message = useMessage()
const formRef = ref()
const submitLoading = ref(false)
const stockLoading = ref(false)
const currentStock = ref<number | undefined>(undefined)
const sampleList = ref<any[]>([])
const warehouseList = ref<any[]>([])
const areaList = ref<any[]>([])
const locationList = ref<any[]>([])
const formData = reactive<any>({
productId: undefined,
warehouseId: undefined,
areaId: undefined,
locationId: undefined,
count: 1,
outReason: undefined,
remark: undefined
})
const formRules = reactive({
productId: [{ required: true, message: '请选择样品', trigger: 'change' }],
locationId: [{ required: true, message: '请选择库位', trigger: 'change' }],
count: [{ required: true, message: '请输入数量', trigger: 'blur' }],
outReason: [{ required: true, message: '请选择出库原因', trigger: 'change' }]
})
const filteredLocationList = computed(() => {
if (!formData.warehouseId || !formData.areaId) return []
return locationList.value.filter((item) => item.warehouseId === formData.warehouseId && item.areaId === formData.areaId)
})
const currentStockText = computed(() => {
if (stockLoading.value) return '查询中...'
return currentStock.value === undefined ? '-' : `${currentStock.value}`
})
const formatSampleLabel = (item: any) => `${item.barCode || item.code || '-'} / ${item.name || '-'}`
const formatLocationLabel = (item: any) => `${item.code || '-'} / ${item.name || '-'}`
const applyDefaultWarehouseArea = () => {
const warehouse = warehouseList.value.find((item) => item.name === DEFAULT_WAREHOUSE_NAME)
if (!warehouse) {
formData.warehouseId = undefined
formData.areaId = undefined
return
}
formData.warehouseId = warehouse.id
const area = areaList.value.find(
(item) => item.warehouseId === warehouse.id && item.areaName === DEFAULT_AREA_NAME
)
formData.areaId = area?.id
}
const handleLocationChange = (locationId?: number) => {
const location = locationList.value.find((item) => item.id === locationId)
if (!location) {
currentStock.value = undefined
return
}
formData.warehouseId = location.warehouseId
formData.areaId = location.areaId
loadCurrentStock()
}
const loadCurrentStock = async () => {
if (!formData.productId || !formData.locationId) {
currentStock.value = undefined
return
}
stockLoading.value = true
try {
currentStock.value = await BitWmsApi.getLocationStockCount(
formData.productId,
formData.locationId
)
} finally {
stockLoading.value = false
}
}
const resetInnerFormData = () => {
formData.productId = undefined
formData.locationId = undefined
formData.count = 1
formData.outReason = undefined
formData.remark = undefined
currentStock.value = undefined
applyDefaultWarehouseArea()
}
const resetForm = () => {
formRef.value?.resetFields()
resetInnerFormData()
}
const submitForm = async () => {
await formRef.value.validate()
submitLoading.value = true
try {
await BitWmsApi.outbound(formData)
message.success('出库成功')
await loadCurrentStock()
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
const [samples, warehouses, areas, locations] = await Promise.all([
BitWmsApi.getSampleSimpleList(),
WarehouseApi.getWarehouseSimpleList(),
WarehouseAreaApi.getWarehouseAreaPage({ pageNo: 1, pageSize: 100 }),
WarehouseLocationApi.getWarehouseLocationPage({ pageNo: 1, pageSize: 100 })
])
sampleList.value = samples || []
warehouseList.value = warehouses || []
areaList.value = areas.list || []
locationList.value = locations.list || []
resetInnerFormData()
})
</script>

@ -0,0 +1,270 @@
<template>
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="auto"
>
<el-form-item label="样品" prop="productId">
<el-select
v-model="queryParams.productId"
placeholder="请选择样品"
clearable
filterable
class="!w-240px"
>
<el-option
v-for="item in sampleList"
:key="item.id"
:label="formatSampleLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="仓库" prop="warehouseId">
<el-select
v-model="queryParams.warehouseId"
placeholder="请选择仓库"
clearable
filterable
class="!w-220px"
@change="handleWarehouseChange"
>
<el-option
v-for="item in warehouseList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="库区" prop="areaId">
<el-select
v-model="queryParams.areaId"
placeholder="请选择库区"
clearable
filterable
class="!w-220px"
@change="handleAreaChange"
>
<el-option
v-for="item in filteredAreaList"
:key="item.id"
:label="item.areaName"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="库位" prop="locationId">
<el-select
v-model="queryParams.locationId"
placeholder="请选择库位"
clearable
filterable
class="!w-240px"
>
<el-option
v-for="item in filteredLocationList"
:key="item.id"
:label="formatLocationLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="业务单号" prop="bizNo">
<el-input
v-model="queryParams.bizNo"
placeholder="请输入业务单号"
clearable
class="!w-220px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />查询</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
row-key="id"
>
<el-table-column label="业务单号" align="center" prop="bizNo" min-width="170" />
<el-table-column label="方向" align="center" prop="bizDirection" width="100">
<template #default="{ row }">
<el-tag v-if="row.bizDirection" :type="getDirectionTagType(row.bizDirection)">{{
row.bizDirection
}}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="样品名称" align="left" prop="productName" min-width="180" />
<!-- <el-table-column label="分类" align="center" prop="categoryName" min-width="120" /> -->
<el-table-column label="仓库" align="center" prop="warehouseName" min-width="140" />
<el-table-column label="库区" align="center" prop="areaName" min-width="120" />
<el-table-column label="库位编码" align="center" prop="locationCode" min-width="130" />
<el-table-column label="库位名称" align="center" prop="locationName" min-width="140" />
<el-table-column label="变动数量" align="center" prop="count" min-width="120">
<template #default="{ row }">
<span :class="getDirectionClass(row.bizDirection)">{{
formatCount(row.count, row.unitName, true, row.bizDirection)
}}</span>
</template>
</el-table-column>
<el-table-column label="结存" align="center" prop="totalCount" min-width="120">
<template #default="{ row }">{{ formatCount(row.totalCount, row.unitName) }}</template>
</el-table-column>
<el-table-column label="操作人" align="center" prop="creatorName" min-width="110" />
<el-table-column
label="操作时间"
align="center"
prop="recordTime"
:formatter="dateFormatter"
width="180"
/>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
</template>
<script setup lang="ts">
import { dateFormatter } from '@/utils/formatTime'
import { BitWmsApi } from '@/api/erp/bitwms'
import { WarehouseApi } from '@/api/erp/stock/warehouse'
import { WarehouseAreaApi } from '@/api/erp/stock/warehousearea'
import { WarehouseLocationApi } from '@/api/erp/stock/warehouselocation'
defineOptions({ name: 'ErpBitWmsRecord' })
const loading = ref(true)
const list = ref<any[]>([])
const total = ref(0)
const queryFormRef = ref()
const sampleList = ref<any[]>([])
const warehouseList = ref<any[]>([])
const areaList = ref<any[]>([])
const locationList = ref<any[]>([])
const queryParams = reactive<any>({
pageNo: 1,
pageSize: 10,
productId: undefined,
warehouseId: undefined,
areaId: undefined,
locationId: undefined,
bizNo: undefined
})
const filteredAreaList = computed(() => {
if (!queryParams.warehouseId) return areaList.value
return areaList.value.filter((item) => item.warehouseId === queryParams.warehouseId)
})
const filteredLocationList = computed(() =>
locationList.value.filter((item) => {
if (queryParams.warehouseId && item.warehouseId !== queryParams.warehouseId) return false
if (queryParams.areaId && item.areaId !== queryParams.areaId) return false
return true
})
)
const formatSampleLabel = (item: any) => `${item.barCode || item.code || '-'} / ${item.name || '-'}`
const formatLocationLabel = (item: any) => `${item.code || '-'} / ${item.name || '-'}`
const isStockIn = (direction?: string) => direction === '入库'
const isStockOut = (direction?: string) => direction === '出库'
const getDirectionTagType = (direction?: string) => {
if (isStockIn(direction)) return 'success'
if (isStockOut(direction)) return 'danger'
return 'info'
}
const getDirectionClass = (direction?: string) => {
if (isStockIn(direction)) return 'bit-wms-record-in'
if (isStockOut(direction)) return 'bit-wms-record-out'
return ''
}
const formatNumber = (value: number | string | undefined) => {
if (value === undefined || value === null || value === '') return '-'
const num = Number(value)
return Number.isFinite(num) ? num.toLocaleString() : String(value)
}
const formatCount = (
value: number | string | undefined,
unit?: string,
withSign = false,
direction?: string
) => {
const unitText = unit ? ` ${unit}` : ''
if (!withSign) return `${formatNumber(value)}${unitText}`
const num = Number(value)
if (!Number.isFinite(num)) return `${value ?? '-'}${unitText}`
const absValue = Math.abs(num).toLocaleString()
if (isStockIn(direction)) return `+${absValue}${unitText}`
if (isStockOut(direction)) return `-${absValue}${unitText}`
return `${formatNumber(value)}${unitText}`
}
const buildQueryParams = () =>
Object.fromEntries(
Object.entries(queryParams).filter(
([, value]) => value !== undefined && value !== null && value !== ''
)
)
const getList = async () => {
loading.value = true
try {
const data = await BitWmsApi.getLocationStockRecordPage(buildQueryParams())
list.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
const handleWarehouseChange = () => {
queryParams.areaId = undefined
queryParams.locationId = undefined
}
const handleAreaChange = () => {
queryParams.locationId = undefined
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
onMounted(async () => {
const [samples, warehouses, areas, locations] = await Promise.all([
BitWmsApi.getSampleSimpleList(),
WarehouseApi.getWarehouseSimpleList(),
WarehouseAreaApi.getWarehouseAreaPage({ pageNo: 1, pageSize: 100 }),
WarehouseLocationApi.getWarehouseLocationPage({ pageNo: 1, pageSize: 100 })
])
sampleList.value = samples || []
warehouseList.value = warehouses || []
areaList.value = areas.list || []
locationList.value = locations.list || []
await getList()
})
</script>
<style scoped>
.bit-wms-record-in {
color: var(--el-color-success);
}
.bit-wms-record-out {
color: var(--el-color-danger);
}
</style>

@ -0,0 +1,155 @@
<template>
<template v-if="!formVisible">
<ContentWrap>
<el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true" label-width="auto">
<el-form-item label="样品码" prop="barCode">
<el-input v-model="queryParams.barCode" placeholder="请输入样品码" clearable class="!w-220px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="样品名称" prop="name">
<el-input v-model="queryParams.name" placeholder="请输入样品名称" clearable class="!w-220px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="样品类型" prop="categoryType">
<el-select v-model="queryParams.categoryType" placeholder="请选择样品类型" clearable class="!w-180px">
<el-option v-for="dict in getIntDictOptions(DICT_TYPE.MATERIAL_CLASSIFICATION_TYPE)" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="样品分类" prop="sampleCategory">
<el-select v-model="queryParams.sampleCategory" placeholder="请选择样品分类" clearable class="!w-180px">
<el-option v-for="dict in getStrDictOptions(DICT_TYPE.BIT_SAMPLE_CATEGORY)" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="queryParams.customerName" placeholder="请输入客户名称" clearable class="!w-220px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />查询</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
<el-button type="primary" plain v-hasPermi="['erp:bit-wms-sample:create']" @click="openProductCreate">
<Icon icon="ep:plus" class="mr-5px" />新增样品
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true" row-key="id">
<el-table-column label="样品码" align="center" prop="barCode" min-width="160" />
<el-table-column label="样品名称" align="left" prop="name" min-width="180" />
<el-table-column label="样品类型" align="center" prop="categoryType" min-width="110">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.MATERIAL_CLASSIFICATION_TYPE" :value="row.categoryType" />
</template>
</el-table-column>
<el-table-column label="样品分类" align="center" prop="sampleCategory" min-width="120">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.BIT_SAMPLE_CATEGORY" :value="row.sampleCategory" />
</template>
</el-table-column>
<el-table-column label="规格" align="center" prop="standard" min-width="140" />
<el-table-column label="客户名称" align="center" prop="customerName" min-width="140" />
<el-table-column label="默认仓库" align="center" min-width="160">
<template #default="{ row }">
{{ row.defaultWarehouseName || row.warehouseName || '-' }}
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="100">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="210" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openProductDetail(row.id)"></el-button>
<el-button link type="primary" v-hasPermi="['erp:bit-wms-sample:update']" @click="openProductEdit(row.id)"></el-button>
<BitWmsPrintButton
v-hasPermi="['erp:bit-wms-sample:print']"
title="样品标签"
:load-label="() => loadSampleLabel(row.id)"
/>
</template>
</el-table-column>
</el-table>
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize" @pagination="getList" />
</ContentWrap>
</template>
<ProductForm v-else ref="formRef" @success="handleFormSuccess" @closed="handleFormClosed" />
</template>
<script setup lang="ts">
import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
import { BitWmsApi } from '@/api/erp/bitwms'
import BitWmsPrintButton from '../components/BitWmsPrintButton.vue'
import ProductForm from '@/views/erp/product/product/ProductForm.vue'
defineOptions({ name: 'ErpBitWmsSample' })
const loading = ref(true)
const list = ref<any[]>([])
const total = ref(0)
const queryFormRef = ref()
const formRef = ref()
const formVisible = ref(false)
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
barCode: undefined,
name: undefined,
categoryType: undefined,
sampleCategory: undefined,
customerName: undefined
})
const buildQueryParams = () =>
Object.fromEntries(Object.entries(queryParams).filter(([, value]) => value !== undefined && value !== null && value !== ''))
const getList = async () => {
loading.value = true
try {
const data = await BitWmsApi.getSamplePage(buildQueryParams())
list.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
const loadSampleLabel = async (id: number) => await BitWmsApi.getSampleLabel(id)
const openProductForm = async (type: 'create' | 'update' | 'detail', id?: number) => {
formVisible.value = true
await nextTick()
const defaultData = {
sampleMode: true,
...(type === 'create'
? {
isSample: true,
bizUnit: 'BIT'
}
: {})
}
formRef.value.open(type, id, defaultData)
}
const openProductCreate = () => openProductForm('create')
const openProductEdit = (id: number) => openProductForm('update', id)
const openProductDetail = (id: number) => openProductForm('detail', id)
const handleFormSuccess = async () => {
formVisible.value = false
await getList()
}
const handleFormClosed = () => {
formVisible.value = false
}
onMounted(getList)
</script>

@ -0,0 +1,205 @@
<template>
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="auto"
>
<el-form-item label="样品" prop="productId">
<el-select
v-model="queryParams.productId"
placeholder="请选择样品"
clearable
filterable
class="!w-240px"
>
<el-option
v-for="item in sampleList"
:key="item.id"
:label="formatSampleLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="仓库" prop="warehouseId">
<el-select
v-model="queryParams.warehouseId"
placeholder="请选择仓库"
clearable
filterable
class="!w-220px"
@change="handleWarehouseChange"
>
<el-option
v-for="item in warehouseList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="库区" prop="areaId">
<el-select
v-model="queryParams.areaId"
placeholder="请选择库区"
clearable
filterable
class="!w-220px"
@change="handleAreaChange"
>
<el-option
v-for="item in filteredAreaList"
:key="item.id"
:label="item.areaName"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="库位" prop="locationId">
<el-select
v-model="queryParams.locationId"
placeholder="请选择库位"
clearable
filterable
class="!w-240px"
>
<el-option
v-for="item in filteredLocationList"
:key="item.id"
:label="formatLocationLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />查询</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
row-key="id"
>
<el-table-column label="样品码" align="center" prop="barCode" min-width="150" />
<el-table-column label="样品名称" align="left" min-width="180">
<template #default="{ row }">{{ row.name || row.productName || '-' }}</template>
</el-table-column>
<!-- <el-table-column label="样品分类" align="center" prop="categoryName" min-width="130" /> -->
<el-table-column label="仓库" align="center" prop="warehouseName" min-width="140" />
<el-table-column label="库区" align="center" prop="areaName" min-width="120" />
<el-table-column label="库位编码" align="center" prop="locationCode" min-width="130" />
<el-table-column label="库位名称" align="center" prop="locationName" min-width="140" />
<el-table-column label="库存" align="center" prop="count" min-width="120">
<template #default="{ row }">{{ formatCount(row.count) }}</template>
</el-table-column>
<el-table-column label="单位" align="center" prop="unitName" width="90" />
<!-- <el-table-column label="库存展示" align="center" prop="stockDisplay" min-width="200">
<template #default="{ row }">{{ row.stockDisplay || '-' }}</template>
</el-table-column> -->
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
</template>
<script setup lang="ts">
import { BitWmsApi } from '@/api/erp/bitwms'
import { WarehouseApi } from '@/api/erp/stock/warehouse'
import { WarehouseAreaApi } from '@/api/erp/stock/warehousearea'
import { WarehouseLocationApi } from '@/api/erp/stock/warehouselocation'
defineOptions({ name: 'ErpBitWmsStock' })
const loading = ref(true)
const list = ref<any[]>([])
const total = ref(0)
const queryFormRef = ref()
const sampleList = ref<any[]>([])
const warehouseList = ref<any[]>([])
const areaList = ref<any[]>([])
const locationList = ref<any[]>([])
const queryParams = reactive<any>({
pageNo: 1,
pageSize: 10,
productId: undefined,
warehouseId: undefined,
areaId: undefined,
locationId: undefined
})
const filteredAreaList = computed(() => {
if (!queryParams.warehouseId) return areaList.value
return areaList.value.filter((item) => item.warehouseId === queryParams.warehouseId)
})
const filteredLocationList = computed(() =>
locationList.value.filter((item) => {
if (queryParams.warehouseId && item.warehouseId !== queryParams.warehouseId) return false
if (queryParams.areaId && item.areaId !== queryParams.areaId) return false
return true
})
)
const formatSampleLabel = (item: any) => `${item.barCode || item.code || '-'} / ${item.name || '-'}`
const formatLocationLabel = (item: any) => `${item.code || '-'} / ${item.name || '-'}`
const formatCount = (value: number | string | undefined) => {
if (value === undefined || value === null || value === '') return '-'
const num = Number(value)
return Number.isFinite(num) ? num.toLocaleString() : String(value)
}
const buildQueryParams = () =>
Object.fromEntries(
Object.entries(queryParams).filter(
([, value]) => value !== undefined && value !== null && value !== ''
)
)
const getList = async () => {
loading.value = true
try {
const data = await BitWmsApi.getLocationStockPage(buildQueryParams())
list.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
const handleWarehouseChange = () => {
queryParams.areaId = undefined
queryParams.locationId = undefined
}
const handleAreaChange = () => {
queryParams.locationId = undefined
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
onMounted(async () => {
const [samples, warehouses, areas, locations] = await Promise.all([
BitWmsApi.getSampleSimpleList(),
WarehouseApi.getWarehouseSimpleList(),
WarehouseAreaApi.getWarehouseAreaPage({ pageNo: 1, pageSize: 100 }),
WarehouseLocationApi.getWarehouseLocationPage({ pageNo: 1, pageSize: 100 })
])
sampleList.value = samples || []
warehouseList.value = warehouses || []
areaList.value = areas.list || []
locationList.value = locations.list || []
await getList()
})
</script>

@ -31,7 +31,7 @@
</el-row>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col v-if="!isSampleMode" :span="12">
<el-form-item :label="t('FactoryModeling.ProductInformation.dialogCategoryTypeLabel')" prop="categoryType">
<el-radio-group v-model="formData.categoryType" @change="handleCategoryTypeChange">
<el-radio
@ -49,7 +49,7 @@
<el-input v-model="formData.name" :placeholder="t('FactoryModeling.ProductInformation.dialogNamePlaceholder')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-col v-if="!isSampleMode" :span="12">
<el-form-item :label="t('FactoryModeling.ProductInformation.dialogCategoryLabel')" prop="categoryId">
<el-tree-select
v-model="formData.categoryId"
@ -74,7 +74,7 @@
</el-select>
</el-form-item>
</el-col>
<el-col v-if="formData.categoryType === 2 || formData.categoryType === 3" :span="12">
<el-col v-if="!isSampleMode && (formData.categoryType === 2 || formData.categoryType === 3)" :span="12">
<el-form-item :label="t('FactoryModeling.ProductInformation.dialogPurchaseUnitLabel')" prop="purchaseUnitId">
<el-select v-model="formData.purchaseUnitId" clearable :placeholder="t('FactoryModeling.ProductInformation.dialogUnitPlaceholder')" class="w-1/1" @change="handlePurchaseUnitChange">
<el-option
@ -86,12 +86,12 @@
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col v-if="!isSampleMode" :span="12">
<el-form-item :label="t('FactoryModeling.ProductInformation.dialogStandardLabel')" prop="standard">
<el-input v-model="formData.standard" :placeholder="t('FactoryModeling.ProductInformation.dialogStandardPlaceholder')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-col v-if="!isSampleMode" :span="12">
<el-form-item :label="t('FactoryModeling.ProductInformation.dialogExpiryDayLabel')" prop="expiryDay">
<el-input-number
v-model="formData.expiryDay"
@ -102,7 +102,7 @@
/>
</el-form-item>
</el-col>
<el-col v-if="isProductCategory" :span="12">
<el-col v-if="!isSampleMode && isProductCategory" :span="12">
<el-form-item label="关联设备" prop="devices">
<el-input
:model-value="deviceDisplayText"
@ -112,7 +112,7 @@
/>
</el-form-item>
</el-col>
<el-col v-if="isProductCategory" :span="12">
<el-col v-if="!isSampleMode && isProductCategory" :span="12">
<el-form-item label="关联模具" prop="molds">
<el-input
:model-value="moldDisplayText"
@ -122,7 +122,7 @@
/>
</el-form-item>
</el-col>
<el-col v-if="formData.categoryType === 1" :span="12">
<el-col v-if="!isSampleMode && formData.categoryType === 1" :span="12">
<el-form-item :label="t('FactoryModeling.ProductInformation.dialogPackagingSchemeLabel')" prop="packagingSchemes">
<el-input
:model-value="displayPackagingSchemeText"
@ -134,7 +134,7 @@
/>
</el-form-item>
</el-col>
<el-col v-if="formData.categoryType === 2 || formData.categoryType === 3" :span="12">
<el-col v-if="!isSampleMode && (formData.categoryType === 2 || formData.categoryType === 3)" :span="12">
<el-form-item :label="t('FactoryModeling.ProductInformation.dialogSupplierLabel')" prop="suppliers">
<el-input
:model-value="displaySupplierText"
@ -146,7 +146,7 @@
/>
</el-form-item>
</el-col>
<el-col v-if="formData.categoryType === 3" :span="8">
<el-col v-if="!isSampleMode && formData.categoryType === 3" :span="8">
<el-form-item :label="t('FactoryModeling.ProductInformation.dialogSparePartLevelLabel')" prop="sparePartLevel">
<el-radio-group v-model="formData.sparePartLevel">
<el-radio
@ -159,7 +159,7 @@
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="formData.categoryType === 3" :span="8">
<el-col v-if="!isSampleMode && formData.categoryType === 3" :span="8">
<el-form-item :label="t('FactoryModeling.ProductInformation.dialogFragileFlagLabel')" prop="fragileFlag">
<el-radio-group v-model="formData.fragileFlag">
<el-radio :label="1">{{ t('common.yes') }}</el-radio>
@ -167,17 +167,17 @@
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="formData.categoryType === 3" :span="8">
<el-col v-if="!isSampleMode && formData.categoryType === 3" :span="8">
<el-form-item :label="t('FactoryModeling.ProductInformation.dialogPurchaseCycleLabel')" prop="purchaseCycle">
<el-input-number v-model="formData.purchaseCycle" :min="0" :precision="0" class="!w-1/1" />
</el-form-item>
</el-col>
<el-col v-if="formData.categoryType === 3" :span="8">
<el-col v-if="!isSampleMode && formData.categoryType === 3" :span="8">
<el-form-item :label="t('FactoryModeling.ProductInformation.dialogBrandLabel')" prop="brand">
<el-input v-model="formData.brand" :placeholder="t('FactoryModeling.ProductInformation.dialogBrandPlaceholder')" />
</el-form-item>
</el-col>
<el-col v-if="formData.categoryType === 2 || formData.categoryType === 3" :span="12">
<el-col v-if="!isSampleMode && (formData.categoryType === 2 || formData.categoryType === 3)" :span="12">
<el-form-item :label="t('FactoryModeling.ProductInformation.dialogPurchaseUnitConvertLabel')" prop="purchaseUnitConvertQuantity">
<el-input-number v-model="formData.purchaseUnitConvertQuantity" :min="1" :precision="0" class="!w-1/1" />
<div class="form-item-tip">{{ purchaseUnitConvertTipText }}</div>
@ -196,6 +196,35 @@
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="!isSampleMode" :span="8">
<el-form-item label="是否样品" prop="isSample">
<el-switch v-model="formData.isSample" />
</el-form-item>
</el-col>
<template v-if="formData.isSample">
<el-col :span="isSampleMode ? 12 : 8">
<el-form-item label="样品分类" prop="sampleCategory">
<el-select v-model="formData.sampleCategory" clearable placeholder="请选择样品分类" class="w-1/1">
<el-option
v-for="dict in getStrDictOptions(DICT_TYPE.BIT_SAMPLE_CATEGORY)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="isSampleMode ? 12 : 8">
<el-form-item label="所属事业部" prop="bizUnit">
<el-input v-model="formData.bizUnit" placeholder="请输入所属事业部" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="formData.customerName" placeholder="请输入客户名称" />
</el-form-item>
</el-col>
</template>
<el-col v-if="formType === 'update'" :span="24">
<div class="flex items-start gap-20px">
<el-form-item :label="t('FactoryModeling.ProductInformation.qrcode')" prop="qrcodeUrl" class="flex-1">
@ -405,7 +434,7 @@ import { PackagingSchemeApi } from '@/api/erp/stock/packagingScheme'
import { SupplierApi } from '@/api/erp/purchase/supplier'
import { CommonStatusEnum } from '@/utils/constants'
import { defaultProps, handleTree } from '@/utils/tree'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
/** ERP 产品 表单 */
defineOptions({ name: 'ProductForm' })
@ -416,6 +445,7 @@ const message = useMessage()
const dialogTitle = ref('')
const formLoading = ref(false)
const formType = ref('')
const isSampleMode = ref(false)
const deviceSelectDialogRef = ref()
const moldSelectDialogRef = ref()
const searchFormRef = ref()
@ -460,6 +490,11 @@ const formData = ref({
categoryId: undefined,
unitId: undefined,
status: undefined,
isSample: false,
sampleCategory: undefined,
bizUnit: undefined,
customerId: undefined,
customerName: undefined,
standard: undefined,
remark: undefined,
expiryDay: undefined,
@ -646,6 +681,10 @@ const validateBarCode = (_rule, value, callback) => {
callback()
}
const validatePackagingSchemes = (_rule, value, callback) => {
if (isSampleMode.value) {
callback()
return
}
if (formData.value.categoryType !== 1) {
callback()
return
@ -661,6 +700,10 @@ const validatePackagingSchemes = (_rule, value, callback) => {
callback()
}
const validateSuppliers = (_rule, value, callback) => {
if (isSampleMode.value) {
callback()
return
}
if (formData.value.categoryType !== 2 && formData.value.categoryType !== 3) {
callback()
return
@ -934,14 +977,64 @@ const supplierList = ref<any[]>([])
let openRequestId = 0
const open = async (type: string, id?: number) => {
const findFirstCategoryId = (tree: any[]): number | undefined => {
const queue = [...(tree || [])]
while (queue.length) {
const node = queue.shift()
if (!node) continue
if (node.id !== undefined && node.id !== null) {
return Number(node.id)
}
if (Array.isArray(node.children) && node.children.length) {
queue.push(...node.children)
}
}
return undefined
}
const applySampleDefaults = () => {
if (!isSampleMode.value) return
formData.value.isSample = true
formData.value.bizUnit = formData.value.bizUnit || 'BIT'
if (!formData.value.categoryId) {
formData.value.categoryId = findFirstCategoryId(categoryList.value) as any
}
if (!formData.value.unitId && unitList.value.length > 0) {
formData.value.unitId = unitList.value[0].id as any
}
}
const validateSampleDefaults = () => {
if (!isSampleMode.value) return true
applySampleDefaults()
if (!formData.value.categoryType || !formData.value.categoryId) {
message.warning('请先维护产品分类基础资料,用于保存样品必需的系统分类')
return false
}
if (!formData.value.unitId) {
message.warning('请先维护库存单位,或为样品选择库存单位')
return false
}
return true
}
const open = async (type: string, id?: number, defaultData?: Record<string, any>) => {
const currentOpenId = ++openRequestId
dialogTitle.value = t('action.' + type)
formType.value = type
isSampleMode.value = Boolean(defaultData?.sampleMode)
resetForm()
if (!id) {
if (defaultData) {
const initialData = { ...defaultData }
delete initialData.sampleMode
formData.value = {
...formData.value,
...initialData
}
}
const typeOptions = getIntDictOptions(DICT_TYPE.MATERIAL_CLASSIFICATION_TYPE)
if (typeOptions.length > 0) {
if (formData.value.categoryType === undefined && typeOptions.length > 0) {
formData.value.categoryType = typeOptions[0].value
}
}
@ -984,12 +1077,15 @@ const open = async (type: string, id?: number) => {
const categoryData = await ProductCategoryApi.getProductCategorySimpleList(formData.value.categoryType)
categoryList.value = handleTree(categoryData, 'id', 'parentId')
unitList.value = await ProductUnitApi.getProductUnitSimpleList()
//
const schemeData = await PackagingSchemeApi.getPackagingSchemePage({ pageNo: 1, pageSize: 100, status: 0 })
packagingSchemeList.value = schemeData.list || []
//
const supplierData = await SupplierApi.getSupplierPage({ pageNo: 1, pageSize: 100, status: 0 })
supplierList.value = supplierData.list || []
applySampleDefaults()
if (!isSampleMode.value) {
//
const schemeData = await PackagingSchemeApi.getPackagingSchemePage({ pageNo: 1, pageSize: 100, status: 0 })
packagingSchemeList.value = schemeData.list || []
//
const supplierData = await SupplierApi.getSupplierPage({ pageNo: 1, pageSize: 100, status: 0 })
supplierList.value = supplierData.list || []
}
}
defineExpose({ open })
@ -1086,6 +1182,7 @@ const normalizeDefaultStatus = (list: any[] | undefined, defaultId: number | und
}
const submitForm = async () => {
if (!validateSampleDefaults()) return
await formRef.value.validate()
formLoading.value = true
try {
@ -1125,6 +1222,11 @@ const resetForm = () => {
categoryId: undefined,
unitId: undefined,
status: CommonStatusEnum.ENABLE,
isSample: false,
sampleCategory: undefined,
bizUnit: undefined,
customerId: undefined,
customerName: undefined,
standard: undefined,
remark: undefined,
expiryDay: undefined,

@ -47,10 +47,10 @@
<el-col :span="8">
<el-form-item :label="t('ErpStock.WarehouseLocation.code')" prop="code">
<el-row :gutter="10" class="!w-full">
<el-col :span="18">
<el-col :span="hideAutoCodeSwitch ? 24 : 18">
<el-input v-model="formData.code" :placeholder="t('ErpStock.WarehouseLocation.placeholderCode')" :disabled="formData.isCode == true || formType === 'update'" />
</el-col>
<el-col :span="6">
<el-col v-if="!hideAutoCodeSwitch" :span="6">
<el-switch v-model="formData.isCode" :disabled="formType === 'update'" />
</el-col>
</el-row>
@ -63,7 +63,7 @@
<el-input v-model="formData.name" :placeholder="t('ErpStock.WarehouseLocation.placeholderName')" class="!w-full" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-col v-if="!isFieldHidden('areaSize')" :span="8">
<el-form-item :label="t('ErpStock.WarehouseLocation.areaSize')" prop="areaSize">
<el-input-number
v-model="formData.areaSize"
@ -74,7 +74,7 @@
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-col v-if="!isFieldHidden('maxLoadWeight')" :span="8">
<el-form-item :label="t('ErpStock.WarehouseLocation.maxLoadWeight')" prop="maxLoadWeight">
<el-input-number
v-model="formData.maxLoadWeight"
@ -87,7 +87,7 @@
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-col v-if="!isFieldHidden('positionX')" :span="8">
<el-form-item :label="t('ErpStock.WarehouseLocation.positionX')" prop="positionX">
<el-input-number
v-model="formData.positionX"
@ -97,7 +97,7 @@
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-col v-if="!isFieldHidden('positionY')" :span="8">
<el-form-item :label="t('ErpStock.WarehouseLocation.positionY')" prop="positionY">
<el-input-number
v-model="formData.positionY"
@ -107,7 +107,7 @@
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-col v-if="!isFieldHidden('positionZ')" :span="8">
<el-form-item :label="t('ErpStock.WarehouseLocation.positionZ')" prop="positionZ">
<el-input-number
v-model="formData.positionZ"
@ -119,12 +119,12 @@
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-col v-if="!isFieldHidden('allowProductMix')" :span="8">
<el-form-item :label="t('ErpStock.WarehouseLocation.allowProductMix')" prop="allowProductMix">
<el-switch v-model="formData.allowProductMix" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-col v-if="!isFieldHidden('allowBatchMix')" :span="8">
<el-form-item :label="t('ErpStock.WarehouseLocation.allowBatchMix')" prop="allowBatchMix">
<el-switch v-model="formData.allowBatchMix" />
</el-form-item>
@ -154,6 +154,14 @@ import { CommonStatusEnum } from '@/utils/constants'
defineOptions({ name: 'WarehouseLocationForm' })
type WarehouseLocationOpenOptions = {
defaultWarehouseName?: string
defaultAreaName?: string
defaultData?: Record<string, any>
hideAutoCodeSwitch?: boolean
hiddenFields?: string[]
}
const { t } = useI18n()
const message = useMessage()
@ -176,9 +184,21 @@ const formData = ref({
status: undefined,
isCode: true
})
const validateCode = (_rule: any, value: any, callback: any) => {
if (formData.value.isCode) {
callback()
return
}
if (value === undefined || value === null || String(value).trim() === '') {
callback(new Error('请输入库位编码'))
return
}
callback()
}
const formRules = reactive({
warehouseId: [{ required: true, message: t('ErpStock.WarehouseLocation.validatorWarehouseIdRequired'), trigger: 'blur' }],
areaId: [{ required: true, message: t('ErpStock.WarehouseLocation.validatorAreaIdRequired'), trigger: 'blur' }],
code: [{ validator: validateCode, trigger: ['blur', 'change'] }],
name: [{ required: true, message: t('ErpStock.WarehouseLocation.validatorNameRequired'), trigger: 'blur' }],
allowProductMix: [{ required: true, message: t('ErpStock.WarehouseLocation.validatorAllowProductMixRequired'), trigger: 'blur' }],
allowBatchMix: [{ required: true, message: t('ErpStock.WarehouseLocation.validatorAllowBatchMixRequired'), trigger: 'blur' }],
@ -187,6 +207,8 @@ const formRules = reactive({
const formRef = ref()
const warehouseList = ref<any[]>([])
const areaList = ref<any[]>([])
const hideAutoCodeSwitch = ref(false)
const hiddenFields = ref<string[]>([])
const filteredAreaList = computed(() => {
if (!formData.value.warehouseId) return areaList.value
@ -197,9 +219,32 @@ const handleWarehouseChange = () => {
formData.value.areaId = undefined
}
const open = async (type: string, id?: number) => {
const isFieldHidden = (field: string) => hiddenFields.value.includes(field)
const applyDefaultWarehouseArea = (options?: WarehouseLocationOpenOptions) => {
if (!options) return
const defaultWarehouse = options.defaultWarehouseName
? warehouseList.value.find((item) => item.name === options.defaultWarehouseName)
: undefined
if (defaultWarehouse) {
formData.value.warehouseId = defaultWarehouse.id
}
const candidateAreaList = formData.value.warehouseId
? areaList.value.filter((item) => item.warehouseId === formData.value.warehouseId)
: areaList.value
const defaultArea = options.defaultAreaName
? candidateAreaList.find((item) => item.areaName === options.defaultAreaName)
: undefined
if (defaultArea) {
formData.value.areaId = defaultArea.id
}
}
const open = async (type: string, id?: number, options?: WarehouseLocationOpenOptions) => {
dialogTitle.value = t('action.' + type)
formType.value = type
hideAutoCodeSwitch.value = Boolean(options?.hideAutoCodeSwitch)
hiddenFields.value = options?.hiddenFields ?? []
resetForm()
warehouseList.value = await WarehouseApi.getWarehouseSimpleList()
const areaData = await WarehouseAreaApi.getWarehouseAreaPage({ pageNo: 1, pageSize: 100 })
@ -211,6 +256,12 @@ const open = async (type: string, id?: number) => {
} finally {
formLoading.value = false
}
} else {
formData.value = {
...formData.value,
...(options?.defaultData ?? {})
}
applyDefaultWarehouseArea(options)
}
}
defineExpose({ open })

@ -1,5 +1,12 @@
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle" width="92%" :fullscreen="false" top="4vh">
<Dialog
v-model="dialogVisible"
:title="dialogTitle"
width="96%"
:fullscreen="false"
:initial-fullscreen="true"
top="2vh"
>
<div class="hiprint-preview">
<div class="hiprint-toolbar">
<div class="hiprint-paper">
@ -167,6 +174,11 @@ type BarcodeElementItem = {
label: string
}
type BarcodeFieldItem = BarcodeElementItem & {
field: string
type: 'image' | 'text'
}
type BarcodeElementGroup = {
value: string | number
label: string
@ -175,6 +187,37 @@ type BarcodeElementGroup = {
const barcodeGroups = ref<BarcodeElementGroup[]>([])
const barcodeDictData = ref<any[]>([])
const barcodeFieldLabelMap: Record<string, string> = {
qrcodeUrl: '二维码',
name: '名称',
code: '编码',
sampleCategory: '样品分类',
customerName: '客户名称'
}
const getBarcodeFieldLabel = (field: string, fallback: string) => barcodeFieldLabelMap[field] || fallback || field
const parseBarcodeFields = (item: any): BarcodeFieldItem[] => {
const parts = String(item.remark || '')
.split(',')
.map((s: string) => s.trim())
.filter(Boolean)
const qrcodeField = parts[0] || ''
const nameField = parts[1] || ''
const codeField = parts[2] || parts[1] || ''
const baseFields: BarcodeFieldItem[] = [
{ tid: `barcodeModule.${item.value}_qrcode`, label: '二维码', field: qrcodeField, type: 'image' },
{ tid: `barcodeModule.${item.value}_name`, label: getBarcodeFieldLabel(nameField, '名称'), field: nameField, type: 'text' },
{ tid: `barcodeModule.${item.value}_code`, label: getBarcodeFieldLabel(codeField, '编码'), field: codeField, type: 'text' }
]
const extraFields = parts.slice(3).map((field: string, index: number) => ({
tid: `barcodeModule.${item.value}_field_${index + 3}`,
label: getBarcodeFieldLabel(field, field),
field,
type: 'text' as const
}))
return [...baseFields, ...extraFields]
}
const loadBarcodeDictData = async () => {
try {
@ -184,11 +227,7 @@ const loadBarcodeDictData = async () => {
barcodeGroups.value = filtered.map((item: any) => ({
value: item.value,
label: item.label,
elements: [
{ tid: `barcodeModule.${item.value}_qrcode`, label: '二维码' },
{ tid: `barcodeModule.${item.value}_name`, label: '名称' },
{ tid: `barcodeModule.${item.value}_code`, label: '编码' }
]
elements: parseBarcodeFields(item).map(({ tid, label }) => ({ tid, label }))
}))
} catch (e) {
console.error('加载条码字典数据失败', e)
@ -201,55 +240,39 @@ const barcodeProvider = function () {
if (!barcodeDictData.value.length) return
const groups: any[] = []
barcodeDictData.value.forEach((item: any) => {
const remark = item.remark || ''
const parts = remark.split(',').map((s: string) => s.trim())
const qrcodeField = parts[0] || ''
const nameField = parts[1] || ''
const codeField = parts[2] || parts[1] || ''
const fields = parseBarcodeFields(item)
groups.push(
new hiprint.PrintElementTypeGroup(item.label, [
{
tid: `barcodeModule.${item.value}_qrcode`,
title: '二维码',
type: 'image',
options: {
field: qrcodeField,
src: qrCodeImg,
testData: qrCodeImg,
width: 80,
height: 80,
title: '二维码'
new hiprint.PrintElementTypeGroup(item.label, fields.map((fieldItem) => {
if (fieldItem.type === 'image') {
return {
tid: fieldItem.tid,
title: fieldItem.label,
type: 'image',
options: {
field: fieldItem.field,
src: qrCodeImg,
testData: qrCodeImg,
width: 80,
height: 80,
title: fieldItem.label
}
}
},
{
tid: `barcodeModule.${item.value}_name`,
title: '名称',
type: 'text',
options: {
field: nameField,
testData: '',
title: '名称',
fontSize: 10,
textAlign: 'center',
width: 80,
height: 16
}
},
{
tid: `barcodeModule.${item.value}_code`,
title: '编码',
}
return {
tid: fieldItem.tid,
title: fieldItem.label,
type: 'text',
options: {
field: codeField,
field: fieldItem.field,
testData: '',
title: '编码',
title: fieldItem.label,
fontSize: 10,
textAlign: 'center',
width: 80,
height: 16
}
}
])
}))
)
})
context.addPrintElementTypes('barcodeModule', groups)
@ -644,6 +667,10 @@ watch(selectedIconColor, () => {
<style scoped lang="scss">
.hiprint-preview {
width: 100%;
height: calc(100vh - 84px);
display: flex;
flex-direction: column;
min-height: 0;
}
.hiprint-toolbar {
@ -651,6 +678,7 @@ watch(selectedIconColor, () => {
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
}
.hiprint-paper {
@ -686,7 +714,8 @@ watch(selectedIconColor, () => {
}
.hiprint-body {
height: 75vh;
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 220px 1fr 400px;
gap: 12px;

@ -1,182 +1,209 @@
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle" width="1500px">
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true" :row-key="getRowKey">
<el-table-column
type="index"
:label="t('EquipmentManagement.WorkOrderManagement.index')"
align="center"
width="70"
/>
<el-table-column
:label="t('EquipmentManagement.WorkOrderManagement.inspectionItemName')"
align="center"
prop="inspectionItemName"
min-width="180"
sortable />
<el-table-column
:label="t('EquipmentManagement.WorkOrderManagement.inspectionMethod')"
align="center"
prop="inspectionMethod"
min-width="140"
>
<template #default="scope">
<dict-tag type="Inspection_method" :value="scope.row.inspectionMethod" />
</template>
</el-table-column>
<el-table-column
:label="t('EquipmentManagement.WorkOrderManagement.judgmentCriteria')"
align="center"
prop="judgmentCriteria"
min-width="160"
/>
<el-table-column
:label="t('EquipmentManagement.WorkOrderManagement.inspectionResult')"
align="center"
prop="inspectionResult"
width="120"
>
<template #default="scope">
<el-tag v-if="String(scope.row.inspectionResult) === '0'" type="info">
{{ t('EquipmentManagement.WorkOrderManagement.jobResultPending') }}
<div class="ticket-result-page">
<ContentWrap>
<div class="ticket-result-header">
<el-button @click="handleClose">{{ t('common.back') }}</el-button>
<span class="ticket-result-title">{{ pageTitle }}</span>
</div>
<el-descriptions :title="t('EquipmentManagement.WorkOrderManagement.basicInfo')" :column="4" border>
<el-descriptions-item :label="t('EquipmentManagement.WorkOrderManagement.planNo')">
{{ currentRow?.planNo || '-' }}
</el-descriptions-item>
<el-descriptions-item :label="t('EquipmentManagement.WorkOrderManagement.deviceName')">
{{ currentRow?.deviceName || '-' }}
</el-descriptions-item>
<el-descriptions-item :label="t('EquipmentManagement.WorkOrderManagement.planType')">
<el-tag v-if="String(currentRow?.planType) === '1'" type="primary">
{{ t('EquipmentManagement.WorkOrderManagement.planTypeInspect') }}
</el-tag>
<el-tag v-else-if="String(currentRow?.planType) === '2'" type="success">
{{ t('EquipmentManagement.WorkOrderManagement.planTypeMaintain') }}
</el-tag>
<el-tag v-else-if="String(scope.row.inspectionResult) === '1'" type="success">
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item :label="t('EquipmentManagement.WorkOrderManagement.jobStatus')">
<dict-tag :type="'job_status'" :value="jobStatus" />
</el-descriptions-item>
<el-descriptions-item :label="t('EquipmentManagement.WorkOrderManagement.configName')">
{{ currentRow?.configName || '-' }}
</el-descriptions-item>
<el-descriptions-item :label="t('EquipmentManagement.WorkOrderManagement.operatorName')">
{{ currentRow?.operatorName || '-' }}
</el-descriptions-item>
<el-descriptions-item :label="t('EquipmentManagement.WorkOrderManagement.taskTime')">
{{ formatDisplayTime(currentRow?.taskTime) }}
</el-descriptions-item>
<el-descriptions-item :label="t('EquipmentManagement.WorkOrderManagement.taskEndTime')">
{{ formatDisplayTime(currentRow?.taskEndTime) }}
</el-descriptions-item>
<el-descriptions-item :label="t('EquipmentManagement.WorkOrderManagement.jobResult')">
<el-tag v-if="currentRow?.jobResult == '1' || currentRow?.jobResult == 'OK'" type="success">
{{ t('EquipmentManagement.WorkOrderManagement.jobResultOk') }}
</el-tag>
<el-tag v-else-if="String(scope.row.inspectionResult) === '2'" type="danger">
<el-tag v-else-if="currentRow?.jobResult == '2' || currentRow?.jobResult == 'NG'" type="danger">
{{ t('EquipmentManagement.WorkOrderManagement.jobResultNg') }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
:label="t('EquipmentManagement.WorkOrderManagement.images')"
align="center"
prop="images"
width="160"
>
<template #default="scope">
<UploadImg
v-if="String(scope.row.inspectionResult) === '0'"
v-model="imageMap[String(scope.row.id)]"
:drag="false" :show-btn-text="false" width="64px" height="64px" />
<el-image
v-else-if="scope.row.images"
:src="parseFirstImage(scope.row.images)"
:preview-src-list="parseImages(scope.row.images)" preview-teleported fit="cover"
style="width: 64px; height: 64px" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :label="t('EquipmentManagement.DvSubject.valueType')" align="center" prop="valueType" width="120" sortable>
<template #default="scope">
<el-tag
v-if="scope.row.valueType !== undefined && scope.row.valueType !== null && String(scope.row.valueType) !== ''"
effect="light" :type="getTagType('value_types', scope.row.valueType)"
:color="getTagColor('value_types', scope.row.valueType)"
:style="getTagStyle('value_types', scope.row.valueType)" disable-transitions>
{{ getTagLabel('value_types', scope.row.valueType) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="输入值" align="center" min-width="200">
<template #default="scope">
<el-input
v-if="String(scope.row.valueType) === '0' || String(scope.row.valueType) === '2'"
v-model="scope.row.textInput"
:disabled="String(scope.row.inspectionResult) !== '0'"
clearable
:rows="2"
type="textarea"
style="resize: both;"
placeholder="请输入"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
:label="t('EquipmentManagement.WorkOrderManagement.remark')"
align="center"
prop="remark"
min-width="160"
/>
<el-table-column
:label="t('EquipmentManagement.WorkOrderManagement.operate')"
align="center"
width="200"
fixed="right"
>
<template #default="scope">
<el-radio-group v-if="String(scope.row.inspectionResult) === '0'" v-model="decisionMap[String(scope.row.id)]">
<el-radio :label="'1'">{{ t('EquipmentManagement.WorkOrderManagement.ok') }}</el-radio>
<el-radio :label="'2'">{{ t('EquipmentManagement.WorkOrderManagement.ng') }}</el-radio>
</el-radio-group>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<el-pagination
v-show="total > 0"
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:background="true"
:page-sizes="[10, 20, 30, 50, 100]"
:pager-count="7"
:total="total"
class="mt-15px mb-15px flex justify-end"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange" />
<div v-if="String(jobStatus) === '4'" class="mt-10px text-14px">
<span>{{ t('EquipmentManagement.WorkOrderManagement.jobStatus') }}</span>
<dict-tag :type="'job_status'" :value="jobStatus" />
<span class="ml-20px">取消原因</span>
<span>{{ cancelReason || '-' }}</span>
</div>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('EquipmentManagement.WorkOrderManagement.dialogCancel') }}</el-button>
<el-button
type="primary"
@click="handleSave"
:loading="submitLoading"
:disabled="submitLoading || !isAllPendingSelected()"
>
{{ t('EquipmentManagement.WorkOrderManagement.dialogSave') }}
</el-button>
</template>
</Dialog>
</el-descriptions-item>
<el-descriptions-item v-if="String(jobStatus) === '4'"
:label="t('EquipmentManagement.WorkOrderManagement.cancelReason')" :span="3">
{{ cancelReason || '-' }}
</el-descriptions-item>
</el-descriptions>
</ContentWrap>
<ContentWrap>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true" :row-key="getRowKey">
<el-table-column type="index" :label="t('EquipmentManagement.WorkOrderManagement.index')" align="center" width="70" />
<el-table-column :label="t('EquipmentManagement.WorkOrderManagement.inspectionItemName')" align="center"
prop="inspectionItemName" min-width="200" sortable />
<el-table-column :label="t('EquipmentManagement.WorkOrderManagement.inspectionMethod')" align="center"
prop="inspectionMethod" min-width="160">
<template #default="scope">
<dict-tag type="Inspection_method" :value="scope.row.inspectionMethod" />
</template>
</el-table-column>
<el-table-column :label="t('EquipmentManagement.WorkOrderManagement.judgmentCriteria')" align="center"
prop="judgmentCriteria" min-width="180" />
<el-table-column :label="t('EquipmentManagement.WorkOrderManagement.inspectionResult')" align="center"
prop="inspectionResult" min-width="140">
<template #default="scope">
<el-tag v-if="String(scope.row.inspectionResult) === '0'" type="info">{{
t('EquipmentManagement.WorkOrderManagement.jobResultPending') }}</el-tag>
<el-tag v-else-if="String(scope.row.inspectionResult) === '1'" type="success">{{
t('EquipmentManagement.WorkOrderManagement.jobResultOk') }}</el-tag>
<el-tag v-else-if="String(scope.row.inspectionResult) === '2'" type="danger">{{
t('EquipmentManagement.WorkOrderManagement.jobResultNg') }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :label="t('EquipmentManagement.WorkOrderManagement.images')" align="center" prop="images" min-width="260">
<template #default="scope">
<div v-if="String(scope.row.inspectionResult) === '0'" v-loading="isImageUploading(String(scope.row.id))"
:element-loading-text="t('EquipmentManagement.WorkOrderManagement.uploading')" class="ticket-result-images"
:class="{ 'is-full': (imageMap[String(scope.row.id)] || []).length >= 3 }">
<UploadImgs v-model="imageMap[String(scope.row.id)]" :limit="3" width="64px" height="64px"
@uploading-change="setImageUploading(String(scope.row.id), $event)" />
</div>
<div v-else-if="parseImages(scope.row.images).length" class="ticket-result-images">
<el-image v-for="image in parseImages(scope.row.images)" :key="image" :src="image"
:preview-src-list="parseImages(scope.row.images)" preview-teleported fit="cover"
class="ticket-result-image" />
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :label="t('EquipmentManagement.DvSubject.valueType')" align="center" prop="valueType" width="120" sortable>
<template #default="scope">
<el-tag
v-if="scope.row.valueType !== undefined && scope.row.valueType !== null && String(scope.row.valueType) !== ''"
effect="light" :type="getTagType('value_types', scope.row.valueType)"
:color="getTagColor('value_types', scope.row.valueType)"
:style="getTagStyle('value_types', scope.row.valueType)" disable-transitions>
{{ getTagLabel('value_types', scope.row.valueType) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :label="t('EquipmentManagement.WorkOrderManagement.inputValue')" align="center" min-width="200">
<template #default="scope">
<el-input
v-if="String(scope.row.valueType) === '0' || String(scope.row.valueType) === '2'"
v-model="scope.row.textInput"
:disabled="String(scope.row.inspectionResult) !== '0'"
clearable
:rows="2"
type="textarea"
style="resize: both;"
:placeholder="t('EquipmentManagement.WorkOrderManagement.placeholderInputValue')"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :label="t('EquipmentManagement.WorkOrderManagement.remark')" align="center" prop="remark" min-width="180" />
<el-table-column :label="t('EquipmentManagement.WorkOrderManagement.operate')" align="center" width="200" fixed="right">
<template #default="scope">
<el-radio-group v-if="String(scope.row.inspectionResult) === '0'" v-model="decisionMap[String(scope.row.id)]">
<el-radio :label="'1'">{{ t('EquipmentManagement.WorkOrderManagement.ok') }}</el-radio>
<el-radio :label="'2'">{{ t('EquipmentManagement.WorkOrderManagement.ng') }}</el-radio>
</el-radio-group>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<el-pagination
v-show="total > 0"
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total" class="mt-15px mb-15px flex justify-end"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
<div class="ticket-result-footer">
<el-button v-if="String(jobStatus) === '0'" type="primary" @click="handleSave" :loading="submitLoading"
:disabled="hasImageUploading">
{{ t('EquipmentManagement.WorkOrderManagement.dialogSave') }}
</el-button>
</div>
</ContentWrap>
</div>
</template>
<script setup lang="ts">
import { TicketManagementApi, TicketResultVO } from '@/api/mes/ticketManagement'
import { formatDate } from '@/utils/formatTime'
import { getStrDictOptions } from '@/utils/dict'
import { isHexColor } from '@/utils/color'
import { useDictStoreWithOut } from '@/store/modules/dict'
import { TicketManagementApi, TicketResultVO } from '@/api/mes/ticketManagement'
import { isHexColor } from '@/utils/color'
import UploadImgs from '@/components/UploadFile/src/UploadImgs.vue'
defineOptions({ name: 'TicketResultDialog' })
const emit = defineEmits(['success'])
const message = useMessage()
const { t } = useI18n()
const message = useMessage()
const dictStore = useDictStoreWithOut()
const props = defineProps<{
ticketRow?: any
title?: string
}>()
const emit = defineEmits<{
success: []
closed: []
}>()
const dictReady = ref(false)
const pageTitle = computed(() => props.title || t('EquipmentManagement.WorkOrderManagement.dialogTitleDefault'))
const currentRow = computed(() => props.ticketRow)
const jobStatus = computed(() => props.ticketRow?.jobStatus)
const cancelReason = computed(() => props.ticketRow?.cancelReason)
const dialogVisible = ref(false)
const dialogTitle = ref(t('EquipmentManagement.WorkOrderManagement.dialogTitleDefault'))
const loading = ref(false)
const submitLoading = ref(false)
const list = ref<TicketResultVO[]>([])
const total = ref(0)
const managementId = ref<number | undefined>(undefined)
const jobStatus = ref<string | number | undefined>(undefined)
const cancelReason = ref<string | undefined>(undefined)
const decisionMap = reactive<Record<string, '1' | '2' | undefined>>({})
const imageMap = reactive<Record<string, string>>({})
const imageMap = reactive<Record<string, string[]>>({})
const imageUploadingMap = reactive<Record<string, boolean>>({})
const hasImageUploading = computed(() => Object.values(imageUploadingMap).some(Boolean))
const setImageUploading = (key: string, value: boolean) => {
if (!key) return
if (value) {
imageUploadingMap[key] = true
} else {
delete imageUploadingMap[key]
}
}
const isImageUploading = (key: string) => {
return !!imageUploadingMap[key]
}
const getTagDict = (dictType: string, value: any) => {
if (!dictReady.value) return undefined
@ -214,33 +241,30 @@ const queryParams = reactive({
pageSize: 10
})
const open = async (options: { managementId: number; title?: string; jobStatus?: string | number; cancelReason?: string }) => {
dialogVisible.value = true
dialogTitle.value = options.title || t('EquipmentManagement.WorkOrderManagement.dialogTitleDefault')
managementId.value = options.managementId
jobStatus.value = options.jobStatus
cancelReason.value = options.cancelReason
const resetResultState = () => {
for (const key of Object.keys(decisionMap)) delete decisionMap[key]
for (const key of Object.keys(imageMap)) delete imageMap[key]
for (const key of Object.keys(imageUploadingMap)) delete imageUploadingMap[key]
list.value = []
total.value = 0
queryParams.pageNo = 1
await getList()
}
defineExpose({ open })
onMounted(async () => {
await dictStore.setDictMap()
dictReady.value = true
})
const getList = async () => {
if (!managementId.value) return
const id = managementId.value
if (!id) return
loading.value = true
try {
const data = await TicketManagementApi.getTicketResultsPage({
pageNo: queryParams.pageNo,
pageSize: queryParams.pageSize,
managementId: managementId.value
managementId: id
})
list.value = data.list
total.value = data.total
@ -248,8 +272,8 @@ const getList = async () => {
for (const row of list.value) {
const id = row?.id
if (!id) continue
if (row.images && !imageMap[String(id)]) {
imageMap[String(id)] = parseFirstImage(row.images)
if (!imageMap[String(id)]) {
imageMap[String(id)] = parseImages(row.images).slice(0, 3)
}
}
} finally {
@ -257,6 +281,16 @@ const getList = async () => {
}
}
watch(
() => props.ticketRow?.id,
async (id) => {
resetResultState()
managementId.value = id
if (id) await getList()
},
{ immediate: true }
)
const handleSizeChange = () => {
queryParams.pageNo = 1
getList()
@ -266,25 +300,16 @@ const handleCurrentChange = () => {
getList()
}
const handleSave = async () => {
const hasUnselected = list.value.some((row) => {
if (!row?.id) return false
if (String(row.inspectionResult) !== '0') return false
const decision = decisionMap[String(row.id)]
return !decision
})
if (hasUnselected) {
message.error(t('EquipmentManagement.WorkOrderManagement.selectAllDecisionError'))
return
}
const payload: TicketResultVO[] = []
for (const row of list.value) {
if (!row?.id) continue
if (String(row.inspectionResult) !== '0') continue
const decision = decisionMap[String(row.id)]
if (!decision) continue
const img = imageMap[String(row.id)]
payload.push({ ...(row as any), inspectionResult: decision, images: img || row.images })
const images = imageMap[String(row.id)]
payload.push({ ...(row as any), inspectionResult: decision, images: Array.isArray(images) ? images.join(',') : row.images })
}
if (!payload.length) {
message.error(t('EquipmentManagement.WorkOrderManagement.selectDecisionError'))
@ -295,7 +320,6 @@ const handleSave = async () => {
await TicketManagementApi.batchUpdateTicketResults(payload)
message.success(t('EquipmentManagement.WorkOrderManagement.updateSuccess'))
emit('success')
dialogVisible.value = false
} catch {
message.error(t('EquipmentManagement.WorkOrderManagement.updateFail'))
} finally {
@ -303,14 +327,12 @@ const handleSave = async () => {
}
}
const isAllPendingSelected = () => {
for (const row of list.value) {
if (!row?.id) continue
if (String(row.inspectionResult) !== '0') continue
const decision = decisionMap[String(row.id)]
if (!decision) return false
}
return true
const handleClose = () => {
emit('closed')
}
const formatDisplayTime = (value: any) => {
return value ? formatDate(value) : '-'
}
const getRowKey = (row: TicketResultVO) => {
@ -326,13 +348,54 @@ const parseImages = (value: any): string[] => {
.filter(Boolean)
}
const parseFirstImage = (value: any): string => {
return parseImages(value)[0] || ''
}
</script>
<style scoped lang="scss">
:deep(.el-upload) {
margin: 0 auto;
}
.ticket-result-images {
display: flex;
justify-content: center;
min-height: 64px;
}
.ticket-result-images :deep(.el-upload-list--picture-card) {
display: flex;
flex-wrap: nowrap;
justify-content: center;
gap: 8px;
}
.ticket-result-images.is-full :deep(.el-upload--picture-card) {
display: none;
}
.ticket-result-image {
width: 64px;
height: 64px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
margin-right: 6px;
}
.ticket-result-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.ticket-result-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.ticket-result-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

@ -1,4 +1,5 @@
<template>
<template>
<template v-if="!resultVisible">
<ContentWrap>
<el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true" min-label-width="68px">
<el-form-item :label="t('EquipmentManagement.WorkOrderManagement.planNo')" prop="planNo">
@ -193,7 +194,16 @@
/>
</ContentWrap>
<TicketResultDialog ref="resultDialogRef" @success="getList" />
</template>
<TicketResultDialog
v-else
:ticket-row="selectedResultRow"
:title="resultTitle"
@success="handleResultSuccess"
@closed="handleResultClosed"
/>
</template>
<script setup lang="ts">
@ -224,6 +234,16 @@ const total = ref(0)
const selectedIds = ref<number[]>([])
const cancelLoading = ref(false)
const exportLoading = ref(false)
const resultVisible = ref(false)
const selectedResultRow = ref<TicketManagementVO>()
const resultTitle = computed(() => {
const row = selectedResultRow.value
if (!row) return t('EquipmentManagement.WorkOrderManagement.dialogTitleDefault')
return row.planNo
? `${t('EquipmentManagement.WorkOrderManagement.dialogTitleDefault')}-${row.planNo}`
: t('EquipmentManagement.WorkOrderManagement.dialogTitleDefault')
})
const queryParams = reactive({
pageNo: 1,
@ -310,19 +330,23 @@ const handleExport = async () => {
}
}
const resultDialogRef = ref<InstanceType<typeof TicketResultDialog>>()
const handleRowClick = async (row: TicketManagementVO, column: any) => {
const handleRowClick = (row: TicketManagementVO, column: any) => {
if (column?.type === 'selection') return
if (!row?.id) return
await resultDialogRef.value?.open({
managementId: row.id,
jobStatus: row.jobStatus,
cancelReason: row.cancelReason,
title: row.planNo
? `${t('EquipmentManagement.WorkOrderManagement.dialogTitleDefault')}-${row.planNo}`
: t('EquipmentManagement.WorkOrderManagement.dialogTitleDefault')
})
selectedResultRow.value = row
resultVisible.value = true
}
const handleResultClosed = () => {
resultVisible.value = false
selectedResultRow.value = undefined
}
const handleResultSuccess = async () => {
resultVisible.value = false
selectedResultRow.value = undefined
await getList()
}
onMounted(async () => {

@ -185,6 +185,7 @@ const total = ref(0)
const getDashboardImage = (item: DashboardItem) => {
if (item.type === '2') return dashboardImage1
if (item.type === '1') return dashboardImage2
if (item.type === '3') return dashboardImage1
return item.indexImage || defaultImage
}
@ -373,6 +374,7 @@ const openEditDialog = (item: DashboardItem) => {
const getRouteByType = (type?: string) => {
if (type === '1') return 'iot/report/dashboardPage/Dashboard1'
if (type === '2') return 'iot/report/dashboardPage/Dashboard8'
if (type === '3') return 'iot/report/dashboardPage/Dashboard3'
return ''
}

@ -0,0 +1,939 @@
<template>
<div ref="dashboardRef" class="run-dashboard">
<div class="bg-grid"></div>
<header class="dashboard-header">
<div class="brand">
<div class="brand-icon">
<Icon icon="ep:cpu" />
</div>
<div class="brand-text">{{ t('Header.subTitle') }}</div>
</div>
<h1>{{ t('Header.title') }}</h1>
<div class="header-meta">
<div><span>{{ t('Header.currentTime') }}</span>{{ currentTime }}</div>
<div><span>{{ t('Header.lastUpdate') }}</span>{{ lastUpdateTime || '-' }}</div>
<div class="refresh-row">
<span>{{ t('Header.refreshRate') }}</span>{{ t('Header.refreshRateValue', { seconds: 15 }) }}
<button type="button" class="screen-btn" @click="toggleFullscreen">
<Icon :icon="isFullscreen ? 'zmdi:fullscreen-exit' : 'zmdi:fullscreen'" />
</button>
</div>
</div>
</header>
<main class="dashboard-main">
<section class="top-row">
<div
v-for="card in statusCards"
:key="card.key"
class="metric-card"
:class="`metric-card--${card.tone}`"
>
<div class="metric-title">
<Icon :icon="card.icon" />
<span>{{ card.label }}</span>
</div>
<div class="metric-value">
<strong>{{ card.value }}</strong>
<span v-if="card.percent !== undefined">{{ formatPercent(card.percent) }}</span>
</div>
</div>
<div class="alarm-board panel">
<div class="alarm-head">
<span>{{ t('AlarmBoard.currentDevice') }}</span>
<span>{{ t('AlarmBoard.todayAlarmDuration') }}</span>
<span>{{ t('AlarmBoard.lastReportTime') }}</span>
</div>
<div v-if="alarmDevices.length" class="alarm-list">
<div v-for="item in alarmDevices" :key="item.id" class="alarm-item">
<strong :title="item.name">{{ item.name }}</strong>
<span>{{ t('AlarmBoard.alarmPrefix') }} {{ formatMinutes(item.alarmMinutes) }}</span>
<span>{{ item.lastReportTime }}</span>
</div>
</div>
<div v-else class="empty-line">{{ t('AlarmBoard.empty') }}</div>
</div>
</section>
<section class="panel detail-panel">
<div class="panel-title">
<h2>{{ t('Detail.title') }}</h2>
<span>{{ t('Detail.carouselText', { page: detailPageText }) }}</span>
</div>
<div class="detail-table">
<div class="table-header table-row">
<span>{{ t('Detail.columns.deviceName') }}</span>
<span>{{ t('Detail.columns.currentStatus') }}</span>
<span>{{ t('Detail.columns.runningTime') }}</span>
<span>{{ t('Detail.columns.standbyTime') }}</span>
<span>{{ t('Detail.columns.alarmTime') }}</span>
<span>{{ t('Detail.columns.efficiency') }}</span>
</div>
<div v-if="pagedRows.length" class="table-body">
<div
v-for="row in pagedRows"
:key="row.id"
class="table-row"
:class="`table-row--${row.status}`"
>
<strong :title="row.name">{{ row.name }}</strong>
<span>
<em class="status-pill" :class="`status-pill--${row.status}`">{{ statusText[row.status] }}</em>
</span>
<span>{{ formatMinutes(row.runningMinutes) }}</span>
<span>{{ formatMinutes(row.standbyMinutes) }}</span>
<span>{{ formatMinutes(row.alarmMinutes) }}</span>
<span class="rate">{{ formatPercent(row.utilizationRate) }}</span>
</div>
</div>
<div v-else class="empty-table">{{ t('Detail.empty') }}</div>
</div>
</section>
<section class="chart-row">
<div class="panel chart-panel">
<div class="panel-title">
<h2>{{ t('Charts.last24Hours') }}</h2>
<span>{{ t('Charts.averageRate', { value: formatPercent(todayAverageRate) }) }}</span>
</div>
<div ref="hourChartRef" class="chart"></div>
</div>
<div class="panel chart-panel">
<div class="panel-title">
<h2>{{ t('Charts.last7Days') }}</h2>
<span>{{ t('Charts.averageRate', { value: formatPercent(sevenDayAverageRate) }) }}</span>
</div>
<div ref="weekChartRef" class="chart"></div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { useFullscreen } from '@vueuse/core'
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
import { DeviceOperationOverviewApi } from '@/api/iot/deviceOperationOverview'
import type { DeviceOperationOverviewRespVO, DeviceOperationOverviewTimelineRowVO } from '@/api/iot/deviceOperationOverview'
import { OrganizationApi } from '@/api/mes/organization'
defineOptions({ name: 'IotReportDashboard3' })
const { t } = useI18n('Dashboard3')
type RunStatus = 'running' | 'standby' | 'fault' | 'offline'
interface OrganizationFilterItem {
id: string
parentId?: string | null
dvId?: number | string | null
machineName?: string
}
interface DetailRow {
id: string
name: string
status: RunStatus
runningMinutes: number
standbyMinutes: number
alarmMinutes: number
utilizationRate: number
lastReportTime: string
}
const route = useRoute()
const dashboardRef = ref<HTMLElement>()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(dashboardRef)
const currentTime = ref('')
const lastUpdateTime = ref('')
const overviewData = ref<DeviceOperationOverviewRespVO>({
metrics: [],
hourlyStatus: [],
summary: [],
summaryTotalHours: 0,
timelineRows: [],
totalDevices: 0
})
const weekTrend = ref<{ date: string; rate: number }[]>([])
const detailPage = ref(1)
const detailPageSize = 8
const organizationList = ref<OrganizationFilterItem[]>([])
const hourChartRef = ref<HTMLElement | null>(null)
const weekChartRef = ref<HTMLElement | null>(null)
let hourChart: echarts.ECharts | null = null
let weekChart: echarts.ECharts | null = null
let clockTimer: number | undefined
let refreshTimer: number | undefined
let carouselTimer: number | undefined
const statusText = computed<Record<RunStatus, string>>(() => ({
running: t('Status.running'),
standby: t('Status.standby'),
fault: t('Status.fault'),
offline: t('Status.offline')
}))
const metricValueMap = computed(() => {
return overviewData.value.metrics.reduce<Record<string, number>>((acc, item) => {
acc[item.key] = Number(item.value) || 0
return acc
}, {})
})
const getMetricValue = (keys: string[]) => {
const foundKey = keys.find((key) => metricValueMap.value[key] !== undefined)
return foundKey ? metricValueMap.value[foundKey] : undefined
}
const summaryMap = computed(() => {
return overviewData.value.summary.reduce<Record<string, number>>((acc, item) => {
acc[item.status] = Number(item.percent) || 0
return acc
}, {})
})
const statusCards = computed(() => [
{
key: 'total',
label: t('Metrics.total'),
icon: 'ep:connection',
value:
overviewData.value.totalDevices ||
getMetricValue(['total', 'deviceTotal', 'totalDevices']) ||
overviewData.value.timelineRows.length,
tone: 'total'
},
{
key: 'online',
label: t('Metrics.online'),
icon: 'ep:cpu',
value: Math.max(0, totalDeviceCount.value - offlineDeviceCount.value),
percent: 100 - (summaryMap.value.offline || 0),
tone: 'online'
},
{
key: 'offline',
label: t('Metrics.offline'),
icon: 'ep:link',
value: offlineDeviceCount.value,
percent: summaryMap.value.offline || 0,
tone: 'offline'
},
{
key: 'running',
label: t('Metrics.running'),
icon: 'ep:video-play',
value: getMetricValue(['running', 'run', 'runningDevices']) ?? getRowsByStatus('running').length,
percent: summaryMap.value.running || 0,
tone: 'running'
},
{
key: 'standby',
label: t('Metrics.standby'),
icon: 'ep:video-pause',
value: getMetricValue(['standby', 'idle', 'standbyDevices']) ?? getRowsByStatus('standby').length,
percent: summaryMap.value.standby || 0,
tone: 'standby'
},
{
key: 'fault',
label: t('Metrics.fault'),
icon: 'ep:bell',
value: getMetricValue(['fault', 'alarm', 'faultDevices']) ?? getRowsByStatus('fault').length,
percent: summaryMap.value.fault || 0,
tone: 'fault'
}
])
const detailRows = computed<DetailRow[]>(() => {
return overviewData.value.timelineRows.map((row) => {
const minutes = calcMinutes(row)
const status = getCurrentStatus(row)
return {
id: String(row.id),
name: row.name || '-',
status,
runningMinutes: minutes.running,
standbyMinutes: minutes.standby,
alarmMinutes: minutes.fault,
utilizationRate: Number(row.utilizationRate) || 0,
lastReportTime: status === 'fault' || minutes.fault > 0 ? lastUpdateTime.value || '-' : '-'
}
})
})
const totalDetailPages = computed(() => Math.max(1, Math.ceil(detailRows.value.length / detailPageSize)))
const totalDeviceCount = computed(
() =>
overviewData.value.totalDevices ||
getMetricValue(['total', 'deviceTotal', 'totalDevices']) ||
detailRows.value.length
)
const offlineDeviceCount = computed(
() => getMetricValue(['offline', 'offlineDevices']) ?? getRowsByStatus('offline').length
)
const pagedRows = computed(() => {
const start = (detailPage.value - 1) * detailPageSize
return detailRows.value.slice(start, start + detailPageSize)
})
const detailPageText = computed(() => `${detailPage.value} / ${totalDetailPages.value}`)
const alarmDevices = computed(() => {
return detailRows.value
.filter((item) => item.status === 'fault' || item.alarmMinutes > 0)
.sort((a, b) => b.alarmMinutes - a.alarmMinutes)
.slice(0, 2)
})
const hourlyRates = computed(() =>
overviewData.value.hourlyStatus.map((item) => {
const total = item.running + item.standby + item.fault + item.offline
return {
hour: item.hour,
rate: total > 0 ? Number(((item.running / total) * 100).toFixed(1)) : 0
}
})
)
const todayAverageRate = computed(() => average(hourlyRates.value.map((item) => item.rate)))
const sevenDayAverageRate = computed(() => average(weekTrend.value.map((item) => item.rate)))
const formatPercent = (value?: number) => `${Number(value || 0).toFixed(1)}%`
const formatMinutes = (minutes: number) => {
const safeMinutes = Math.max(0, Math.round(minutes || 0))
const hours = Math.floor(safeMinutes / 60)
const rest = safeMinutes % 60
if (!hours) return t('Time.minute', { minute: rest })
if (!rest) return t('Time.hour', { hour: hours })
return t('Time.hourMinute', { hour: hours, minute: rest })
}
const average = (values: number[]) => {
const valid = values.filter((item) => Number.isFinite(item))
if (!valid.length) return 0
return Number((valid.reduce((sum, item) => sum + item, 0) / valid.length).toFixed(1))
}
const getRowsByStatus = (status: RunStatus) => detailRows.value.filter((row) => row.status === status)
const getCurrentStatus = (row: DeviceOperationOverviewTimelineRowVO): RunStatus => {
const currentHour = dayjs().hour() + dayjs().minute() / 60
const currentSegment = row.segments?.find((item) => currentHour >= item.startHour && currentHour < item.endHour)
return currentSegment?.status || row.segments?.[row.segments.length - 1]?.status || 'offline'
}
const calcMinutes = (row: DeviceOperationOverviewTimelineRowVO) => {
return (row.segments || []).reduce<Record<RunStatus, number>>(
(acc, segment) => {
const minutes = Math.max(0, (Number(segment.endHour) - Number(segment.startHour)) * 60)
acc[segment.status] += minutes
return acc
},
{ running: 0, standby: 0, fault: 0, offline: 0 }
)
}
const updateClock = () => {
currentTime.value = dayjs().format('YYYY/M/D HH:mm:ss')
}
const childrenByParentId = computed(() => {
return organizationList.value.reduce<Record<string, OrganizationFilterItem[]>>((acc, item) => {
const parentKey = String(item.parentId ?? 0)
if (!acc[parentKey]) acc[parentKey] = []
acc[parentKey].push(item)
return acc
}, {})
})
const collectDeviceIdsByOrg = (orgId?: string) => {
const deviceIds = new Set<string>()
const pushDevice = (node?: OrganizationFilterItem) => {
if (node?.dvId) deviceIds.add(String(node.dvId))
}
if (!orgId) {
organizationList.value.forEach(pushDevice)
return Array.from(deviceIds)
}
const queue = [orgId]
while (queue.length) {
const currentId = queue.shift() as string
const current = organizationList.value.find((item) => item.id === currentId)
pushDevice(current)
;(childrenByParentId.value[currentId] || []).forEach((child) => queue.push(child.id))
}
return Array.from(deviceIds)
}
const getDeviceIds = () => {
const routeIds = String(route.query.ids || route.query.deviceIds || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
if (routeIds.length) return routeIds
return collectDeviceIdsByOrg(route.query.orgId ? String(route.query.orgId) : undefined)
}
const buildParams = (startTime: string, endTime: string, pageSize = 1000) => ({
ids: getDeviceIds().join(','),
startTime,
endTime,
timelinePageNo: 1,
timelinePageSize: pageSize
})
const loadOrganizationList = async () => {
const data = await OrganizationApi.getOrganizationList()
organizationList.value = Array.isArray(data)
? data.map((item: any) => ({
id: String(item.id),
parentId: item.parentId != null ? String(item.parentId) : item.parentId,
dvId: item.dvId,
machineName: item.machineName
}))
: []
}
const refreshTodayData = async () => {
const start = dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss')
const end = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss')
const response = await DeviceOperationOverviewApi.getRunOverview(buildParams(start, end))
overviewData.value = {
metrics: response?.metrics || [],
hourlyStatus: response?.hourlyStatus || [],
summary: response?.summary || [],
summaryTotalHours: response?.summaryTotalHours || 0,
timelineRows: response?.timelineRows || [],
totalDevices: response?.totalDevices || 0
}
lastUpdateTime.value = dayjs().format('YYYY/M/D HH:mm:ss')
}
const refreshWeekTrend = async () => {
const requests = Array.from({ length: 7 }).map((_, index) => {
const date = dayjs().subtract(6 - index, 'day')
return DeviceOperationOverviewApi.getRunOverview(
buildParams(date.startOf('day').format('YYYY-MM-DD HH:mm:ss'), date.endOf('day').format('YYYY-MM-DD HH:mm:ss'), 1)
).then((res) => {
const running = res?.summary?.find((item) => item.status === 'running')?.percent || 0
return {
date: date.format('MM-DD'),
rate: Number(Number(running).toFixed(1))
}
})
})
weekTrend.value = await Promise.all(requests)
}
const refreshData = async () => {
await refreshTodayData()
await refreshWeekTrend()
renderCharts()
}
const getLineChartOption = (labels: string[], values: number[]): EChartsOption => ({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
valueFormatter: (value) => `${value}%`
},
grid: { left: 42, right: 24, top: 26, bottom: 32 },
xAxis: {
type: 'category',
boundaryGap: false,
data: labels,
axisLine: { lineStyle: { color: 'rgba(105, 165, 235, 0.55)' } },
axisTick: { show: false },
axisLabel: { color: '#a9c8ef', fontSize: 12 }
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { color: '#a9c8ef', formatter: '{value}%' },
splitLine: { lineStyle: { color: 'rgba(76, 144, 219, 0.18)' } }
},
series: [
{
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 5,
data: values,
lineStyle: { width: 3, color: '#4692ff' },
itemStyle: { color: '#54a7ff' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(70, 146, 255, 0.46)' },
{ offset: 1, color: 'rgba(70, 146, 255, 0.04)' }
])
}
}
]
})
const renderCharts = () => {
if (hourChartRef.value && !hourChart) hourChart = echarts.init(hourChartRef.value, 'dark', { renderer: 'canvas' })
if (weekChartRef.value && !weekChart) weekChart = echarts.init(weekChartRef.value, 'dark', { renderer: 'canvas' })
hourChart?.setOption(
getLineChartOption(
hourlyRates.value.map((item) => item.hour),
hourlyRates.value.map((item) => item.rate)
)
)
weekChart?.setOption(
getLineChartOption(
weekTrend.value.map((item) => item.date),
weekTrend.value.map((item) => item.rate)
)
)
}
const resizeCharts = () => {
hourChart?.resize()
weekChart?.resize()
}
onMounted(async () => {
updateClock()
clockTimer = window.setInterval(updateClock, 1000)
await loadOrganizationList()
await refreshData()
refreshTimer = window.setInterval(() => {
void refreshData()
}, 15000)
carouselTimer = window.setInterval(() => {
detailPage.value = detailPage.value >= totalDetailPages.value ? 1 : detailPage.value + 1
}, 5000)
window.addEventListener('resize', resizeCharts)
})
onUnmounted(() => {
if (clockTimer) window.clearInterval(clockTimer)
if (refreshTimer) window.clearInterval(refreshTimer)
if (carouselTimer) window.clearInterval(carouselTimer)
window.removeEventListener('resize', resizeCharts)
hourChart?.dispose()
weekChart?.dispose()
})
</script>
<style scoped>
.run-dashboard {
--bg: #03142d;
--panel: rgb(10 44 88 / 82%);
--panel-deep: rgb(6 27 61 / 88%);
--line: rgb(47 129 222 / 62%);
--text: #eef6ff;
--muted: #91b7e5;
--blue: #49a0ff;
--green: #40f06d;
--red: #ff4d5b;
--gray: #aec0d7;
position: relative;
width: 100%;
min-width: 1366px;
min-height: 100vh;
overflow: hidden;
font-family: "Microsoft Yahei", "PingFang SC", Arial, sans-serif;
color: var(--text);
background:
radial-gradient(circle at 50% 0, rgb(21 88 157 / 38%), transparent 34%),
linear-gradient(180deg, #061a38 0%, #041329 48%, #031026 100%);
}
.bg-grid {
position: absolute;
pointer-events: none;
background-image:
linear-gradient(rgb(42 122 204 / 16%) 1px, transparent 1px),
linear-gradient(90deg, rgb(42 122 204 / 16%) 1px, transparent 1px);
background-size: 96px 40px;
inset: 0;
}
.dashboard-header {
position: relative;
z-index: 1;
display: grid;
height: 86px;
padding: 12px 24px 8px;
grid-template-columns: 360px 1fr 360px;
align-items: start;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
color: #c7dcf6;
font-size: 18px;
}
.brand-icon {
display: grid;
width: 34px;
height: 34px;
color: #8cd3ff;
background: rgb(78 157 255 / 16%);
border: 1px solid rgb(93 177 255 / 40%);
border-radius: 6px;
place-items: center;
}
.dashboard-header h1 {
margin: 0;
color: #fff;
font-size: 42px;
font-weight: 800;
line-height: 1.15;
text-align: center;
text-shadow: 0 0 12px rgb(105 178 255 / 86%);
letter-spacing: 0;
}
.header-meta {
justify-self: end;
color: #b6d0ef;
font-size: 14px;
line-height: 1.75;
}
.header-meta span {
color: #fff;
font-weight: 700;
}
.refresh-row {
display: flex;
align-items: center;
gap: 6px;
}
.screen-btn {
display: inline-grid;
width: 26px;
height: 26px;
padding: 0;
color: #8fc7ff;
cursor: pointer;
background: rgb(16 62 116 / 80%);
border: 1px solid rgb(77 151 232 / 70%);
border-radius: 4px;
place-items: center;
}
.dashboard-main {
position: relative;
z-index: 1;
display: flex;
height: calc(100vh - 86px);
min-height: 820px;
padding: 8px 24px 16px;
box-sizing: border-box;
flex-direction: column;
gap: 12px;
}
.top-row {
display: grid;
grid-template-columns: repeat(6, minmax(150px, 1fr)) 2fr;
gap: 10px;
}
.panel,
.metric-card {
background: linear-gradient(135deg, var(--panel), var(--panel-deep));
border: 1px solid var(--line);
border-radius: 5px;
box-shadow: inset 0 0 24px rgb(34 113 203 / 18%);
}
.metric-card {
height: 120px;
padding: 16px;
box-sizing: border-box;
}
.metric-title {
display: flex;
align-items: center;
gap: 12px;
color: #d7e9ff;
font-size: 16px;
}
.metric-title .iconify {
font-size: 22px;
}
.metric-value {
display: flex;
margin-top: 16px;
align-items: baseline;
gap: 18px;
}
.metric-value strong {
font-size: 40px;
line-height: 1;
}
.metric-value span {
color: #a8bfdd;
font-size: 16px;
font-weight: 700;
}
.metric-card--online .metric-value strong,
.metric-card--running .metric-value strong,
.metric-card--online .metric-title,
.metric-card--running .metric-title {
color: var(--green);
}
.metric-card--offline .metric-value strong,
.metric-card--offline .metric-title {
color: var(--gray);
}
.metric-card--standby .metric-value strong,
.metric-card--standby .metric-title {
color: var(--blue);
}
.metric-card--fault {
border-color: rgb(255 67 84 / 86%);
}
.metric-card--fault .metric-value strong,
.metric-card--fault .metric-title {
color: var(--red);
}
.alarm-board {
height: 120px;
padding: 12px;
box-sizing: border-box;
}
.alarm-head,
.alarm-item {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
align-items: center;
gap: 8px;
}
.alarm-head {
color: #fff;
font-size: 15px;
font-weight: 700;
}
.alarm-list {
display: flex;
margin-top: 8px;
flex-direction: column;
gap: 8px;
}
.alarm-item {
height: 30px;
padding: 0 8px;
overflow: hidden;
color: #e8f2ff;
background: linear-gradient(90deg, rgb(102 25 56 / 76%), rgb(33 31 69 / 52%));
border: 1px solid rgb(255 64 82 / 52%);
border-radius: 4px;
font-size: 13px;
}
.alarm-item strong {
overflow: hidden;
color: #fff;
text-overflow: ellipsis;
white-space: nowrap;
}
.alarm-item span:nth-child(2) {
color: #ff5365;
font-weight: 700;
text-align: center;
}
.alarm-item span:nth-child(3) {
color: #d7aab4;
text-align: right;
}
.empty-line,
.empty-table {
display: grid;
height: 100%;
color: var(--muted);
place-items: center;
}
.detail-panel {
min-height: 0;
padding: 14px 16px;
flex: 1.25;
}
.panel-title {
display: flex;
height: 30px;
align-items: center;
justify-content: space-between;
}
.panel-title h2 {
margin: 0;
color: #fff;
font-size: 22px;
font-weight: 800;
}
.panel-title span {
color: #89bdf5;
font-size: 14px;
}
.detail-table {
display: flex;
height: calc(100% - 34px);
min-height: 0;
flex-direction: column;
}
.table-row {
display: grid;
grid-template-columns: 2fr 1fr 1.4fr 1.4fr 1.4fr 1.4fr;
min-height: 43px;
padding: 0 14px;
align-items: center;
gap: 12px;
border-bottom: 1px solid rgb(60 134 212 / 35%);
}
.table-header {
min-height: 44px;
color: #98bee8;
font-size: 14px;
}
.table-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
.table-body .table-row {
color: #dcecff;
background: rgb(12 52 98 / 42%);
border: 1px solid rgb(60 134 212 / 48%);
border-radius: 5px;
font-size: 16px;
}
.table-row strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-row--fault {
color: #ff5768;
background: linear-gradient(90deg, rgb(108 27 58 / 82%), rgb(11 39 83 / 46%)) !important;
border-color: rgb(255 64 82 / 80%) !important;
}
.table-row--running strong,
.table-row--running .rate {
color: var(--green);
}
.table-row--offline strong {
color: #96adca;
}
.rate {
color: #54a7ff;
font-weight: 800;
}
.status-pill {
display: inline-flex;
min-width: 42px;
height: 24px;
padding: 0 10px;
font-style: normal;
border: 1px solid currentcolor;
border-radius: 13px;
align-items: center;
justify-content: center;
}
.status-pill--running {
color: var(--green);
background: rgb(34 197 94 / 12%);
}
.status-pill--standby {
color: var(--blue);
background: rgb(59 130 246 / 12%);
}
.status-pill--fault {
color: var(--red);
background: rgb(239 68 68 / 14%);
}
.status-pill--offline {
color: #b5c4d9;
background: rgb(148 163 184 / 14%);
}
.chart-row {
display: grid;
min-height: 280px;
flex: 0.9;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.chart-panel {
min-height: 0;
padding: 14px 16px;
}
.chart {
width: 100%;
height: calc(100% - 34px);
min-height: 220px;
}
@media (width <= 1600px) {
.dashboard-header h1 {
font-size: 34px;
}
.dashboard-header {
grid-template-columns: 310px 1fr 320px;
}
.metric-card {
padding: 14px 12px;
}
.metric-value strong {
font-size: 34px;
}
}
</style>
Loading…
Cancel
Save