This commit is contained in:
zlwu committed 2026-08-28 17:38:37 +08:00
1 parent 3d98104455
commit 0d52442902
76 files changed
+21994 -12642

No files matched your search

@@ -0,0 +1,874 @@
<!-- OA 发起审批 · 单流程历史记录(WorkTab 页,按 appId 多开) -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">{{ pageTitle }}</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="流程名称">
<el-input :model-value="appName" readonly placeholder="流程名称" />
</el-form-item>
<el-form-item label="审批状态">
<el-select v-model="searchForm.instanceStatus" placeholder="全部" clearable>
<el-option label="全部" value="" />
<el-option label="进行中" value="running" />
<el-option label="已完成" value="completed" />
<el-option label="已拒绝" value="rejected" />
<el-option label="已打回" value="returned" />
<el-option label="已撤销" value="cancelled" />
</el-select>
</el-form-item>
<el-form-item label="审批 ID">
<el-input
v-model="searchForm.instanceId"
placeholder="请输入审批 ID"
clearable
/>
</el-form-item>
<el-form-item label="发起时间">
<el-date-picker
v-model="applyTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="完成时间">
<el-date-picker
v-model="finishTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item v-if="canViewAll" label="申请人">
<el-input
v-model="searchForm.initiatorName"
placeholder="请输入申请人"
clearable
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>
重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button
class="btn-ghost"
:disabled="selectedRows.length === 0 || exporting"
:loading="exporting && exportMode === 'selected'"
@click="handleExportSelected"
>
<el-icon><Download /></el-icon>
导出勾选
</el-button>
<el-button
class="btn-ghost"
:disabled="total <= 0 || exporting"
:loading="exporting && exportMode === 'all'"
@click="handleExportAll"
>
<el-icon><Download /></el-icon>
导出全部
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="rows"
v-loading="loading"
stripe
row-key="id"
empty-text="暂无历史记录"
@selection-change="onSelectionChange"
@row-dblclick="openDetail"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="indexMethod"
/>
<el-table-column
prop="id"
label="审批 ID"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.id || '-' }}</template>
</el-table-column>
<el-table-column
prop="appName"
label="流程名称"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.appName || appName || '-' }}</template>
</el-table-column>
<el-table-column
prop="initiatorUserName"
label="申请人"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.initiatorUserName || '-' }}</template>
</el-table-column>
<el-table-column
v-for="label in previewFieldLabels"
:key="label"
:label="label"
:min-width="previewColumnMinWidth(label)"
align="center"
header-align="center"
:show-overflow-tooltip="!isUploadPreviewLabel(label)"
>
<template #default="{ row }">
<div
v-if="previewFiles(row, label).length"
class="hist-attach"
:class="{ 'is-multi': previewFiles(row, label).length > 1 }"
>
<div
v-for="(file, idx) in previewFiles(row, label)"
:key="`${file.url}-${idx}`"
class="hist-attach__card"
:title="file.name || '附件'"
>
<div class="hist-attach__preview">
<el-image
v-if="isImageFile(file)"
class="hist-attach__img"
:src="fileFullUrl(file.url)"
:preview-src-list="imagePreviewList(row, label)"
:initial-index="imagePreviewIndex(row, label, file.url)"
fit="cover"
preview-teleported
>
<template #error>
<div class="hist-attach__fallback">
<el-icon :size="18"><Picture /></el-icon>
</div>
</template>
</el-image>
<button
v-else
type="button"
class="hist-attach__doc"
@click="previewFile(file)"
>
<span class="hist-attach__doc-icon">
<el-icon :size="18"><Document /></el-icon>
</span>
<span class="hist-attach__ext">{{ fileExt(file) }}</span>
</button>
<div class="hist-attach__hint" aria-hidden="true">
<el-icon :size="14">
<ZoomIn v-if="isImageFile(file)" />
<View v-else />
</el-icon>
</div>
</div>
<div class="hist-attach__footer">
<span class="hist-attach__name">{{ shortFileLabel(file) }}</span>
<button
type="button"
class="hist-attach__dl"
title="下载"
@click.stop="downloadFile(file)"
>
<el-icon :size="12"><Download /></el-icon>
</button>
</div>
</div>
</div>
<span v-else>{{ previewValue(row, label) }}</span>
</template>
</el-table-column>
<el-table-column
prop="applyTime"
label="发起时间"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.applyTime || '-' }}</template>
</el-table-column>
<el-table-column
prop="finishTime"
label="完成时间"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.finishTime || '-' }}</template>
</el-table-column>
<el-table-column
prop="instanceStatus"
label="审批状态"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag size="small" :type="statusType(row.instanceStatus || row.status)">
{{ statusText(row.instanceStatus || row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="currentNodeName"
label="当前节点"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.currentNodeName || '-' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="openDetail(row)">
<el-icon><View /></el-icon>
查看
</el-button>
<el-button
class="btn-op-item"
:loading="printingId === row.id"
@click="printRow(row)"
>
<el-icon><Printer /></el-icon>
打印
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
@size-change="load"
@current-change="load"
/>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import {
Document,
Download,
Picture,
Printer,
RefreshLeft,
Search,
View,
ZoomIn
} from '@element-plus/icons-vue'
import { fetchOaInstanceDetail, fetchOaInstanceHistory, fetchOaInstanceHistoryExport } from '@/api/oaApproval'
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
import { useMenuPerm } from '@/hooks/core/useMenuPerm'
import { useUserStore } from '@/store/modules/user'
import { useWorktabStore } from '@/store/modules/worktab'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { downloadOaHistoryExcel } from '@/utils/oa-history-export'
import { exportOaApprovalPdf } from '@/utils/oa-approval-print'
import { isOaUploadImageFile } from '@/utils/oa-upload-file-type'
defineOptions({ name: 'OaApprovalHistory' })
type PreviewFile = { url: string; name: string }
const OA_START_HISTORY_ALL = 'oa:start:history:all'
const { hasMenuPerm } = useMenuPerm()
const canViewAll = computed(() => hasMenuPerm(OA_START_HISTORY_ALL))
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const worktabStore = useWorktabStore()
// KeepAlive 多开:setup 时锁定本实例对应的流程,避免全局 route 切走后串数据/标题
const boundAppId = String(route.params.appId || route.query.appId || '').trim()
const boundPath = String(route.path || '')
const boundAppName = ref(String(route.query.appName || '').trim())
const loading = ref(false)
const exporting = ref(false)
const exportMode = ref<'selected' | 'all' | ''>('')
const printingId = ref('')
const selectedRows = ref<any[]>([])
const rows = ref<any[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const applyTimeRange = ref<string[] | null>(null)
const finishTimeRange = ref<string[] | null>(null)
const searchForm = reactive({
instanceStatus: '',
instanceId: '',
initiatorName: ''
})
const appId = computed(() => boundAppId)
const appName = computed(() => boundAppName.value)
const pageTitle = computed(() =>
appName.value ? `历史记录 · ${appName.value}` : '历史记录'
)
function isActiveHistoryInstance() {
return (
route.name === 'OaApprovalHistory' &&
String(route.params.appId || route.query.appId || '').trim() === boundAppId
)
}
const previewFieldLabels = computed(() => {
const labels: string[] = []
for (const row of rows.value) {
for (const f of row.previewFields || []) {
const lb = String(f?.label || '').trim()
if (lb && !labels.includes(lb)) labels.push(lb)
}
}
return labels.slice(0, 6)
})
const uploadPreviewLabels = computed(() => {
const set = new Set<string>()
for (const row of rows.value) {
for (const f of row.previewFields || []) {
const lb = String(f?.label || '').trim()
if (!lb) continue
const files = Array.isArray(f?.files) ? f.files : []
if (files.length || isUploadFieldType(f?.fieldType)) set.add(lb)
}
}
return set
})
function isUploadFieldType(t: unknown) {
const s = String(t || '')
return s === 'upload' || s === 'fcUpload' || s === 'image' || s === 'oaAttachment'
}
function isUploadPreviewLabel(label: string) {
return uploadPreviewLabels.value.has(label)
}
function previewColumnMinWidth(label: string) {
return isUploadPreviewLabel(label) ? 168 : 120
}
function syncWorktabTitle() {
if (!isActiveHistoryInstance() || !boundPath) return
worktabStore.updateTabTitle(boundPath, pageTitle.value)
}
function indexMethod(index: number) {
return (page.value - 1) * pageSize.value + index + 1
}
function onSelectionChange(selection: any[]) {
selectedRows.value = selection
}
function buildExportParams(instanceIds?: string[]) {
const params: Record<string, string> = { appId: appId.value }
if (searchForm.instanceId) params.instanceId = searchForm.instanceId
if (searchForm.instanceStatus) params.instanceStatus = searchForm.instanceStatus
if (applyTimeRange.value?.[0]) params.applyTimeFrom = applyTimeRange.value[0]
if (applyTimeRange.value?.[1]) params.applyTimeTo = applyTimeRange.value[1]
if (finishTimeRange.value?.[0]) params.finishTimeFrom = finishTimeRange.value[0]
if (finishTimeRange.value?.[1]) params.finishTimeTo = finishTimeRange.value[1]
if (canViewAll.value && searchForm.initiatorName) {
params.initiatorName = searchForm.initiatorName
}
if (instanceIds?.length) {
params.instanceIds = instanceIds.join(',')
}
return params
}
async function runExport(instanceIds?: string[]) {
if (!appId.value) {
ElMessage.warning('缺少流程信息,无法导出')
return
}
exporting.value = true
try {
const data = await fetchOaInstanceHistoryExport(buildExportParams(instanceIds))
if (!data?.rows?.length) {
ElMessage.warning('暂无可导出数据')
return
}
await downloadOaHistoryExcel(data, {
filenamePrefix: appName.value || data.appName || '历史记录'
})
ElMessage.success(`已导出 ${data.rows.length} 条`)
} catch {
ElMessage.error('导出失败')
} finally {
exporting.value = false
exportMode.value = ''
}
}
function handleExportSelected() {
const ids = selectedRows.value.map((r) => String(r?.id || '').trim()).filter(Boolean)
if (!ids.length) {
ElMessage.warning('请先勾选要导出的记录')
return
}
exportMode.value = 'selected'
void runExport(ids)
}
function handleExportAll() {
if (total.value <= 0) {
ElMessage.warning('暂无可导出数据')
return
}
exportMode.value = 'all'
void runExport()
}
function previewField(row: any, label: string) {
return (row.previewFields || []).find((f: any) => f.label === label)
}
function previewValue(row: any, label: string) {
const hit = previewField(row, label)
return hit?.value || '-'
}
function previewFiles(row: any, label: string): PreviewFile[] {
const hit = previewField(row, label)
const files = Array.isArray(hit?.files) ? hit.files : []
return files
.map((f: any) => ({
url: String(f?.url || '').trim(),
name: String(f?.name || '').trim()
}))
.filter((f: PreviewFile) => !!f.url)
}
function fileFullUrl(url: string) {
return attachmentFullUrl(url) || url
}
function isImageFile(file: PreviewFile) {
return isOaUploadImageFile({ url: file.url, name: file.name })
}
function fileExt(file: PreviewFile) {
const name = String(file.name || file.url || '')
const m = name.match(/\.([a-z0-9]+)(?:\?|$)/i)
return (m?.[1] || 'FILE').toUpperCase().slice(0, 4)
}
/** 列表内短名:UUID 文件名改为「图片/文件.ext」,避免一长串哈希 */
function shortFileLabel(file: PreviewFile) {
const raw = String(file.name || '').trim()
if (!raw) return isImageFile(file) ? '图片' : '附件'
const extMatch = raw.match(/\.([a-z0-9]+)$/i)
const ext = extMatch?.[1] || ''
const base = ext ? raw.slice(0, -(ext.length + 1)) : raw
if (/^[a-f0-9_-]{16,}$/i.test(base)) {
if (isImageFile(file)) return ext ? `图片.${ext.toLowerCase()}` : '图片'
return ext ? `文件.${ext.toLowerCase()}` : '文件'
}
if (raw.length <= 14) return raw
if (ext) {
const keep = Math.max(4, 10 - ext.length)
return `${base.slice(0, keep)}….${ext.toLowerCase()}`
}
return `${raw.slice(0, 12)}…`
}
function imagePreviewList(row: any, label: string) {
return previewFiles(row, label)
.filter((f) => isImageFile(f))
.map((f) => fileFullUrl(f.url))
}
function imagePreviewIndex(row: any, label: string, url: string) {
const list = previewFiles(row, label).filter((f) => isImageFile(f))
const idx = list.findIndex((f) => f.url === url)
return idx >= 0 ? idx : 0
}
function previewFile(file: PreviewFile) {
const url = fileFullUrl(file.url)
if (url) window.open(url, '_blank')
}
function downloadFile(file: PreviewFile) {
void downloadAttachmentFile(
{ fileUrl: file.url, fileName: file.name || undefined },
{ fullFileUrl: (u) => attachmentFullUrl(u) }
)
}
function currentPrinterName() {
const info: any = userStore.info || userStore.getUserInfo || {}
return String(
info.realName || info.nickName || info.userName || info.username || ''
).trim()
}
function statusType(s: string) {
if (s === 'pending' || s === 'running') return 'warning'
if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
return 'info'
}
function statusText(s: string) {
if (!s) return '-'
const map: Record<string, string> = {
pending: '待处理',
running: '进行中',
approved: '已同意',
rejected: '已拒绝',
completed: '已完成',
cancelled: '已撤销',
done: '已结束',
returned: '已打回',
transferred: '已转交'
}
return map[s] || s
}
function resetFilters() {
searchForm.instanceStatus = ''
searchForm.instanceId = ''
searchForm.initiatorName = ''
applyTimeRange.value = null
finishTimeRange.value = null
page.value = 1
}
function handleSearch() {
page.value = 1
void load()
}
function handleReset() {
resetFilters()
void load()
}
async function load() {
if (!appId.value) {
rows.value = []
total.value = 0
return
}
loading.value = true
try {
const params: any = {
appId: appId.value,
instanceId: searchForm.instanceId || undefined,
instanceStatus: searchForm.instanceStatus || undefined,
applyTimeFrom: applyTimeRange.value?.[0],
applyTimeTo: applyTimeRange.value?.[1],
finishTimeFrom: finishTimeRange.value?.[0],
finishTimeTo: finishTimeRange.value?.[1],
page: page.value,
pageSize: pageSize.value
}
if (canViewAll.value && searchForm.initiatorName) {
params.initiatorName = searchForm.initiatorName
}
const data = await fetchOaInstanceHistory(params)
rows.value = data?.list || []
total.value = Number(data?.total || 0)
} catch {
rows.value = []
total.value = 0
} finally {
loading.value = false
}
}
function openDetail(row: any) {
const id = row?.id
if (!id) return
router.push({ path: `/system-manage/oa-approval/instances/${id}` })
}
async function printRow(row: any) {
const id = String(row?.id || '').trim()
if (!id || printingId.value) return
printingId.value = id
let detail: any
try {
detail = await fetchOaInstanceDetail(id)
} catch {
printingId.value = ''
ElMessage.error('加载审批单失败')
return
}
try {
await exportOaApprovalPdf(detail, { printerName: currentPrinterName() })
ElMessage.success('PDF 已下载')
} catch {
ElMessage.error('导出 PDF 失败')
} finally {
printingId.value = ''
}
}
function bootstrapFromRoute() {
if (!isActiveHistoryInstance()) return
const qName = String(route.query.appName || '').trim()
if (qName) boundAppName.value = qName
resetFilters()
syncWorktabTitle()
void load()
}
watch(
() => [route.name, route.params.appId, route.query.appId, route.query.appName] as const,
() => {
if (!isActiveHistoryInstance()) return
bootstrapFromRoute()
}
)
onMounted(bootstrapFromRoute)
onActivated(() => {
if (!isActiveHistoryInstance()) return
const qName = String(route.query.appName || '').trim()
if (qName) boundAppName.value = qName
syncWorktabTitle()
void load()
})
</script>
<style scoped lang="scss">
.hist-attach {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
max-width: 100%;
padding: 2px 0;
vertical-align: middle;
}
.hist-attach__card {
width: 88px;
overflow: hidden;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
transform 0.15s ease;
&:hover {
border-color: #10b981;
box-shadow: 0 4px 12px rgb(16 185 129 / 14%);
transform: translateY(-1px);
.hist-attach__hint {
opacity: 1;
}
}
}
.hist-attach__preview {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 64px;
overflow: hidden;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
}
.hist-attach__img {
display: block;
width: 100%;
height: 64px;
cursor: zoom-in;
:deep(.el-image__inner) {
width: 100%;
height: 64px;
object-fit: cover;
}
}
.hist-attach__fallback,
.hist-attach__doc {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
width: 100%;
height: 64px;
padding: 0;
color: #64748b;
cursor: pointer;
background: transparent;
border: none;
}
.hist-attach__doc-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: #0284c7;
background: #e0f2fe;
border-radius: 8px;
}
.hist-attach__ext {
font-size: 10px;
font-weight: 600;
line-height: 1;
color: #475569;
letter-spacing: 0.02em;
}
.hist-attach__hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
pointer-events: none;
background: rgb(15 23 42 / 38%);
opacity: 0;
transition: opacity 0.15s ease;
}
.hist-attach__footer {
display: flex;
gap: 4px;
align-items: center;
min-width: 0;
padding: 5px 6px;
background: #fff;
border-top: 1px solid #f1f5f9;
}
.hist-attach__name {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 11px;
line-height: 1.3;
color: #334155;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.hist-attach__dl {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
color: #64748b;
cursor: pointer;
background: #f1f5f9;
border: none;
border-radius: 5px;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover {
color: #fff;
background: #10b981;
}
}
.hist-attach.is-multi {
gap: 6px;
.hist-attach__card {
width: 72px;
border-radius: 7px;
}
.hist-attach__preview,
.hist-attach__img,
.hist-attach__fallback,
.hist-attach__doc {
height: 52px;
}
.hist-attach__img :deep(.el-image__inner) {
height: 52px;
}
.hist-attach__footer {
padding: 4px 5px;
}
.hist-attach__name {
font-size: 10px;
}
.hist-attach__dl {
width: 18px;
height: 18px;
}
}
</style>
@@ -93,33 +93,32 @@
@saved="onDialogSaved"
@submitted="onDialogSubmitted"
/>
<OaAppHistoryDialog ref="historyDialogRef" />
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onActivated, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Clock, Promotion, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchOaGroupList, fetchOaTemplates } from '@/api/oaApproval'
import OaAppIcon from '@/components/oa-approval/OaAppIcon.vue'
import OaAppHistoryDialog from '@/components/oa-approval/OaAppHistoryDialog.vue'
import OaStartFormDialog from '@/components/oa-approval/OaStartFormDialog.vue'
import { useWorktabStore } from '@/store/modules/worktab'
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
defineOptions({ name: 'OaApprovalStart' })
const router = useRouter()
const route = useRoute()
const worktabStore = useWorktabStore()
const loading = ref(false)
const templates = ref<any[]>([])
const groupOptions = ref<Array<{ id: string; groupName: string }>>([])
const searchKeyword = ref('')
const activeGroupId = ref('')
const startDialogRef = ref<InstanceType<typeof OaStartFormDialog> | null>(null)
const historyDialogRef = ref<InstanceType<typeof OaAppHistoryDialog> | null>(null)
const displayGroups = computed(() => {
const kw = (searchKeyword.value || '').trim().toLowerCase()
@@ -168,7 +167,14 @@
}
function openHistory(row: any) {
historyDialogRef.value?.open({ id: row.id, appName: row.appName })
const id = String(row?.id || '').trim()
if (!id) return
router.push({
path: `/system-manage/oa-approval/history/${encodeURIComponent(id)}`,
query: {
appName: String(row?.appName || '').trim()
}
})
}
function onDialogSaved() {
@@ -182,7 +188,16 @@
}
}
function restoreStartTabTitle() {
worktabStore.resetTabTitle('/system-manage/oa-approval/start')
}
onActivated(() => {
restoreStartTabTitle()
})
onMounted(async () => {
restoreStartTabTitle()
loading.value = true
try {
const [tpl, groupsRes] = await Promise.all([fetchOaTemplates(), fetchOaGroupList()])
@@ -2456,16 +2456,98 @@
}
}
function buildBatchStockOutPrefillFromSelection(): {
lines: BatchStockOutLine[]
options: ProductionMaterialBatchOption[]
skippedZero: number
} {
const sel = selectedRows.value || []
const lines: BatchStockOutLine[] = []
const options: ProductionMaterialBatchOption[] = []
const seen = new Set<string>()
let skippedZero = 0
for (const row of sel) {
const rem = Number(row.remainingQty) || 0
if (rem <= 0) {
skippedZero += 1
continue
}
const batchId = row.batchId || row.id
if (!batchId || seen.has(batchId)) continue
seen.add(batchId)
const unit = (row.unit || '').trim()
const mfr = (row.manufacturer || '').trim()
const cat = (row.catalogNo || '').trim()
lines.push({
batchId,
stockInNo: row.stockInNo || '',
materialName: row.materialName || '',
batchNo: row.batchNo || '',
remainingQty: rem,
qty: Math.min(1, rem),
usageUnit: unit,
remark: ''
})
options.push({
id: batchId,
batchId,
stockInNo: row.stockInNo || '',
stockInDate: row.stockInDate,
expireDate: row.expireDate,
productionDate: row.productionDate,
materialId: row.materialId,
materialCode: row.materialCode || '',
materialName: row.materialName || '',
materialType: row.materialType || '',
specModel: row.specModel || '',
unit,
batchNo: row.batchNo || '',
catalogNo: row.catalogNo || '',
manufacturer: row.manufacturer || '',
storageLocation: row.storageLocation,
unitPrice: row.unitPrice,
amount: row.amount,
remainingQty: rem,
label: `${row.stockInNo || ''} / ${row.materialName || ''} / 厂家:${mfr || '—'} / 批号:${
row.batchNo || '—'
} / 货号:${cat || '—'} / 剩:${rem}${unit}`
})
if (unit) ensureUnitOption(unit)
}
return { lines, options, skippedZero }
}
function mergeBatchOptions(extra: ProductionMaterialBatchOption[]) {
if (!extra.length) return
const ids = new Set(batchOptions.value.map((o) => o.id))
const missing = extra.filter((o) => o.id && !ids.has(o.id))
if (missing.length) {
batchOptions.value = [...missing, ...batchOptions.value]
}
}
function handleBatchStockOut() {
batchStockOutLines.value = [newBatchStockOutLine()]
const { lines, options, skippedZero } = buildBatchStockOutPrefillFromSelection()
const selectedCount = (selectedRows.value || []).length
if (selectedCount > 0 && lines.length === 0) {
ElMessage.warning('所选物料剩余数量均为 0,无法出库,请手动选择有库存的批次')
} else if (skippedZero > 0) {
ElMessage.warning(`已跳过 ${skippedZero} 项剩余数量为 0 的物料`)
}
batchStockOutLines.value = lines.length ? lines : [newBatchStockOutLine()]
batchOptions.value = options
batchStockOutForm.stockOutTime = formatNowDatetime()
batchStockOutForm.receiver = ''
batchStockOutForm.purpose = ''
batchStockOutForm.remark = ''
batchStockOutVisible.value = true
void searchReceiverUsers('')
void searchBatchOptions('')
void searchUnitOptions('')
void searchBatchOptions('').then(() => mergeBatchOptions(options))
void searchUnitOptions('').then(() => {
for (const line of batchStockOutLines.value) {
if (line.usageUnit) ensureUnitOption(line.usageUnit)
}
})
batchStockOutFormRef.value?.clearValidate?.()
}
@@ -2434,16 +2434,95 @@
}
}
function buildBatchStockOutPrefillFromSelection(): {
lines: BatchStockOutLine[]
options: QcMaterialBatchOption[]
skippedZero: number
} {
const sel = selectedRows.value || []
const lines: BatchStockOutLine[] = []
const options: QcMaterialBatchOption[] = []
const seen = new Set<string>()
let skippedZero = 0
for (const row of sel) {
const rem = Number(row.remainingQty) || 0
if (rem <= 0) {
skippedZero += 1
continue
}
const batchId = row.batchId || row.id
if (!batchId || seen.has(batchId)) continue
seen.add(batchId)
const unit = (row.unit || '').trim()
const mfr = (row.manufacturer || '').trim()
const cat = (row.catalogNo || '').trim()
lines.push({
batchId,
stockInNo: row.stockInNo || '',
materialName: row.materialName || '',
batchNo: row.batchNo || '',
remainingQty: rem,
qty: Math.min(1, rem),
usageUnit: unit,
remark: ''
})
options.push({
id: batchId,
batchId,
stockInNo: row.stockInNo || '',
stockInDate: row.stockInDate,
expireDate: row.expireDate,
materialId: row.materialId,
materialCode: row.materialCode || '',
materialName: row.materialName || '',
specModel: row.specModel || '',
unit,
batchNo: row.batchNo || '',
catalogNo: row.catalogNo || '',
manufacturer: row.manufacturer || '',
unitPrice: row.unitPrice,
amount: row.amount,
remainingQty: rem,
label: `${row.stockInNo || ''} / ${row.materialName || ''} / 厂家:${mfr || '—'} / 批号:${
row.batchNo || '—'
} / 货号:${cat || '—'} / 剩:${rem}${unit}`
})
if (unit) ensureUnitOption(unit)
}
return { lines, options, skippedZero }
}
function mergeBatchOptions(extra: QcMaterialBatchOption[]) {
if (!extra.length) return
const ids = new Set(batchOptions.value.map((o) => o.id))
const missing = extra.filter((o) => o.id && !ids.has(o.id))
if (missing.length) {
batchOptions.value = [...missing, ...batchOptions.value]
}
}
function handleBatchStockOut() {
batchStockOutLines.value = [newBatchStockOutLine()]
const { lines, options, skippedZero } = buildBatchStockOutPrefillFromSelection()
const selectedCount = (selectedRows.value || []).length
if (selectedCount > 0 && lines.length === 0) {
ElMessage.warning('所选物料剩余数量均为 0,无法出库,请手动选择有库存的批次')
} else if (skippedZero > 0) {
ElMessage.warning(`已跳过 ${skippedZero} 项剩余数量为 0 的物料`)
}
batchStockOutLines.value = lines.length ? lines : [newBatchStockOutLine()]
batchOptions.value = options
batchStockOutForm.stockOutTime = formatNowDatetime()
batchStockOutForm.receiver = ''
batchStockOutForm.purpose = ''
batchStockOutForm.remark = ''
batchStockOutVisible.value = true
void searchReceiverUsers('')
void searchBatchOptions('')
void searchUnitOptions('')
void searchBatchOptions('').then(() => mergeBatchOptions(options))
void searchUnitOptions('').then(() => {
for (const line of batchStockOutLines.value) {
if (line.usageUnit) ensureUnitOption(line.usageUnit)
}
})
batchStockOutFormRef.value?.clearValidate?.()
}