feat:库存调拨模块

master
黄伟杰 3 days ago
parent 723367cf43
commit 0b996ac758

@ -0,0 +1,49 @@
import request from '@/utils/request'
export function createMaterialMove(data) {
return request({
url: '/admin-api/erp/stock-move/create',
method: 'post',
data
})
}
export function getMaterialMovePage(params = {}) {
return request({
url: '/admin-api/erp/stock-move/page',
method: 'get',
params
})
}
export function submitMaterialMove(data) {
return request({
url: '/admin-api/erp/stock-move/submit',
method: 'put',
data
})
}
export function auditMaterialMove(data) {
return request({
url: '/admin-api/erp/stock-move/audit',
method: 'put',
data
})
}
export function getMoveProductPage(params = {}) {
return request({
url: '/admin-api/erp/product/page',
method: 'get',
params
})
}
export function getProductStockList(productId) {
return request({
url: '/admin-api/erp/stock-move/product-stock-list',
method: 'get',
params: { productId }
})
}

@ -703,6 +703,34 @@
"navigationStyle": "custom"
}
},
{
"path": "materialMove/index",
"style": {
"navigationBarTitleText": "\u5e93\u5b58\u8c03\u62e8",
"navigationStyle": "custom"
}
},
{
"path": "materialMove/create",
"style": {
"navigationBarTitleText": "\u65b0\u589e\u5e93\u5b58\u8c03\u62e8",
"navigationStyle": "custom"
}
},
{
"path": "materialMove/productConfirm",
"style": {
"navigationBarTitleText": "\u786e\u8ba4\u8c03\u62e8\u7269\u6599",
"navigationStyle": "custom"
}
},
{
"path": "materialMove/productSelect",
"style": {
"navigationBarTitleText": "\u9009\u62e9\u7269\u6599",
"navigationStyle": "custom"
}
},
{
"path": "sparepartCheck/index",
"style": {

@ -0,0 +1,287 @@
<template>
<view class="page-container">
<NavBar :title="pageTitle" />
<scroll-view scroll-y class="detail-scroll">
<view class="content-section">
<view class="section-card">
<view class="section-header">
<view class="section-icon">
<uni-icons type="compose" size="24" color="#1f7cff"></uni-icons>
</view>
<text class="section-title">调拨信息</text>
</view>
<view class="form-field">
<text class="form-label">调拨时间<text class="required-star">*</text></text>
<picker mode="date" :value="moveDate" @change="handleDateChange">
<view class="select-field">
<text :class="['select-text', moveDate ? '' : 'placeholder']">{{ moveDate || '请选择调拨时间' }}</text>
<uni-icons type="calendar" size="18" color="#9ca3af"></uni-icons>
</view>
</picker>
</view>
<view class="form-field">
<text class="form-label">备注</text>
<textarea v-model="remark" class="form-textarea" placeholder="请输入备注信息" placeholder-class="placeholder-text" maxlength="500" />
</view>
</view>
<view class="section-card">
<view class="section-header list-header">
<view class="section-title-wrap">
<view class="section-icon">
<uni-icons type="list" size="24" color="#1f7cff"></uni-icons>
</view>
<text class="section-title">调拨清单{{ itemList.length }}</text>
</view>
<view class="add-product-btn" @click="handleAddItem">
<uni-icons type="plusempty" size="16" color="#1f7cff"></uni-icons>
<text>添加{{ itemLabel }}</text>
</view>
</view>
<view v-if="itemList.length" class="summary-strip">
<view class="summary-item">
<text class="summary-value">{{ itemList.length }}</text>
<text class="summary-label">{{ itemLabel }}</text>
</view>
<view class="summary-item">
<text class="summary-value">{{ totalCount }}</text>
<text class="summary-label">总数量</text>
</view>
</view>
<view v-if="itemList.length" class="item-list">
<view v-for="(item, index) in itemList" :key="item._key || index" class="item-card" @click="editItem(index)">
<view class="item-info">
<view class="item-header">
<view class="item-title-wrap">
<text class="item-name">{{ textValue(item.productName) }}</text>
<text class="item-code">{{ textValue(item.productBarCode) }}</text>
</view>
<view class="delete-btn" @click.stop="removeItem(index)">
<uni-icons type="trash" size="18" color="#ef4444"></uni-icons>
</view>
</view>
<view class="info-grid">
<view class="info-cell info-cell-wide">
<text class="info-label">调出</text>
<text class="info-value">{{ textValue(item.fromWarehouseName) }} / {{ textValue(item.fromAreaName) }}</text>
</view>
<view class="info-cell info-cell-wide">
<text class="info-label">调入</text>
<text class="info-value">{{ textValue(item.toWarehouseName) }} / {{ textValue(item.toAreaName) }}</text>
</view>
<view class="info-cell">
<text class="info-label">数量</text>
<text class="info-value highlight">{{ textValue(item.count) }}{{ textUnit(item.productUnitName) }}</text>
</view>
<view class="info-cell">
<text class="info-label">库存</text>
<text class="info-value">{{ textValue(item.stockCount) }}{{ textUnit(item.productUnitName) }}</text>
</view>
</view>
</view>
</view>
</view>
<view v-else class="empty-card" @click="handleAddItem">
<uni-icons type="plusempty" size="30" color="#94a3b8"></uni-icons>
<text>请添加调拨{{ itemLabel }}</text>
</view>
</view>
</view>
</scroll-view>
<view class="action-bar">
<view class="action-btn back-btn" @click="handleCancel"></view>
<view class="action-btn submit-btn" @click="handleSubmit"></view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import NavBar from '@/components/common/NavBar.vue'
import { createMaterialMove } from '@/api/mes/materialMove'
const itemList = ref([])
const moveDate = ref(formatDate(new Date()))
const categoryType = ref(2)
const remark = ref('')
const categoryNameMap = {
1: '产品',
2: '物料',
3: '备件'
}
const itemLabel = computed(() => categoryNameMap[Number(categoryType.value)] || '物料')
const pageTitle = computed(() => '新增' + itemLabel.value + '库存调拨')
const totalCount = computed(() => itemList.value.reduce((sum, item) => sum + (Number(item.count) || 0), 0))
function formatDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function textValue(value) {
if (value === 0) return '0'
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function textUnit(value) {
if (value === null || value === undefined) return ''
return String(value).trim()
}
function handleDateChange(event) {
moveDate.value = event.detail.value
}
function handleAddItem() {
const gd = getApp().globalData
gd._materialMoveItems = [...itemList.value]
gd._materialMoveEditIndex = null
gd._materialMoveEditItem = null
gd._materialMoveCategoryType = categoryType.value
uni.navigateTo({ url: `/pages_function/pages/materialMove/productConfirm?categoryType=${categoryType.value}` })
}
function editItem(index) {
const item = itemList.value[index]
if (!item) return
const gd = getApp().globalData
gd._materialMoveItems = [...itemList.value]
gd._materialMoveEditIndex = index
gd._materialMoveEditItem = JSON.parse(JSON.stringify(item))
gd._materialMoveCategoryType = categoryType.value
uni.navigateTo({ url: `/pages_function/pages/materialMove/productConfirm?mode=edit&categoryType=${categoryType.value}` })
}
function removeItem(index) {
itemList.value.splice(index, 1)
getApp().globalData._materialMoveItems = [...itemList.value]
}
function handleCancel() {
const gd = getApp().globalData
gd._materialMoveItems = []
gd._materialMoveEditIndex = null
gd._materialMoveEditItem = null
uni.navigateBack()
}
function validateForm() {
if (!moveDate.value) return '请选择调拨时间'
if (!itemList.value.length) return '请先添加' + itemLabel.value
const invalid = itemList.value.find((item) => !item.productId || !item.fromWarehouseId || !item.fromAreaId || !item.toWarehouseId || !item.toAreaId || !Number(item.count))
if (invalid) return '请完善调拨明细'
const sameArea = itemList.value.find((item) => String(item.fromAreaId) === String(item.toAreaId))
if (sameArea) return '调出库区和调入库区不能相同'
const overStock = itemList.value.find((item) => Number(item.count) > Number(item.stockCount))
if (overStock) return '调拨数量不能大于库存'
return ''
}
function buildMoveTime() {
const now = new Date()
const [y, m, d] = moveDate.value.split('-').map(Number)
return new Date(y, m - 1, d, now.getHours(), now.getMinutes(), now.getSeconds()).getTime()
}
async function handleSubmit() {
const error = validateForm()
if (error) {
uni.showToast({ title: error, icon: 'none' })
return
}
const data = {
moveTime: buildMoveTime(),
categoryType: categoryType.value,
status: 0,
totalCount: totalCount.value,
totalPrice: 0,
remark: remark.value || '',
items: itemList.value.map((item) => ({
productId: item.productId,
productName: item.productName,
productBarCode: item.productBarCode,
productUnitName: item.productUnitName,
fromWarehouseId: item.fromWarehouseId,
fromAreaId: item.fromAreaId,
toWarehouseId: item.toWarehouseId,
toAreaId: item.toAreaId,
stockCount: item.stockCount,
count: Number(item.count),
remark: item.remark || ''
}))
}
uni.showLoading({ title: '提交中...', mask: true })
try {
await createMaterialMove(data)
uni.hideLoading()
getApp().globalData._materialMoveItems = []
uni.showToast({ title: '调拨单已创建', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1000)
} catch (e) {
uni.hideLoading()
const msg = e?.message || e?.data?.msg || '保存失败'
uni.showToast({ title: String(msg).slice(0, 50), icon: 'none' })
}
}
onLoad((options) => {
const gd = getApp().globalData || {}
categoryType.value = Number(options?.categoryType || gd._materialMoveCategoryType || 2)
gd._materialMoveCategoryType = categoryType.value
})
onShow(() => {
const gd = getApp().globalData || {}
const items = gd._materialMoveItems
if (Array.isArray(items)) itemList.value = [...items]
if (gd._materialMoveCategoryType) categoryType.value = Number(gd._materialMoveCategoryType)
})
</script>
<style lang="scss" scoped>
.page-container { min-height: 100vh; background: #f5f7fb; }
.detail-scroll { height: calc(100vh - 116rpx); }
.content-section { padding: 20rpx 24rpx 28rpx; }
.section-card { background: #ffffff; border-radius: 20rpx; padding: 24rpx; margin-bottom: 20rpx; border: 1rpx solid #eef2f7; box-shadow: 0 6rpx 18rpx rgba(15, 23, 42, 0.04); }
.section-header { display: flex; align-items: center; gap: 12rpx; margin-bottom: 22rpx; padding-bottom: 18rpx; border-bottom: 1rpx solid #f1f5f9; }
.section-icon { width: 40rpx; height: 40rpx; border-radius: 10rpx; background: #eff6ff; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.section-title { font-size: 32rpx; font-weight: 600; color: #1f2937; }
.section-title-wrap { display: flex; align-items: center; gap: 12rpx; min-width: 0; }
.list-header { justify-content: space-between; gap: 16rpx; }
.form-field { display: flex; flex-direction: column; gap: 12rpx; }
.form-field + .form-field { margin-top: 24rpx; }
.form-label { font-size: 26rpx; color: #4b5563; font-weight: 500; }
.required-star { color: #ef4444; font-size: 28rpx; margin-left: 4rpx; }
.select-field { display: flex; align-items: center; justify-content: space-between; padding: 0 24rpx; height: 76rpx; background: #f8fafc; border: 1rpx solid #e5e7eb; border-radius: 14rpx; box-sizing: border-box; }
.select-text { flex: 1; min-width: 0; font-size: 28rpx; color: #374151; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.placeholder, .placeholder-text { color: #9ca3af; }
.form-textarea { width: 100%; min-height: 120rpx; background: #f8fafc; border-radius: 14rpx; padding: 18rpx 24rpx; font-size: 28rpx; color: #374151; box-sizing: border-box; }
.add-product-btn { height: 60rpx; padding: 0 18rpx; border-radius: 999rpx; border: 1rpx solid #bfdbfe; background: #eff6ff; color: #1f7cff; font-size: 24rpx; font-weight: 600; display: flex; align-items: center; gap: 8rpx; flex-shrink: 0; }
.summary-strip { display: grid; grid-template-columns: 1fr 1fr; background: #f8fafc; border: 1rpx solid #e8eef6; border-radius: 16rpx; overflow: hidden; margin-bottom: 18rpx; }
.summary-item { min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 6rpx; padding: 16rpx 8rpx; border-right: 1rpx solid #eef2f7; }
.summary-item:last-child { border-right: 0; }
.summary-value { max-width: 100%; font-size: 30rpx; font-weight: 700; color: #1f4b79; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.summary-label { font-size: 22rpx; color: #8a94a6; }
.item-list { display: flex; flex-direction: column; gap: 18rpx; }
.item-card { padding: 20rpx; background: #ffffff; border: 1rpx solid #eef2f7; border-radius: 18rpx; box-shadow: 0 6rpx 18rpx rgba(15, 23, 42, 0.04); }
.item-info { min-width: 0; }
.item-header { display: flex; align-items: flex-start; gap: 12rpx; margin-bottom: 14rpx; }
.item-title-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6rpx; }
.item-name { flex: 1; min-width: 0; font-size: 30rpx; font-weight: 600; color: #1f2937; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-code { font-size: 24rpx; color: #94a3b8; }
.delete-btn { width: 48rpx; height: 48rpx; border-radius: 24rpx; background: #fef2f2; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12rpx; }
.info-cell { min-width: 0; display: flex; flex-direction: column; gap: 4rpx; }
.info-cell-wide { grid-column: 1 / -1; }
.info-label { font-size: 22rpx; color: #9ca3af; }
.info-value { font-size: 26rpx; color: #374151; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.info-value.highlight { color: #1f4b79; font-weight: 700; }
.empty-card { min-height: 220rpx; border: 2rpx dashed #d7dde8; border-radius: 18rpx; background: #f8fafc; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14rpx; color: #94a3b8; font-size: 27rpx; }
.action-bar { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 18rpx; padding: 18rpx 24rpx calc(18rpx + env(safe-area-inset-bottom)); background: #ffffff; box-shadow: 0 -8rpx 24rpx rgba(15, 23, 42, 0.06); z-index: 99; }
.action-btn { flex: 1; height: 84rpx; border-radius: 16rpx; display: flex; align-items: center; justify-content: center; font-size: 30rpx; font-weight: 600; }
.back-btn { background: #eef2f7; color: #475569; }
.submit-btn { background: #1f4b79; color: #ffffff; }
</style>

@ -0,0 +1,495 @@
<template>
<view class="page-container">
<NavBar :title="pageTitle" />
<view class="filter-bar">
<view class="filter-row quick-row">
<picker :range="statusPickerLabels" :value="statusPickerIndex" @change="onStatusFilterChange">
<view class="status-filter">
<text :class="['status-filter-text', selectedStatus === '' ? 'placeholder' : '']">{{ currentStatusLabel }}</text>
<uni-icons type="bottom" size="14" color="#a8adb7"></uni-icons>
</view>
</picker>
</view>
<view class="filter-row search-row">
<view class="keyword-wrap">
<input
v-model="searchKeyword"
class="keyword-input"
type="text"
placeholder="搜索调拨单号"
confirm-type="search"
@input="handleKeywordInput"
@confirm="handleSearch"
/>
</view>
<view class="icon-filter-btn" @click="resetFilters">
<uni-icons type="refresh" size="24" color="#7b8491"></uni-icons>
</view>
<view class="icon-filter-btn" @click="openFilterDrawer">
<uni-icons type="settings" size="24" color="#7b8491"></uni-icons>
</view>
</view>
</view>
<uni-popup ref="filterPopupRef" class="move-filter-popup" type="right" background-color="transparent" :animation="false">
<view class="filter-drawer">
<view class="drawer-header">
<text class="drawer-title">更多筛选</text>
</view>
<scroll-view scroll-y class="drawer-body">
<view class="drawer-section">
<view class="drawer-section-head">
<text class="drawer-section-title">调拨信息</text>
</view>
<view class="drawer-grid">
<view class="drawer-field drawer-field-wide">
<text class="drawer-label">创建人</text>
<view class="drawer-picker" @click="toggleCreatorPanel">
<text :class="['drawer-picker-text', !selectedCreator ? 'placeholder' : '']">{{ selectedCreatorLabel }}</text>
<uni-icons type="bottom" size="14" color="#9ca3af"></uni-icons>
</view>
<scroll-view v-if="creatorPanelOpen" scroll-y class="drawer-option-panel">
<view
v-for="item in creatorOptions"
:key="item.value"
:class="['drawer-option-item', selectedCreator === item.value ? 'active' : '']"
@click="selectCreator(item)"
>
<text class="drawer-option-text">{{ item.label }}</text>
<text v-if="selectedCreator === item.value" class="drawer-option-check"></text>
</view>
</scroll-view>
</view>
<view class="drawer-field drawer-field-wide">
<text class="drawer-label">调拨时间</text>
<view class="drawer-date">
<uni-datetime-picker
v-model="moveTimeFilter"
type="daterange"
:clear-icon="true"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="drawer-actions">
<view class="drawer-action reset" @click="resetFilters"></view>
<view class="drawer-action confirm" @click="confirmFilterDrawer"></view>
</view>
</view>
</uni-popup>
<scroll-view
scroll-y
class="list-scroll"
:scroll-top="scrollTop"
@scroll="onScroll"
@scrolltolower="loadMore"
:lower-threshold="80"
>
<view class="list-wrap">
<view v-for="item in list" :key="item.id" class="task-card">
<view class="card-header">
<view class="header-main">
<view class="header-left">
<text class="task-no">{{ textValue(item.no) }}</text>
</view>
<view class="header-tags">
<text :class="['record-tag', statusClass(item.status)]">{{ statusText(item.status) }}</text>
</view>
</view>
</view>
<view class="card-body">
<view class="row">
<text class="label">{{ itemLabel }}信息</text>
<text class="value">{{ textValue(item.productNames) }}</text>
</view>
<view class="row">
<text class="label">调拨时间</text>
<text class="value">{{ formatDateTime(item.moveTime || item.createTime) }}</text>
</view>
<view class="row">
<text class="label">创建人</text>
<text class="value">{{ textValue(item.creatorName || item.creator) }}</text>
</view>
<view class="row">
<text class="label">数量</text>
<text class="value highlight">{{ textValue(item.totalCount) }}</text>
</view>
<view class="row">
<text class="label">审核人</text>
<text class="value">{{ textValue(item.auditUserName) }}</text>
</view>
</view>
<view v-if="[0, 1].includes(Number(item.status))" class="card-actions">
<view class="action-btn submit-btn" @click.stop="handleSubmitAudit(item)">提交审核</view>
</view>
<view v-if="Number(item.status) === 10" class="card-actions">
<view class="action-btn approve-btn" @click.stop="handleApprove(item)">审核通过</view>
<view class="action-btn reject-btn" @click.stop="handleReject(item)">审核驳回</view>
</view>
</view>
<view v-if="loading && pageNo === 1" class="hint">...</view>
<view v-else-if="!list.length" class="hint">暂无调拨单</view>
<view v-else-if="loadingMore" class="hint">加载更多...</view>
<view v-else-if="finished" class="hint">没有更多了</view>
</view>
</scroll-view>
<view v-if="showGoTop" class="go-top-btn" @click="goTop">
<uni-icons type="arrow-up" size="20" color="#1f4b79"></uni-icons>
</view>
<view class="add-btn" @click="goAdd">
<text class="add-icon">+</text>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import NavBar from '@/components/common/NavBar.vue'
import { auditMaterialMove, getMaterialMovePage, submitMaterialMove } from '@/api/mes/materialMove'
import { getSimpleUserList } from '@/api/mes/moldget'
const selectedStatus = ref('')
const searchKeyword = ref('')
const filterPopupRef = ref(null)
const selectedCreator = ref(null)
const moveTimeFilter = ref([])
const creatorOptions = ref([])
const creatorPanelOpen = ref(false)
const categoryType = ref(2)
const categoryNameMap = {
1: '产品',
2: '物料',
3: '备件'
}
const itemLabel = computed(() => categoryNameMap[Number(categoryType.value)] || '物料')
const pageTitle = computed(() => itemLabel.value + '库存调拨')
const statusOptions = computed(() => [
{ label: '待调拨', value: '0' },
{ label: '待审核', value: '10' },
{ label: '已调拨', value: '20' },
{ label: '已驳回', value: '1' }
])
const statusPickerLabels = computed(() => ['全部状态', ...statusOptions.value.map((item) => item.label)])
const statusPickerIndex = computed(() => {
if (selectedStatus.value === '') return 0
const idx = statusOptions.value.findIndex((item) => item.value === selectedStatus.value)
return idx >= 0 ? idx + 1 : 0
})
const currentStatusLabel = computed(() => {
if (selectedStatus.value === '') return '全部状态'
const current = statusOptions.value.find((item) => item.value === selectedStatus.value)
return current ? current.label : '全部状态'
})
const selectedCreatorLabel = computed(() => {
if (!selectedCreator.value) return '创建人'
const found = creatorOptions.value.find((item) => item.value === selectedCreator.value)
return found ? found.label : '创建人'
})
const list = ref([])
const loading = ref(false)
const loadingMore = ref(false)
const finished = ref(false)
const pageNo = ref(1)
const pageSize = ref(10)
const scrollTop = ref(0)
const showGoTop = ref(false)
let searchTimer = null
function textValue(value) {
if (value === 0) return '0'
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function formatDateTime(value) {
if (!value) return '-'
if (Array.isArray(value) && value.length >= 3) {
const [year, month, day] = value
const pad = (n) => String(n).padStart(2, '0')
return `${year}-${pad(month)}-${pad(day)}`
}
const numeric = Number(value)
const date = Number.isFinite(numeric) ? new Date(String(value).length === 10 ? numeric * 1000 : numeric) : new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
const STATUS_MAP = {
0: '待调拨',
10: '待审核',
20: '已调拨',
1: '已驳回'
}
function statusText(status) {
const num = Number(status)
return STATUS_MAP[num] || textValue(status)
}
function statusClass(status) {
const num = Number(status)
if (num === 0) return 'text-primary'
if (num === 10) return 'text-warning'
if (num === 20) return 'text-success'
if (num === 1) return 'text-danger'
return ''
}
function normalizePageData(res) {
const root = res && res.data !== undefined ? res.data : res
const pageResult = root?.pageResult || root?.data?.pageResult || root?.data || root || {}
const candidateList = pageResult?.list || pageResult?.rows || pageResult?.records || root?.list || []
const candidateTotal = pageResult?.total ?? root?.total ?? candidateList.length
return {
list: Array.isArray(candidateList) ? candidateList : [],
total: Number(candidateTotal || 0)
}
}
async function fetchList(reset) {
if (reset) {
pageNo.value = 1
finished.value = false
}
if (pageNo.value === 1) {
loading.value = true
} else {
loadingMore.value = true
}
try {
const moveTimeRange = Array.isArray(moveTimeFilter.value) ? moveTimeFilter.value : []
const params = {
pageNo: pageNo.value,
pageSize: pageSize.value,
no: searchKeyword.value.trim() || undefined,
categoryType: categoryType.value,
statusList: selectedStatus.value !== '' ? [Number(selectedStatus.value)] : undefined,
creator: selectedCreator.value || undefined,
moveTime: moveTimeRange.length === 2 ? [moveTimeRange[0] + ' 00:00:00', moveTimeRange[1] + ' 23:59:59'] : undefined
}
const res = await getMaterialMovePage(params)
const page = normalizePageData(res)
list.value = reset ? page.list : [...list.value, ...page.list]
finished.value = list.value.length >= page.total || page.list.length < pageSize.value
} catch (e) {
if (!reset) pageNo.value = Math.max(1, pageNo.value - 1)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
loadingMore.value = false
}
}
function onStatusFilterChange(event) {
const index = Number(event?.detail?.value || 0)
selectedStatus.value = index === 0 ? '' : (statusOptions.value[index - 1]?.value || '')
fetchList(true)
}
function handleSearch() {
clearSearchTimer()
uni.hideKeyboard()
fetchList(true)
}
function handleKeywordInput() {
clearSearchTimer()
searchTimer = setTimeout(() => fetchList(true), 300)
}
function openFilterDrawer() {
loadCreatorOptions()
filterPopupRef.value?.open()
}
function confirmFilterDrawer() {
filterPopupRef.value?.close()
fetchList(true)
}
function toggleCreatorPanel() {
creatorPanelOpen.value = !creatorPanelOpen.value
}
function selectCreator(item) {
selectedCreator.value = selectedCreator.value === item.value ? null : item.value
creatorPanelOpen.value = false
}
async function loadCreatorOptions() {
if (creatorOptions.value.length) return
try {
const res = await getSimpleUserList()
const data = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : [])
creatorOptions.value = data.map((user) => ({
value: user.id || user.userId,
label: user.nickname || user.userName || user.name || String(user.id || '')
}))
} catch (e) {}
}
function resetFilters() {
clearSearchTimer()
searchKeyword.value = ''
selectedStatus.value = ''
selectedCreator.value = null
moveTimeFilter.value = []
fetchList(true)
}
async function loadMore() {
if (loading.value || loadingMore.value || finished.value) return
pageNo.value += 1
await fetchList(false)
}
function handleSubmitAudit(item) {
if (!item?.id) return
uni.showModal({
title: '确认',
content: '确认提交审核?',
confirmColor: '#1f4b79',
success: async (res) => {
if (!res.confirm) return
try {
await submitMaterialMove({ id: item.id })
uni.showToast({ title: '提交成功', icon: 'success' })
fetchList(true)
} catch (e) {
uni.showToast({ title: '提交失败', icon: 'none' })
}
}
})
}
function handleApprove(item) {
auditMove(item, 20, '确认审核通过?', '审核通过')
}
function handleReject(item) {
auditMove(item, 1, '确认审核驳回?', '已驳回')
}
function auditMove(item, status, content, successText) {
if (!item?.id) return
uni.showModal({
title: '确认',
content,
confirmColor: status === 20 ? '#16a34a' : '#dc2626',
success: async (res) => {
if (!res.confirm) return
try {
await auditMaterialMove({ id: item.id, status })
uni.showToast({ title: successText, icon: 'success' })
fetchList(true)
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
})
}
function onScroll(event) {
showGoTop.value = (event?.detail?.scrollTop || 0) > 600
}
function goTop() {
scrollTop.value = 0
}
function goAdd() {
uni.navigateTo({ url: `/pages_function/pages/materialMove/create?categoryType=${categoryType.value}` })
}
function clearSearchTimer() {
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
}
onLoad((options) => {
const gd = getApp().globalData || {}
categoryType.value = Number(options?.categoryType || gd._materialMoveCategoryType || 2)
gd._materialMoveCategoryType = categoryType.value
})
onShow(() => {
fetchList(true)
})
onUnload(() => {
clearSearchTimer()
})
</script>
<style lang="scss" scoped>
.page-container { min-height: 100vh; background: #f4f5f7; }
.filter-bar { padding: 18rpx 14rpx 20rpx; background: #f3f4f6; }
.filter-row { display: flex; align-items: center; gap: 18rpx; }
.search-row { margin-top: 18rpx; }
.quick-row > picker { min-width: 0; flex: 1; }
.keyword-wrap, .status-filter, .icon-filter-btn { height: 66rpx; border: 1rpx solid #d9dde5; background: #ffffff; box-sizing: border-box; }
.keyword-wrap { min-width: 0; flex: 1; display: flex; align-items: center; }
.keyword-input { width: 100%; height: 64rpx; padding: 0 20rpx; font-size: 26rpx; color: #374151; }
.status-filter { min-width: 0; flex: 1; display: flex; align-items: center; justify-content: space-between; padding: 0 18rpx 0 26rpx; }
.status-filter-text { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 26rpx; color: #374151; }
.status-filter-text.placeholder { color: #a8adb7; }
.icon-filter-btn { width: 66rpx; flex: 0 0 66rpx; display: flex; align-items: center; justify-content: center; border-color: transparent; background: transparent; }
::deep(.move-filter-popup.right .uni-popup__content-transition) { transform: none !important; }
.filter-drawer { width: 630rpx; height: calc(100vh - var(--status-bar-height)); margin-top: var(--status-bar-height); background: #f5f5f7; display: flex; flex-direction: column; overflow: hidden; border-radius: 28rpx 0 0 28rpx; }
.drawer-header { height: 104rpx; padding: 18rpx 34rpx 0; background: #ffffff; display: flex; align-items: center; box-sizing: border-box; }
.drawer-title { color: #1f2937; font-size: 34rpx; line-height: 1.3; font-weight: 700; }
.drawer-body { flex: 1; min-height: 0; padding-bottom: 40rpx; box-sizing: border-box; }
.drawer-actions { height: 126rpx; padding: 18rpx 28rpx 24rpx; box-sizing: border-box; display: flex; align-items: center; background: #ffffff; box-shadow: 0 -8rpx 24rpx rgba(17, 24, 39, 0.06); }
.drawer-action { flex: 1; height: 72rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; font-weight: 600; border: 2rpx solid #174b78; box-sizing: border-box; }
.drawer-action.reset { border-radius: 12rpx 0 0 12rpx; background: #ffffff; color: #174b78; }
.drawer-action.confirm { border-radius: 0 12rpx 12rpx 0; background: #174b78; color: #ffffff; }
.drawer-section { margin-bottom: 18rpx; padding: 28rpx 28rpx 30rpx; border-radius: 24rpx; background: #ffffff; box-sizing: border-box; }
.drawer-section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
.drawer-section-title { font-size: 32rpx; line-height: 1.3; color: #1f2937; font-weight: 700; }
.drawer-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22rpx 20rpx; }
.drawer-field { min-width: 0; }
.drawer-field-wide { grid-column: 1 / -1; }
.drawer-label { display: block; margin-bottom: 12rpx; font-size: 24rpx; line-height: 1.3; color: #4b5563; font-weight: 500; }
.drawer-picker { width: 100%; min-height: 74rpx; border-radius: 8rpx; background: #f7f8fb; box-sizing: border-box; display: flex; align-items: center; justify-content: center; padding: 0 18rpx; gap: 8rpx; }
.drawer-picker-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 26rpx; color: #111827; text-align: center; }
.drawer-picker-text.placeholder { color: #9ca3af; }
.drawer-option-panel { max-height: 360rpx; margin-top: 12rpx; border-radius: 12rpx; background: #f7f8fb; overflow: hidden; }
.drawer-option-item { min-height: 72rpx; padding: 0 24rpx; display: flex; align-items: center; justify-content: space-between; border-bottom: 1rpx solid #eceff3; box-sizing: border-box; }
.drawer-option-item:last-child { border-bottom: 0; }
.drawer-option-item.active { background: rgba(23, 75, 120, 0.1); }
.drawer-option-item.active .drawer-option-text { color: #174b78; font-weight: 600; }
.drawer-option-text { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 26rpx; color: #1f2937; }
.drawer-option-check { flex-shrink: 0; font-size: 28rpx; color: #174b78; margin-left: 16rpx; }
.drawer-date { width: 100%; min-height: 74rpx; border-radius: 8rpx; background: #f7f8fb; box-sizing: border-box; display: flex; align-items: center; padding: 0 12rpx; }
.drawer-date :deep(.uni-date), .drawer-date :deep(.uni-date-editor), .drawer-date :deep(.uni-date-editor--x), .drawer-date :deep(.uni-date-x) { width: 100%; }
.drawer-date :deep(.uni-date-editor--x), .drawer-date :deep(.uni-date-x) { border: 0; padding: 0; background: transparent; }
.list-scroll { height: calc(100vh - 194rpx); }
.list-wrap { padding: 0 24rpx 160rpx; }
.task-card { position: relative; margin-top: 20rpx; padding: 28rpx; background: #fff; border-radius: 22rpx; box-shadow: 0 8rpx 28rpx rgba(15, 23, 42, 0.06); }
.card-header { margin-bottom: 18rpx; }
.header-main { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.header-left { min-width: 0; flex: 1; }
.task-no { font-size: 32rpx; font-weight: 700; color: #0f172a; }
.header-tags { display: flex; align-items: center; gap: 12rpx; flex-shrink: 0; }
.record-tag { padding: 8rpx 18rpx; border-radius: 999rpx; font-size: 22rpx; line-height: 1; background: #e2e8f0; color: #64748b; }
.card-body .row { display: flex; justify-content: space-between; align-items: flex-start; gap: 20rpx; margin-top: 12rpx; }
.card-body .row:first-child { margin-top: 0; }
.label { width: 140rpx; font-size: 25rpx; color: #94a3b8; flex-shrink: 0; }
.value { flex: 1; text-align: right; font-size: 27rpx; color: #334155; line-height: 1.5; }
.value.highlight { color: #1f4b79; font-weight: 600; }
.card-actions { display: flex; gap: 16rpx; margin-top: 20rpx; padding-top: 20rpx; border-top: 1rpx solid #f0f0f0; }
.action-btn { flex: 1; height: 64rpx; line-height: 64rpx; text-align: center; border-radius: 10rpx; font-size: 26rpx; font-weight: 500; }
.action-btn.approve-btn { background: #dcfce7; color: #16a34a; }
.action-btn.reject-btn { background: #fee2e2; color: #dc2626; }
.action-btn.submit-btn { background: #dbeafe; color: #1d4ed8; }
.text-success { color: #16a34a; }
.text-danger { color: #dc2626; }
.text-warning { color: #d97706; }
.text-primary { color: #2563eb; }
.record-tag.text-success { color: #15803d; background: #dcfce7; }
.record-tag.text-danger { color: #dc2626; background: #fee2e2; }
.record-tag.text-warning { color: #d97706; background: #fef3c7; }
.record-tag.text-primary { color: #1d4ed8; background: #dbeafe; }
.hint { padding: 36rpx 0; text-align: center; color: #94a3b8; font-size: 26rpx; }
.go-top-btn { position: fixed; right: 28rpx; bottom: calc(160rpx + env(safe-area-inset-bottom)); width: 92rpx; height: 92rpx; border-radius: 46rpx; background: rgba(255, 255, 255, 0.96); box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.12); display: flex; align-items: center; justify-content: center; }
.add-btn { position: fixed; right: 28rpx; bottom: calc(56rpx + env(safe-area-inset-bottom)); width: 92rpx; height: 92rpx; border-radius: 46rpx; background: #1f4b79; box-shadow: 0 14rpx 30rpx rgba(24, 63, 108, 0.24); display: flex; align-items: center; justify-content: center; }
.add-icon { color: #ffffff; font-size: 64rpx; line-height: 1; margin-top: -4rpx; }
</style>

@ -0,0 +1,586 @@
<template>
<view class="page-container">
<NavBar :title="pageTitle" />
<scroll-view scroll-y class="detail-scroll">
<view class="content-section">
<view class="section-card">
<view class="section-header">
<view class="section-icon">
<uni-icons type="paperplane" size="24" color="#1f7cff"></uni-icons>
</view>
<text class="section-title">{{ itemLabel }}信息</text>
</view>
<view class="form-field">
<text class="form-label">{{ itemLabel }}<text class="required-star">*</text></text>
<view class="product-search-row">
<input
id="material-move-product-scan-input"
v-model="productScanInput"
class="scan-input"
type="text"
:placeholder="'扫码或输入' + itemLabel + '码'"
confirm-type="done"
@confirm="onProductScanConfirm"
/>
<view class="select-btn" @click="goSelectProduct">
<text class="select-btn-text">选择{{ itemLabel }}</text>
</view>
</view>
</view>
<view v-if="productId" class="info-panel">
<view class="info-grid">
<view class="info-item info-item-wide">
<text class="info-label">{{ itemLabel }}名称</text>
<text class="info-value">{{ textValue(productName) }}</text>
</view>
<view class="info-item">
<text class="info-label">编码</text>
<text class="info-value">{{ textValue(productBarCode) }}</text>
</view>
<view class="info-item">
<text class="info-label">单位</text>
<text class="info-value">{{ textValue(productUnitName) }}</text>
</view>
</view>
</view>
</view>
<view class="section-card">
<view class="section-header">
<view class="section-icon">
<uni-icons type="map" size="24" color="#1f7cff"></uni-icons>
</view>
<text class="section-title">调出位置</text>
</view>
<view class="form-field">
<text class="form-label">调出仓库<text class="required-star">*</text></text>
<picker mode="selector" :range="fromWarehouseOptions" range-key="label" :value="getWarehouseIndex(fromWarehouseId, fromWarehouseOptions)" :disabled="!productId" @change="onFromWarehouseChange">
<view :class="['select-field', fromWarehouseId ? 'selected' : '']">
<text :class="fromWarehouseId ? 'select-value' : 'select-placeholder'">{{ fromWarehouseName || '请先选择调出仓库' }}</text>
<uni-icons type="bottom" size="18" color="#9ca3af"></uni-icons>
</view>
</picker>
</view>
<view class="form-field">
<text class="form-label">调出库区<text class="required-star">*</text></text>
<view class="product-search-row">
<input
v-model="fromAreaScanInput"
class="scan-input"
type="text"
placeholder="扫码或输入库区码"
confirm-type="done"
@confirm="onAreaScanConfirm('from')"
/>
<picker mode="selector" :range="fromAreaOptions" range-key="label" :value="getAreaIndex(fromAreaId, fromAreaOptions)" :disabled="!fromWarehouseId" @change="onFromAreaChange">
<view :class="['area-select-btn', fromWarehouseId ? '' : 'disabled']">选择</view>
</picker>
</view>
<view v-if="fromAreaId" class="selected-tip">
<text>{{ fromAreaName }}</text>
<text class="stock-text">库存 {{ stockCount }}{{ productUnitName }}</text>
</view>
</view>
</view>
<view class="section-card">
<view class="section-header">
<view class="section-icon">
<uni-icons type="location" size="24" color="#1f7cff"></uni-icons>
</view>
<text class="section-title">调入位置</text>
</view>
<view class="form-field">
<text class="form-label">调入仓库<text class="required-star">*</text></text>
<picker mode="selector" :range="warehouseOptions" range-key="label" :value="getWarehouseIndex(toWarehouseId, warehouseOptions)" @change="onToWarehouseChange">
<view :class="['select-field', toWarehouseId ? 'selected' : '']">
<text :class="toWarehouseId ? 'select-value' : 'select-placeholder'">{{ toWarehouseName || '请选择调入仓库' }}</text>
<uni-icons type="bottom" size="18" color="#9ca3af"></uni-icons>
</view>
</picker>
</view>
<view class="form-field">
<text class="form-label">调入库区<text class="required-star">*</text></text>
<view class="product-search-row">
<input
v-model="toAreaScanInput"
class="scan-input"
type="text"
placeholder="扫码或输入库区码"
confirm-type="done"
@confirm="onAreaScanConfirm('to')"
/>
<picker mode="selector" :range="toAreaOptions" range-key="label" :value="getAreaIndex(toAreaId, toAreaOptions)" :disabled="!toWarehouseId" @change="onToAreaChange">
<view :class="['area-select-btn', toWarehouseId ? '' : 'disabled']">选择</view>
</picker>
</view>
<view v-if="toAreaId" class="selected-tip">
<text>{{ toAreaName }}</text>
</view>
</view>
</view>
<view class="section-card">
<view class="section-header">
<view class="section-icon">
<uni-icons type="list" size="24" color="#1f7cff"></uni-icons>
</view>
<text class="section-title">调拨数量</text>
</view>
<view class="form-field">
<text class="form-label">数量<text class="required-star">*</text></text>
<view class="quantity-row">
<input v-model="count" class="quantity-input" type="digit" placeholder="请输入" confirm-type="done" />
<text class="quantity-unit">{{ productUnitName }}</text>
</view>
</view>
<view v-if="countExceeded" class="warning-box"></view>
<view v-if="sameArea" class="warning-box"></view>
</view>
</view>
</scroll-view>
<view class="action-bar">
<view class="action-btn back-btn" @click="handleCancel"></view>
<view class="action-btn submit-btn" @click="handleConfirm"></view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import NavBar from '@/components/common/NavBar.vue'
import { getMoveProductPage, getProductStockList } from '@/api/mes/materialMove'
import { getWarehouseAreaSimpleList, getWarehouseSimpleList } from '@/api/mes/moldget'
const categoryType = ref(2)
const editingIndex = ref(null)
const product = ref({})
const productScanInput = ref('')
const warehouseOptions = ref([])
const warehouseAreaMap = ref({})
const productStockList = ref([])
const fromAreaScanInput = ref('')
const toAreaScanInput = ref('')
const fromWarehouseId = ref(null)
const fromWarehouseName = ref('')
const fromAreaId = ref(null)
const fromAreaName = ref('')
const toWarehouseId = ref(null)
const toWarehouseName = ref('')
const toAreaId = ref(null)
const toAreaName = ref('')
const stockCount = ref(0)
const count = ref('')
const categoryNameMap = {
1: '产品',
2: '物料',
3: '备件'
}
const itemLabel = computed(() => categoryNameMap[Number(categoryType.value)] || '物料')
const pageTitle = computed(() => '确认调拨' + itemLabel.value)
const productId = computed(() => product.value?.id || null)
const productName = computed(() => product.value?.name || '')
const productBarCode = computed(() => product.value?.barCode || product.value?.code || '')
const productUnitName = computed(() => product.value?.unitName || product.value?.minStockUnitName || '')
const fromWarehouseOptions = computed(() => {
const map = new Map()
productStockList.value.forEach((item) => {
if (!item.warehouseId || map.has(String(item.warehouseId))) return
map.set(String(item.warehouseId), { value: item.warehouseId, label: item.warehouseName || getWarehouseName(item.warehouseId) })
})
return Array.from(map.values())
})
const fromAreaOptions = computed(() => productStockList.value
.filter((item) => String(item.warehouseId) === String(fromWarehouseId.value))
.map((item) => ({ ...item, value: item.areaId, label: getAreaLabel(item), stockCount: Number(item.stockCount || 0) })))
const toAreaOptions = computed(() => getAreaOptions(toWarehouseId.value))
const sameArea = computed(() => fromAreaId.value && toAreaId.value && String(fromAreaId.value) === String(toAreaId.value))
const countExceeded = computed(() => Number(count.value) > Number(stockCount.value))
onLoad(async (options) => {
const gd = getApp().globalData || {}
categoryType.value = Number(options?.categoryType || gd._materialMoveCategoryType || 2)
gd._materialMoveCategoryType = categoryType.value
const index = gd._materialMoveEditIndex
const item = gd._materialMoveEditItem
if (item && index !== null && index !== undefined) {
editingIndex.value = Number(index)
hydrateEditItem(item)
gd._materialMoveEditIndex = null
gd._materialMoveEditItem = null
}
await loadWarehouses()
if (productId.value) await loadProductStocks()
})
onShow(async () => {
const gd = getApp().globalData || {}
const selected = gd._materialMoveProductSelectResult
if (selected) {
setProduct(selected)
gd._materialMoveProductSelectResult = null
await loadProductStocks()
}
await loadWarehouses()
})
function hydrateEditItem(item) {
product.value = {
id: item.productId,
name: item.productName,
barCode: item.productBarCode,
unitName: item.productUnitName
}
fromWarehouseId.value = item.fromWarehouseId || null
fromWarehouseName.value = item.fromWarehouseName || ''
fromAreaId.value = item.fromAreaId || null
fromAreaName.value = item.fromAreaName || ''
toWarehouseId.value = item.toWarehouseId || null
toWarehouseName.value = item.toWarehouseName || ''
toAreaId.value = item.toAreaId || null
toAreaName.value = item.toAreaName || ''
stockCount.value = Number(item.stockCount || 0)
count.value = item.count || ''
}
function textValue(value) {
if (value === 0) return '0'
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function normalizePageList(res) {
const root = res && res.data !== undefined ? res.data : res
const pageResult = root?.pageResult || root?.data?.pageResult || root?.data || root || {}
return Array.isArray(root) ? root : (pageResult?.list || pageResult?.rows || pageResult?.records || root?.list || [])
}
function normalizeScanValue(value) {
return String(value || '').trim().toLowerCase()
}
function getProductScanKeywords(value) {
const rawText = String(value || '').trim()
const raw = normalizeScanValue(rawText)
if (!raw) return []
const candidates = [raw, raw.replace(/^(productmaterial|product|material|spare)[-_:]/i, '')]
try {
const parsed = JSON.parse(rawText)
candidates.push(parsed?.id, parsed?.productId, parsed?.code, parsed?.productCode, parsed?.barCode)
} catch (e) {}
return [...new Set(candidates.map(normalizeScanValue).filter(Boolean))]
}
function getAreaScanId(value) {
const text = String(value || '').trim()
if (!text) return ''
const match = text.match(/WAREHOUSE_AREA[-_:](\d+)/i)
if (match) return match[1]
const tail = text.match(/(\d+)$/)
return tail ? tail[1] : text
}
function setProduct(item) {
product.value = { ...item }
productScanInput.value = ''
resetLocation()
}
function resetLocation() {
productStockList.value = []
fromWarehouseId.value = null
fromWarehouseName.value = ''
fromAreaId.value = null
fromAreaName.value = ''
toWarehouseId.value = null
toWarehouseName.value = ''
toAreaId.value = null
toAreaName.value = ''
stockCount.value = 0
count.value = ''
fromAreaScanInput.value = ''
toAreaScanInput.value = ''
}
async function findProductByScanCode(value) {
const keywords = getProductScanKeywords(value)
if (!keywords.length) return null
for (let pageNo = 1; pageNo <= 5; pageNo += 1) {
const res = await getMoveProductPage({ pageNo, pageSize: 100, categoryType: categoryType.value })
const list = normalizePageList(res)
const matched = list.find((item) => {
const values = [item?.id, item?.barCode, item?.code, item?.name].map(normalizeScanValue).filter(Boolean)
return keywords.some((keyword) => values.includes(keyword))
})
if (matched) return matched
if (list.length < 100) break
}
return null
}
async function onProductScanConfirm() {
const keywords = getProductScanKeywords(productScanInput.value)
if (!keywords.length) return
try {
uni.showLoading({ title: '查询中...', mask: true })
const matched = await findProductByScanCode(productScanInput.value)
uni.hideLoading()
if (!matched?.id) {
uni.showToast({ title: '未找到' + itemLabel.value, icon: 'none' })
return
}
setProduct(matched)
await loadProductStocks()
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '查询失败', icon: 'none' })
}
}
function goSelectProduct() {
const suffix = productId.value ? `?selectedId=${productId.value}&categoryType=${categoryType.value}` : `?categoryType=${categoryType.value}`
uni.navigateTo({ url: `/pages_function/pages/materialMove/productSelect${suffix}` })
}
async function loadWarehouses() {
if (warehouseOptions.value.length) return
try {
const res = await getWarehouseSimpleList({ categoryType: categoryType.value })
const data = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : [])
warehouseOptions.value = data.map((item) => ({ value: item.id, label: item.name || String(item.id || '') }))
} catch (e) {}
}
async function loadAreasForWarehouse(warehouseId) {
if (!warehouseId || warehouseAreaMap.value[String(warehouseId)]) return
try {
const res = await getWarehouseAreaSimpleList(warehouseId)
const data = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : [])
warehouseAreaMap.value = {
...warehouseAreaMap.value,
[String(warehouseId)]: data.map((area) => ({
value: area.id,
areaId: area.id,
label: getAreaLabel(area),
areaName: area.name || area.areaName || String(area.id || ''),
areaCode: area.areaCode || area.code,
warehouseId,
warehouseName: getWarehouseName(warehouseId)
}))
}
} catch (e) {
warehouseAreaMap.value = { ...warehouseAreaMap.value, [String(warehouseId)]: [] }
}
}
async function loadProductStocks() {
if (!productId.value) return
try {
const res = await getProductStockList(productId.value)
const root = res && res.data !== undefined ? res.data : res
const list = Array.isArray(root) ? root : (root?.list || root?.rows || [])
const normalized = (Array.isArray(list) ? list : []).map((item) => ({
...item,
warehouseId: item.warehouseId ?? item.fromWarehouseId,
warehouseName: item.warehouseName || item.fromWarehouseName || getWarehouseName(item.warehouseId ?? item.fromWarehouseId),
areaId: item.areaId ?? item.fromAreaId,
areaName: item.areaName || item.fromAreaName || '',
areaCode: item.areaCode || item.code,
stockCount: Number(item.stockCount ?? item.count ?? 0)
}))
const warehouseIds = Array.from(new Set(normalized.map((item) => item.warehouseId).filter(Boolean)))
await Promise.all(warehouseIds.map((id) => loadAreasForWarehouse(id)))
productStockList.value = normalized.map((item) => enrichStockArea(item))
if (fromWarehouseId.value) {
const currentStock = productStockList.value.find((item) => String(item.areaId) === String(fromAreaId.value))
if (currentStock) stockCount.value = Number(currentStock.stockCount || 0)
}
} catch (e) {
productStockList.value = []
}
}
function enrichStockArea(stock) {
const areas = getAreaOptions(stock.warehouseId)
const area = areas.find((item) => String(item.areaId) === String(stock.areaId))
return {
...stock,
areaName: stock.areaName || area?.areaName || String(stock.areaId || ''),
areaCode: stock.areaCode || area?.areaCode,
label: getAreaLabel({ ...stock, areaName: stock.areaName || area?.areaName, areaCode: stock.areaCode || area?.areaCode })
}
}
function getWarehouseName(id) {
return warehouseOptions.value.find((item) => String(item.value) === String(id))?.label || ''
}
function getAreaOptions(warehouseId) {
return warehouseAreaMap.value[String(warehouseId)] || []
}
function getAreaLabel(item) {
const code = item.areaCode || item.code
const name = item.areaName || item.name || item.fromAreaName || ''
return code ? `${code} - ${name}` : name || String(item.id || item.areaId || '')
}
function getWarehouseIndex(id, options) {
const index = options.findIndex((item) => String(item.value) === String(id))
return index >= 0 ? index : 0
}
function getAreaIndex(id, options) {
const index = options.findIndex((item) => String(item.areaId || item.value) === String(id))
return index >= 0 ? index : 0
}
async function onFromWarehouseChange(event) {
const option = fromWarehouseOptions.value[Number(event.detail.value)]
if (!option) return
fromWarehouseId.value = option.value
fromWarehouseName.value = option.label
fromAreaId.value = null
fromAreaName.value = ''
stockCount.value = 0
await loadAreasForWarehouse(option.value)
}
function onFromAreaChange(event) {
const option = fromAreaOptions.value[Number(event.detail.value)]
if (!option) return
fromAreaId.value = option.areaId || option.value
fromAreaName.value = option.areaName || option.label
stockCount.value = Number(option.stockCount || 0)
}
async function onToWarehouseChange(event) {
const option = warehouseOptions.value[Number(event.detail.value)]
if (!option) return
toWarehouseId.value = option.value
toWarehouseName.value = option.label
toAreaId.value = null
toAreaName.value = ''
await loadAreasForWarehouse(option.value)
}
function onToAreaChange(event) {
const option = toAreaOptions.value[Number(event.detail.value)]
if (!option) return
toAreaId.value = option.areaId || option.value
toAreaName.value = option.areaName || option.label
}
async function onAreaScanConfirm(type) {
const raw = type === 'from' ? fromAreaScanInput.value : toAreaScanInput.value
const areaId = getAreaScanId(raw)
if (!areaId) return
if (type === 'from') {
if (!fromWarehouseId.value) {
uni.showToast({ title: '请先选择调出仓库', icon: 'none' })
return
}
const area = fromAreaOptions.value.find((item) => String(item.areaId || item.value) === String(areaId))
if (!area) {
uni.showToast({ title: '当前调出仓库未找到该库区库存', icon: 'none' })
return
}
fromAreaId.value = area.areaId || area.value
fromAreaName.value = area.areaName || area.label
stockCount.value = Number(area.stockCount || 0)
fromAreaScanInput.value = ''
return
}
if (!toWarehouseId.value) {
uni.showToast({ title: '请先选择调入仓库', icon: 'none' })
return
}
await loadAreasForWarehouse(toWarehouseId.value)
const area = toAreaOptions.value.find((item) => String(item.areaId || item.value) === String(areaId))
if (!area) {
uni.showToast({ title: '当前调入仓库未找到该库区', icon: 'none' })
return
}
toAreaId.value = area.areaId || area.value
toAreaName.value = area.areaName || area.label
toAreaScanInput.value = ''
}
function validateForm() {
if (!productId.value) return '请选择' + itemLabel.value
if (!fromWarehouseId.value) return '请选择调出仓库'
if (!fromAreaId.value) return '请选择调出库区'
if (!toWarehouseId.value) return '请选择调入仓库'
if (!toAreaId.value) return '请选择调入库区'
if (!Number(count.value)) return '请输入调拨数量'
if (sameArea.value) return '调出库区和调入库区不能相同'
if (countExceeded.value) return '调拨数量不能大于库存'
return ''
}
function handleCancel() {
uni.navigateBack()
}
function handleConfirm() {
const error = validateForm()
if (error) {
uni.showToast({ title: error, icon: 'none' })
return
}
const item = {
_key: Date.now() + '-' + Math.random(),
productId: productId.value,
productName: productName.value,
productBarCode: productBarCode.value,
productUnitName: productUnitName.value,
fromWarehouseId: fromWarehouseId.value,
fromWarehouseName: fromWarehouseName.value,
fromAreaId: fromAreaId.value,
fromAreaName: fromAreaName.value,
toWarehouseId: toWarehouseId.value,
toWarehouseName: toWarehouseName.value,
toAreaId: toAreaId.value,
toAreaName: toAreaName.value,
stockCount: Number(stockCount.value || 0),
count: Number(count.value || 0)
}
const gd = getApp().globalData
if (!gd._materialMoveItems) gd._materialMoveItems = []
const index = Number(editingIndex.value)
if (Number.isInteger(index) && index >= 0 && index < gd._materialMoveItems.length) {
gd._materialMoveItems.splice(index, 1, item)
} else {
gd._materialMoveItems.push(item)
}
gd._materialMoveEditIndex = null
gd._materialMoveEditItem = null
uni.showToast({ title: '已添加', icon: 'success', duration: 900 })
setTimeout(() => uni.navigateBack(), 600)
}
</script>
<style lang="scss" scoped>
.page-container { min-height: 100vh; background: #f5f7fb; }
.detail-scroll { height: calc(100vh - 116rpx); }
.content-section { padding: 20rpx 24rpx calc(160rpx + env(safe-area-inset-bottom)); }
.section-card { background: #ffffff; border-radius: 20rpx; padding: 24rpx; margin-bottom: 20rpx; border: 1rpx solid #eef2f7; box-shadow: 0 6rpx 18rpx rgba(15, 23, 42, 0.04); }
.section-header { display: flex; align-items: center; gap: 12rpx; margin-bottom: 22rpx; padding-bottom: 18rpx; border-bottom: 1rpx solid #f1f5f9; }
.section-icon { width: 40rpx; height: 40rpx; border-radius: 10rpx; background: #eff6ff; display: flex; align-items: center; justify-content: center; }
.section-title { font-size: 32rpx; font-weight: 600; color: #1f2937; }
.form-field { display: flex; flex-direction: column; gap: 12rpx; }
.form-field + .form-field { margin-top: 24rpx; }
.form-label { font-size: 26rpx; color: #4b5563; font-weight: 500; }
.required-star { color: #ef4444; font-size: 28rpx; margin-left: 4rpx; }
.product-search-row { display: flex; align-items: center; gap: 16rpx; }
.scan-input { flex: 1; min-width: 0; height: 70rpx; padding: 0 22rpx; background: #f8fafc; border: 1rpx solid #e5e7eb; border-radius: 14rpx; box-sizing: border-box; font-size: 28rpx; color: #1f2937; }
.select-btn, .area-select-btn { flex-shrink: 0; width: 160rpx; min-height: 70rpx; padding: 0 18rpx; background: #1f4b79; border-radius: 14rpx; box-sizing: border-box; display: flex; align-items: center; justify-content: center; color: #ffffff; font-size: 26rpx; font-weight: 600; }
.area-select-btn.disabled { background: #94a3b8; }
.select-btn-text { color: #ffffff; font-size: 26rpx; font-weight: 600; }
.select-field { min-height: 70rpx; padding: 16rpx 22rpx; background: #f8fafc; border: 1rpx solid #e5e7eb; border-radius: 14rpx; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: 12rpx; }
.select-field.selected { background: #f9fbff; border-color: #bfdbfe; box-shadow: 0 4rpx 12rpx rgba(31, 124, 255, 0.08); }
.select-value { flex: 1; min-width: 0; font-size: 28rpx; font-weight: 600; color: #1f2937; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.select-placeholder { flex: 1; font-size: 28rpx; color: #9ca3af; }
.info-panel { margin-top: 22rpx; padding: 20rpx; background: #f8fafc; border: 1rpx solid #e8eef6; border-radius: 16rpx; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0; background: #ffffff; border: 1rpx solid #eef2f7; border-radius: 14rpx; overflow: hidden; }
.info-item { min-width: 0; display: flex; flex-direction: column; gap: 8rpx; padding: 18rpx 20rpx; border-right: 1rpx solid #f1f5f9; border-bottom: 1rpx solid #f1f5f9; }
.info-item:nth-child(2n) { border-right: 0; }
.info-item-wide { grid-column: 1 / -1; }
.info-label { font-size: 23rpx; color: #8a94a6; }
.info-value { font-size: 27rpx; color: #334155; line-height: 1.35; word-break: break-all; }
.selected-tip { margin-top: 10rpx; padding: 14rpx 18rpx; border-radius: 12rpx; background: #f8fafc; color: #334155; font-size: 24rpx; display: flex; justify-content: space-between; gap: 14rpx; }
.stock-text { color: #1f4b79; font-weight: 700; white-space: nowrap; }
.quantity-row { display: flex; align-items: center; gap: 14rpx; }
.quantity-input { flex: 1; height: 76rpx; padding: 0 22rpx; background: #f8fafc; border-radius: 14rpx; border: 1rpx solid #e5e7eb; box-sizing: border-box; font-size: 28rpx; color: #1f2937; }
.quantity-unit { min-width: 70rpx; height: 76rpx; line-height: 76rpx; color: #64748b; font-size: 26rpx; }
.warning-box { margin-top: 14rpx; padding: 14rpx 18rpx; border-radius: 12rpx; background: #fef2f2; color: #b91c1c; font-size: 24rpx; }
.action-bar { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 18rpx; padding: 18rpx 24rpx calc(18rpx + env(safe-area-inset-bottom)); background: #ffffff; box-shadow: 0 -8rpx 24rpx rgba(15, 23, 42, 0.06); z-index: 99; }
.action-btn { flex: 1; height: 84rpx; border-radius: 16rpx; display: flex; align-items: center; justify-content: center; font-size: 30rpx; font-weight: 600; }
.back-btn { background: #eef2f7; color: #475569; }
.submit-btn { background: #1f4b79; color: #ffffff; }
</style>

@ -0,0 +1,177 @@
<template>
<view class="page-container">
<NavBar :title="pageTitle" />
<view class="search-bar">
<view class="search-input-wrap">
<uni-icons type="search" size="18" color="#9ca3af"></uni-icons>
<input
v-model="searchText"
class="search-input"
:placeholder="'搜索' + itemLabel + '名称/编码/规格'"
placeholder-class="search-placeholder"
confirm-type="search"
/>
<view v-if="searchText" class="search-clear" @click="clearSearch">
<uni-icons type="closeempty" size="18" color="#9ca3af"></uni-icons>
</view>
</view>
</view>
<scroll-view v-if="filteredList.length" scroll-y class="product-list">
<view
v-for="item in filteredList"
:key="item.id"
:class="['product-card', isSelected(item) ? 'active' : '']"
@click="selectedId = item.id"
>
<view class="product-header">
<view class="product-title-wrap">
<view class="product-name-row">
<text class="product-name">{{ textValue(item.name) }}</text>
<text v-if="getCategoryName(item)" class="category-tag">{{ getCategoryName(item) }}</text>
</view>
<text class="product-code">{{ textValue(item.barCode || item.code) }}</text>
</view>
<view class="check-badge">
<uni-icons v-if="isSelected(item)" type="checkmarkempty" size="16" color="#ffffff"></uni-icons>
</view>
</view>
<view class="info-grid">
<view class="info-cell">
<text class="info-label">规格</text>
<text class="info-value">{{ textValue(item.standard || item.deviceSpec) }}</text>
</view>
<view class="info-cell">
<text class="info-label">单位</text>
<text class="info-value">{{ textValue(item.unitName || item.minStockUnitName) }}</text>
</view>
</view>
</view>
</scroll-view>
<view v-else class="empty-wrap">
<uni-icons type="info" size="30" color="#cbd5e1"></uni-icons>
<text>{{ loading ? '加载中...' : '暂无' + itemLabel }}</text>
</view>
<view class="action-bar">
<view :class="['action-btn', selectedId ? '' : 'action-btn-disabled']" @click="handleConfirm"></view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import NavBar from '@/components/common/NavBar.vue'
import { getMoveProductPage } from '@/api/mes/materialMove'
const productList = ref([])
const selectedId = ref(null)
const searchText = ref('')
const loading = ref(false)
const categoryType = ref(2)
const categoryNameMap = {
1: '产品',
2: '物料',
3: '备件'
}
const itemLabel = computed(() => categoryNameMap[Number(categoryType.value)] || '物料')
const pageTitle = computed(() => '选择' + itemLabel.value)
onLoad((options) => {
selectedId.value = options?.selectedId ? String(options.selectedId) : null
categoryType.value = Number(options?.categoryType || getApp().globalData?._materialMoveCategoryType || 2)
})
const filteredList = computed(() => {
const keyword = searchText.value.trim().toLowerCase()
if (!keyword) return productList.value
return productList.value.filter((item) =>
String(item.name || '').toLowerCase().includes(keyword) ||
String(item.barCode || item.code || '').toLowerCase().includes(keyword) ||
String(item.standard || item.deviceSpec || '').toLowerCase().includes(keyword) ||
String(getCategoryName(item)).toLowerCase().includes(keyword)
)
})
function textValue(value) {
if (value === 0) return '0'
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function isSelected(item) {
return String(selectedId.value) === String(item.id)
}
function getCategoryName(item) {
return item?.subCategoryName || item?.categoryName || item?.category?.name || ''
}
function clearSearch() {
searchText.value = ''
}
function normalizePageList(res) {
const root = res && res.data !== undefined ? res.data : res
const pageResult = root?.pageResult || root?.data?.pageResult || root?.data || root || {}
return Array.isArray(root) ? root : (pageResult?.list || pageResult?.rows || pageResult?.records || root?.list || [])
}
async function loadProducts() {
loading.value = true
try {
const raw = []
for (let pageNo = 1; pageNo <= 5; pageNo += 1) {
const res = await getMoveProductPage({ pageNo, pageSize: 100, categoryType: categoryType.value })
const list = normalizePageList(res)
if (!list.length) break
raw.push(...list)
if (list.length < 100) break
}
productList.value = raw
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function handleConfirm() {
if (!selectedId.value) {
uni.showToast({ title: '请选择' + itemLabel.value, icon: 'none' })
return
}
const item = productList.value.find((product) => String(product.id) === String(selectedId.value))
if (!item) return
getApp().globalData._materialMoveProductSelectResult = { ...item }
uni.navigateBack()
}
onShow(loadProducts)
</script>
<style lang="scss" scoped>
.page-container { min-height: 100vh; background: #f5f7fb; padding-bottom: calc(120rpx + env(safe-area-inset-bottom)); }
.search-bar { padding: 18rpx 24rpx; background: #ffffff; box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.03); }
.search-input-wrap { display: flex; align-items: center; gap: 12rpx; height: 76rpx; padding: 0 22rpx; background: #f8fafc; border: 1rpx solid #e5e7eb; border-radius: 14rpx; box-sizing: border-box; }
.search-input { flex: 1; min-width: 0; font-size: 28rpx; color: #374151; }
.search-placeholder { color: #9ca3af; }
.search-clear { width: 44rpx; height: 44rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.product-list { height: calc(100vh - 294rpx); }
.product-card { margin: 18rpx 24rpx 0; padding: 24rpx; background: #ffffff; border: 1rpx solid #eef2f7; border-radius: 20rpx; box-shadow: 0 6rpx 18rpx rgba(15, 23, 42, 0.04); }
.product-card.active { border-color: #bfdbfe; background: #f9fbff; box-shadow: 0 8rpx 22rpx rgba(31, 124, 255, 0.08); }
.product-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18rpx; }
.product-title-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.product-name-row { display: flex; align-items: center; gap: 12rpx; min-width: 0; }
.product-name { min-width: 0; font-size: 30rpx; font-weight: 600; color: #1f2937; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.category-tag { max-width: 180rpx; padding: 5rpx 14rpx; border-radius: 999rpx; background: #eff6ff; color: #1f7cff; font-size: 22rpx; line-height: 1.3; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.product-code { font-size: 24rpx; color: #8a94a6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.check-badge { width: 36rpx; height: 36rpx; border-radius: 18rpx; border: 1rpx solid #d1d5db; background: #ffffff; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.product-card.active .check-badge { border-color: #1f7cff; background: #1f7cff; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14rpx; margin-top: 20rpx; padding-top: 18rpx; border-top: 1rpx solid #f1f5f9; }
.info-cell { min-width: 0; display: flex; flex-direction: column; gap: 6rpx; }
.info-label { font-size: 23rpx; color: #9ca3af; }
.info-value { font-size: 27rpx; color: #374151; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty-wrap { height: calc(100vh - 294rpx); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14rpx; color: #94a3b8; font-size: 27rpx; }
.action-bar { position: fixed; left: 0; right: 0; bottom: 0; padding: 18rpx 24rpx calc(18rpx + env(safe-area-inset-bottom)); background: #ffffff; box-shadow: 0 -8rpx 24rpx rgba(15, 23, 42, 0.06); z-index: 99; }
.action-btn { height: 84rpx; border-radius: 16rpx; background: #1f4b79; color: #ffffff; display: flex; align-items: center; justify-content: center; font-size: 30rpx; font-weight: 600; }
.action-btn-disabled { background: #94a3b8; }
</style>

@ -121,6 +121,11 @@ const MENU_ROUTE_MAP = {
materialOutbound: '/pages_function/pages/materialOutbound/index',
materialoutbound: '/pages_function/pages/materialOutbound/index',
'物料出库': '/pages_function/pages/materialOutbound/index',
materialMove: '/pages_function/pages/materialMove/index',
materialmove: '/pages_function/pages/materialMove/index',
stockMove: '/pages_function/pages/materialMove/index',
stockmove: '/pages_function/pages/materialMove/index',
'\u5e93\u5b58\u8c03\u62e8': '/pages_function/pages/materialMove/index',
productInventory: '/pages_function/pages/productInventory/index',
productinventory: '/pages_function/pages/productInventory/index',
productStock: '/pages_function/pages/productInventory/index',

Loading…
Cancel
Save