This commit is contained in:
andy 2026-06-01 14:09:15 +08:00
commit f29e30aadb
5 changed files with 1271 additions and 0 deletions

28
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.6.5",
"dhtmlx-gantt": "^9.1.4",
"disable-devtool": "^0.3.9",
"echarts": "^6.0.0",
"jsencrypt": "^3.3.2",
@ -19,6 +20,7 @@
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"vue": "^3.4.15",
"vue-draggable-plus": "^0.6.1",
"vue-router": "^4.2.5"
},
"devDependencies": {
@ -1201,6 +1203,11 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/sortablejs": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="
},
"node_modules/@vicons/ionicons5": {
"version": "0.12.0",
"resolved": "https://registry.npmmirror.com/@vicons/ionicons5/-/ionicons5-0.12.0.tgz",
@ -1555,6 +1562,11 @@
"node": ">=8"
}
},
"node_modules/dhtmlx-gantt": {
"version": "9.1.4",
"resolved": "https://registry.npmjs.org/dhtmlx-gantt/-/dhtmlx-gantt-9.1.4.tgz",
"integrity": "sha512-XCNA5QUiuV79Xq1ykNpH9LFNR2IVpDZMqnmBV6dsBeOkHyPMOpkyQ/gqAPCcK2GAvYHoN2nGAMYb2LldCWhMuQ=="
},
"node_modules/disable-devtool": {
"version": "0.3.9",
"resolved": "https://registry.npmmirror.com/disable-devtool/-/disable-devtool-0.3.9.tgz",
@ -2392,6 +2404,22 @@
}
}
},
"node_modules/vue-draggable-plus": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/vue-draggable-plus/-/vue-draggable-plus-0.6.1.tgz",
"integrity": "sha512-FbtQ/fuoixiOfTZzG3yoPl4JAo9HJXRHmBQZFB9x2NYCh6pq0TomHf7g5MUmpaDYv+LU2n6BPq2YN9sBO+FbIg==",
"dependencies": {
"@types/sortablejs": "^1.15.8"
},
"peerDependencies": {
"@types/sortablejs": "^1.15.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",

View File

@ -13,6 +13,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.6.5",
"dhtmlx-gantt": "^9.1.4",
"disable-devtool": "^0.3.9",
"echarts": "^6.0.0",
"jsencrypt": "^3.3.2",

142
src/api/orderProject.ts Normal file
View File

@ -0,0 +1,142 @@
import { request } from '@/utils/request'
// 项目表 类型定义
export interface OrderProject {
id?: number
name?: string
code?: string
mainCode?: string
mainName?: string
quantity?: string
source?: number
prodType?: number
sourceId?: string
bomVersion?: string
specModel?: string
unit?: string
customerName?: string
version?: string
salesDept?: string
parentId?: number
deliveryTime?: string
status?: number
planFinishTime?: string
createTime?: string
createBy?: string
changeTime?: string
changeBy?: string
}
/** 金蝶生产订单(树父节点) */
export interface KingdeePrdMo {
productionOrderNo: string
billNo: string
workShopName: string
materialCode: string
quantity: number | null
specModel: string | null
unit: string | null
materialName: string | null
routingNo: string | null
planStartTime: string | null
planFinishTime: string | null
pickMtrlStatus: string | null
children: KingdeeProcessRoute[]
}
/** 金蝶工艺路线工序(树子节点) */
export interface KingdeeProcessRoute {
operNumber: number | null
workCenterName: string | null
departmentName: string | null
processProperty: string | null
operDescription: string | null
activityUnit: string | null
optCtrlCodeName: string | null
activityQty: number | null
planStartTime: string | null
planFinishTime: string | null
}
// 项目表 API
export const orderProjectApi = {
// 分页查询
page(params: { page: number; pageSize: number; id?: number; name?: string; status?: number }) {
return request({ url: '/biz/orderProject/page', method: 'get', params })
},
// 获取详情
detail(id: string) {
return request({ url: `/biz/orderProject/${id}`, method: 'get' })
},
// 新增
create(data: OrderProject) {
return request({ url: '/biz/orderProject', method: 'post', data })
},
// 修改
update(data: OrderProject) {
return request({ url: '/biz/orderProject', method: 'put', data })
},
// 删除
delete(ids: string[]) {
return request({ url: `/biz/orderProject/${ids.join(',')}`, method: 'delete' })
},
// 导出
export(params?: { ids?: string[]; id?: number; name?: string; status?: number }) {
const p: Record<string, any> = {}
if (params?.ids?.length) p.ids = params.ids.join(',')
if (params?.id !== undefined && params?.id !== null) p.id = params.id
if (params?.name !== undefined && params?.name !== null) p.name = params.name
if (params?.status !== undefined && params?.status !== null) p.status = params.status
return request({ url: `/biz/orderProject/export`, method: 'get', params: p, responseType: 'blob' })
},
// 导入
importData(file: File) {
const formData = new FormData()
formData.append('file', file)
return request<{ success: number; fail: number; errors: string[] }>({
url: `/biz/orderProject/import`,
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
},
// 下载导入模板
downloadTemplate() {
return request({ url: `/biz/orderProject/template`, method: 'get', responseType: 'blob' })
},
// 查询金蝶生产订单
getKingdeeOrder(id: number) {
return request<KingdeePrdMo[]>({ url: `/biz/orderProject/kingdee/order/${id}`, method: 'get' })
},
}

View File

@ -0,0 +1,243 @@
<template>
<div class="gantt-schedule-page">
<div class="gantt-header">
<n-space justify="space-between" align="center">
<n-h2 style="margin: 0">甘特图排产</n-h2>
<n-button @click="handleBack">返回</n-button>
</n-space>
</div>
<div class="gantt-container" ref="ganttContainer"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, toRaw } from 'vue'
import { useMessage } from 'naive-ui'
import { useRouter } from 'vue-router'
import { gantt } from 'dhtmlx-gantt'
import 'dhtmlx-gantt/codebase/dhtmlxgantt.css'
import { orderProjectApi } from '@/api/orderProject'
const router = useRouter()
const ganttContainer = ref<HTMLElement | null>(null)
const message = useMessage()
let ganttEventIds: string[] = []
const tableData = ref<any[]>([])
function handleBack() {
router.back()
}
onMounted(async () => {
try {
initGantt()
const stateStr = sessionStorage.getItem('gantt_schedule_data')
if (stateStr) {
const parsedData = JSON.parse(stateStr)
tableData.value = parsedData
renderGanttData()
} else {
message.warning('未获取到排产数据')
}
} catch (error) {
message.error('加载排产数据失败')
}
})
function formatGanttDate(date: Date) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function initGantt() {
if (!ganttContainer.value) return
//
ganttEventIds.forEach(id => gantt.detachEvent(id))
ganttEventIds = []
gantt.clearAll()
//
gantt.config.date_format = "%Y-%m-%d %H:%i:%s"
gantt.config.scale_height = 50
gantt.config.scales = [
{ unit: "month", step: 1, format: "%Y年 %m月" },
{ unit: "day", step: 1, format: "%d日" }
]
gantt.config.columns = [
{ name: "text", label: "单号 / 序号", tree: true, width: 200, resize: true },
{ name: "materialCode", label: "物料编码", align: "center", width: 140, resize: true },
{ name: "name", label: "名称", align: "center", width: 160, resize: true },
{ name: "start_date", label: "计划开始", align: "center", width: 130 },
{ name: "end_date", label: "计划结束", align: "center", width: 130 },
]
//
gantt.config.drag_progress = false //
gantt.config.drag_links = false // 线
gantt.config.work_time = true //
// gantt.config.auto_scheduling
gantt.i18n.setLocale("cn")
// marker
gantt.plugins({
marker: true
})
//
gantt.templates.task_text = function(start, end, task) {
if (task._type === 'route') {
return task.name; //
}
return task.text;
};
gantt.init(ganttContainer.value)
//
const today = new Date()
gantt.addMarker({
start_date: today,
css: "today-marker",
text: "今日",
title: "今日: " + formatGanttDate(today)
})
}
function renderGanttData() {
gantt.clearAll()
//
const tasks: any[] = []
const rawData = toRaw(tableData.value) || []
const processColors = ['#3498db', '#1abc9c', '#9b59b6', '#e67e22', '#e74c3c', '#2ecc71', '#f1c40f', '#34495e', '#7f8c8d']
rawData.forEach((mo: any, moIndex: number) => {
const moId = `mo_${moIndex}_${mo.productionOrderNo}_${mo.billNo}`
// 使
let minDate: Date | null = mo.planStartTime ? new Date(mo.planStartTime.replace(' ', 'T')) : null
let maxDate: Date | null = mo.planFinishTime ? new Date(mo.planFinishTime.replace(' ', 'T')) : null
if (!minDate || isNaN(minDate.getTime())) minDate = new Date()
if (!maxDate || isNaN(maxDate.getTime())) maxDate = new Date(minDate.getTime() + 86400000)
if (maxDate <= minDate) maxDate = new Date(minDate.getTime() + 86400000)
tasks.push({
id: moId,
text: mo.productionOrderNo || mo.billNo || '未知单号',
materialCode: mo.materialCode || '',
name: mo.materialName || '产品',
start_date: formatGanttDate(minDate),
end_date: formatGanttDate(maxDate),
open: false, //
type: gantt.config.types.project,
_type: 'mo',
_raw: mo
})
if (mo.processRoutes && mo.processRoutes.length > 0) {
mo.processRoutes.forEach((route: any, index: number) => {
const routeId = `route_${moId}_${index}`
let rStart = route.planStartTime ? new Date(route.planStartTime.replace(' ', 'T')) : null
let rEnd = route.planFinishTime ? new Date(route.planFinishTime.replace(' ', 'T')) : null
if (!rStart || isNaN(rStart.getTime())) rStart = minDate
if (!rEnd || isNaN(rEnd.getTime())) rEnd = maxDate
if (rEnd <= rStart) rEnd = new Date(rStart!.getTime() + 3600000) // 1
tasks.push({
id: routeId,
text: route.operNumber != null ? String(route.operNumber) : String(index + 1),
materialCode: '',
name: route.processProperty || '工序',
color: processColors[index % processColors.length],
start_date: formatGanttDate(rStart!),
end_date: formatGanttDate(rEnd),
parent: moId,
_type: 'route',
_raw: route
})
})
}
})
gantt.parse({ data: tasks, links: [] })
//
const evDragId = gantt.attachEvent("onBeforeTaskDrag", (id, mode, e) => {
const task = gantt.getTask(id)
if (task.type === gantt.config.types.project) {
return false
}
return true
})
ganttEventIds.push(evDragId)
//
const evId = gantt.attachEvent("onAfterTaskDrag", (id, mode, e) => {
const task = gantt.getTask(id)
if (mode === 'move' || mode === 'resize') {
const start = gantt.date.date_to_str(gantt.config.date_format)(task.start_date)
const end = gantt.date.date_to_str(gantt.config.date_format)(task.end_date)
message.success(`任务 [${task.text}] 计划时间已更新: ${start} ~ ${end}`)
//
if (task._type === 'mo') {
task._raw.planStartTime = start
task._raw.planFinishTime = end
} else if (task._type === 'route') {
task._raw.planStartTime = start
task._raw.planFinishTime = end
}
}
})
ganttEventIds.push(evId)
}
onUnmounted(() => {
ganttEventIds.forEach(id => gantt.detachEvent(id))
gantt.clearAll()
})
</script>
<style scoped>
.gantt-schedule-page {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
background-color: var(--n-color);
padding: 16px;
border-radius: 8px;
box-sizing: border-box;
}
.gantt-header {
margin-bottom: 16px;
flex-shrink: 0;
}
.gantt-container {
flex: 1;
width: 100%;
min-height: 0; /* 必须设置min-height以允许flex子元素内部滚动 */
border: 1px solid var(--n-border-color);
border-radius: 4px;
overflow: hidden;
}
:deep(.today-marker) {
background-color: #ff5252;
}
:deep(.today-marker .gantt_marker_content) {
background-color: #ff5252;
color: white;
border-radius: 2px;
padding: 0 4px;
}
</style>

View File

@ -0,0 +1,857 @@
<template>
<div class="page-container">
<n-card>
<!-- 搜索表单 -->
<div class="search-form">
<n-form inline :model="searchForm" label-placement="left">
<n-form-item label="项目名称">
<n-input v-model:value="searchForm.name" placeholder="请输入项目名称" clearable />
</n-form-item>
<n-form-item label="项目状态">
<n-select v-model:value="searchForm.status" placeholder="请选择项目状态" clearable style="width: 150px" :options="statusOptions" />
</n-form-item>
<n-form-item>
<n-space>
<n-button type="primary" @click="handleSearch">
<template #icon><n-icon><SearchOutline /></n-icon></template>
搜索
</n-button>
<n-button @click="handleReset">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
重置
</n-button>
</n-space>
</n-form-item>
</n-form>
</div>
<!-- 工具栏 -->
<div class="table-toolbar">
<n-space>
<n-button type="primary" @click="handleAdd">
<template #icon><n-icon><AddOutline /></n-icon></template>
新增
</n-button>
<n-button @click="importModalVisible = true">
<template #icon><n-icon><CloudUploadOutline /></n-icon></template>
导入
</n-button>
<n-button @click="handleExport">
<template #icon><n-icon><DownloadOutline /></n-icon></template>
导出{{ selectedIds.length > 0 ? `(${selectedIds.length})` : '' }}
</n-button>
<n-button type="error" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
<template #icon><n-icon><TrashOutline /></n-icon></template>
删除
</n-button>
</n-space>
</div>
<!-- 表格 -->
<n-data-table
:columns="columns"
:data="tableData"
:loading="loading"
:pagination="pagination"
:row-key="(row) => row.id"
:scroll-x="2720"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
@update:checked-row-keys="handleCheck"
/>
</n-card>
<!-- 新增/编辑弹窗 -->
<n-modal v-model:show="modalVisible" preset="card" :title="modalTitle" style="width: 800px">
<n-form ref="formRef" :model="formData" :rules="formRules" label-placement="left" label-width="100px">
<n-grid :cols="2" :x-gap="24">
<n-form-item-gi label="项目名称" path="name">
<n-input v-model:value="formData.name" placeholder="请输入项目名称" />
</n-form-item-gi>
<n-form-item-gi label="生产令号" path="code">
<n-input v-model:value="formData.code" placeholder="请输入生产令号" />
</n-form-item-gi>
<n-form-item-gi label="产品编码" path="mainCode">
<n-input v-model:value="formData.mainCode" placeholder="请输入产品编码" />
</n-form-item-gi>
<n-form-item-gi label="产品名称" path="mainName">
<n-input v-model:value="formData.mainName" placeholder="请输入产品名称" />
</n-form-item-gi>
<n-form-item-gi label="生产数量" path="quantity">
<n-input v-model:value="formData.quantity" placeholder="请输入生产数量" />
</n-form-item-gi>
<n-form-item-gi label="项目来源" path="source">
<n-select v-model:value="formData.source" placeholder="请选择项目来源" :options="sourceOptions" />
</n-form-item-gi>
<n-form-item-gi label="生产类型" path="prodType">
<n-select v-model:value="formData.prodType" placeholder="请选择生产类型" :options="[]" />
</n-form-item-gi>
<n-form-item-gi label="来源单据编码" path="sourceId">
<n-input v-model:value="formData.sourceId" placeholder="请输入项目来源单据编码" />
</n-form-item-gi>
<n-form-item-gi label="BOM版本" path="bomVersion">
<n-input v-model:value="formData.bomVersion" placeholder="请输入BOM版本" />
</n-form-item-gi>
<n-form-item-gi label="规格型号" path="specModel">
<n-input v-model:value="formData.specModel" placeholder="请输入规格型号" />
</n-form-item-gi>
<n-form-item-gi label="单位" path="unit">
<n-input v-model:value="formData.unit" placeholder="请输入单位" />
</n-form-item-gi>
<n-form-item-gi label="客户" path="customerName">
<n-input v-model:value="formData.customerName" placeholder="请输入客户" />
</n-form-item-gi>
<n-form-item-gi label="当前版本" path="version">
<n-input v-model:value="formData.version" placeholder="请输入当前版本" />
</n-form-item-gi>
<n-form-item-gi label="销售部门" path="salesDept">
<n-input v-model:value="formData.salesDept" placeholder="请输入销售部门" />
</n-form-item-gi>
<n-form-item-gi label="上级id" path="parentId">
<n-input v-model:value="formData.parentId" placeholder="请输入上级id" />
</n-form-item-gi>
<n-form-item-gi label="要货日期" path="deliveryTime">
<n-date-picker v-model:value="formData.deliveryTime" type="datetime" clearable style="width: 100%" />
</n-form-item-gi>
<n-form-item-gi label="项目状态" path="status">
<n-select v-model:value="formData.status" placeholder="请选择项目状态" :options="statusOptions" />
</n-form-item-gi>
<n-form-item-gi label="计划完成日期" path="planFinishTime">
<n-date-picker v-model:value="formData.planFinishTime" type="datetime" clearable style="width: 100%" />
</n-form-item-gi>
<n-form-item-gi label="变更时间" path="changeTime">
<n-date-picker v-model:value="formData.changeTime" type="datetime" clearable style="width: 100%" />
</n-form-item-gi>
<n-form-item-gi label="变更人" path="changeBy">
<n-input v-model:value="formData.changeBy" placeholder="请输入变更人" />
</n-form-item-gi>
</n-grid>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="modalVisible = false">取消</n-button>
<n-button type="primary" @click="handleSubmit">确定</n-button>
</n-space>
</template>
</n-modal>
<!-- 金蝶预生产查询弹窗 -->
<n-modal
v-model:show="kingdeeModalVisible"
preset="card"
:title="kingdeeModalTitle"
class="kingdee-modal"
:style="{ width: '96vw', maxWidth: '1920px' }"
:content-style="{ padding: '16px 24px', minHeight: '72vh' }"
:mask-closable="false"
:closable="false"
:close-on-esc="false"
>
<div class="kingdee-toolbar">
<n-space>
<n-button
size="small"
:disabled="kingdeeLoading || !kingdeeTableData.length"
@click="kingdeeToggleExpandAll"
>
{{ kingdeeAllExpanded ? '全部收起' : '全部展开' }}
</n-button>
<n-button
size="small"
type="primary"
:disabled="kingdeeLoading || !kingdeeTableData.length"
@click="handleGanttSchedule"
>
<template #icon><n-icon><CalendarOutline /></n-icon></template>
甘特图排产
</n-button>
</n-space>
</div>
<n-data-table
v-model:expanded-row-keys="kingdeeExpandedKeys"
:columns="kingdeeMoColumns"
:data="kingdeeTableData"
:loading="kingdeeLoading"
:row-key="kingdeeMoRowKey"
:scroll-x="kingdeeMoScrollX"
:max-height="kingdeeTableMaxHeight"
:row-class-name="kingdeeMoRowClassName"
size="small"
/>
<template #footer>
<n-button @click="kingdeeModalVisible = false">关闭</n-button>
</template>
</n-modal>
<!-- 导入弹窗 -->
<n-modal v-model:show="importModalVisible" preset="card" title="导入项目表" style="width: 500px">
<n-space vertical>
<n-alert type="info">
<template #header>导入说明</template>
<ul style="margin: 0; padding-left: 16px; line-height: 1.8">
<li>请先下载导入模板按模板格式填写数据</li>
<li>支持 .xlsx .xls 格式</li>
</ul>
</n-alert>
<n-space>
<n-button type="primary" @click="handleDownloadTemplate">
<template #icon><n-icon><DownloadOutline /></n-icon></template>
下载模板
</n-button>
</n-space>
<n-upload :max="1" accept=".xlsx,.xls" :show-file-list="true" :custom-request="handleImportUpload">
<n-upload-dragger>
<div style="margin-bottom: 12px">
<n-icon size="48" :depth="3"><CloudUploadOutline /></n-icon>
</div>
<n-text style="font-size: 16px">点击或拖拽文件到此处上传</n-text>
<n-p depth="3" style="margin: 8px 0 0 0">支持 .xlsx .xls 格式</n-p>
</n-upload-dragger>
</n-upload>
</n-space>
<template #footer>
<n-button @click="importModalVisible = false">关闭</n-button>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, h, computed, onMounted } from 'vue'
import { NButton, NSpace, NIcon, NTag, NDatePicker, NDataTable, NUpload, useMessage, useDialog, type DataTableColumns, type UploadCustomRequestOptions } from 'naive-ui'
import { SearchOutline, RefreshOutline, AddOutline, TrashOutline, CreateOutline, CloudUploadOutline, DownloadOutline, EyeOutline, CalendarOutline } from '@vicons/ionicons5'
import { orderProjectApi, type OrderProject, type KingdeePrdMo, type KingdeeProcessRoute } from '@/api/orderProject'
import { dictDataApi } from '@/api/org'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const message = useMessage()
const dialog = useDialog()
const userStore = useUserStore()
const router = useRouter()
const hasPermission = (permission: string) => userStore.hasPermission(permission)
//
const searchForm = reactive({
id: null as number | null,
name: '',
status: null as number | null,
})
//
const tableData = ref<OrderProject[]>([])
const loading = ref(false)
const selectedIds = ref<number[]>([])
const pagination = reactive({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50]
})
//
const modalVisible = ref(false)
const modalTitle = ref('')
const importModalVisible = ref(false)
const kingdeeModalVisible = ref(false)
const kingdeeModalTitle = ref('金蝶生产订单')
const kingdeeLoading = ref(false)
/** 主表行:工序仅存 processRoutes避免 children 触发树形重复行 */
type KingdeeMoTableRow = Omit<KingdeePrdMo, 'children'> & { processRoutes: KingdeeProcessRoute[] }
const kingdeeTableData = ref<KingdeeMoTableRow[]>([])
const kingdeeExpandedKeys = ref<Array<string | number>>([])
const kingdeeTableMaxHeight = 'min(78vh, 720px)'
const kingdeeMoScrollX = 1820
const kingdeeProcessScrollX = 1360
function kingdeeMoRowKey(row: KingdeeMoTableRow) {
return `mo-${row.productionOrderNo}-${row.billNo}`
}
function kingdeeProcessRowKey(row: KingdeeProcessRoute) {
return `route-${row.operNumber}-${row.workCenterName}-${row.operDescription}`
}
function getKingdeeRoutes(mo: KingdeeMoTableRow) {
return mo.processRoutes
}
function formatKingdeeQty(val: number | null | undefined) {
return val != null ? Number(val).toFixed(3) : '-'
}
function kingdeeMoRowClassName() {
return 'kingdee-mo-row'
}
const kingdeeAllExpanded = computed(() => {
const total = kingdeeTableData.value.length
if (!total) return false
return kingdeeExpandedKeys.value.length >= total
})
function kingdeeToggleExpandAll() {
if (kingdeeAllExpanded.value) {
kingdeeExpandedKeys.value = []
} else {
kingdeeExpandedKeys.value = kingdeeTableData.value.map(kingdeeMoRowKey)
}
}
function parseKingdeePlanTime(val: string | number | null | undefined): number | null {
if (val == null || val === '') return null
if (typeof val === 'number') return val
const ts = new Date(val.replace(' ', 'T')).getTime()
return Number.isNaN(ts) ? null : ts
}
function renderKingdeePlanPicker(row: KingdeeProcessRoute, field: 'planStartTime' | 'planFinishTime') {
return h(NDatePicker, {
value: parseKingdeePlanTime(row[field]),
type: 'datetime',
clearable: true,
size: 'small',
style: { width: '100%' },
'onUpdate:value': (val: number | null) => {
row[field] = val != null ? formatDateTime(val) : null
},
})
}
function renderKingdeeExpand(row: KingdeeMoTableRow) {
const routes = getKingdeeRoutes(row)
if (!routes.length) {
return h('div', { class: 'kingdee-expand-empty' }, '暂无工艺路线工序')
}
return h('div', { class: 'kingdee-expand-wrap' }, [
h(NDataTable, {
class: 'kingdee-process-table',
columns: kingdeeProcessColumns,
data: routes,
rowKey: kingdeeProcessRowKey,
size: 'small',
bordered: true,
striped: true,
scrollX: kingdeeProcessScrollX,
}),
])
}
const formRef = ref()
const defaultFormData: OrderProject = {
name: '',
code: '',
mainCode: '',
mainName: '',
quantity: undefined,
source: undefined,
prodType: undefined,
sourceId: '',
bomVersion: '',
specModel: '',
unit: '',
customerName: '',
version: '',
salesDept: '',
parentId: undefined,
deliveryTime: undefined,
status: undefined,
planFinishTime: undefined,
changeTime: undefined,
changeBy: '',
}
const formData = reactive<OrderProject>({ ...defaultFormData })
// //使
const sourceOptions = ref<{ label: string; value: any }[]>([])
const statusOptions = ref<{ label: string; value: any }[]>([])
//
const formRules = {
mainCode: { required: true, message: '请输入产品编码', trigger: 'blur' },
mainName: { required: true, message: '请输入产品名称', trigger: 'blur' },
changeTime: { required: true, type: 'number', message: '请选择变更时间', trigger: ['change', 'blur'] },
changeBy: { required: true, message: '请输入变更人', trigger: 'blur' },
}
//
const columns: DataTableColumns<OrderProject> = [
{ type: 'selection', width: 48, fixed: 'left' },
{ title: '生产令号', key: 'code', width: 140, align: 'center', fixed: 'left' },
{ title: '产品编码', key: 'mainCode', width: 120, align: 'center' },
{ title: '产品名称', key: 'mainName', width: 160, ellipsis: { tooltip: true } },
{ title: '生产数量', key: 'quantity', width: 90 ,align: 'center'},
{ title: '项目来源', key: 'source', width: 110,align: 'center',
render(row) {
const val = row.source
const opt = sourceOptions.value.find(o => o.value === val || String(o.value) === String(val))
return opt ? opt.label : (val ?? '-')
}
},
{ title: '生产类型', key: 'prodType', width: 100 ,align: 'center'},
{ title: 'BOM版本', key: 'bomVersion', width: 100 ,align: 'center'},
{ title: '规格型号', key: 'specModel', width: 140, ellipsis: { tooltip: true } },
{ title: '单位', key: 'unit', width: 70 ,align: 'center'},
{ title: '客户', key: 'customerName', width: 140, ellipsis: { tooltip: true } ,align: 'center'},
{ title: '销售部门', key: 'salesDept', width: 120 ,align: 'center'},
{ title: '要货日期', key: 'deliveryTime', width: 170 ,align: 'center'},
{ title: '项目状态', key: 'status', width: 100,
render(row) {
const val = row.status
const opt = statusOptions.value.find(o => o.value === val || String(o.value) === String(val))
return opt ? opt.label : (val ?? '-')
}
},
{ title: '计划完成日期', key: 'planFinishTime', width: 170 ,align: 'center'},
{ title: '创建时间', key: 'createTime', width: 180,align: 'center' },
{ title: '创建人', key: 'createBy', width: 100 ,align: 'center'},
{ title: '变更时间', key: 'changeTime', width: 180 ,align: 'center'},
{ title: '变更人', key: 'changeBy', width: 100,align: 'center' },
{
title: '操作',
key: 'actions',
width: 260,
fixed: 'right',
render(row) {
const buttons = []
if (hasPermission('biz:orderProject:getKingdeeOrder')) {
buttons.push(
h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => handleKingdeeQuery(row) }, {
default: () => [h(NIcon, null, { default: () => h(EyeOutline) }), ' 预生产查询']
})
)
}
buttons.push(
h(NButton, { size: 'small', quaternary: true, onClick: () => handleEdit(row) }, {
default: () => [h(NIcon, null, { default: () => h(CreateOutline) }), ' 编辑']
}),
h(NButton, { size: 'small', quaternary: true, type: 'error', onClick: () => handleDelete(row) }, {
default: () => [h(NIcon, null, { default: () => h(TrashOutline) }), ' 删除']
})
)
return h('div', { style: { display: 'flex', alignItems: 'center', gap: '4px', flexWrap: 'nowrap' } }, buttons)
}
}
]
const pickMtrlStatusMap: Record<string, string> = {
'0': '未领料',
'1': '已领料',
}
const kingdeeMoColumns: DataTableColumns<KingdeeMoTableRow> = [
{
type: 'expand',
expandable: () => true,
renderExpand: (row) => renderKingdeeExpand(row),
},
{
title: '类型', key: 'rowType', width: 96, align: 'center',
render() {
return h(NTag, { size: 'small', type: 'info', bordered: false }, { default: () => '生产订单' })
}
},
{ title: '单据编号', key: 'billNo', width: 120, align: 'center', ellipsis: { tooltip: true } },
{ title: '生产令号', key: 'productionOrderNo', width: 130, align: 'center', ellipsis: { tooltip: true } },
{ title: '工艺路线', key: 'routingNo', width: 110, align: 'center', ellipsis: { tooltip: true } },
{ title: '物料编码', key: 'materialCode', width: 180, ellipsis: { tooltip: true } },
{ title: '物料名称', key: 'materialName', width: 160, ellipsis: { tooltip: true } },
{
title: '数量', key: 'quantity', width: 90, align: 'center',
render(row) {
return formatKingdeeQty(row.quantity)
}
},
{ title: '生产车间', key: 'workShopName', width: 100, align: 'center', ellipsis: { tooltip: true } },
{ title: '规格型号', key: 'specModel', width: 110, align: 'center', ellipsis: { tooltip: true } },
{ title: '单位', key: 'unit', width: 60, align: 'center' },
{ title: '计划开工', key: 'planStartTime', width: 165, align: 'center' },
{ title: '计划完工', key: 'planFinishTime', width: 165, align: 'center' },
{
title: '领料状态', key: 'pickMtrlStatus', width: 90, align: 'center',
render(row) {
const val = row.pickMtrlStatus
return val != null ? (pickMtrlStatusMap[String(val)] ?? val) : '-'
}
},
]
const kingdeeProcessColumns: DataTableColumns<KingdeeProcessRoute> = [
{
title: '类型', key: 'rowType', width: 72, align: 'center',
render() {
return h(NTag, { size: 'small', type: 'warning', bordered: false }, { default: () => '工序' })
}
},
{
title: '工序号', key: 'operNumber', width: 80, align: 'center',
render(row) {
return row.operNumber != null ? String(row.operNumber) : '-'
}
},
{ title: '工序名称', key: 'processProperty', width: 100, align: 'center' },
{ title: '工序说明', key: 'operDescription', width: 140, ellipsis: { tooltip: true } },
{ title: '工作中心', key: 'workCenterName', width: 110, align: 'center', ellipsis: { tooltip: true } },
{ title: '生产车间', key: 'departmentName', width: 110, align: 'center', ellipsis: { tooltip: true } },
{
title: '计划开始时间', key: 'planStartTime', width: 180, align: 'center',
render(row) {
return renderKingdeePlanPicker(row, 'planStartTime')
}
},
{
title: '计划结束时间', key: 'planFinishTime', width: 180, align: 'center',
render(row) {
return renderKingdeePlanPicker(row, 'planFinishTime')
}
},
{
title: '活动量', key: 'activityQty', width: 90, align: 'center',
render(row) {
return formatKingdeeQty(row.activityQty)
}
},
{ title: '活动单位', key: 'activityUnit', width: 80, align: 'center' },
{ title: '控制码', key: 'optCtrlCodeName', width: 120, align: 'center', ellipsis: { tooltip: true } },
]
//
async function loadData() {
loading.value = true
try {
const res = await orderProjectApi.page({
page: pagination.page,
pageSize: pagination.pageSize,
id: searchForm.id || undefined,
name: searchForm.name || undefined,
status: searchForm.status || undefined,
})
tableData.value = res.list
pagination.itemCount = res.total
} finally {
loading.value = false
}
}
//
function handleSearch() {
pagination.page = 1
loadData()
}
//
function handleReset() {
searchForm.id = null
searchForm.name = ''
searchForm.status = null
handleSearch()
}
//
function handlePageChange(page: number) {
pagination.page = page
loadData()
}
function handlePageSizeChange(pageSize: number) {
pagination.pageSize = pageSize
pagination.page = 1
loadData()
}
//
function handleCheck(keys: Array<string | number>) {
selectedIds.value = keys as number[]
}
//
function handleAdd() {
modalTitle.value = '新增项目表'
Object.assign(formData, defaultFormData)
modalVisible.value = true
}
//
function handleEdit(row: OrderProject) {
modalTitle.value = '编辑项目表'
Object.assign(formData, row)
if (formData.deliveryTime && typeof formData.deliveryTime === 'string') {
formData.deliveryTime = new Date(formData.deliveryTime.replace(' ', 'T')).getTime()
}
if (formData.planFinishTime && typeof formData.planFinishTime === 'string') {
formData.planFinishTime = new Date(formData.planFinishTime.replace(' ', 'T')).getTime()
}
if (formData.createTime && typeof formData.createTime === 'string') {
formData.createTime = new Date(formData.createTime.replace(' ', 'T')).getTime()
}
if (formData.changeTime && typeof formData.changeTime === 'string') {
const ts = new Date(formData.changeTime.replace(' ', 'T')).getTime()
formData.changeTime = Number.isNaN(ts) ? undefined : ts
}
modalVisible.value = true
}
function formatDateTime(ts: number) {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
//
async function handleSubmit() {
try {
await formRef.value?.validate()
} catch {
return
}
try {
const submitData = { ...formData } as OrderProject
if (typeof submitData.deliveryTime === 'number') {
submitData.deliveryTime = formatDateTime(submitData.deliveryTime)
}
if (typeof submitData.planFinishTime === 'number') {
submitData.planFinishTime = formatDateTime(submitData.planFinishTime)
}
if (typeof submitData.createTime === 'number') {
submitData.createTime = formatDateTime(submitData.createTime)
}
if (typeof submitData.changeTime === 'number') {
submitData.changeTime = formatDateTime(submitData.changeTime)
}
if (submitData.id) {
await orderProjectApi.update(submitData)
message.success('修改成功')
} else {
await orderProjectApi.create(submitData)
message.success('新增成功')
}
modalVisible.value = false
loadData()
} catch (error) {
//
}
}
//
function handleGanttSchedule() {
// sessionStorage
sessionStorage.setItem('gantt_schedule_data', JSON.stringify(kingdeeTableData.value))
//
router.push('/biz/orderProject/gantt')
}
//
async function handleKingdeeQuery(row: OrderProject) {
if (!row.id) {
message.warning('缺少项目ID无法查询')
return
}
kingdeeModalTitle.value = `金蝶生产订单${row.code ? ` - ${row.code}` : ''}`
kingdeeModalVisible.value = true
kingdeeLoading.value = true
kingdeeTableData.value = []
kingdeeExpandedKeys.value = []
try {
const data = await orderProjectApi.getKingdeeOrder(row.id)
kingdeeTableData.value = (Array.isArray(data) ? data : []).map((mo) => {
const { children, ...rest } = mo
const routes = [...(children ?? [])].sort((a, b) => (a.operNumber ?? 0) - (b.operNumber ?? 0)).map((r) => ({
...r,
planStartTime: r.planStartTime ?? null,
planFinishTime: r.planFinishTime ?? null,
}))
return { ...rest, processRoutes: routes }
})
if (kingdeeTableData.value.length === 0) {
message.info('未查询到金蝶生产订单数据')
}
} finally {
kingdeeLoading.value = false
}
}
//
function handleDelete(row: OrderProject) {
dialog.warning({
title: '提示',
content: '确定要删除该记录吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
await orderProjectApi.delete([row.id!])
message.success('删除成功')
loadData()
} catch (error) {
//
}
}
})
}
//
function handleBatchDelete() {
dialog.warning({
title: '提示',
content: `确定要删除选中的 ${selectedIds.value.length} 条记录吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
await orderProjectApi.delete(selectedIds.value)
message.success('删除成功')
selectedIds.value = []
loadData()
} catch (error) {
//
}
}
})
}
//
async function handleExport() {
try {
const params: Record<string, any> = {}
if (selectedIds.value.length > 0) params.ids = selectedIds.value
if (searchForm.id != null) params.id = searchForm.id
if (searchForm.name) params.name = searchForm.name
if (searchForm.status != null) params.status = searchForm.status
const blob = await orderProjectApi.export(params)
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '项目表数据.xlsx'
link.click()
window.URL.revokeObjectURL(url)
} catch (error) {
//
}
}
//
async function handleDownloadTemplate() {
try {
const blob = await orderProjectApi.downloadTemplate()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '项目表导入模板.xlsx'
link.click()
window.URL.revokeObjectURL(url)
} catch (error) {
//
}
}
//
async function handleImportUpload({ file }: UploadCustomRequestOptions) {
if (!file.file) return
try {
const result = await orderProjectApi.importData(file.file)
if (result.fail > 0) {
dialog.warning({
title: '导入结果',
content: `成功: ${result.success} 条,失败: ${result.fail}\n错误信息: ${(result.errors || []).join('\n') || '无'}`,
positiveText: '确定'
})
} else {
message.success(`导入成功,共 ${result.success} 条数据`)
importModalVisible.value = false
}
loadData()
} catch (error) {
//
}
}
//
async function loadDictOptions() {
try {
const data = await dictDataApi.listByType('pro_source')
sourceOptions.value = data.map(d => ({ label: d.dictLabel, value: (Number(d.dictValue) || d.dictValue) }))
} catch {}
try {
const data = await dictDataApi.listByType('pro_status')
statusOptions.value = data.map(d => ({ label: d.dictLabel, value: (Number(d.dictValue) || d.dictValue) }))
} catch {}
}
onMounted(() => {
loadData()
loadDictOptions()
})
</script>
<style scoped>
.search-form {
margin-bottom: 16px;
}
.table-toolbar {
margin-bottom: 16px;
}
.kingdee-toolbar {
margin-bottom: 12px;
}
:deep(.kingdee-modal .n-card) {
max-height: 94vh;
display: flex;
flex-direction: column;
}
:deep(.kingdee-modal .n-card__content) {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.kingdee-mo-row td) {
background-color: rgba(32, 128, 240, 0.06) !important;
font-weight: 500;
}
:deep(.kingdee-mo-row:hover td) {
background-color: rgba(32, 128, 240, 0.1) !important;
}
.kingdee-expand-wrap {
margin: 4px 8px 8px 36px;
padding: 12px 14px 14px;
border: 1px solid var(--n-border-color);
border-left: 4px solid #18a058;
border-radius: 6px;
background: var(--n-action-color);
}
:deep(.kingdee-process-table .n-data-table-th) {
background: rgba(24, 160, 88, 0.12) !important;
}
:deep(.kingdee-process-table .n-data-table-td) {
background-color: var(--n-color) !important;
}
:deep(.kingdee-process-table .n-data-table-tr--striped .n-data-table-td) {
background-color: var(--n-action-color) !important;
}
.kingdee-expand-empty {
margin: 4px 8px 8px 36px;
padding: 12px 14px;
font-size: 13px;
color: var(--n-text-color-3);
border: 1px dashed var(--n-border-color);
border-radius: 6px;
background: var(--n-action-color);
}
</style>