Files
KCGL/inventory-web/src/views/transaction/records.vue
yueli ece5dd8d72 feat(borrow): 前端转交/流转明细 UI 与借用人选人适配
转交 UI(借还记录页)
----
· 操作列新增【转交】,v-permission="'borrow_transfer'" + 按钮 loading 防抖
· 弹窗明示「仅支持整单全部转交」与「转交后须由接收人本人归还」,并在确认框
  复述接收人与转交数量
· 列表是 borrow_no 主子表结构,trans_borrow.id 在**明细行**上,而一张单的明细
  可各有不同持有人(转交是逐条明细进行的),故弹窗先让用户选定明细;单条明细
  时自动选中(实测 54/60 的单号只有 1 条,等于零额外点击)。
  该「单号按钮 → 弹窗选明细」范式沿用本页既有的「申请报废」。

流转明细时间线
----
· 操作列新增【流转记录】,无特殊权限要求(页面本身已由 op_records 把守)
· Drawer + el-timeline 倒序展示 借出 → 转交(可多次) → 归还 → 报废,
  每节点显示时间、动作、当事人(借用人 / 转出→接收 / 实际归还人)与经手库管,
  转交备注一并展示
· 主列表新增「当前持有人」列:转交后可能与「借用人」不是同一人;一张单的明细
  持有人可能不同,去重后逐个展示,已全部回库显示「已回库」

身份锚点适配
----
· 借出页:借用人由「自由输入姓名」改为「用户下拉」,提交 borrower_id
  (姓名无法唯一锚定一个人,重名即责任链断裂)
· 归还页:新增「实际归还人」下拉,默认取首件物品的当前持有人,提交 returner_id;
  主表新增「当前持有人」列提示库管该由谁来还
· 错误提示不再本地重复弹出 —— request 拦截器已按后端 msg 全局 toast,
  再弹一次会双份

验证:vite build 通过;接口契约经后端回归用例校验。
2026-09-17 09:18:06 +08:00

861 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="userStore.hasPermission('op_records')" class="app-container">
<!-- 高级搜索与出库记录 / 报废记录统一版式 -->
<el-form :inline="true" class="filter-form" @submit.prevent>
<el-form-item>
<el-radio-group v-model="status" @change="handleSearchImmediate">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="borrowed">未归还</el-radio-button>
<el-radio-button label="returned">已归还</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="搜索">
<el-input
v-model="keyword"
placeholder="输入关键词"
style="width: 240px"
clearable
@input="debouncedSearch"
@clear="handleClearSearch"
>
<template #prepend>
<el-select v-model="searchType" placeholder="类型" style="width: 110px" @change="handleSearchImmediate">
<el-option label="全部" value="all" />
<el-option label="单号" value="no" />
<el-option label="借用人" value="name" />
<el-option label="SKU" value="sku" />
<el-option label="物料名称" value="material_name" />
<el-option label="规格型号" value="spec_model" />
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="借出日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
<!-- 高级筛选对齐 material/list.vue 既有模式 -->
<el-popover
v-model:visible="advancedFilterVisible"
placement="bottom"
title="高级筛选"
width="600"
trigger="manual"
>
<template #reference>
<el-button plain @click="advancedFilterVisible = !advancedFilterVisible">
高级筛选
<el-badge v-if="appliedConditions.length" :value="appliedConditions.length" style="margin-left:6px" />
</el-button>
</template>
<div class="advanced-filter">
<div
v-for="(condition, index) in advancedConditions"
:key="index"
class="condition-row"
>
<el-select v-model="condition.field" placeholder="字段" style="width:180px" :teleported="false">
<el-option v-for="f in fieldOptions" :key="f.value" :label="f.label" :value="f.value" />
</el-select>
<el-select v-model="condition.operator" placeholder="操作符" style="width:120px; margin-left:8px" :teleported="false">
<el-option v-for="op in operatorOptions" :key="op.value" :label="op.label" :value="op.value" />
</el-select>
<el-input v-model="condition.value" placeholder="值" style="width:180px; margin-left:8px" />
<el-button v-if="advancedConditions.length > 1" type="danger" link @click="removeCondition(index)" style="margin-left:8px">删除</el-button>
</div>
<div style="margin-top:12px">
<el-button type="primary" link @click="addCondition">添加条件</el-button>
<el-button type="primary" @click="applyAdvancedFilter">应用筛选</el-button>
<el-button @click="resetAdvancedFilter">重置</el-button>
</div>
</div>
</el-popover>
</el-form-item>
</el-form>
<el-table
:data="list"
border
stripe
style="margin-top:20px"
v-loading="loading"
type="expand"
>
<el-table-column type="expand">
<template #default="props">
<div style="padding: 10px 40px;">
<h4 style="margin: 0 0 10px; font-size: 14px; color: #606266;">借出明细</h4>
<el-table :data="props.row.children" border size="small">
<el-table-column prop="material_name" label="物料名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="借出数量" width="80" align="center">
<template #default="{row}">
<el-tag type="info">{{ row.quantity }}</el-tag>
</template>
</el-table-column>
<el-table-column label="已还数量" width="80" align="center">
<template #default="{row}">
<el-tag type="success">{{ row.returned_quantity || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="待还数量" width="80" align="center">
<template #default="{row}">
<el-tag v-if="row.pending_quantity > 0" type="warning">{{ row.pending_quantity }}</el-tag>
<el-tag v-else type="success">0</el-tag>
</template>
</el-table-column>
<el-table-column prop="location" label="借出库位" min-width="120">
<template #default="{row}">
<span v-if="row.location">{{ row.location }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column prop="return_location" label="归还库位" min-width="120">
<template #default="{row}">
<span v-if="row.return_location">{{ row.return_location }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('borrow_no')" prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="100" />
<!-- 当前持有人转交后可能与借用人不是同一人
一张单的明细可能持有人各不相同故去重后逐个展示 -->
<el-table-column label="当前持有人" width="120">
<template #default="{row}">
<template v-if="currentHolders(row).length">
<el-tag
v-for="h in currentHolders(row)"
:key="h"
type="warning"
size="small"
style="margin: 1px 2px"
>{{ h }}</el-tag>
</template>
<span v-else-if="row.status === 'returned'" style="color: #67C23A; font-size: 12px">已回库</span>
<span v-else style="color: #c0c4cc"></span>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('borrow_time')" prop="borrow_time" label="借出时间" width="160" sortable />
<el-table-column label="借出物品" width="90" align="center">
<template #default="{row}">
<el-tag type="info">{{ row.children ? row.children.length : 0 }} </el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('return_operator')" prop="return_operator" label="归还人" width="100" />
<el-table-column v-if="hasColumnPermission('expected_return_time') || hasColumnPermission('return_time')" label="归还时间 / 预计" min-width="200">
<template #default="{row}">
<div v-if="row.status === 'returned'">
<el-tag type="success" size="small">实际</el-tag>
{{ row.return_time || '-' }}
</div>
<div v-else-if="!row.expected_return_time">
<el-tag type="primary" size="small">无限期</el-tag>
</div>
<div v-else>
<span>预计 {{ formatExpectedTime(row.expected_return_time).text }} </span>
<span :style="{
color: formatExpectedTime(row.expected_return_time).style.color || 'inherit',
fontWeight: formatExpectedTime(row.expected_return_time).style.fontWeight || 'normal',
marginLeft: '4px'
}">
{{ formatExpectedTime(row.expected_return_time).diffText }}
</span>
</div>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{row}">
<el-tag v-if="row.status === 'scrapped'" type="info">已报废</el-tag>
<el-tag v-else-if="row.status === 'returned'" type="success">已还</el-tag>
<el-tag v-else-if="row.status === 'partial_returned'" type="warning">部分归还</el-tag>
<el-tag v-else type="danger">未还</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('borrow_signature') || hasColumnPermission('return_signature')" label="电子签名" width="140" align="center">
<template #default="{row}">
<div style="display:flex; justify-content: center; gap:10px">
<el-popover trigger="hover" placement="top" v-if="row.borrow_signature && hasColumnPermission('borrow_signature')" width="220">
<template #reference><el-tag size="small"></el-tag></template>
<img :src="row.borrow_signature" style="width:200px; border:1px solid #eee" />
</el-popover>
<el-popover trigger="hover" placement="top" v-if="row.return_signature && hasColumnPermission('return_signature')" width="220">
<template #reference><el-tag type="success" size="small"></el-tag></template>
<img :src="row.return_signature" style="width:200px; border:1px solid #eee" />
</el-popover>
</div>
</template>
</el-table-column>
<!-- 操作列
· 申请报废 / 转交 仅未归还记录且分别受各自权限约束
· 流转记录 无特殊权限要求凡能看到本列表者皆可查看 -->
<el-table-column label="操作" width="210" align="center" fixed="right">
<template #default="{row}">
<el-button
v-if="canScrap && isUnreturned(row)"
type="danger" link size="small"
@click="openScrapDialog(row)"
>申请报废</el-button>
<el-button
v-if="isUnreturned(row)"
v-permission="'borrow_transfer'"
type="primary" link size="small"
@click="openTransferDialog(row)"
>转交</el-button>
<el-button
type="info" link size="small"
@click="openHistoryDrawer(row)"
>流转记录</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
background
layout="prev, pager, next"
:total="total"
@current-change="handlePage"
style="margin-top:10px; text-align:right"
/>
<!-- 借库报废**申请**弹窗需审批不再直报 -->
<el-dialog v-model="scrapDialogVisible" title="申请借库报废(未归还)" width="650px" destroy-on-close>
<el-alert
title="提交后进入审批流程:审批通过并执行后,该笔借出才不再追讨归还,并从总库存中扣除。"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 12px"
/>
<div style="margin-bottom: 12px;">
<div style="font-weight: 600; margin-bottom: 6px;">单号: {{ scrapBorrowNo }}</div>
<div style="font-size: 13px; color: #909399;">请勾选要报废的未归还明细</div>
</div>
<el-table :data="scrapCandidates" border size="small" max-height="300" @selection-change="handleScrapSelection">
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="material_name" label="物料名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="100" show-overflow-tooltip />
<el-table-column label="待还数量" width="80" align="center">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">{{ row.pending_quantity }}</span>
</template>
</el-table-column>
<el-table-column prop="borrow_time" label="借出时间" width="130" />
</el-table>
<el-form label-width="80px" style="margin-top: 14px;">
<el-form-item label="报废原因" required>
<el-input
v-model="scrapReason"
type="textarea"
:rows="2"
placeholder="请填写报废原因,如:物品丢失 / 损坏无法归还"
/>
</el-form-item>
<el-form-item label="审批人" required>
<el-select v-model="scrapApproverId" placeholder="请选择审批人" style="width: 100%" filterable>
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="scrapDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="scrapSubmitting" @click="confirmScrap">提交报废申请</el-button>
</template>
</el-dialog>
<!-- 借库转交弹窗一期仅支持整单全部转交 -->
<el-dialog v-model="transferDialogVisible" title="借库转交" width="640px" destroy-on-close>
<el-alert
title="仅支持整单全部转交"
description="一张借出单同时只能有一个持有人。部分转交会让「当前持有人」无法确定,故一期不支持;如需拆分持有,请先办理部分归还,再对剩余量转交。"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 14px"
/>
<div style="margin-bottom: 10px;">
<div style="font-weight: 600; margin-bottom: 6px;">单号{{ transferBorrowNo }}</div>
<div style="font-size: 13px; color: #909399;">请选择要转交的明细转交后该明细的持有人整体变更为接收人</div>
</div>
<el-table
:data="transferCandidates"
border
size="small"
max-height="260"
highlight-current-row
@row-click="(row: any) => transferTargetId = row.id"
>
<el-table-column width="46" align="center">
<template #default="{row}">
<el-radio v-model="transferTargetId" :value="row.id" :label="row.id">&nbsp;</el-radio>
</template>
</el-table-column>
<el-table-column prop="material_name" label="物料名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="110" show-overflow-tooltip />
<el-table-column label="当前持有人" width="100" align="center">
<template #default="{row}">
<el-tag type="warning" size="small">{{ row.current_holder_name || '—' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="待还数量" width="90" align="center">
<template #default="{row}">
<span style="color:#F56C6C; font-weight:bold">{{ row.pending_quantity }}</span>
</template>
</el-table-column>
</el-table>
<el-form label-width="80px" style="margin-top: 14px;">
<el-form-item label="接收人" required>
<el-select v-model="transferToUserId" placeholder="请选择接收人" style="width: 100%" filterable>
<el-option v-for="u in transferUsers" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="转交备注">
<el-input
v-model="transferRemark"
type="textarea"
:rows="2"
placeholder="项目交接 / 人员离岗可留空"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="transferSubmitting" @click="confirmTransfer">确认转交</el-button>
</template>
</el-dialog>
<!-- ★ 流转明细时间线:借出 → 转交(可多次) → 归还 → 报废,倒序展示 -->
<el-drawer v-model="historyDrawerVisible" title="流转明细" size="580px" destroy-on-close>
<div v-loading="historyLoading">
<div style="font-weight: 600; margin-bottom: 4px;">单号:{{ historyBorrowNo }}</div>
<div style="font-size: 12px; color: #909399; margin-bottom: 16px;">
按时间倒序,最新动作在最上方
</div>
<el-empty v-if="!historyEvents.length" description="暂无流转记录" :image-size="70" />
<el-timeline v-else>
<el-timeline-item
v-for="(e, i) in historyEvents"
:key="i"
:timestamp="e.time"
placement="top"
:type="eventMeta(e.type).type"
:hollow="e.type === 'borrow'"
>
<div style="display:flex; align-items:center; gap:8px; margin-bottom:4px;">
<el-tag :type="eventMeta(e.type).type" size="small">{{ eventMeta(e.type).label }}</el-tag>
<span style="font-size: 13px; color:#606266;">{{ e.material_name || e.sku }}</span>
<span style="font-size: 13px; color:#909399;">× {{ e.quantity }}</span>
</div>
<div style="font-size: 13px; color:#303133;">
<template v-if="e.type === 'borrow'">
借用人:<b>{{ e.actor_name || '—' }}</b>
<span v-if="e.operator_name" style="color:#909399;"> · 发货库管:{{ e.operator_name }}</span>
</template>
<template v-else-if="e.type === 'transfer'">
<b>{{ e.from_name || '—' }}</b> → <b>{{ e.actor_name || '—' }}</b>
<span v-if="e.operator_name" style="color:#909399;"> · 经手库管:{{ e.operator_name }}</span>
</template>
<template v-else-if="e.type === 'return'">
归还人:<b>{{ e.actor_name || '—' }}</b>
<span v-if="e.operator_name" style="color:#909399;"> · 经手库管:{{ e.operator_name }}</span>
</template>
<template v-else>
报废 · 经手库管:<b>{{ e.operator_name || '—' }}</b>
</template>
</div>
<div v-if="e.remark" style="font-size: 12px; color:#909399; margin-top:2px;">
备注:{{ e.remark }}
</div>
</el-timeline-item>
</el-timeline>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
import { useUserStore } from '@/stores/user'
import { getApproversList } from '@/api/auth'
import {
submitBorrowScrapRequest,
transferBorrow,
getBorrowUsers,
getBorrowSlipHistory,
} from '@/api/transaction'
const userStore = useUserStore()
// 防抖定时器
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// 防抖搜索函数
const debouncedSearch = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => {
page.value = 1
fetchData()
}, 500)
}
// 清空搜索时立即触发查询
const handleClearSearch = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
page.value = 1
fetchData()
}
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string | null> = {
// 基础显示列 — 始终可见
borrow_no: null,
borrower_name: null,
sku: null,
borrow_time: null,
return_time: null,
return_operator: null,
status: null,
expected_return_time: null,
return_location: null,
// 签名列 — 始终可见sys_element 补齐后再改为显式权限码)
borrow_signature: null,
return_signature: null,
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
if (!(prop in permissionMap)) return false
const code = permissionMap[prop]
if (code === null || code === undefined) return true
return userStore.hasPermission(code)
}
const list = ref<any[]>([])
const total = ref(0)
// ★ 修改点:默认状态改为 'borrowed' (未归还)
const status = ref('borrowed')
const keyword = ref('')
const searchType = ref('all')
const dateRange = ref<string[]>([])
const page = ref(1)
const loading = ref(false)
// --- ★ 高级筛选 ---
const advancedFilterVisible = ref(false)
const advancedConditions = ref([{ field: '', operator: '', value: '' }])
const appliedConditions = ref<any[]>([])
const advancedFilters = ref<any[]>([])
const fieldOptions = [
{ value: 'no', label: '单号' },
{ value: 'sku', label: 'SKU' },
{ value: 'material_name', label: '物料名称' },
{ value: 'borrower_name', label: '操作人' },
]
const operatorOptions = [
{ value: 'contains', label: '包含' },
{ value: 'eq', label: '等于' },
{ value: 'not_contains', label: '不包含' },
{ value: 'ne', label: '不等于' },
]
const addCondition = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' })
}
const removeCondition = (index: number) => {
advancedConditions.value.splice(index, 1)
}
const applyAdvancedFilter = () => {
const valid = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '')
advancedFilters.value = valid
appliedConditions.value = valid
advancedFilterVisible.value = false
page.value = 1
fetchData()
}
const resetAdvancedFilter = () => {
advancedConditions.value = [{ field: '', operator: '', value: '' }]
advancedFilters.value = []
appliedConditions.value = []
advancedFilterVisible.value = false
page.value = 1
fetchData()
}
// ★ 借库报废**申请**相关(需审批,不再直报)
const canScrap = computed(() =>
userStore.role === 'SUPER_ADMIN' ||
userStore.hasPermission('op_return:operation')
)
const scrapDialogVisible = ref(false)
const scrapSubmitting = ref(false)
const scrapBorrowNo = ref('')
const scrapCandidates = ref<any[]>([])
const scrapSelected = ref<any[]>([])
const scrapReason = ref('')
const scrapApproverId = ref<number | null>(null)
const approvers = ref<any[]>([])
const loadApprovers = async () => {
try {
const res = await getApproversList()
approvers.value = res.data || []
} catch (e) {
console.error('[借库报废申请] 审批人列表加载失败', e)
}
}
// 打开报废申请弹窗:收集该单号下所有未归还明细
const openScrapDialog = (row: any) => {
const unreturned = (row.children || []).filter((c: any) => (c.pending_quantity || 0) > 0)
scrapBorrowNo.value = row.borrow_no
scrapCandidates.value = unreturned
scrapSelected.value = []
scrapReason.value = ''
scrapApproverId.value = null
scrapDialogVisible.value = true
loadApprovers()
}
// 勾选变更
const handleScrapSelection = (rows: any[]) => {
scrapSelected.value = rows
}
// 提交报废申请
const confirmScrap = async () => {
if (scrapSelected.value.length === 0) return ElMessage.warning('请至少选择一条要申请报废的借出记录')
if (!scrapReason.value.trim()) return ElMessage.warning('请填写报废原因')
if (!scrapApproverId.value) return ElMessage.warning('请选择审批人')
try {
await ElMessageBox.confirm(
`确定对 ${scrapSelected.value.length} 条借出记录提交报废申请吗?\n\n`
+ '提交后进入审批流程,审批通过并执行后才会扣减总库存。',
'报废申请确认',
{ confirmButtonText: '提交申请', cancelButtonText: '取消', type: 'warning' }
)
} catch (e) {
return // 用户取消
}
scrapSubmitting.value = true
try {
const recordIds = scrapSelected.value.map((c: any) => c.id)
const res = await submitBorrowScrapRequest({
record_ids: recordIds,
reason: scrapReason.value,
approver_id: scrapApproverId.value,
})
// ★ 提示里点明「尚未扣减」—— 申请不立即改状态,否则用户会以为没生效而重复提交
ElMessage.success(
`${res?.msg || '报废申请已提交'}:审批通过并执行后才会标记为已报废`,
)
scrapDialogVisible.value = false
fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || err?.message || '提交报废申请失败')
} finally {
scrapSubmitting.value = false
}
}
// ==========================================================================
// ★ 借库转交(一期:仅支持整单全部转交)
// ==========================================================================
// 未归还判定:主表状态下 borrowed / partial_returned 都还有实物未回库
const isUnreturned = (row: any) =>
row.status === 'borrowed' || row.status === 'partial_returned'
// 当前持有人:整单下各明细可能持有人不同(转交是逐条明细进行的),去重后展示
const currentHolders = (row: any): string[] => {
const names = (row.children || [])
.map((c: any) => c.current_holder_name)
.filter((n: any) => !!n)
return Array.from(new Set(names)) as string[]
}
const transferDialogVisible = ref(false)
const transferSubmitting = ref(false)
const transferBorrowNo = ref('')
const transferCandidates = ref<any[]>([])
const transferTargetId = ref<number | null>(null)
const transferToUserId = ref<number | null>(null)
const transferRemark = ref('')
const transferUsers = ref<Array<{ id: number; name: string }>>([])
const loadTransferUsers = async () => {
if (transferUsers.value.length) return // 名单会话内复用,避免每次开弹窗都请求
try {
const res: any = await getBorrowUsers()
transferUsers.value = res?.data || []
} catch {
transferUsers.value = []
}
}
const openTransferDialog = (row: any) => {
// 仅未还清的明细可转交(与后端的状态准入一致)
const candidates = (row.children || []).filter((c: any) => (c.pending_quantity || 0) > 0)
if (!candidates.length) {
ElMessage.warning('该单号下没有未归还的明细,无法转交')
return
}
transferBorrowNo.value = row.borrow_no
transferCandidates.value = candidates
// 实测 54/60 的单号只有 1 条明细 —— 单条时直接选中,省掉一次点击
transferTargetId.value = candidates.length === 1 ? candidates[0].id : null
transferToUserId.value = null
transferRemark.value = ''
transferDialogVisible.value = true
loadTransferUsers()
}
const confirmTransfer = async () => {
if (!transferTargetId.value) return ElMessage.warning('请选择要转交的明细')
if (!transferToUserId.value) return ElMessage.warning('请选择接收人')
const target = transferCandidates.value.find((c: any) => c.id === transferTargetId.value)
const receiver = transferUsers.value.find(u => u.id === transferToUserId.value)
// 提前拦截「转给自己」,省一次无效往返(后端也会拒)
if (target && receiver && target.current_holder_id === receiver.id) {
return ElMessage.warning(`接收人与当前持有人同为【${receiver.name}】,无需转交`)
}
const qty = target?.pending_quantity ?? 0
try {
await ElMessageBox.confirm(
`将【${target?.material_name || target?.sku || ''}】的持有权整单转交给【${receiver?.name || ''}】?\n\n`
+ `转交数量:${qty}(整单全量,不支持部分转交)\n`
+ `转交后,该物品须由接收人本人办理归还。`,
'转交确认',
{ confirmButtonText: '确认转交', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return // 用户取消
}
transferSubmitting.value = true // ★ 按钮 loading 防抖,避免重复提交
try {
const res: any = await transferBorrow(transferTargetId.value, {
transfer_qty: qty, // 一期必须等于待还量,后端会二次校验
to_user_id: transferToUserId.value,
to_user_name: receiver?.name,
remark: transferRemark.value,
})
ElMessage.success(res?.msg || '转交成功')
transferDialogVisible.value = false
fetchData() // 刷新列表:持有人/操作列状态已变化
} catch {
// ★ 不重复弹错request 拦截器已按后端 msg 全局 toast此处再弹会双份
} finally {
transferSubmitting.value = false
}
}
// ==========================================================================
// ★ 流转明细时间线(借出 → 转交(可多次) → 归还 → 报废)
// ==========================================================================
const historyDrawerVisible = ref(false)
const historyLoading = ref(false)
const historyBorrowNo = ref('')
const historyEvents = ref<any[]>([])
const EVENT_META: Record<string, { label: string; type: any }> = {
borrow: { label: '借出', type: 'primary' },
transfer: { label: '转交', type: 'warning' },
return: { label: '归还', type: 'success' },
scrap: { label: '报废', type: 'danger' },
}
const eventMeta = (t: string) => EVENT_META[t] || { label: t, type: 'info' }
const openHistoryDrawer = async (row: any) => {
historyBorrowNo.value = row.borrow_no
historyEvents.value = []
historyDrawerVisible.value = true
historyLoading.value = true
try {
const res: any = await getBorrowSlipHistory(row.borrow_no)
historyEvents.value = res?.data?.events || []
} catch {
// 同上:错误提示由 request 拦截器统一处理
} finally {
historyLoading.value = false
}
}
const fetchData = async () => {
loading.value = true
try {
const params: any = {
page: page.value,
status: status.value,
keyword: keyword.value,
search_type: searchType.value,
// ★ 高级筛选:后端约定参数名为 advancedFilters值为 JSON 字符串
advancedFilters: JSON.stringify(advancedFilters.value || []),
}
if (dateRange.value && dateRange.value.length === 2) {
params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1]
}
const res = await request({
url: '/v1/transactions/records',
method: 'get',
params
})
// ★ 按 borrow_no 分组聚合为主子表结构
const groupMap = new Map()
;(res.data.items || []).forEach(item => {
if (!groupMap.has(item.borrow_no)) {
groupMap.set(item.borrow_no, {
...item,
children: []
})
}
groupMap.get(item.borrow_no).children.push(item)
})
// ★ 整单状态重算:所有明细都已报废 → 整单标记 scrapped
list.value = Array.from(groupMap.values()).map(group => {
const allScrapped = group.children.length > 0 &&
group.children.every((c: any) => c.status === 'scrapped')
if (allScrapped) {
group.status = 'scrapped'
}
return group
})
total.value = res.data.total
} finally { loading.value = false }
}
const handlePage = (val: number) => {
page.value = val
fetchData()
}
// 立即查询(状态/类型切换用,取消防抖)
const handleSearchImmediate = () => {
if (debounceTimer) clearTimeout(debounceTimer)
page.value = 1
fetchData()
}
const resetFilter = () => {
if (debounceTimer) clearTimeout(debounceTimer)
status.value = 'borrowed'
keyword.value = ''
searchType.value = 'all'
dateRange.value = []
advancedFilters.value = []
advancedConditions.value = [{ field: '', operator: '', value: '' }]
appliedConditions.value = []
page.value = 1
fetchData()
}
// ★ 新增:格式化预计归还时间及倒计时逻辑(交通灯预警系统)
const formatExpectedTime = (timeStr: string) => {
if (!timeStr) return { text: '无限期', diffText: '', style: {} }
// 后端返回的可能是 YYYY-MM-DD HH:mm:ss我们只取日期部分比较
const expected = dayjs(timeStr).startOf('day')
const today = dayjs().startOf('day')
const diffDays = expected.diff(today, 'day')
let diffText = ''
let style: Record<string, string> = {}
// 这里的 timeStr 只展示前10位 (日期),或者展示完整
const displayTime = timeStr.substring(0, 10)
if (diffDays < 0) {
// 已逾期 - 红色加粗
diffText = ` (已逾期 ${Math.abs(diffDays)} 天)`
style = { color: '#F56C6C', fontWeight: 'bold' }
} else if (diffDays <= 1) {
// 极其紧急(剩 0-1 天)- 警告黄加粗
diffText = diffDays === 0 ? ` (今天到期)` : ` (剩 ${diffDays} 天)`
style = { color: '#E6A23C', fontWeight: 'bold' }
} else if (diffDays <= 7) {
// 即将到期(剩 2-7 天)- 安全绿
diffText = ` (剩 ${diffDays} 天)`
style = { color: '#67C23A' }
} else {
// 安全/无期限 - 默认颜色
diffText = ` (剩 ${diffDays} 天)`
style = {}
}
return { text: displayTime, diffText, style }
}
onMounted(fetchData)
// 组件销毁前清理定时器,防止内存泄漏
onBeforeUnmount(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
})
</script>
<style scoped>
/* ★ 统一高级搜索版式(与出库记录 / 报废记录一致) */
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
/* 高级筛选条件行 */
.condition-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
</style>