mes-vue/src/views/biz/outsourcing/index.vue
2026-07-18 11:01:31 +08:00

540 lines
17 KiB
Vue

<template>
<div class="page-container">
<n-card>
<div class="table-toolbar">
<n-space>
<n-button type="primary" @click="handleGoCreate">
<template #icon><n-icon><AddOutline /></n-icon></template>
新建工序委外
</n-button>
<n-button disabled title="待后端接口支持">下推</n-button>
<n-button type="error" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
<template #icon><n-icon><TrashOutline /></n-icon></template>
批量删除
</n-button>
</n-space>
<n-space>
<n-button quaternary circle @click="loadData">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
</n-button>
</n-space>
</div>
<div class="search-form">
<n-form inline :model="searchForm" label-placement="left" size="small">
<n-form-item label="委外编号">
<n-input v-model:value="searchForm.outsourceCode" placeholder="工单委外编号" clearable style="width: 160px" />
</n-form-item>
<n-form-item label="供应商">
<n-input v-model:value="searchForm.supplierName" placeholder="委外供应商" clearable style="width: 140px" />
</n-form-item>
<n-form-item label="工序">
<n-input v-model:value="searchForm.name" placeholder="委外工序" clearable style="width: 140px" />
</n-form-item>
<n-form-item label="状态">
<n-select
v-model:value="searchForm.status"
placeholder="当前阶段"
clearable
style="width: 120px"
:options="statusOptions"
/>
</n-form-item>
<n-form-item>
<n-space>
<n-button type="primary" size="small" @click="handleSearch">
<template #icon><n-icon><SearchOutline /></n-icon></template>
搜索
</n-button>
<n-button size="small" @click="handleReset">重置</n-button>
</n-space>
</n-form-item>
</n-form>
</div>
<n-data-table
:columns="columns"
:data="tableData"
:loading="loading"
:pagination="pagination"
:row-key="(row) => row.id!"
:scroll-x="1520"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
@update:checked-row-keys="handleCheck"
/>
</n-card>
<!-- 详情 -->
<n-modal v-model:show="detailVisible" preset="card" title="委外工单详情" style="width: 760px">
<n-descriptions v-if="detailData" :column="2" bordered size="small" label-placement="left">
<n-descriptions-item label="工单委外编号">{{ detailData.outsourceCode || '-' }}</n-descriptions-item>
<n-descriptions-item label="当前阶段">{{ statusLabel(detailData.status) }}</n-descriptions-item>
<n-descriptions-item label="委外工序">{{ detailData.name || '-' }}</n-descriptions-item>
<n-descriptions-item label="工序工单号">{{ detailData.businessCode || '-' }}</n-descriptions-item>
<n-descriptions-item label="委外供应商">{{ detailData.supplierName || '-' }}</n-descriptions-item>
<n-descriptions-item label="优先级">{{ priorityLabel(detailData.priority) }}</n-descriptions-item>
<n-descriptions-item label="委外数量">{{ formatQty(detailData.quantity) }}</n-descriptions-item>
<n-descriptions-item label="委外单价">{{ formatMoney(detailData.unitPrice) }}</n-descriptions-item>
<n-descriptions-item label="委外总金额">{{ formatMoney(detailData.totalAmount) }}</n-descriptions-item>
<n-descriptions-item label="已发料数量">{{ formatDecimal(detailData.issuedQuantity) }}</n-descriptions-item>
<n-descriptions-item label="已收货数量">{{ formatDecimal(detailData.receivedQuantity) }}</n-descriptions-item>
<n-descriptions-item label="合格数量">{{ formatDecimal(detailData.qualifiedQuantity) }}</n-descriptions-item>
<n-descriptions-item label="预计开始">{{ detailData.beginPreparationTime || '-' }}</n-descriptions-item>
<n-descriptions-item label="预计结束">{{ detailData.endPreparationTime || '-' }}</n-descriptions-item>
<n-descriptions-item label="备注" :span="2">{{ detailData.remark || '-' }}</n-descriptions-item>
</n-descriptions>
</n-modal>
<!-- 编辑 -->
<n-modal v-model:show="modalVisible" preset="card" :title="modalTitle" style="width: 720px">
<n-form ref="formRef" :model="formData" :rules="formRules" label-placement="left" label-width="110px">
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi label="工单委外编号">
<n-input :value="formData.outsourceCode" disabled />
</n-form-item-gi>
<n-form-item-gi label="委外工序">
<n-input :value="formData.name" disabled />
</n-form-item-gi>
<n-form-item-gi label="供应商" path="supplierName">
<n-input v-model:value="formData.supplierName" placeholder="请输入供应商" />
</n-form-item-gi>
<n-form-item-gi label="委外数量" path="quantity">
<n-input-number v-model:value="formData.quantity" :min="1" :precision="0" style="width: 100%" />
</n-form-item-gi>
<n-form-item-gi label="单价" path="unitPrice">
<n-input-number v-model:value="formData.unitPrice" :min="0" :precision="2" style="width: 100%" />
</n-form-item-gi>
<n-form-item-gi label="优先级" path="priority">
<n-select v-model:value="formData.priority" :options="priorityOptions" />
</n-form-item-gi>
<n-form-item-gi label="当前阶段" path="status">
<n-select v-model:value="formData.status" :options="statusOptions" />
</n-form-item-gi>
<n-form-item-gi label="预计开始" path="beginPreparationTime">
<n-date-picker v-model:value="formData.beginPreparationTime" type="datetime" clearable style="width: 100%" />
</n-form-item-gi>
<n-form-item-gi label="预计结束" path="endPreparationTime">
<n-date-picker v-model:value="formData.endPreparationTime" type="datetime" clearable style="width: 100%" />
</n-form-item-gi>
<n-form-item-gi :span="2" label="备注" path="remark">
<n-input v-model:value="formData.remark" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" />
</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" :loading="submitLoading" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, h, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
NButton, NSpace, NIcon, NTag, NModal, NForm, NFormItem, NFormItemGi, NGrid,
NInput, NInputNumber, NSelect, NDatePicker, NDescriptions, NDescriptionsItem,
useMessage, useDialog, type DataTableColumns, type FormRules,
} from 'naive-ui'
import {
SearchOutline, RefreshOutline, AddOutline, TrashOutline,
CopyOutline, EllipseOutline, TimeOutline,
} from '@vicons/ionicons5'
import {
outsourcingApi,
OUTSOURCE_STATUS_MAP,
OUTSOURCE_PRIORITY_MAP,
type Outsourcing,
} from '@/api/outsourcing'
const message = useMessage()
const dialog = useDialog()
const router = useRouter()
const searchForm = reactive({
outsourceCode: '',
supplierName: '',
name: '',
status: null as number | null,
})
const statusOptions = Object.entries(OUTSOURCE_STATUS_MAP).map(([value, item]) => ({
label: item.label,
value: Number(value),
}))
const priorityOptions = Object.entries(OUTSOURCE_PRIORITY_MAP).map(([value, item]) => ({
label: item.label,
value: Number(value),
}))
const tableData = ref<Outsourcing[]>([])
const loading = ref(false)
const submitLoading = ref(false)
const selectedIds = ref<number[]>([])
const pagination = reactive({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50],
})
const detailVisible = ref(false)
const detailData = ref<Outsourcing | null>(null)
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
const defaultFormData = (): Record<string, unknown> => ({
id: undefined,
outsourceCode: '',
name: '',
supplierName: '',
quantity: null,
unitPrice: null,
priority: 1,
status: 1,
beginPreparationTime: null,
endPreparationTime: null,
remark: '',
})
const formData = reactive<Record<string, unknown>>(defaultFormData())
const formRules: FormRules = {
supplierName: [{ required: true, message: '请输入供应商', trigger: 'blur' }],
quantity: [{ required: true, type: 'number', message: '请输入委外数量', trigger: ['blur', 'change'] }],
}
function formatQty(val?: number | null) {
if (val == null) return '0.00'
return Number(val).toFixed(2)
}
function formatDecimal(val?: number | string | null) {
if (val == null || val === '') return '0.00'
return Number(val).toFixed(2)
}
function formatMoney(val?: number | string | null, mask = false) {
if (mask) return '¥***'
if (val == null || val === '') return '-'
return `¥${Number(val).toFixed(2)}`
}
function statusLabel(status?: number) {
if (status == null) return '-'
return OUTSOURCE_STATUS_MAP[status]?.label ?? String(status)
}
function priorityLabel(priority?: number) {
if (priority == null) return '-'
return OUTSOURCE_PRIORITY_MAP[priority]?.label ?? String(priority)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
message.success('已复制')
} catch {
message.warning('复制失败')
}
}
const columns: DataTableColumns<Outsourcing> = [
{ type: 'selection', width: 48, align: 'center', fixed: 'left' },
{
title: '工单委外编号',
key: 'outsourceCode',
width: 200,
align: 'center',
fixed: 'left',
ellipsis: { tooltip: true },
render(row) {
const code = row.outsourceCode || (row.id ? `WW${row.id}` : '-')
return h('div', { class: 'cell-center code-cell' }, [
h(NButton, {
text: true,
type: 'primary',
size: 'small',
onClick: () => openDetail(row),
}, { default: () => code }),
h(NButton, {
text: true,
size: 'tiny',
onClick: () => copyText(code),
}, {
default: () => h(NIcon, { size: 14 }, { default: () => h(CopyOutline) }),
}),
])
},
},
{
title: '委外数量',
key: 'quantity',
width: 96,
align: 'center',
render: (row) => formatQty(row.quantity),
},
{
title: '委外供应商',
key: 'supplierName',
width: 140,
align: 'center',
ellipsis: { tooltip: true },
},
{
title: '委外工序',
key: 'name',
width: 120,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => row.name || '-',
},
{
title: '委外单价',
key: 'unitPrice',
width: 96,
align: 'center',
render: (row) => formatMoney(row.unitPrice, true),
},
{
title: '委外总金额',
key: 'totalAmount',
width: 108,
align: 'center',
render: (row) => formatMoney(row.totalAmount, true),
},
{
title: '优先级',
key: 'priority',
width: 88,
align: 'center',
render(row) {
const item = OUTSOURCE_PRIORITY_MAP[row.priority ?? 1] ?? OUTSOURCE_PRIORITY_MAP[1]
return h('div', { class: 'cell-center' }, [
h(NTag, { size: 'small', type: item.type, bordered: false }, { default: () => item.label }),
])
},
},
{
title: '已发料数量',
key: 'issuedQuantity',
width: 104,
align: 'center',
render: (row) => formatDecimal(row.issuedQuantity),
},
{
title: '已收货数量',
key: 'receivedQuantity',
width: 104,
align: 'center',
render: (row) => formatDecimal(row.receivedQuantity),
},
{
title: '合格数量',
key: 'qualifiedQuantity',
width: 96,
align: 'center',
render: (row) => formatDecimal(row.qualifiedQuantity),
},
{
title: '当前阶段',
key: 'status',
width: 100,
align: 'center',
render(row) {
const status = row.status ?? 1
const item = OUTSOURCE_STATUS_MAP[status] ?? OUTSOURCE_STATUS_MAP[1]
const icon = status === 1 ? TimeOutline : EllipseOutline
const color = status === 1 ? '#2080f0' : '#bfbfbf'
return h('div', { class: 'cell-center status-cell' }, [
h(NIcon, { color, size: 16 }, { default: () => h(icon) }),
h('span', item.label),
])
},
},
{
title: '操作',
key: 'actions',
width: 120,
align: 'center',
fixed: 'right',
render(row) {
return h('div', { class: 'cell-center' }, [
h(NSpace, { size: 4, justify: 'center' }, {
default: () => [
h(NButton, { text: true, type: 'primary', size: 'small', onClick: () => openDetail(row) }, { default: () => '详情' }),
h(NButton, { text: true, type: 'primary', size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
],
}),
])
},
},
]
async function loadData() {
loading.value = true
try {
const res = await outsourcingApi.page({
page: pagination.page,
pageSize: pagination.pageSize,
name: searchForm.name || undefined,
outsourceCode: searchForm.outsourceCode || undefined,
supplierName: searchForm.supplierName || undefined,
status: searchForm.status ?? undefined,
})
tableData.value = res.list ?? []
pagination.itemCount = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.outsourceCode = ''
searchForm.supplierName = ''
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 handleGoCreate() {
router.push('/biz/orderProcessPlan/board')
message.info('请在看板中选择工序后创建委外')
}
function parseDateTime(val?: string | null) {
if (!val) return null
const ts = new Date(val.replace(' ', 'T')).getTime()
return Number.isNaN(ts) ? null : ts
}
async function openDetail(row: Outsourcing) {
if (!row.id) return
detailData.value = await outsourcingApi.detail(row.id)
detailVisible.value = true
}
function handleEdit(row: Outsourcing) {
modalTitle.value = '编辑委外工单'
Object.assign(formData, defaultFormData(), row, {
quantity: row.quantity ?? null,
unitPrice: row.unitPrice != null ? Number(row.unitPrice) : null,
beginPreparationTime: parseDateTime(row.beginPreparationTime),
endPreparationTime: parseDateTime(row.endPreparationTime),
})
modalVisible.value = true
}
function formatSubmitDateTime(ts: number | null) {
if (!ts) return undefined
return new Date(ts).toISOString().slice(0, 19).replace('T', ' ')
}
async function handleSubmit() {
await formRef.value?.validate()
submitLoading.value = true
try {
const qty = formData.quantity as number
const price = formData.unitPrice as number | null
const payload: Outsourcing = {
id: formData.id as number,
supplierName: formData.supplierName as string,
quantity: qty,
unitPrice: price ?? undefined,
totalAmount: price != null ? Number((qty * price).toFixed(2)) : undefined,
priority: formData.priority as number,
status: formData.status as number,
beginPreparationTime: formatSubmitDateTime(formData.beginPreparationTime as number | null),
endPreparationTime: formatSubmitDateTime(formData.endPreparationTime as number | null),
remark: (formData.remark as string) || undefined,
}
await outsourcingApi.update(payload)
message.success('保存成功')
modalVisible.value = false
loadData()
} finally {
submitLoading.value = false
}
}
function handleBatchDelete() {
dialog.warning({
title: '提示',
content: `确定删除选中的 ${selectedIds.value.length} 条委外工单吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
await outsourcingApi.delete(selectedIds.value)
message.success('删除成功')
selectedIds.value = []
loadData()
},
})
}
onMounted(loadData)
</script>
<style scoped>
.search-form {
margin-bottom: 16px;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.cell-center {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.code-cell {
gap: 4px;
}
.status-cell {
gap: 6px;
font-size: 13px;
}
:deep(.n-data-table-th) {
text-align: center !important;
}
:deep(.n-data-table-td) {
text-align: center;
}
</style>