daShangDao_psiWebApp/src/components/wave/camera.vue
97694731 939d55c950
Some checks failed
CI / build (18.x) (push) Failing after 1m34s
CI / build (20.x) (push) Failing after 34s
CI / deploy-preview (push) Has been skipped
CI / lint (push) Failing after 34s
CI / test (push) Failing after 34s
CI / security (push) Failing after 35s
6.22lct bug 修改
2026-06-22 17:10:51 +08:00

3470 lines
110 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 class="camera-container" ref="cameraRef">
<div class="camera-content">
<div class="video-section">
<!-- 历史波次选择器 -->
<div class="wave-switch-bar">
<span class="wave-selector-label">切换波次</span>
<el-select v-model="selectedWaveNo" filterable clearable placeholder="选择波次" :loading="waveSearchLoading"
style="width: 220px" @change="handleWaveSelect" @visible-change="handleWaveDropdownVisible">
<el-option v-for="wave in waveSearchResults" :key="wave.id" :label="wave.wave_no" :value="wave.id" />
</el-select>
</div>
<div class="video-wrapper">
<video ref="videoRef" autoplay playsinline class="camera-video"></video>
</div>
<canvas ref="canvasRef" style="display: none;"></canvas>
<div class="controls">
<div class="button-group">
<!-- 模式切换按钮组 -->
<el-button-group>
<el-button :type="scanMode === 'scan' ? 'primary' : 'default'" @click="scanMode = 'scan'">
仅拍照(alt+c)
</el-button>
<el-button :type="scanMode === 'recognize' ? 'primary' : 'default'" @click="scanMode = 'recognize'">
切换OCR识别(alt+c)
</el-button>
</el-button-group>
<!-- 拍照识别按钮 -->
<el-button type="default" :loading="isProcessing" @click="handlePhotoAction">
拍照识别(alt+a)
</el-button>
</div>
<!-- 隐藏输入框始终渲染(不受 scanMode 影响),供扫码枪输入快捷键指令 -->
<input v-model="scannedCode" class="hidden-scanner-input" @input="handleScanInput" @keyup.enter="handleScan"
:disabled="isProcessing || isPageLocked" />
<!-- <div class="status" :class="{ 'status-success': barcodeSuccess, 'status-error': barcodeError }">
{{ barcodeStatus }}
</div> -->
<!-- 创建波次按钮区域 -->
<div class="wave-create-section">
<!-- 无活跃波次时:显示创建波次按钮 -->
<template v-if="!hasActiveWave">
<el-button type="warning" :disabled="scannedCount === 0" @click="handleCreateWave">
创建波次(alt+x)
</el-button>
</template>
<!-- 有活跃波次时:显示创建新波次 + 向当前波次追加 -->
<template v-else>
<div class="wave-buttons-row">
<el-button type="warning" @click="handleCreateNewWave">
创建新波次(alt+x)
</el-button>
<!-- <el-button
type="success"
:disabled="uncommittedCount === 0"
@click="handleAppendToWave"
>
向当前波次追加
</el-button> -->
</div>
</template>
<span v-if="scannedCount > 0" class="wave-hint">
已扫描 {{ scannedCount }} 本书,已入波次 {{ committedCount }} 本,待追加 {{ uncommittedCount }} 本
</span>
</div>
<div class="wave-create-section">
<!-- 当前波次信息提示 -->
<span v-if="hasActiveWave" class="wave-hint wave-active-hint">
当前波次:{{ waveNo }}
</span>
<!-- 生成条形码按钮 - 波次创建成功后显示 -->
<el-button v-if="waveNo" type="primary" :loading="barcodeLoading" @click="generateWaveBarcode">
生成波次条形码(alt+b)
</el-button>
</div>
<!-- 波次条形码图片展示 -->
<div v-if="barcodeImageBase64" class="barcode-display-section">
<div class="barcode-wave-no">波次号:{{ waveNo }}</div>
<div class="barcode-img-wrapper">
<img :src="barcodeImageUrl" alt="波次条形码" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 套装书选择弹窗 -->
<SuitBookDialog v-model="suitBookDialogVisible" :isbn="currentSuitIsbn" :bookInfo="currentSuitBookInfo ?? undefined" @select="handleSuitBookSelect" />
<!-- OCR 识别结果弹窗 -->
<OcrResultDialog v-model:visible="ocrDialogVisible" :loading="ocrLoading" :ocr-result="ocrResult"
@assign="handleOcrAssign" />
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed, Ref, nextTick, unref, inject, provide } from 'vue'
import { ElButton, ElMessage, ElMessageBox } from 'element-plus'
import { createPurchaseOrderWithWave, releaseWave, updatePurchaseOrder } from '@/api/purchaseOrder'
import { getWaveStatusById } from '@/api/waveTask'
import type { GoodsInfo } from './goodsInfo.vue'
import SuitBookDialog from './suitBookDialog.vue'
import OcrResultDialog from './ocrResultDialog.vue'
import { ocrImage } from '@/api/product'
import { generateBarcode } from '@/api/barcode'
import { getBookInfo, syncBook } from '@/api/book'
import { queryGoodsPrice } from '@/api/config'
import { getAdminUserInfo } from '@/utils/auth'
import axios from 'axios'
import { uploadLivingPicture } from '@/utils/uploadLivingPicture'
// 定义 BookInfo 接口
interface BookInfo {
bookName: string
author: string
publisher: string
publishDate: string
binding: string
price: number
pageCount: number
wordCount: number
book_pic?: {
localPath: string
pddPath: string
}
totalBook: number
ownPrice: number
capturedPhoto?: string
/** 是否为套装书子书用于核价时走套装书专用逻辑 */
isSuit?: boolean
}
const props = defineProps<{
modelValue: {
photoSrc: string
isbn: string
firstPagePhoto: string
barcode: string
}
/** Wave.vue 传入的仓库ID(computed 自动解包) */
warehouseId?: number | null
/** Wave.vue 传入的品相值(来自 Car 组件品相下拉) */
quality?: number
}>()
// inject 页面锁定状态(来自 Wave.vue
const isPageLocked = inject<{ value: boolean }>('isPageLocked', { value: false })
const connectionStatus = inject<{ value: string }>('connectionStatus', { value: 'idle' })
// 错误阻断器(来自 Wave.vue
const errorBlocker = inject<any>('errorBlocker', undefined)
const emit = defineEmits<{
(e: 'update:modelValue', value: typeof props.modelValue): void
(e: 'book-info-update', value: BookInfo | null): void
/** 拍照预览确认,携带书籍信息、拍摄的图片、商品ID和售价 */
(e: 'photo-preview-confirm', value: { bookInfo: BookInfo; photoSrc: string; productId: number | null; salePrice?: number | null }): void
}>()
const videoRef = ref<HTMLVideoElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
// 提供主摄像头视频流引用供子组件suitBookDialog复用拍照
provide('mainVideoRef', videoRef)
const barcodeStatus = ref('拍照后自动识别条形码')
const barcodeSuccess = ref(false)
const barcodeError = ref(false)
const isProcessing = ref(false)
const actualWidth = ref(0)
const actualHeight = ref(0)
const scannedCode = ref('')
// 专门保存最后一次扫码的ISBN,用于拍照上传等场景
const lastScannedIsbn = ref('')
// 保存 /api/getBookInfo 返回的 book_pic_s,供保存商品时使用
const lastBookPicS = ref('')
/** 扫码输入防抖定时器 */
let scanInputTimer: ReturnType<typeof setTimeout> | null = null
/**
* 检查是否已选择小车,若未选择则弹出警告并阻止后续操作
*/
function checkCarSelected(): boolean {
if (!injectedCarId.value) {
ElMessage.warning({ message: '请先选择小车', duration: 1000, customClass: 'scan-warning-message' })
return false
}
return true
}
// ========== 套装书选择相关 ==========
const suitBookDialogVisible = ref(false)
const currentSuitIsbn = ref('')
/** 首次调用 syncBook 返回的 fid用于后续同步时作为 f_id 传入 */
const currentSuitFid = ref<number | null>(null)
/** 当前扫码的书籍信息,用于套装书弹窗自定义添加项的预填 */
const currentSuitBookInfo = ref<{
bookName?: string
author?: string
publisher?: string
publishDate?: string
binding?: string
price?: number
pageCount?: number | string
wordCount?: number | string
book_pic?: any
} | null>(null)
/** 套装书预览模式标记:来自 Car 搜索时,选择后仅更新预览不保存加入列表 */
const suitPreviewOnly = ref(false)
// ========== OCR 识别相关 ==========
const ocrDialogVisible = ref(false)
const ocrLoading = ref(false)
const ocrResult = ref<{
success: boolean
texts: string[]
BookName?: string
Author?: string
Publisher?: string
} | null>(null)
// OCR 模式下捕获的 base64 图片(用于后续上传和预览)
const ocrCapturedBase64 = ref('')
// 保存最后一次上传实拍图片的文件名(用于后续关联),格式:{id}-{isbn}-{5位随机数}.jpg
const lastLivingPictureName = ref('')
// 记录每个 ISBN 对应的 product ID 和自定义价格,结构:{ id: number, ownPrice: number }
// id=0 表示首次创建,之后复用响应中的 id
const isbnProductIdMap = new Map<string, { id: number; ownPrice: number }>()
// ========== goods/query 队列相关 ==========
interface PendingGoodsQuery {
isbn: string
outId: string
quality: string
productId: number
/** 套装书时传递书名 */
bookName?: string
/** 套装书时传递作者 */
author?: string
/** 套装书时传递出版社 */
publisher?: string
/** 是否为套装书 */
isSuit?: boolean
}
const pendingGoodsQueryQueue: PendingGoodsQuery[] = []
/**
* 调用 goods/query 接口(直连核价器)
* @param isbn - ISBN常规图书使用
* @param productId - 商品ID
* @param quality - 品质
* @param options - 套装书时的额外信息
*/
async function queryGoodsApi(isbn: string, productId: number, quality: string, options?: { bookName: string; author: string; publisher: string }): Promise<void> {
const userInfo = getAdminUserInfo()
const userId = userInfo?.about_id ?? ''
const queryIndex = parseInt(localStorage.getItem('verify_index') || '1')
// 从 localStorage 获取 IP 和端口
const ip = localStorage.getItem('test_ip') || '127.0.0.1'
const port = localStorage.getItem('test_port') || '8080'
const isSuit = !!options
const bookName = options?.bookName || ''
const author = options?.author || ''
const publisher = options?.publisher || ''
console.log('[goods/query] 发送请求 - 直连:', { ip, port, isbn, productId, quality, isSuit, bookName, author, publisher })
try {
// 直接请求核价器,避免 CORS 需服务端支持
const response = await queryGoodsPrice({
ip,
port,
isbn,
bookName,
author,
publisher,
isSuit,
outId: productId,
quality,
queryIndex,
userId,
placeholderDownPrice: localStorage.getItem('placeholder_down_price') || '0.01',
minShippingFee: localStorage.getItem('min_shipping_fee') || '5.00',
minPrice: localStorage.getItem('min_price') || '1.00'
})
console.log('[goods/query] 响应:', response)
} catch (err) {
// console.error('[goods/query] 请求失败:', err instanceof Error ? err.message : String(err))
// 网络错误不中断流程
}
}
/**
* 处理堆积的书籍查询队列
*/
async function processPendingGoodsQueryQueue(): Promise<void> {
if (pendingGoodsQueryQueue.length === 0) return
console.log(`[goods/query] 开始处理堆积队列,数量: ${pendingGoodsQueryQueue.length}`)
const queueCopy = [...pendingGoodsQueryQueue]
pendingGoodsQueryQueue.length = 0 // 清空队列
for (const item of queueCopy) {
try {
if (item.isSuit) {
await queryGoodsApi(item.isbn, item.productId, item.quality, {
bookName: item.bookName || '',
author: item.author || '',
publisher: item.publisher || ''
})
} else {
await queryGoodsApi(item.isbn, item.productId, item.quality)
}
} catch (err) {
// console.error('[goods/query] 处理队列项失败:', err)
}
}
console.log('[goods/query] 队列处理完成')
}
// ========== 波次号识别相关 ==========
// 波次号正则以1-4个字母开头后跟数字如 WH20260426001, WV001
// 可根据后端实际格式调整
const WAVE_NO_PATTERN = /^[A-Za-z]{1,4}\d+$/
/**
* 判断扫码内容是否可能是波次号
* 排除纯数字ISBN和过短/过长的内容
*/
function isWaveNoPattern(code: string): boolean {
const trimmed = code.trim()
// 长度合理:波次号通常 4-30 个字符
if (trimmed.length < 4 || trimmed.length > 30) return false
return WAVE_NO_PATTERN.test(trimmed)
}
// 波次选择器相关状态
const selectedWaveNo = ref('')
const waveSearchLoading = ref(false)
const waveSearchResults = ref<any[]>([])
// 扫描模式:'scan' 表示仅拍照(使用扫码枪),'recognize' 表示拍照并识别条码
const scanMode = ref('scan')
// 波次号和条形码相关状态
const waveNo = ref('')
const barcodeImageBase64 = ref('')
const barcodeLoading = ref(false)
// 当前活跃波次状态(创建波次后持续存在,直到用户主动结束)
const currentWaveId = ref<number | null>(null)
const currentOrderId = ref<number | null>(null)
const currentWarehouseId = ref<number | null>(null)
// 当前波次已追加的商品列表(已废弃,改用 _committed 追踪)
// const waveItems = ref<Array<{ product_id: number; quantity: number; unit_price: number }>>([])
/** 注入 goosListRef由 Wave.vue 通过 provide 提供) */
const injectedGoosListRef = inject<{ value: { getAllGoods: () => any[]; getUncommittedGoods?: () => any[]; markAllCommitted?: () => void; clearAll?: () => void } | null } | null>('goosListRef', null)
/** 获取 goosList 组件实例的辅助函数 */
const injectedCarRef = inject<{ value: { selectedWarehouseId: number | null; quality: number; selectedCarId: number | null; selectedCarCode: string | null; selectedCarCapacity: number | null } | null } | null>('carRef', null)
/** 获取 goosList 组件实例的辅助函数 */
function getGoosListInstance() {
// 使用 inject 获取
if (injectedGoosListRef?.value) {
return injectedGoosListRef.value
}
return null
}
/** 监听仓库切换,自动恢复扫码输入框焦点 */
watch(
() => props.warehouseId,
() => {
setTimeout(() => {
const inputElement = document.querySelector('.hidden-scanner-input') as HTMLInputElement
if (inputElement) {
inputElement.focus()
}
}, 50)
}
)
// 条形码图片 URL(computed)
const barcodeImageUrl = computed(() => {
if (!barcodeImageBase64.value) return ''
return `data:image/png;base64,${barcodeImageBase64.value}`
})
// 是否已有活跃波次
const hasActiveWave = computed(() => !!currentWaveId.value)
// 已扫描书籍总数
const scannedCount = computed(() => {
const goosListInstance = getGoosListInstance()
return goosListInstance?.getAllGoods?.()?.length ?? 0
})
// 待追加书籍数(未提交到波次的)
const uncommittedCount = computed(() => {
const goosListInstance = getGoosListInstance()
return goosListInstance?.getUncommittedGoods?.()?.length ?? 0
})
// 已入波次书籍数
const committedCount = computed(() => scannedCount.value - uncommittedCount.value)
// 输出分辨率(1:1 正方形)
const OUTPUT_SIZE = 1080
let stream: MediaStream | null = null
// 书籍信息
const bookInfo = ref<BookInfo | null>(null)
// ========== 仓库选择 end ==========
// Wave.vue 模板 ref,供读取用户自定义价格(ownPrice,分单位)
const cameraRef = ref<HTMLElement | null>(null)
/** 品质值:从 carRef inject 获取 */
const injectedQuality = computed(() => {
const propQ = props.quality
if (typeof propQ === 'number') return propQ
const injectedQ = injectedCarRef?.value?.quality
return typeof injectedQ === 'number' ? injectedQ : 85
})
/** 小车信息:从 carRef inject 获取 */
const injectedCarId = computed(() => injectedCarRef?.value?.selectedCarId ?? null)
const injectedCarCode = computed(() => injectedCarRef?.value?.selectedCarCode ?? null)
const injectedCarCapacity = computed(() => injectedCarRef?.value?.selectedCarCapacity ?? null)
// Wave.vue 组件实例(已废弃,改用 inject保留引用避免 TypeScript 报错)
const waveComponent = ref<{ currentGoods: GoodsInfo | null } | null>(null)
// 价格格式化计算属性
const priceFormatted = computed({
get: () => {
if (!bookInfo.value) return ''
return (bookInfo.value.price / 100).toFixed(2)
},
set: (value) => {
if (bookInfo.value) {
const price = parseFloat(value) * 100
bookInfo.value.price = isNaN(price) ? 0 : Math.round(price)
}
}
})
/**
* 规范化条码文本,去除空白和连字符
*/
function normalizeBarcode(barcode: string): string {
return barcode.replace(/[\s-]+/g, '')
}
/**
* 校验 ISBN-13
*/
function isValidISBN13(barcode: string): boolean {
const digits = normalizeBarcode(barcode)
if (!/^\d{13}$/.test(digits)) return false
let sum = 0
for (let i = 0; i < 13; i++) {
const n = Number(digits[i])
sum += i % 2 === 0 ? n : n * 3
}
return sum % 10 === 0
}
/**
* 打开摄像头(失败后自动重试一次)
* @param retryCount 当前重试次数0=首次1=重试)
*/
const openCamera = async (retryCount = 0) => {
try {
// 先释放可能残留的旧流
if (stream) {
stream.getTracks().forEach(track => track.stop())
stream = null
}
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30 }
}
})
if (videoRef.value) {
videoRef.value.srcObject = stream
videoRef.value.onloadedmetadata = () => {
if (videoRef.value) {
actualWidth.value = videoRef.value.videoWidth
actualHeight.value = videoRef.value.videoHeight
console.log(`实际视频分辨率: ${actualWidth.value}x${actualHeight.value}`)
}
}
}
barcodeStatus.value = '摄像头已就绪'
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
console.warn(`摄像头打开失败(第${retryCount + 1}次):`, errMsg)
if (retryCount === 0) {
// 首次失败等待800ms后重试一次可能是页面刷新后旧流未完全释放
barcodeStatus.value = '摄像头打开失败,正在重试...'
await new Promise(resolve => setTimeout(resolve, 800))
return openCamera(1)
} else {
// 重试仍失败,提示用户
barcodeStatus.value = '摄像头打开失败,请检查权限或重启浏览器'
// 区分错误类型给更精确的提示
if (errMsg.includes('NotAllowedError') || errMsg.includes('PermissionDenied')) {
barcodeStatus.value = '摄像头权限被拒绝,请在浏览器设置中允许摄像头访问'
} else if (errMsg.includes('NotFoundError')) {
barcodeStatus.value = '未检测到摄像头设备,请检查摄像头连接'
} else if (errMsg.includes('NotReadableError') || errMsg.includes('TrackStartError')) {
barcodeStatus.value = '摄像头被其他程序占用,请关闭其他使用摄像头的应用后重试'
}
}
}
}
const closeCamera = () => {
if (stream) {
stream.getTracks().forEach(track => track.stop())
stream = null
}
}
/**
* 仅拍照功能
*/
const takePhotoOnly = async () => {
const video = videoRef.value
const canvas = canvasRef.value
if (!video || !canvas) {
barcodeStatus.value = '摄像头未就绪'
return
}
if (video.videoWidth === 0 || video.videoHeight === 0) {
barcodeStatus.value = '视频流未就绪,请稍后重试'
return
}
isProcessing.value = true
barcodeStatus.value = '正在拍照...'
barcodeSuccess.value = false
barcodeError.value = false
const ctx = canvas.getContext('2d')
if (!ctx) {
isProcessing.value = false
return
}
canvas.width = OUTPUT_SIZE
canvas.height = OUTPUT_SIZE
const videoW = video.videoWidth
const videoH = video.videoHeight
let sx, sy, sWidth, sHeight
if (videoW > videoH) {
sHeight = videoH
sWidth = videoH
sx = (videoW - sWidth) / 2
sy = 0
} else {
sWidth = videoW
sHeight = videoW
sx = 0
sy = (videoH - sHeight) / 2
}
ctx.drawImage(video, sx, sy, sWidth, sHeight, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE)
const base64Data = canvas.toDataURL('image/jpeg', 0.9)
const newFormData = {
...props.modelValue,
photoSrc: base64Data
}
emit('update:modelValue', newFormData)
barcodeStatus.value = '拍照成功'
barcodeSuccess.value = true
barcodeError.value = false
isProcessing.value = false
}
/**
* 重新拍照(仅替换照片预览,不新增到已扫描列表)
*/
const retakePhoto = async () => {
const video = videoRef.value
const canvas = canvasRef.value
if (!video || !canvas) {
barcodeStatus.value = '摄像头未就绪'
return
}
if (video.videoWidth === 0 || video.videoHeight === 0) {
barcodeStatus.value = '视频流未就绪,请稍后重试'
return
}
isProcessing.value = true
barcodeStatus.value = '正在重新拍照...'
barcodeSuccess.value = false
barcodeError.value = false
const ctx = canvas.getContext('2d')
if (!ctx) {
isProcessing.value = false
return
}
canvas.width = OUTPUT_SIZE
canvas.height = OUTPUT_SIZE
const videoW = video.videoWidth
const videoH = video.videoHeight
let sx, sy, sWidth, sHeight
if (videoW > videoH) {
sHeight = videoH
sWidth = videoH
sx = (videoW - sWidth) / 2
sy = 0
} else {
sWidth = videoW
sHeight = videoW
sx = 0
sy = (videoH - sHeight) / 2
}
ctx.drawImage(video, sx, sy, sWidth, sHeight, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE)
const base64Data = canvas.toDataURL('image/jpeg', 0.9)
// 上传封面图片到图片服务(使用最后一次扫码的ISBN)
const isbn = lastScannedIsbn.value
const bookPicS = lastBookPicS.value
console.log('[重新拍照] ISBN:', isbn, '| lastScannedIsbn:', lastScannedIsbn.value)
if (!isbn) {
console.warn('[重新拍照] ISBN 为空,跳过上传')
barcodeSuccess.value = true
barcodeError.value = false
isProcessing.value = false
return
}
// 价格来源:用户通过 priceFormatted 输入或 OCR/查询结果(已转为分)
const productPrice = bookInfo.value?.price ?? 0
// 从已扫描列表中查找已有 productId用于更新而非新建
let existingProductId = 0
const goosListRef_tmp = injectedGoosListRef?.value as { goodsList?: any[] } | null | undefined
if (goosListRef_tmp?.goodsList) {
const item = goosListRef_tmp.goodsList.find((g: any) => g.isbn === isbn)
if (item?.productId) {
existingProductId = item.productId
}
}
// 第一步:调用 /api/product/save 保存(有 productId 则更新,无则新建)
const { updateProductLiveImageAsPddUrl } = await import('@/api/product')
const productId = await saveProductBeforeUpload({
isbn,
name: bookInfo.value?.bookName || '',
price: productPrice,
bookPicS,
appearance: injectedQuality.value as number | undefined,
binding: bookInfo.value?.binding,
pageCount: bookInfo.value?.pageCount,
wordCount: bookInfo.value?.wordCount,
productId: existingProductId
})
// 第二步:上传实拍图片
const random5 = String(Math.floor(Math.random() * 100000)).padStart(5, '0')
const pictureName = productId ? `${productId}-${isbn}-${random5}.jpg` : null
if (pictureName) {
lastLivingPictureName.value = pictureName
await uploadLivingPicture(base64Data, pictureName, isbn)
// 第三步:上传拼多多图片空间
let pddUrl = ''
try {
const CryptoJS = (await import('crypto-js')).default
const timestamp = String(Math.floor(Date.now() / 1000))
const signParams: Record<string, string> = {
type: 'pdd.goods.filespace.image.upload',
access_token: '5f7dcc92211549f3b8b05451288a92fa9546732d',
timestamp,
client_id: '203c5a7ba8bd4b8488d5e26f93052642',
data_type: 'JSON'
}
const sortedKeys = Object.keys(signParams).sort()
let signStr = '892ffaa86e12b7a3d8d2942b669d9aa520ad8179'
for (const key of sortedKeys) {
signStr += key + signParams[key]
}
signStr += '892ffaa86e12b7a3d8d2942b669d9aa520ad8179'
const sign = CryptoJS.MD5(signStr).toString().toUpperCase()
const pddFormData = new FormData()
pddFormData.append('type', signParams.type)
pddFormData.append('access_token', signParams.access_token)
pddFormData.append('timestamp', signParams.timestamp)
pddFormData.append('client_id', signParams.client_id)
pddFormData.append('data_type', signParams.data_type)
pddFormData.append('sign', sign)
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '')
const binaryStr = atob(base64)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
const blob = new Blob([bytes], { type: 'image/jpeg' })
pddFormData.append('file', blob, `${isbn}.jpg`)
const pddRes = await axios.post('https://gw-upload.pinduoduo.com/api/upload', pddFormData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30000
})
const pddResult = pddRes.data
if (pddResult?.goods_filespace_image_upload_response?.image_url) {
pddUrl = pddResult.goods_filespace_image_upload_response.image_url
} else if (pddResult?.goods_filespace_image_upload_response?.url) {
pddUrl = pddResult.goods_filespace_image_upload_response.url
}
} catch (pddErr) {
console.warn('[重新拍照] PDD上传异常:', pddErr instanceof Error ? pddErr.message : String(pddErr))
}
// 第四步:更新商品 live_image
await updateProductLiveImageAsPddUrl(productId, pddUrl, {
barcode: isbn,
name: bookInfo.value?.bookName || '',
price: productPrice,
appearance: injectedQuality.value
})
const photoUrl = pddUrl
// 仅更新 imageCut 预览(不触发 photo-preview-confirm不新增到已扫描列表
emit('update:modelValue', {
...props.modelValue,
photoSrc: photoUrl
})
if (bookInfo.value) {
bookInfo.value.capturedPhoto = photoUrl
}
// 更新已扫描列表中的拍摄图片
const goosListRef_tmp = injectedGoosListRef?.value as { goodsList?: any[] } | null | undefined
if (goosListRef_tmp?.goodsList) {
const item = goosListRef_tmp.goodsList.find((g: any) => g.isbn === isbn)
if (item) {
item.capturedPhoto = photoUrl
}
}
barcodeStatus.value = '重新拍照完成'
} else {
console.warn('[重新拍照] product ID 为空,跳过图片上传')
}
barcodeSuccess.value = true
barcodeError.value = false
isProcessing.value = false
// 恢复扫码输入框焦点
setTimeout(() => {
const inputElement = document.querySelector('.hidden-scanner-input') as HTMLInputElement
if (inputElement && !isOtherInputFocused()) {
inputElement.focus()
}
}, 100)
}
/**
* 拍照预览功能
* 拍照并确认添加到已扫描书籍列表
*/
const takePhotoPreview = async () => {
const video = videoRef.value
const canvas = canvasRef.value
if (!video || !canvas) {
barcodeStatus.value = '摄像头未就绪'
return
}
if (video.videoWidth === 0 || video.videoHeight === 0) {
barcodeStatus.value = '视频流未就绪,请稍后重试'
return
}
isProcessing.value = true
barcodeStatus.value = '正在拍照预览...'
barcodeSuccess.value = false
barcodeError.value = false
const ctx = canvas.getContext('2d')
if (!ctx) {
isProcessing.value = false
return
}
canvas.width = OUTPUT_SIZE
canvas.height = OUTPUT_SIZE
const videoW = video.videoWidth
const videoH = video.videoHeight
let sx, sy, sWidth, sHeight
if (videoW > videoH) {
sHeight = videoH
sWidth = videoH
sx = (videoW - sWidth) / 2
sy = 0
} else {
sWidth = videoW
sHeight = videoW
sx = 0
sy = (videoH - sHeight) / 2
}
ctx.drawImage(video, sx, sy, sWidth, sHeight, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE)
const base64Data = canvas.toDataURL('image/jpeg', 0.9)
// 上传封面图片到图片服务(使用最后一次扫码的ISBN)
const isbn = lastScannedIsbn.value
const bookPicS = lastBookPicS.value
console.log('=============测试实拍图片:', isbn, '| lastBookPicS:', lastBookPicS.value)
console.log('[上传实拍] ISBN:', isbn, '| lastScannedIsbn:', lastScannedIsbn.value)
if (!isbn) {
console.warn('[上传实拍] ISBN 为空,跳过上传')
barcodeSuccess.value = true
barcodeError.value = false
isProcessing.value = false
return
}
// 价格来源:用户通过 priceFormatted 输入或 OCR/查询结果(已转为分)
const productPrice = bookInfo.value?.price ?? 0
console.log('[上传实拍] 价格字段 - bookInfo price:', bookInfo.value?.price, '| 最终取值:', productPrice)
// 第一步:调用 /api/product/save 保存商品,获取 product ID
const { saveProduct, updateProductLiveImage, updateProductLiveImageForTest, updateProductLiveImageAsPddUrl } = await import('@/api/product')
const productId = await saveProductBeforeUpload({
isbn,
name: bookInfo.value?.bookName || '',
price: productPrice, // 单位:分,整数类型
bookPicS,
appearance: injectedQuality.value as number | undefined,
binding: bookInfo.value?.binding,
pageCount: bookInfo.value?.pageCount,
wordCount: bookInfo.value?.wordCount
})
// 第二步:上传实拍图片,文件名为 {id}-{isbn}-{5位随机数}.jpg
const random5 = String(Math.floor(Math.random() * 100000)).padStart(5, '0')
const pictureName = productId ? `${productId}-${isbn}-${random5}.jpg` : null
if (pictureName) {
lastLivingPictureName.value = pictureName
await uploadLivingPicture(base64Data, pictureName, isbn)
console.log('[实拍] 图片名:', pictureName, '| 地址: https://shxy.image.yushutx.com/living-picture/' + pictureName)
// 第三步半:将视频帧图片上传拼多多图片空间,获取 pddUrl
let pddUrl = ''
try {
const CryptoJS = (await import('crypto-js')).default
const timestamp = String(Math.floor(Date.now() / 1000))
const signParams: Record<string, string> = {
type: 'pdd.goods.filespace.image.upload',
access_token: '5f7dcc92211549f3b8b05451288a92fa9546732d',
timestamp,
client_id: '203c5a7ba8bd4b8488d5e26f93052642',
data_type: 'JSON'
}
// 生成签名
const sortedKeys = Object.keys(signParams).sort()
let signStr = '892ffaa86e12b7a3d8d2942b669d9aa520ad8179'
for (const key of sortedKeys) {
signStr += key + signParams[key]
}
signStr += '892ffaa86e12b7a3d8d2942b669d9aa520ad8179'
const sign = CryptoJS.MD5(signStr).toString().toUpperCase()
// 构造 FormData
const pddFormData = new FormData()
pddFormData.append('type', signParams.type)
pddFormData.append('access_token', signParams.access_token)
pddFormData.append('timestamp', signParams.timestamp)
pddFormData.append('client_id', signParams.client_id)
pddFormData.append('data_type', signParams.data_type)
pddFormData.append('sign', sign)
// base64 转 Blob
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '')
const binaryStr = atob(base64)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
const blob = new Blob([bytes], { type: 'image/jpeg' })
pddFormData.append('file', blob, `${isbn}.jpg`)
const pddRes = await axios.post('https://gw-upload.pinduoduo.com/api/upload', pddFormData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30000
})
const pddResult = pddRes.data
console.log('[PDD上传] 响应:', JSON.stringify(pddResult))
if (pddResult?.goods_filespace_image_upload_response?.image_url) {
pddUrl = pddResult.goods_filespace_image_upload_response.image_url
console.log('[PDD上传] 成功,图片地址:', pddUrl)
} else if (pddResult?.goods_filespace_image_upload_response?.url) {
pddUrl = pddResult.goods_filespace_image_upload_response.url
console.log('[PDD上传] 成功,图片地址(url字段):', pddUrl)
} else if (pddResult?.error_response) {
errorBlocker?.block(`拍照失败`)
console.warn('[PDD上传] 失败:', pddResult.error_response.error_msg || JSON.stringify(pddResult.error_response))
}
} catch (pddErr) {
errorBlocker?.block(`拍照失败`)
console.warn('[PDD上传] 异常:', pddErr instanceof Error ? pddErr.message : String(pddErr))
}
console.log('[实拍] 上传完成,开始更新商品图片 - productId:', productId, '| bookPicS:', pddUrl)
// 第四步:用第一步拿到的 productId 更新商品的 live_image 为 pdd 图片地址测试阶段使用bookPicS
await updateProductLiveImageAsPddUrl(productId, pddUrl || bookPicS , {
barcode: isbn,
name: bookInfo.value?.bookName || '',
price: productPrice, // 单位:分,整数类型
appearance: injectedQuality.value
})
// 第五步:第二次调用 /api/product/save 后,调用 goods/query
// 套装书isSuit=true需传递 bookName/author/publisher 走套装书专用核价
const isSuitBook = !!(bookInfo.value?.isSuit)
if (hasActiveWave.value) {
if (isSuitBook) {
await queryGoodsApi(isbn, productId, String(injectedQuality.value), {
bookName: bookInfo.value?.bookName || '',
author: bookInfo.value?.author || '',
publisher: bookInfo.value?.publisher || ''
})
} else {
await queryGoodsApi(isbn, productId, String(injectedQuality.value))
}
} else {
if (isSuitBook) {
pendingGoodsQueryQueue.push({
isbn,
outId: productId.toString(),
quality: String(injectedQuality.value),
productId,
bookName: bookInfo.value?.bookName || '',
author: bookInfo.value?.author || '',
publisher: bookInfo.value?.publisher || '',
isSuit: true,
})
} else {
pendingGoodsQueryQueue.push({
isbn,
outId: productId.toString(),
quality: String(injectedQuality.value),
productId
})
}
console.log(`[goods/query] 无活跃波次,暂存队列(当前数量: ${pendingGoodsQueryQueue.length})`)
}
console.log('=======实拍图片地址: ', pddUrl)
// 第四步:上传完成后再 emit,使用网络 URL 替代 base64
const photoUrl = pddUrl
// 更新 imageCut 预览(网络地址)
emit('update:modelValue', {
...props.modelValue,
photoSrc: photoUrl
})
// // 把 URL 回填到 bookInfo 并通知已扫描书籍列表(只 emit photo-preview-confirm,不 emit book-info-update,避免触发 handleBookInfoUpdate 再次 +1)
if (bookInfo.value) {
bookInfo.value.capturedPhoto = photoUrl
// 获取商品售价
let salePrice = null
if (productId) {
try {
const req = (await import('@/utils/request')).default
const listRes = await req.get('/product/list', {
params: { status: 1, page: 1, page_size: 20, 'ids[0]': productId }
})
console.log('[条码扫描] /product/list 响应:', listRes)
const list = listRes?.data?.list || listRes?.data?.data || listRes?.data?.rows || []
console.log('[条码扫描] 提取的列表:', list, '第一条:', list[0])
if (list.length > 0) {
salePrice = list[0].sale_price
console.log('[条码扫描] salePrice:', salePrice)
}
} catch (e) {
console.warn('[条码扫描] 获取 sale_price 失败:', e)
}
}
// 强制 ownPrice 为 0防止被意外修改
if (bookInfo.value) {
bookInfo.value.ownPrice = 0
}
emit('photo-preview-confirm', {
bookInfo: bookInfo.value,
photoSrc: photoUrl,
productId,
salePrice
})
barcodeStatus.value = '已添加到已扫描书籍'
// 自动追加到当前波次(如果有活跃波次)
// 必须等待 nextTick,确保 GoosList 的 watch 已执行并将新书加入列表
if (hasActiveWave.value) {
await nextTick()
await handleAppendToWave()
}
} else {
barcodeStatus.value = '拍照预览成功(无书籍信息)'
}
} else {
console.warn('[实拍] product ID 为空,跳过图片上传')
}
barcodeSuccess.value = true
barcodeError.value = false
isProcessing.value = false
ElMessage.success({ message: `拍照成功`, duration: 1000, customClass: 'scan-success-message' })
// 恢复扫码输入框焦点,准备下一次扫描
setTimeout(() => {
const inputElement = document.querySelector('.hidden-scanner-input') as HTMLInputElement
if (inputElement && !isOtherInputFocused()) {
inputElement.focus()
}
}, 100)
}
/**
* 根据当前模式执行拍照动作
* scan 模式: 调用 takePhotoPreview (原有逻辑)
* recognize 模式: 调用 takePhotoOcr (OCR识别)
*/
const handlePhotoAction = async () => {
if (isPageLocked.value) {
return
}
if (!checkCarSelected()) return
// 检查波次状态:有活跃波次且已开始拣货时阻止拍照
if (hasActiveWave.value && currentWaveId.value) {
try {
const res = await getWaveStatusById(currentWaveId.value)
const status = res?.data?.status
if (status !== undefined && status !== 1 && status !== 2) {
errorBlocker?.block('该波次已开始拣货不能继续追加商品,请扫描波次码清空当前波次,重新扫描书籍')
return
}
} catch (err) {
console.error('[拍照] 波次状态查询失败:', err)
}
}
// 检查小车容量:当前已扫描数 >= 容量时阻止拍照
const capacity = injectedCarCapacity.value
if (capacity !== null && scannedCount.value >= capacity) {
errorBlocker?.block('小车容量已满,请创建波次上书或者创建新波次')
return
}
if (scanMode.value === 'recognize') {
await takePhotoOcr()
} else {
await takePhotoPreview()
}
}
/**
* 拍照并调用 OCR 识别
*/
const takePhotoOcr = async () => {
const video = videoRef.value
const canvas = canvasRef.value
if (!video || !canvas) {
barcodeStatus.value = '摄像头未就绪'
return
}
if (video.videoWidth === 0 || video.videoHeight === 0) {
barcodeStatus.value = '视频流未就绪,请稍后重试'
return
}
isProcessing.value = true
barcodeStatus.value = '正在拍照识别...'
const ctx = canvas.getContext('2d')
if (!ctx) {
isProcessing.value = false
return
}
// 绘制视频帧到 canvas
canvas.width = OUTPUT_SIZE
canvas.height = OUTPUT_SIZE
const videoW = video.videoWidth
const videoH = video.videoHeight
let sx, sy, sWidth, sHeight
if (videoW > videoH) {
sHeight = videoH
sWidth = videoH
sx = (videoW - sWidth) / 2
sy = 0
} else {
sWidth = videoW
sHeight = videoW
sx = 0
sy = (videoH - sHeight) / 2
}
ctx.drawImage(video, sx, sy, sWidth, sHeight, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE)
// 保存捕获的 base64 图片(用于后续上传和预览)
const base64Data = canvas.toDataURL('image/jpeg', 0.9)
ocrCapturedBase64.value = base64Data
// 转换为 Blob 并调用 OCR API
try {
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => {
if (b) resolve(b)
else reject(new Error('Canvas toBlob failed'))
}, 'image/jpeg', 0.9)
})
ocrLoading.value = true
const result = await ocrImage(blob)
ocrLoading.value = false
if (result.code === 200 && result.data?.success) {
ocrResult.value = result.data
ocrDialogVisible.value = true
barcodeStatus.value = 'OCR识别成功,请选择字段分配'
} else {
barcodeStatus.value = 'OCR识别失败: ' + (result.data?.texts?.join(', ') || '未知错误')
barcodeError.value = true
}
} catch (err) {
// console.error('[OCR] 识别失败:', err)
barcodeStatus.value = 'OCR识别失败'
barcodeError.value = true
ocrLoading.value = false
} finally {
isProcessing.value = false
}
}
/**
* 处理 OCR 结果分配
* @param assignments - 字段分配对象 { field: value }
*/
const handleOcrAssign = async (assignments: Record<string, string>) => {
if (!bookInfo.value) {
// 如果 bookInfo 为空,先创建一个基础对象
bookInfo.value = {
bookName: '',
author: '',
publisher: '',
publishDate: '',
binding: '',
price: 0,
pageCount: 0,
wordCount: 0,
totalBook: 1,
ownPrice: 0
}
}
// 映射字段名
const fieldMap: Record<string, keyof NonNullable<typeof bookInfo.value>> = {
bookName: 'bookName',
author: 'author',
publisher: 'publisher',
publishDate: 'publishDate',
binding: 'binding',
price: 'price',
pageCount: 'pageCount',
wordCount: 'wordCount'
}
// 应用所有分配
const assignedFields: string[] = []
for (const [field, value] of Object.entries(assignments)) {
const targetField = fieldMap[field]
if (targetField && bookInfo.value) {
// 特殊处理 price 字段(需要转换为数字)
if (field === 'price') {
// 价格可能包含 '元' 或其他字符,提取数字
const priceNum = parseFloat(value.replace(/[^\d.]/g, ''))
if (!isNaN(priceNum)) {
; (bookInfo.value as any)[targetField] = Math.round(priceNum * 100) // 转换为分
}
} else {
; (bookInfo.value as any)[targetField] = value
}
assignedFields.push(field)
}
}
// 关闭弹窗
ocrDialogVisible.value = false
// ========== 后续逻辑与 takePhotoPreview 相同 ==========
const base64Data = ocrCapturedBase64.value
if (!base64Data) {
barcodeStatus.value = 'OCR 图片丢失,请重新拍照'
barcodeError.value = true
return
}
isProcessing.value = true
barcodeStatus.value = '正在获取商品编码...'
// OCR 模式:先调用 /api/getProCode 获取商品编码
const { getProCode } = await import('@/api/product')
const proCode = await getProCode({
book_name: bookInfo.value?.bookName || '',
author: bookInfo.value?.author || '',
publisher: bookInfo.value?.publisher || ''
})
// 使用 getProCode 返回的编码作为 barcode失败时回退到占位符
const isbn = proCode || lastScannedIsbn.value || `OCR-${Date.now()}`
console.log('[OCR] getProCode 返回:', proCode, '→ 使用 barcode:', isbn)
// OCR 模式:使用 OCR 识别的定价(已转为分)
const productPrice = bookInfo.value?.price ?? 0
// 第一步:先上传截取的视频帧到图片服务,文件名为 {barcode}.jpg
const coverPictureName = `${isbn}.jpg`
console.log('[OCR上传封面] 开始上传, 文件名:', coverPictureName)
await uploadLivingPicture(base64Data, coverPictureName, isbn)
const coverImageUrl = `https://shxy.image.yushutx.com/living-picture/${coverPictureName}`
console.log('[OCR上传封面] 完成, URL:', coverImageUrl)
// 第二步:调用 /api/product/save 保存商品,使用上传后的 URL 作为 live_image[0]
barcodeStatus.value = '正在保存商品...'
const { saveProduct, updateProductLiveImage } = await import('@/api/product')
const productId = await saveProductBeforeUpload({
isbn,
name: bookInfo.value?.bookName || '',
price: productPrice,
bookPicS: coverImageUrl,
appearance: injectedQuality.value as number | undefined,
binding: bookInfo.value?.binding,
pageCount: bookInfo.value?.pageCount,
wordCount: bookInfo.value?.wordCount
})
// 第三步:上传实拍图片
const random5 = String(Math.floor(Math.random() * 100000)).padStart(5, '0')
const pictureName = productId ? `${productId}-${isbn}-${random5}.jpg` : null
if (pictureName) {
lastLivingPictureName.value = pictureName
await uploadLivingPicture(base64Data, pictureName, isbn)
// 第四步:更新商品的 live_image 为实拍图片地址
await updateProductLiveImage(productId, pictureName, {
barcode: isbn,
name: bookInfo.value?.bookName || '',
price: productPrice,
appearance: injectedQuality.value
})
// 第四步半:第二次调用 /api/product/save 后,调用 goods/query
if (hasActiveWave.value) {
await queryGoodsApi(isbn, productId, String(injectedQuality.value))
} else {
pendingGoodsQueryQueue.push({
isbn,
outId: productId.toString(),
quality: String(injectedQuality.value),
productId
})
console.log(`[goods/query] 无活跃波次,暂存队列(当前数量: ${pendingGoodsQueryQueue.length})`)
}
// 第五步:上传完成后 emit使用网络 URL
const photoUrl = `https://shxy.image.yushutx.com/living-picture/${pictureName}`
// 更新照片预览
emit('update:modelValue', {
...props.modelValue,
photoSrc: photoUrl
})
// 通知已扫描书籍列表
if (bookInfo.value) {
bookInfo.value.capturedPhoto = photoUrl
// 获取商品售价
let salePrice = null
if (productId) {
try {
const req = (await import('@/utils/request')).default
const listRes = await req.get('/product/list', {
params: { status: 1, page: 1, page_size: 20, 'ids[0]': productId }
})
console.log('[OCR] /product/list 响应:', listRes)
const list = listRes?.data?.list || listRes?.data?.data || listRes?.data?.rows || []
console.log('[OCR] 提取的列表:', list, '第一条:', list[0])
if (list.length > 0) {
salePrice = list[0].sale_price
console.log('[OCR] salePrice:', salePrice)
}
} catch (e) {
// console.error('[OCR] 获取商品售价失败:', e)
}
}
emit('photo-preview-confirm', {
bookInfo: bookInfo.value,
photoSrc: photoUrl,
productId,
salePrice
})
barcodeStatus.value = '已添加到已扫描书籍'
// 自动追加到当前波次(如果有活跃波次)
if (hasActiveWave.value) {
await nextTick()
await handleAppendToWave()
}
} else {
barcodeStatus.value = 'OCR 识别成功(无书籍信息)'
}
} else {
barcodeStatus.value = '商品保存失败,无法上传图片'
barcodeError.value = true
}
barcodeSuccess.value = true
barcodeError.value = false
isProcessing.value = false
// 清空 OCR 捕获的图片
ocrCapturedBase64.value = ''
// 恢复扫码输入框焦点
setTimeout(() => {
const inputElement = document.querySelector('.hidden-scanner-input') as HTMLInputElement
if (inputElement && !isOtherInputFocused()) {
inputElement.focus()
}
}, 100)
}
/**
* 保存商品(/api/product/save)
* 参数:name, barcode, price, live_image[0] = book_pic_s
* 返回:product ID(成功时),null(失败时)
*/
async function saveProductBeforeUpload({ isbn, name, price, bookPicS, appearance, binding, pageCount, wordCount, productId: existingId = 0 }) {
try {
const { saveProduct } = await import('@/api/product')
// 如有已有 productId 则更新否则新建id=0
const id = existingId
const res = await saveProduct({
id,
category_id: 1,
standard_product_id: 1,
name,
barcode: isbn,
price,
is_batch_managed: 0,
is_shelf_life_managed: 0,
status: 1,
appearance: appearance ?? props.quality ?? 85,
live_image: bookPicS ? [bookPicS] : [],
binding: binding ?? undefined,
page_count: pageCount ? parseInt(String(pageCount)) : undefined,
word_count: wordCount ? parseInt(String(wordCount)) : undefined
})
const productId = res?.data?.id ?? null
console.log(res.data)
console.log('[保存商品] 成功, ID:', productId, '| ISBN:', isbn)
return productId
} catch (err) {
console.warn('[保存商品] 失败:', err instanceof Error ? err.message : String(err))
return null
}
}
/**
* 创建波次(第一步:调用 /api/purchase-order/create-with-wave)
*/
async function handleCreateWave() {
if (!checkCarSelected()) return
try {
// 获取仓库ID:优先从 props(Wave.vue 传入的 computed 已 unwrap)
let warehouseId: number | null = props.warehouseId ?? null
// 备用方案:从 carRef inject 获取
if (!warehouseId && injectedCarRef?.value) {
const wh = injectedCarRef.value.selectedWarehouseId
warehouseId = wh
console.log('[创建波次] 从 injectedCarRef 获取 warehouseId:', warehouseId)
}
console.log('[创建波次] 最终 warehouseId:', warehouseId)
if (!warehouseId) {
ElMessage.warning({ message: '请先选择仓库', duration: 1000, customClass: 'scan-warning-message' })
return
}
// 构建 items 数组:遍历 goosList 每一条记录,直接使用每条记录的 productId
const allGoods = getGoosListInstance()?.getAllGoods?.() ?? []
console.log('[创建波次] allGoods from getAllGoods:', JSON.stringify(allGoods))
const items: { product_id: number; quantity: number; unit_price: number }[] = []
for (const g of allGoods) {
const productId = g.id
if (!productId) continue
console.log(`[创建波次] item: productId=${productId}, totalBook=${g.totalBook}, ownPrice=${g.ownPrice}`)
items.push({
product_id: productId,
quantity: g.totalBook,
unit_price: g.ownPrice
})
}
console.log('[创建波次] final items:', JSON.stringify(items))
if (items.length === 0) {
ElMessage.warning({
message: '没有已扫描的书籍<br>请先扫描一本书籍拍照扫描后再创建波次',
duration: 1000,
customClass: 'scan-warning-message',
dangerouslyUseHTMLString: true // 添加这一行
})
return
}
const carId = injectedCarId.value
const carCode = injectedCarCode.value
const carCapacity = injectedCarCapacity.value
console.log('[创建波次] carId:', carId, '| carCode:', carCode, '| carCapacity:', carCapacity)
// 调用创建波次接口
const res = await createPurchaseOrderWithWave({
warehouse_id: warehouseId,
supplier_id: 1,
expected_arrival_date: '',
remark: '',
items,
direction: 1,
car_id: carId,
car_code: carCode
})
console.log('[创建波次] 响应:', res.data)
// 第二步:释放波次 /api/wave/release
const waveId = res.data?.wave_id
const purchaseOrderId = res.data?.order_id
if (waveId && purchaseOrderId) {
try {
// 获取小车信息
const carId = injectedCarId.value
const carCode = injectedCarCode.value
const carCapacity = injectedCarCapacity.value
console.log('[创建波次] carId:', carId, '| carCode:', carCode, '| carCapacity:', carCapacity)
const releaseRes = await releaseWave({
wave_id: waveId,
related_order_id: purchaseOrderId,
items,
car_id: carId,
car_code: carCode
})
console.log('[释放波次] 响应:', releaseRes.data)
// 保存波次号用于生成条形码
const releasedWaveNo = releaseRes.data?.wave_no || releaseRes.wave_no
if (releasedWaveNo) {
waveNo.value = releasedWaveNo
// 保存当前活跃波次状态,后续可追加
currentWaveId.value = waveId
currentOrderId.value = purchaseOrderId
currentWarehouseId.value = warehouseId
// 处理堆积的 goods/query 队列
await processPendingGoodsQueryQueue()
// 标记所有书籍为已提交到波次
const goosListInstance = getGoosListInstance()
goosListInstance?.markAllCommitted?.()
ElMessage.success({ message: `波次创建并释放成功,波次号:${releasedWaveNo}`, duration: 1000, customClass: 'scan-success-message' })
} else {
ElMessage.success({ message: '波次创建并释放成功', duration: 1000, customClass: 'scan-success-message' })
}
} catch (releaseErr) {
console.warn('[释放波次] 失败:', releaseErr instanceof Error ? releaseErr.message : String(releaseErr))
ElMessage.warning({ message: '波次已创建,但释放失败: ' + (releaseErr instanceof Error ? releaseErr.message : String(releaseErr)), duration: 1000, customClass: 'scan-warning-message' })
}
} else {
ElMessage.success({ message: '波次创建成功,但响应中缺少 wave_id 或 order_id,无法自动释放', duration: 1000, customClass: 'scan-success-message' })
}
} catch (err) {
console.warn('[创建波次] 失败:', err instanceof Error ? err.message : String(err))
errorBlocker?.block('波次创建失败: ' + (err instanceof Error ? err.message : String(err)))
}
}
/**
* 向当前活跃波次追加商品
* 调用 /api/wave/release,传递 currentWaveId 和 currentOrderId,后端会追加到同一波次
* 只传递尚未提交到波次的书籍(通过 _committed 标记追踪)
*/
async function handleAppendToWave() {
if (!checkCarSelected()) return
if (!currentWaveId.value || !currentOrderId.value) {
ElMessage.warning({ message: '当前没有活跃波次,请先创建波次', duration: 1000, customClass: 'scan-warning-message' })
return
}
// 检查波次状态:已开始拣货时阻止追加
try {
const res = await getWaveStatusById(currentWaveId.value)
const status = res?.data?.status
if (status !== undefined && status !== 1 && status !== 2) {
errorBlocker?.block('该波次已开始拣货不能继续追加商品,请扫描波次码清空当前波次,重新扫描书籍')
return
}
} catch (err) {
console.error('[追加波次] 波次状态查询失败:', err)
}
try {
// 获取未提交到波次的书籍
const goosListInstance = getGoosListInstance()
const uncommittedGoods = goosListInstance?.getUncommittedGoods?.() ?? []
if (uncommittedGoods.length === 0) {
ElMessage.warning({ message: '没有新扫描的书籍可追加', duration: 1000, customClass: 'scan-warning-message' })
return
}
// 构建 items 数组:每个未提交的书籍都作为独立条目,直接使用每条记录的 productId
console.log('[追加波次] uncommittedGoods:', JSON.stringify(uncommittedGoods))
const newItems: { product_id: number; quantity: number; unit_price: number }[] = []
for (const g of uncommittedGoods) {
const productId = g.id
if (!productId) continue
console.log(`[追加波次] item: productId=${productId}, totalBook=${g.totalBook}, ownPrice=${g.ownPrice}`)
newItems.push({
product_id: productId,
quantity: g.totalBook,
unit_price: g.ownPrice
})
}
console.log('[追加波次] final newItems:', JSON.stringify(newItems))
if (newItems.length === 0) {
ElMessage.warning({ message: '没有可追加的书籍(商品未保存)', duration: 1000, customClass: 'scan-warning-message' })
return
}
// 获取小车信息
const carId = injectedCarId.value
const carCode = injectedCarCode.value
const carCapacity = injectedCarCapacity.value
console.log('[追加波次] carId:', carId, '| carCode:', carCode, '| carCapacity:', carCapacity)
const releaseRes = await releaseWave({
wave_id: currentWaveId.value,
related_order_id: currentOrderId.value,
items: newItems,
car_id: carId,
car_code: carCode
})
console.log('[追加波次] 响应:', releaseRes.data)
// 标记所有书籍为已提交
goosListInstance?.markAllCommitted?.()
const releasedWaveNo = releaseRes.data?.wave_no || releaseRes.wave_no
if (releasedWaveNo) {
waveNo.value = releasedWaveNo
}
ElMessage.success({ message: `已追加到波次 ${releasedWaveNo || ''},新增 ${newItems.length} 本书籍`, duration: 1000, customClass: 'scan-success-message' })
} catch (err) {
console.warn('[追加波次] 失败:', err instanceof Error ? err.message : String(err))
errorBlocker?.block('追加波次失败: ' + (err instanceof Error ? err.message : String(err)))
}
}
/**
* 创建新波次:清空所有数据,重新开始
* 点击后会清空扫描列表、波次状态,用户需要重新扫描后创建新波次
*/
async function handleCreateNewWave() {
if (!checkCarSelected()) return
// 1. 清空书籍列表(通过 waveComponent.goosListRef,注意 goosListRef 是 Vue ref,需 .value)
const goosListInstance = getGoosListInstance()
if (goosListInstance && typeof goosListInstance.clearAll === 'function') {
goosListInstance.clearAll()
}
// 2. 清空 isbnProductIdMap
isbnProductIdMap.clear()
// 3. 清空波次状态
currentWaveId.value = null
currentOrderId.value = null
currentWarehouseId.value = null
waveNo.value = ''
// 4. 清空 bookInfo
bookInfo.value = null
// 5. 清空 modelValue(photoSrc/isbn/firstPagePhoto/barcode)
emit('update:modelValue', {
photoSrc: '',
isbn: '',
firstPagePhoto: '',
barcode: ''
})
// 6. 清空 Wave.vue 中的状态(currentGoods 等)
if (waveComponent.value) {
; (waveComponent.value as any).currentGoods = null
}
// 7. 清空条形码显示
barcodeImageBase64.value = ''
barcodeLoading.value = false
ElMessage.success({ message: '已清空,请重新扫描图书', duration: 1000, customClass: 'scan-success-message' })
}
/**
* 检测到波次号扫码时的处理
* 1. 调用接口验证波次号是否存在
* 2. 存在则弹窗确认是否切换
* 3. 确认则切换到该波次,取消则静默略过
*/
async function handleWaveNoDetected(waveNoStr: string) {
try {
const { fetchWaveTaskByNo } = await import('@/api/waveTask')
const waveData = await fetchWaveTaskByNo(waveNoStr)
if (!waveData) {
// 不是有效的波次号,静默略过
console.log('[波次号识别] 未找到波次:', waveNoStr)
return
}
// 弹窗确认
try {
await ElMessageBox.confirm(
`检测到扫描波次号 ${waveNoStr},是否切换到该波次,未追加部分书籍信息会丢失。`,
'切换波次',
{
confirmButtonText: '切换',
cancelButtonText: '取消',
type: 'warning'
}
)
// 用户确认,切换波次
await switchToWave(waveData)
} catch {
// 用户取消,静默略过
}
} catch (err) {
console.warn('[波次号查询] 失败:', err instanceof Error ? err.message : String(err))
// API错误静默略过
}
}
/**
* 切换到指定的波次
* 清空当前扫描状态,更新波次和仓库信息
*/
async function switchToWave(waveData: any) {
// 1. 清空书籍列表
const goosListInstance = getGoosListInstance()
goosListInstance?.clearAll?.()
// 2. 清空 isbnProductIdMap
isbnProductIdMap.clear()
// 3. 更新波次状态
currentWaveId.value = waveData.wave_id || waveData.id
currentOrderId.value = waveData.related_order_id || waveData.order_id
currentWarehouseId.value = waveData.warehouse_id
waveNo.value = waveData.wave_no
// 4. 清空书籍信息预览
bookInfo.value = null
// 5. 清空 modelValue
emit('update:modelValue', {
photoSrc: '',
isbn: '',
firstPagePhoto: '',
barcode: ''
})
// 6. 清空 Wave.vue 中的 currentGoods
if (waveComponent.value) {
; (waveComponent.value as any).currentGoods = null
}
// 7. 清空条形码显示
barcodeImageBase64.value = ''
barcodeLoading.value = false
// 8. 切换仓库选择器到该波次的仓库
if (waveData.warehouse_id && waveComponent.value && typeof (waveComponent.value as any).switchWarehouse === 'function') {
; (waveComponent.value as any).switchWarehouse(waveData.warehouse_id)
}
ElMessage.success({ message: `已切换到波次 ${waveData.wave_no}`, duration: 1000, customClass: 'scan-success-message' })
}
/**
* 波次选择器下拉展开时加载波次列表
*/
async function handleWaveDropdownVisible(visible: boolean) {
if (!visible) return
waveSearchLoading.value = true
try {
const { fetchWaveTaskList } = await import('@/api/waveTask')
const res = await fetchWaveTaskList({
page: 1,
pageSize: 100
})
// 从波次任务列表中提取不重复的波次号
const taskList = res.list || []
const seen = new Set<string>()
const waves: any[] = []
for (const task of taskList) {
const wn = task.wave_no
if (wn && !seen.has(wn)) {
seen.add(wn)
waves.push({ wave_no: wn, id: task.id, wave_id: task.wave_id })
}
}
waveSearchResults.value = waves
console.log('[波次选择] 接收到的数据:', JSON.parse(JSON.stringify(waves)))
} catch {
waveSearchResults.value = []
} finally {
waveSearchLoading.value = false
}
}
/**
* 波次选择器选中波次
*/
async function handleWaveSelect(waveId: string | number) {
if (!waveId) return
try {
const { fetchWaveById } = await import('@/api/waveTask')
const waveData = await fetchWaveById(waveId)
if (!waveData) {
ElMessage.warning({ message: '未找到该波次', duration: 1000, customClass: 'scan-warning-message' })
return
}
try {
await ElMessageBox.confirm(
`确定切换到波次 ${waveData.wave_no || waveId},未追加部分书籍信息会丢失。`,
'切换波次',
{
confirmButtonText: '切换',
cancelButtonText: '取消',
type: 'warning'
}
)
await switchToWave(waveData)
} catch {
// 用户取消
}
} catch (err) {
console.warn('[波次选择器] 查询失败:', err instanceof Error ? err.message : String(err))
} finally {
// 清空选择器显示
selectedWaveNo.value = ''
}
}
/**
* 生成波次条形码
* 调用 /api/barcode/generate 接口,使用 wave_no 生成条形码图片
*/
async function generateWaveBarcode() {
if (!checkCarSelected()) return
if (!waveNo.value) {
ElMessage.warning({ message: '波次号为空,无法生成条形码', duration: 1000, customClass: 'scan-warning-message' })
return
}
barcodeLoading.value = true
try {
const res = await generateBarcode(waveNo.value)
const resData = res as any
const imageBase64 = resData.data?.image_base64
if (resData.code === 200 && imageBase64) {
barcodeImageBase64.value = imageBase64
ElMessage.success({ message: '条形码生成成功', duration: 1000, customClass: 'scan-success-message' })
} else {
errorBlocker?.block('条形码生成失败:' + (resData.msg || resData.message || '未知错误'))
}
} catch (err) {
console.warn('[生成条形码] 失败:', err instanceof Error ? err.message : String(err))
errorBlocker?.block('条形码生成失败: ' + (err instanceof Error ? err.message : String(err)))
} finally {
barcodeLoading.value = false
}
}
/**
* 处理扫码输入变化
* 兼容不带 Enter 的扫码枪:输入变化时立即检测快捷键或长输入自动触发。
* 注意:带 Enter 的扫码枪仍会触发 @keyup.enter 的 handleScanhandleScan 内部
* 会清空输入框防重入,因此重复触发时第二次检测到空值直接 return。
*/
const handleScanInput = () => {
// 页面锁定时不处理任何扫码输入
if (isPageLocked.value) return
const code = scannedCode.value.trim().toLowerCase()
// ---- 1. 优先检测快捷键(短文本,立即触发) ----
const shortcutAction = SCAN_SHORTCUT_MAP[code]
if (shortcutAction) {
console.log('[handleScanInput] 识别到快捷键:', code)
scannedCode.value = '' // 立即清空,防重入
shortcutAction()
return
}
// ---- 2. 检测到过长的输入ISBN/常规条码),自动触发 handleScan ----
// 有些扫码枪不发送 Enter当输入超过一定长度时自动处理
if (code.length >= 8) {
// 防抖:清除之前的定时器,确保最后一次输入停顿后再处理,避免扫码枪字符间隙导致截断
if (scanInputTimer) {
clearTimeout(scanInputTimer)
scanInputTimer = null
}
scanInputTimer = setTimeout(() => {
scanInputTimer = null
if (scannedCode.value.trim().length >= 8) {
handleScan()
}
}, 200)
}
}
/**
* 扫码快捷键映射表
* 当扫码枪扫到这些文本时,执行对应的快捷键动作
*/
const SCAN_SHORTCUT_MAP: Record<string, () => void> = {
'alt+a': () => handlePhotoAction(),
'alt+b': () => generateWaveBarcode(),
'alt+c': () => { scanMode.value = scanMode.value === 'scan' ? 'recognize' : 'scan' },
'alt+x': () => hasActiveWave.value ? handleCreateNewWave() : handleCreateWave(),
}
/**
* 处理扫码完成
* 注意handleScanInput 中长输入(>=8字符的 50ms 延迟兜底也可能触发此函数,
* 因此内部先立即清空 scannedCode 防止重复处理(防重入)。
*/
async function handleScan() {
// 页面锁定时不处理任何扫码操作
if (isPageLocked.value) {
scannedCode.value = ''
return
}
const code = scannedCode.value.trim()
if (!code) return
// 扫码操作解除错误阻断状态
errorBlocker?.unblock()
// 没选择小车的情况下,提示并阻止扫码操作
if (!checkCarSelected()) {
scannedCode.value = ''
return
}
// 清理防抖定时器,防止与 Enter 触发产生竞态
if (scanInputTimer) {
clearTimeout(scanInputTimer)
scanInputTimer = null
}
// 立即清空输入框,防止 handleScanInput 的 setTimeout 兜底再次触发 handleScan
scannedCode.value = ''
// 检测扫码快捷键:如果扫描内容匹配快捷键文本,执行对应动作
const lowerCode = code.toLowerCase().trim()
console.log('进入监听'), console.log('扫码输入:', code, '| 规范化后:', lowerCode)
const shortcutAction = SCAN_SHORTCUT_MAP[lowerCode]
if (shortcutAction) {
console.log('[扫码快捷键] 识别到快捷键:', lowerCode)
shortcutAction()
return
}
// 优先检测波次号如果不是有效ISBN且匹配波次号格式尝试切换波次
const normalizedCode = normalizeBarcode(code)
const isISBN = isValidISBN13(normalizedCode) || isValidISBN10(normalizedCode)
if (!isISBN && isWaveNoPattern(code)) {
await handleWaveNoDetected(code)
return
}
// 原有ISBN处理逻辑
const extractedIsbn = extractISBN(code)
await handleScannedCode(extractedIsbn || code)
}
/**
* 从扫码数据中提取ISBN
*/
function extractISBN(data: string): string {
// 尝试提取13位数字的ISBN
const isbn13Match = data.match(/\d{13}/)
if (isbn13Match) {
return isbn13Match[0]
}
// 尝试提取10位数字的ISBN
const isbn10Match = data.match(/\d{9}[\dXx]/)
if (isbn10Match) {
return isbn10Match[0]
}
// 尝试提取包含连字符的ISBN
const isbnWithDashesMatch = data.match(/\d{1,5}[- ]\d{1,7}[- ]\d{1,6}[- ]\d{1,3}/)
if (isbnWithDashesMatch) {
return isbnWithDashesMatch[0]
}
return ''
}
/**
* 处理扫码结果
* 扫码枪只用于获取二维码数据,不自动拍照
*/
async function handleScannedCode(code: string) {
// 扫码操作解除错误阻断状态
errorBlocker?.unblock()
const normalizedCode = normalizeBarcode(code)
console.log('规范化后的条码:', normalizedCode)
console.log('条码长度:', normalizedCode.length)
// 清空照片预览(扫码只获取数据,不拍照)
const newFormData = {
...props.modelValue,
photoSrc: '', // 清空照片预览
isbn: '',
barcode: ''
}
emit('update:modelValue', newFormData)
// 有活跃波次时,先查询波次状态
if (hasActiveWave.value && currentWaveId.value) {
try {
const res = await getWaveStatusById(currentWaveId.value)
console.log('[WaveStatus] 波次状态:', res)
const status = res?.data?.status
// status 不为 1 或 2 时,说明波次已开始拣货,阻止后续操作
if (status !== undefined && status !== 1 && status !== 2) {
errorBlocker?.block('该波次已开始拣货不能继续追加商品,请扫描波次码清空当前波次,重新扫描书籍')
return
}
} catch (err) {
console.error('[WaveStatus] 查询失败:', err)
}
}
// 尝试多种ISBN格式验证
if (isValidISBN13(normalizedCode) || isValidISBN10(normalizedCode)) {
// 使用规范化后的条码
const finalIsbn = isValidISBN13(normalizedCode) ? normalizedCode : convertISBN10to13(normalizedCode)
// 更新条码数据
emit('update:modelValue', {
...newFormData,
isbn: finalIsbn,
barcode: finalIsbn
})
// 加载书籍信息
loadBookInfo(finalIsbn, true)
// 保存ISBN供拍照上传使用
lastScannedIsbn.value = finalIsbn
// 显示扫码成功状态
barcodeStatus.value = `扫码成功:${finalIsbn}`
barcodeSuccess.value = true
barcodeError.value = false
ElMessage.success({ message: `扫码成功:${finalIsbn}`, duration: 1000, customClass: 'scan-success-message' })
} else {
console.log('ISBN验证失败,尝试直接使用原始条码')
// 即使验证失败,也尝试使用原始条码
// 更新条码数据
emit('update:modelValue', {
...newFormData,
isbn: normalizedCode,
barcode: normalizedCode
})
// 尝试加载书籍信息
loadBookInfo(normalizedCode, true)
// 保存ISBN供拍照上传使用
lastScannedIsbn.value = normalizedCode
// 显示扫码失败状态并阻断后续操作
barcodeStatus.value = `扫码失败:${normalizedCode}`
barcodeSuccess.value = false
barcodeError.value = true
errorBlocker?.block(`扫码失败:${normalizedCode}`)
return
}
}
/**
* 校验 ISBN-10
*/
function isValidISBN10(barcode: string): boolean {
const digits = normalizeBarcode(barcode)
if (!/^\d{9}[\dXx]$/.test(digits)) return false
let sum = 0
for (let i = 0; i < 9; i++) {
const n = Number(digits[i])
sum += n * (10 - i)
}
const checkDigit = digits[9]
const calculatedCheckDigit = (11 - (sum % 11)) % 11
const checkDigitValue = checkDigit === 'X' || checkDigit === 'x' ? 10 : Number(checkDigit)
return checkDigitValue === calculatedCheckDigit
}
/**
* 将 ISBN-10 转换为 ISBN-13
*/
function convertISBN10to13(isbn10: string): string {
const digits = normalizeBarcode(isbn10).slice(0, 9)
const isbn13WithoutCheck = '978' + digits
let sum = 0
for (let i = 0; i < 12; i++) {
const n = Number(isbn13WithoutCheck[i])
sum += i % 2 === 0 ? n : n * 3
}
const checkDigit = (10 - (sum % 10)) % 10
return isbn13WithoutCheck + checkDigit
}
/**
* 检查当前是否有其他可交互元素正在获得焦点
* 如果有,则不应该抢夺焦点给扫码输入框
*
* 覆盖场景:
* - 原生控件:input / textarea / select / contentEditable
* - Element Plus 组件:el-select(role=combobox)、el-cascader、el-date-picker、
* el-input-number、el-radio-group、el-checkbox-group、el-switch、el-slider 等
* - 任何带有 role=textbox/combobox/listbox/searchbox/slider 的 ARIA 控件
*/
function isOtherInputFocused(): boolean {
const activeEl = document.activeElement as HTMLElement
if (!activeEl) return false
// 1. 扫码输入框自身 → 不算"其他"
if (activeEl.classList.contains('hidden-scanner-input')) return false
// 2. 原生表单控件
const tagName = activeEl.tagName.toLowerCase()
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') return true
// 3. contentEditable 元素
if (activeEl.isContentEditable) return true
// 4. ARIA 交互角色(Element Plus 下拉框、输入框等都带这些 role)
const role = activeEl.getAttribute('role')
const interactiveRoles = ['textbox', 'combobox', 'listbox', 'searchbox', 'slider', 'spinbutton', 'switch', 'checkbox', 'radio', 'treeitem', 'option']
if (role && interactiveRoles.includes(role)) return true
// 5. Element Plus 组件:焦点可能落在 .el-input__inner / .el-textarea__inner 等内部元素上
if (activeEl.classList.contains('el-input__inner') ||
activeEl.classList.contains('el-textarea__inner') ||
activeEl.classList.contains('el-input-number__decrease') ||
activeEl.classList.contains('el-input-number__increase')) {
return true
}
// 6. Element Plus 下拉弹层中的元素(el-select / el-cascader / el-date-picker 打开后的选项)
// 弹层挂在 body 下,焦点可能落在 .el-select-dropdown / .el-cascader-panel 等
const elPopupParent = activeEl.closest('.el-select-dropdown, .el-cascader-panel, .el-date-picker, .el-picker-panel, .el-table__body')
if (elPopupParent) return true
// 7. 任何 .el-input / .el-textarea 容器内的焦点(兜底)
const elInputParent = activeEl.closest('.el-input, .el-textarea, .el-input-number, .el-radio-group, .el-checkbox-group, .el-switch, .el-slider')
if (elInputParent) return true
return false
}
// 全局点击事件处理函数
const handleGlobalClick = (event: MouseEvent) => {
// 只有在扫描模式为'scan'时才聚焦输入框
if (scanMode.value !== 'scan' || isProcessing.value) return
const target = event.target as HTMLElement
// 点击了这些元素,不抢焦点(用户可能在复制/操作这些内容)
if (isInteractiveTarget(target)) return
if (target.tagName.toLowerCase() === 'img') return
if (target.tagName.toLowerCase() === 'video') return
if (target.tagName.toLowerCase() === 'canvas') return
if (target.classList.contains('wave-barcode-section')) return
if (target.classList.contains('barcode-display-section')) return
if (target.classList.contains('barcode-image-container')) return
if (target.classList.contains('wave-no-display')) return
if (target.tagName.toLowerCase() === 'label') return
// 延迟检查,确保点击后焦点已转移
setTimeout(() => {
if (isOtherInputFocused()) return
const inputElement = document.querySelector('.hidden-scanner-input') as HTMLInputElement
if (inputElement) {
inputElement.focus()
}
}, 100)
}
/**
* 判断点击目标是否为可交互元素(不需要抢焦点的目标)
* 用于 click 事件快速判断
*/
function isInteractiveTarget(target: HTMLElement): boolean {
const tagName = target.tagName.toLowerCase()
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') return true
if (target.isContentEditable) return true
// Element Plus 按钮、下拉、开关等
if (target.closest('.el-input, .el-textarea, .el-select, .el-cascader, .el-date-picker, .el-picker-panel, .el-input-number, .el-radio-group, .el-checkbox-group, .el-switch, .el-slider, .el-button, .el-dropdown, .el-tooltip__popper, .el-select-dropdown, .el-cascader-panel, .el-table')) {
return true
}
// 通用可交互属性
const role = target.getAttribute('role')
const interactiveRoles = ['textbox', 'combobox', 'listbox', 'searchbox', 'slider', 'spinbutton', 'switch', 'checkbox', 'radio', 'button', 'treeitem', 'option', 'tab', 'menuitem']
if (role && interactiveRoles.includes(role)) return true
return false
}
// 键盘事件处理函数
const handleKeyDown = (event: KeyboardEvent) => {
// 页面锁定状态下拦截所有快捷键操作
if (isPageLocked.value) {
event.preventDefault()
event.stopImmediatePropagation()
return
}
// 检测 alt+a 快捷键
if (event.altKey && event.key === 'a') {
handlePhotoAction()
event.preventDefault()
}
// 检测 alt+c 快捷键:切换扫描模式
if (event.altKey && event.key === 'c') {
scanMode.value = scanMode.value === 'scan' ? 'recognize' : 'scan'
event.preventDefault()
}
// 检测 alt+x 快捷键:创建波次(无活跃波次时创建新波次,有活跃波次时清空重新开始)
if (event.altKey && event.key === 'x') {
hasActiveWave.value ? handleCreateNewWave() : handleCreateWave()
event.preventDefault()
}
// 检测 alt+b 快捷键:生成波次条形码
if (event.altKey && event.key === 'b') {
generateWaveBarcode()
event.preventDefault()
}
}
/**
* 全局 focusin 事件:当焦点从其他输入框离开后,自动聚焦扫码输入框
* 注意:扫码输入框自身获得焦点时也会触发,需排除
*/
const handleGlobalFocusIn = (event: FocusEvent) => {
if (scanMode.value !== 'scan' || isProcessing.value) return
const target = event.target as HTMLElement
// 扫码输入框自身获得焦点,无需处理
if (target.classList.contains('hidden-scanner-input')) return
// 其他可交互元素获得焦点,不抢夺
if (isOtherInputFocused()) return
// 延迟聚焦,等焦点稳定
setTimeout(() => {
if (isOtherInputFocused()) return
const inputElement = document.querySelector('.hidden-scanner-input') as HTMLInputElement
if (inputElement && document.activeElement !== inputElement) {
inputElement.focus()
}
}, 200)
}
/**
* 页面关闭/刷新前释放摄像头资源,防止浏览器缓存流占用摄像头
*/
function handleBeforeUnload() {
closeCamera()
}
onMounted(() => {
openCamera()
// 添加 beforeunload 事件,确保页面刷新/关闭时释放摄像头
window.addEventListener('beforeunload', handleBeforeUnload)
// 聚焦输入框,准备接收扫码枪输入(仅当当前无其他输入获得焦点时)
setTimeout(() => {
if (!isOtherInputFocused()) {
const inputElement = document.querySelector('.hidden-scanner-input') as HTMLInputElement
if (inputElement) {
inputElement.focus()
}
}
}, 500)
// 添加全局事件监听
document.addEventListener('click', handleGlobalClick)
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('focusin', handleGlobalFocusIn)
document.addEventListener('selectstart', onSelectStart)
document.addEventListener('mouseup', onMouseUp)
})
onBeforeUnmount(() => {
closeCamera()
window.removeEventListener('beforeunload', handleBeforeUnload)
// 移除全局事件监听
document.removeEventListener('click', handleGlobalClick)
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('focusin', handleGlobalFocusIn)
document.removeEventListener('selectstart', onSelectStart)
document.removeEventListener('mouseup', onMouseUp)
})
// 监听文本选择开始/结束,临时隐藏隐藏输入框防止抢焦点
function onSelectStart() {
const inputElement = document.querySelector('.hidden-scanner-input') as HTMLInputElement
if (inputElement) {
inputElement.classList.add('selection-active')
}
}
function onMouseUp() {
// 延迟清除,恢复扫码枪焦点功能
setTimeout(() => {
const inputElement = document.querySelector('.hidden-scanner-input') as HTMLInputElement
if (inputElement) {
inputElement.classList.remove('selection-active')
}
}, 300)
}
/**
* 智能格式化出版日期
* - 已经是 YYYY-MM 格式 → 直接使用
* - Unix 时间戳(秒/毫秒)→ 转换为 YYYY-MM
* - 其他格式 → 原样返回
*/
function formatPublishDate(value: any): string {
if (!value) return ''
// 已经是 YYYY-MM 格式,直接返回
if (typeof value === 'string' && /^\d{4}-\d{2}$/.test(value)) {
return value
}
// 时间戳转换
let date: Date | null = null
if (typeof value === 'number') {
date = new Date(value > 1e12 ? value : value * 1000)
} else if (typeof value === 'string' && /^\d+$/.test(value)) {
// 纯数字字符串,视为时间戳
const num = Number(value)
date = new Date(num > 1e12 ? num : num * 1000)
}
if (date && !isNaN(date.getTime())) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
}
return String(value)
}
// 查询书籍信息
async function loadBookInfo(isbn: string, previewOnly = false) {
if (!isbn) {
bookInfo.value = null
return
}
// 同步保存 ISBN供拍照上传使用Car 搜索查询流程不走 handleScannedCode需在此处设置
lastScannedIsbn.value = isbn
try {
const payload = await getBookInfo(isbn)
const data = payload?.data
if (!data) {
bookInfo.value = null
ElMessage.warning({ message: '数据库中暂无该书数据请使用OCR识别', duration: 1000, customClass: 'scan-warning-message' })
return
}
// 检测是否为套装书 (is_suit === 1)
if (data.is_suit === 1) {
currentSuitIsbn.value = isbn
// 保存 book_pic_s.pddResponse
lastBookPicS.value = data.book_pic_s?.pddResponse || ''
// 先构建书籍数据并更新预览,再弹窗
const suitPreviewBookPic = data.live_image?.[0]
? { localPath: data.live_image[0], pddPath: data.live_image[0] }
: (data.book_pic || undefined)
const bookData = {
bookName: data.book_name || '',
author: data.author || '',
publisher: data.publisher || '',
publishDate: formatPublishDate(data.publication_time),
binding: data.binding_layout || '',
price: typeof data.fix_price === 'number' ? data.fix_price : 0,
pageCount: Number(data.page_count) || 0,
wordCount: Number(data.word_count) || 0,
book_pic: suitPreviewBookPic,
totalBook: 1,
ownPrice: 0,
salePrice: 0,
isbn: isbn,
isSuit: true
}
bookInfo.value = bookData
emit('book-info-update', bookData)
// 保存书籍信息,用于套装书弹窗自定义添加项的预填
currentSuitBookInfo.value = {
bookName: data.book_name || '',
author: data.author || '',
publisher: data.publisher || '',
publishDate: formatPublishDate(data.publication_time),
binding: data.binding_layout || '',
price: typeof data.fix_price === 'number' ? data.fix_price : 0,
pageCount: Number(data.page_count) || 0,
wordCount: Number(data.word_count) || 0,
book_pic: suitPreviewBookPic,
}
// 套装书弹窗打开时立即同步数据到第三方平台f_isbn=000f_book_name为空串
const liveImage = data.book_pic?.pddPath || data.book_pic?.localPath || data.book_pic_s?.pddResponse || ''
const syncRes = await callSyncBookApi({
book_name: data.book_name || '',
author: data.author || '',
publisher: data.publisher || '',
publication_time: toPublishTimestamp(String(data.publication_time ?? '')),
binding_layout: data.binding_layout || '',
fix_price: typeof data.fix_price === 'number' ? data.fix_price : 0,
isbn: isbn,
page_count: String(data.page_count || ''),
word_count: String(data.word_count || ''),
book_format: String(data.book_format ?? ''),
fid: 0,
f_isbn: '000',
live_image: liveImage,
type: '2',
})
// 保存返回的 id供后续同步作为 f_id 使用
if (syncRes) {
const r = syncRes as any
currentSuitFid.value = r.id ?? r.data?.id ?? null
}
// 预览模式时,套装书选择后仅更新预览,不执行保存/加入列表
suitPreviewOnly.value = previewOnly
suitBookDialogVisible.value = true
return
}
// 保存 book_pic_s.pddResponse,供保存商品时使用
lastBookPicS.value = data.book_pic_s?.pddResponse || ''
const bookData = {
bookName: data.book_name || '',
author: data.author || '',
publisher: data.publisher || '',
publishDate: formatPublishDate(data.publication_time),
binding: data.binding_layout || '',
price: typeof data.fix_price === 'number' ? data.fix_price : 0,
pageCount: Number(data.page_count) || 0,
wordCount: Number(data.word_count) || 0,
book_pic: data.live_image?.[0]
? { localPath: data.live_image[0], pddPath: data.live_image[0] }
: (data.book_pic || undefined),
totalBook: 1,
ownPrice: 0,
salePrice: 0,
isbn: isbn
}
bookInfo.value = bookData
emit('book-info-update', bookData)
} catch (error) {
console.warn('书籍信息查询失败:', error instanceof Error ? error.message : String(error))
bookInfo.value = null
emit('book-info-update', null)
}
}
/**
* 处理套装书选择
* 当用户从套装书列表中选择一本书后,将该书信息更新到书籍预览
* 自定义项如果有拍摄的照片,则执行完整的上传+保存流程
*/
async function handleSuitBookSelect(book: any, photoSrc?: string, customTotalBook?: number, customOwnPrice?: number) {
// ---- 自定义项且有拍摄的照片:执行上传保存流程 ----
if (photoSrc && photoSrc.startsWith('data:')) {
await processCustomItemUpload(book, photoSrc, customTotalBook, customOwnPrice)
currentSuitBookInfo.value = null
return
}
// ---- 自定义项无照片(仅编辑文本字段):保存商品并添加到已扫描列表 ----
// customTotalBook 或 customOwnPrice 有值即表示为自定义项API 书籍不会传这些参数)
if (customTotalBook !== undefined || customOwnPrice !== undefined) {
await processCustomItemWithoutPhoto(book, photoSrc, customTotalBook, customOwnPrice)
currentSuitBookInfo.value = null
return
}
// ---- API 选择的书籍:保存商品并添加到已扫描列表 ----
lastBookPicS.value = book.book_pic_s?.pddResponse || ''
const isbn = book.isbn || currentSuitIsbn.value
const bookName = book.book_name || ''
const price = typeof book.fix_price === 'number' ? book.fix_price : 0
const binding = book.binding_layout || ''
const pageCountValue = Number(book.page_count) || 0
const wordCountValue = Number(book.word_count) || 0
const originalBookPic = currentSuitBookInfo.value?.book_pic || book.book_pic
// 拍照预览和已扫描列表使用 /api/getSuitBook 的 live_image套装书列表中映射为 book.book_pic
const liveImgUrl = book.book_pic?.pddPath || book.book_pic?.localPath || ''
const appearance = injectedQuality.value as number | undefined
// 预览模式(来自 Car 搜索/扫码枪预览):仅更新预览,不执行保存/加入列表
if (suitPreviewOnly.value) {
const displayIsbn = isbn + (book.f_isbn ? '-' + book.f_isbn : '')
const displayBookName = bookName + (book.f_book_name ? '-' + book.f_book_name : '')
const bookData = {
bookName: displayBookName,
author: book.author || '',
publisher: book.publisher || '',
publishDate: formatPublishDate(book.publication_time),
binding,
price,
pageCount: pageCountValue,
wordCount: wordCountValue,
totalBook: customTotalBook ?? 1,
ownPrice: customOwnPrice ? Number(customOwnPrice) : 0,
isbn: displayIsbn,
book_pic: book.book_pic || originalBookPic,
isSuit: true, // 标记为套装书,后续拍照核价时走套装书专用逻辑
}
bookInfo.value = bookData as BookInfo
emit('book-info-update', bookData)
currentSuitBookInfo.value = null
suitPreviewOnly.value = false
return
}
barcodeStatus.value = '正在保存商品...'
// 保存商品(使用 API 返回的 live_img 作为实拍图)
const productId = await saveProductBeforeUpload({
isbn, name: bookName, price, bookPicS: liveImgUrl, appearance, binding,
pageCount: pageCountValue, wordCount: wordCountValue,
})
if (!productId) {
ElMessage.warning({ message: '商品保存失败', duration: 1000, customClass: 'scan-warning-message' })
barcodeStatus.value = '商品保存失败'
return
}
// 调用 goods/query套装书不传 isbn传 book_name、author、publisher
const queryBookName = bookName + (book.f_book_name ? '-' + book.f_book_name : '')
if (hasActiveWave.value) {
await queryGoodsApi(isbn, productId, String(injectedQuality.value), {
bookName: queryBookName,
author: book.author || '',
publisher: book.publisher || ''
})
} else {
pendingGoodsQueryQueue.push({
isbn, outId: productId.toString(), quality: String(injectedQuality.value), productId,
bookName: queryBookName,
author: book.author || '',
publisher: book.publisher || '',
isSuit: true,
})
}
// 获取 salePrice
let salePrice = null
try {
const req = (await import('@/utils/request')).default
const listRes = await req.get('/product/list', {
params: { status: 1, page: 1, page_size: 20, 'ids[0]': productId },
})
const list = listRes?.data?.list || listRes?.data?.data || listRes?.data?.rows || []
if (list.length > 0) salePrice = list[0].sale_price
} catch (e) {
console.warn('[套装书] 获取 salePrice 失败:', e)
}
// 构建 bookData书籍预览使用封面图拍照预览使用 API 返回的 live_img
const displayIsbn = isbn + (book.f_isbn ? '-' + book.f_isbn : '')
const displayBookName = bookName + (book.f_book_name ? '-' + book.f_book_name : '')
const bookData = {
bookName: displayBookName,
author: book.author || '',
publisher: book.publisher || '',
publishDate: formatPublishDate(book.publication_time),
binding,
price,
pageCount: pageCountValue,
wordCount: wordCountValue,
totalBook: customTotalBook ?? 1,
ownPrice: customOwnPrice ? Number(customOwnPrice) : 0,
isbn: displayIsbn,
book_pic: originalBookPic,
capturedPhoto: liveImgUrl,
isSuit: true,
}
bookInfo.value = bookData as BookInfo
emit('book-info-update', bookData)
// 拍照预览展示 API 返回的 live_img
emit('update:modelValue', { ...props.modelValue, photoSrc: liveImgUrl })
await nextTick()
emit('photo-preview-confirm', {
bookInfo: bookData, photoSrc: liveImgUrl, productId, salePrice, originalBookPic,
} as any)
barcodeStatus.value = '套装书商品已添加'
if (hasActiveWave.value) {
await nextTick()
await handleAppendToWave()
}
currentSuitBookInfo.value = null
}
/**
* 自定义项照片的上传+保存全流程(与 takePhotoPreview 的拍照后逻辑一致)
*/
async function processCustomItemUpload(
customBook: any,
base64Data: string,
customTotalBook?: number,
customOwnPrice?: number
) {
const isbn = customBook.isbn || currentSuitIsbn.value
const bookName = customBook.book_name || ''
const price = typeof customBook.fix_price === 'number' ? customBook.fix_price : 0
const binding = customBook.binding_layout || ''
const pageCountValue = Number(customBook.page_count) || 0
const wordCountValue = Number(customBook.word_count) || 0
const bookPicS = lastBookPicS.value || ''
const appearance = injectedQuality.value as number | undefined
barcodeStatus.value = '正在保存自定义商品...'
// 第1步:调用 /api/product/save 保存商品,获取 product ID
const { updateProductLiveImageAsPddUrl } = await import('@/api/product')
const productId = await saveProductBeforeUpload({
isbn,
name: bookName,
price,
bookPicS,
appearance,
binding,
pageCount: pageCountValue,
wordCount: wordCountValue
})
if (!productId) {
ElMessage.warning({ message: '自定义商品保存失败', duration: 1000, customClass: 'scan-warning-message' })
barcodeStatus.value = '自定义商品保存失败'
return
}
// 第2步:上传实拍图片到图片服务
const random5 = String(Math.floor(Math.random() * 100000)).padStart(5, '0')
const pictureName = `${productId}-${isbn}-${random5}.jpg`
await uploadLivingPicture(base64Data, pictureName, isbn)
// 第3步:上传到拼多多图片空间
let pddUrl = ''
try {
const CryptoJS = (await import('crypto-js')).default
const timestamp = String(Math.floor(Date.now() / 1000))
const signParams: Record<string, string> = {
type: 'pdd.goods.filespace.image.upload',
access_token: '5f7dcc92211549f3b8b05451288a92fa9546732d',
timestamp,
client_id: '203c5a7ba8bd4b8488d5e26f93052642',
data_type: 'JSON'
}
const sortedKeys = Object.keys(signParams).sort()
let signStr = '892ffaa86e12b7a3d8d2942b669d9aa520ad8179'
for (const key of sortedKeys) {
signStr += key + signParams[key]
}
signStr += '892ffaa86e12b7a3d8d2942b669d9aa520ad8179'
const sign = CryptoJS.MD5(signStr).toString().toUpperCase()
const pddFormData = new FormData()
pddFormData.append('type', signParams.type)
pddFormData.append('access_token', signParams.access_token)
pddFormData.append('timestamp', signParams.timestamp)
pddFormData.append('client_id', signParams.client_id)
pddFormData.append('data_type', signParams.data_type)
pddFormData.append('sign', sign)
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '')
const binaryStr = atob(base64)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
const blob = new Blob([bytes], { type: 'image/jpeg' })
pddFormData.append('file', blob, `${isbn}.jpg`)
const pddRes = await axios.post('https://gw-upload.pinduoduo.com/api/upload', pddFormData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30000
})
const pddResult = pddRes.data
if (pddResult?.goods_filespace_image_upload_response?.image_url) {
pddUrl = pddResult.goods_filespace_image_upload_response.image_url
} else if (pddResult?.goods_filespace_image_upload_response?.url) {
pddUrl = pddResult.goods_filespace_image_upload_response.url
} else if (pddResult?.error_response) {
console.warn('[自定义PDD] 失败:', pddResult.error_response.error_msg)
}
} catch (pddErr) {
console.warn('[自定义PDD] 异常:', pddErr instanceof Error ? pddErr.message : String(pddErr))
}
// 第4步:更新商品 live_image
const photoUrl = pddUrl || bookPicS
await updateProductLiveImageAsPddUrl(productId, photoUrl, {
barcode: isbn,
name: bookName,
price,
appearance: injectedQuality.value
})
// 第5步:调用 goods/query套装书不传 isbn传 book_name、author、publisher
const queryCustomBookName = bookName + (customBook.subTitle ? '-' + customBook.subTitle : '')
if (hasActiveWave.value) {
await queryGoodsApi(isbn, productId, String(injectedQuality.value), {
bookName: queryCustomBookName,
author: customBook.author || '',
publisher: customBook.publisher || ''
})
} else {
pendingGoodsQueryQueue.push({
isbn,
outId: productId.toString(),
quality: String(injectedQuality.value),
productId,
bookName: queryCustomBookName,
author: customBook.author || '',
publisher: customBook.publisher || '',
isSuit: true,
})
}
// 第7步:获取 salePriceupdate:modelValue 移至 book-info-update 之后,避免被 handleBookInfoUpdate 清空)
let salePrice = null
try {
const req = (await import('@/utils/request')).default
const listRes = await req.get('/product/list', {
params: { status: 1, page: 1, page_size: 20, 'ids[0]': productId }
})
const list = listRes?.data?.list || listRes?.data?.data || listRes?.data?.rows || []
if (list.length > 0) {
salePrice = list[0].sale_price
}
} catch (e) {
console.warn('[自定义] 获取 salePrice 失败:', e)
}
// 保存原始接口 book_pic 数据(供已扫描列表使用,不覆盖接口封面)
const originalBookPic = currentSuitBookInfo.value?.book_pic
// 第8步:构建 bookData 并推送到预览和已扫描列表
// book_pic 使用接口原始封面(书籍信息预览展示接口封面,照片预览单独通过 update:modelValue 展示拍摄照片)
const bookData = {
bookName,
author: customBook.author || '',
publisher: customBook.publisher || '',
publishDate: customBook.publication_time || '',
binding,
price,
pageCount: pageCountValue,
wordCount: wordCountValue,
totalBook: customTotalBook ?? 1,
ownPrice: customOwnPrice ? Number(customOwnPrice) : 0,
isbn,
book_pic: originalBookPic,
capturedPhoto: photoUrl
}
bookInfo.value = bookData as BookInfo
emit('book-info-update', bookData)
// book-info-update 后重新设置照片预览handleBookInfoUpdate 会清空 photoSrc
emit('update:modelValue', {
...props.modelValue,
photoSrc: photoUrl
})
// 等待父组件更新 currentGoods 后,再 emit photo-preview-confirm
// 携带 originalBookPic 让父组件在已扫描列表中使用接口原始封面
await nextTick()
emit('photo-preview-confirm', {
bookInfo: bookData,
photoSrc: photoUrl,
productId,
salePrice,
originalBookPic
} as any)
barcodeStatus.value = '自定义商品已添加'
// 自动追加到当前波次
if (hasActiveWave.value) {
await nextTick()
await handleAppendToWave()
}
// 同步书本数据到第三方平台(使用用户拍摄的 PDD 图片 URL
callSyncBookApi({
book_name: bookName,
author: customBook.author || '',
publisher: customBook.publisher || '',
publication_time: toPublishTimestamp(customBook.publication_time),
binding_layout: binding,
fix_price: price,
isbn,
page_count: String(pageCountValue || ''),
word_count: String(wordCountValue || ''),
fid: currentSuitFid.value ?? 0,
f_isbn: customBook.subIsbn || '',
f_book_name: customBook.subTitle || '',
live_image: photoUrl,
type: '2',
})
console.log('[自定义] 上传+保存完成, 商品ID:', productId, '| 图片:', photoUrl)
}
/**
* 自定义项无照片时的保存+添加流程
* 跳过图片上传步骤,但同样保存商品并添加到已扫描列表
*/
async function processCustomItemWithoutPhoto(
customBook: any,
coverUrl?: string,
customTotalBook?: number,
customOwnPrice?: number
) {
const isbn = customBook.isbn || currentSuitIsbn.value
const bookName = customBook.book_name || ''
const price = typeof customBook.fix_price === 'number' ? customBook.fix_price : 0
const binding = customBook.binding_layout || ''
const pageCountValue = Number(customBook.page_count) || 0
const wordCountValue = Number(customBook.word_count) || 0
const bookPicS = lastBookPicS.value || coverUrl || ''
const appearance = injectedQuality.value as number | undefined
barcodeStatus.value = '正在保存自定义商品...'
// 第1步: 调用 /api/product/save 保存商品(使用封面图作为 live_image
const productId = await saveProductBeforeUpload({
isbn,
name: bookName,
price,
bookPicS,
appearance,
binding,
pageCount: pageCountValue,
wordCount: wordCountValue
})
if (!productId) {
ElMessage.warning({ message: '自定义商品保存失败', duration: 1000, customClass: 'scan-warning-message' })
barcodeStatus.value = '自定义商品保存失败'
return
}
// 第2步: 调用 goods/query套装书不传 isbn传 book_name、author、publisher
const queryCustomNoPhotoBookName = bookName + (customBook.subTitle ? '-' + customBook.subTitle : '')
if (hasActiveWave.value) {
await queryGoodsApi(isbn, productId, String(injectedQuality.value), {
bookName: queryCustomNoPhotoBookName,
author: customBook.author || '',
publisher: customBook.publisher || ''
})
} else {
pendingGoodsQueryQueue.push({
isbn,
outId: productId.toString(),
quality: String(injectedQuality.value),
productId,
bookName: queryCustomNoPhotoBookName,
author: customBook.author || '',
publisher: customBook.publisher || '',
isSuit: true,
})
}
// 第3步: 获取 salePrice
let salePrice = null
try {
const req = (await import('@/utils/request')).default
const listRes = await req.get('/product/list', {
params: { status: 1, page: 1, page_size: 20, 'ids[0]': productId }
})
const list = listRes?.data?.list || listRes?.data?.data || listRes?.data?.rows || []
if (list.length > 0) {
salePrice = list[0].sale_price
}
} catch (e) {
console.warn('[自定义无照片] 获取 salePrice 失败:', e)
}
// 第5步: 构建 bookData将封面 URL 包装为 book_pic 对象供书籍预览展示)
const previewUrl = bookPicS || ''
const bookData = {
bookName,
author: customBook.author || '',
publisher: customBook.publisher || '',
publishDate: customBook.publication_time || '',
binding,
price,
pageCount: pageCountValue,
wordCount: wordCountValue,
totalBook: customTotalBook ?? 1,
ownPrice: customOwnPrice ? Number(customOwnPrice) : 0,
isbn,
book_pic: coverUrl ? { localPath: coverUrl, pddPath: coverUrl } : undefined,
capturedPhoto: previewUrl
}
bookInfo.value = bookData as BookInfo
// 先 emit book-info-update 更新书籍预览,再 emit update:modelValue 更新照片预览
// 注意顺序handleBookInfoUpdate 会清空 image.photoSrc所以 update:modelValue 必须在其之后
emit('book-info-update', bookData)
emit('update:modelValue', {
...props.modelValue,
photoSrc: previewUrl
})
// 等待父组件更新 currentGoods 后 emit photo-preview-confirm
await nextTick()
emit('photo-preview-confirm', {
bookInfo: bookData,
photoSrc: previewUrl,
productId,
salePrice
})
barcodeStatus.value = '自定义商品已添加'
// 自动追加到当前波次
if (hasActiveWave.value) {
await nextTick()
await handleAppendToWave()
}
// 同步书本数据到第三方平台(无照片时使用封面图 URL
callSyncBookApi({
book_name: bookName,
author: customBook.author || '',
publisher: customBook.publisher || '',
publication_time: toPublishTimestamp(customBook.publication_time),
binding_layout: binding,
fix_price: price,
isbn,
page_count: String(pageCountValue || ''),
word_count: String(wordCountValue || ''),
fid: currentSuitFid.value ?? 0,
f_isbn: customBook.subIsbn || '',
f_book_name: customBook.subTitle || '',
live_image: bookPicS,
type: '2',
})
console.log('[自定义无照片] 保存完成, 商品ID:', productId)
}
/**
* 出版时间转为 10 位 Unix 时间戳
* "YYYY-MM" → 当月 1 日 0 时的时间戳,其他格式原样返回
*/
function toPublishTimestamp(value: string): string {
if (!value) return ''
if (/^\d{10}$/.test(value)) return value
if (/^\d{4}-\d{2}$/.test(value)) {
return String(Math.floor(new Date(value + '-01T00:00:00+08:00').getTime() / 1000))
}
if (/^\d{4}$/.test(value)) {
return String(Math.floor(new Date(value + '-01-01T00:00:00+08:00').getTime() / 1000))
}
return value
}
/**
* 调用 /api/syncBook 同步书本数据到第三方平台
* POST formdata 请求
*/
async function callSyncBookApi(params: {
book_name?: string
author?: string
publisher?: string
publication_time?: string
binding_layout?: string
fix_price?: number
isbn?: string
page_count?: string
word_count?: string
book_format?: string
fid?: number
f_isbn?: string
f_book_name?: string
live_image?: string
type?: string
}) {
try {
const res = await syncBook(params)
console.log('[syncBook] 同步成功:', res)
return res
} catch (err) {
console.warn('[syncBook] 失败:', err instanceof Error ? err.message : String(err))
return null
}
}
defineExpose({ hasActiveWave, handlePhotoAction, retakePhoto, loadBookInfo, currentWaveId, waveNo })
</script>
<style scoped>
.camera-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
.camera-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
width: 100%;
}
.video-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.video-wrapper {
width: 400px;
height: 400px;
border-radius: 8px;
overflow: hidden;
background-color: #000;
position: relative;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.camera-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
}
.hidden-scanner-input {
position: fixed;
/* 用 fixed 而非 absolute,避免受父容器影响 */
width: 1px;
height: 1px;
padding: 0;
margin: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
z-index: 9999;
/* 确保在最上层,能接收焦点 */
/* 不使用 pointer-events: none,否则无法接收键盘输入 */
left: 0;
top: 0;
}
/* 当用户正在操作文本(复制/选择)时,临时禁用隐藏输入框的聚焦 */
.hidden-scanner-input.selection-active {
z-index: -1;
}
.button-group {
display: flex;
gap: 12px;
margin-top: 4px;
}
.status {
font-size: 14px;
color: #666;
text-align: center;
min-height: 20px;
}
.status-success {
color: #67c23a;
}
.status-error {
color: #f56c6c;
}
.info-section {
/* width: 450px; */
min-width: 550px;
padding: 20px;
border-radius: 12px;
background-color: #ffffff;
/* box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); */
/* border: 1px solid #e5e7eb; */
/* transition: all 0.3s ease; */
}
.book-detail {
display: flex;
flex-direction: column;
gap: 16px;
}
.book-title {
font-weight: 600;
font-size: 18px;
color: #1a1a1a;
margin: 0;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
/* 新的书籍内容布局 - 使用 Flex 布局替代浮动 */
.book-content {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.book-image-container {
width: 120px;
flex-shrink: 0;
}
.book-image {
width: 120px;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.book-image img {
max-width: 100%;
max-height: 100%;
object-fit: cover;
}
.book-image-placeholder {
width: 120px;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #ddd;
border-radius: 8px;
background-color: #f5f5f5;
}
.book-image-placeholder p {
color: #888;
text-align: center;
margin: 0;
font-size: 12px;
}
/* 右侧区域:书名和作者 */
.book-info-header {
flex: 1;
min-width: 200px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* 下方其他字段网格布局:一行两个 */
.book-other-fields {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 8px;
}
/* 表单项通用样式 */
.form-group {
margin-bottom: 0;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
color: #666;
margin-bottom: 4px;
}
.form-input,
.form-textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.form-textarea {
resize: vertical;
min-height: 60px;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.no-info {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #999;
text-align: center;
}
.no-info p {
margin: 0;
font-size: 14px;
}
.no-info::before {
content: "📚";
font-size: 32px;
margin-bottom: 12px;
opacity: 0.5;
}
.wave-create-section {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.wave-buttons-row {
display: flex;
gap: 6px;
}
.wave-hint {
font-size: 12px;
color: #909399;
}
.wave-active-hint {
color: #67c23a;
font-weight: 600;
}
.wave-switch-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 4px 0;
}
.wave-selector-label {
font-size: 13px;
color: #606266;
white-space: nowrap;
}
/* 波次条形码展示区域 */
.wave-barcode-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin-top: 16px;
padding: 16px;
background-color: #f5f7fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
.wave-no-display {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.wave-no-label {
color: #606266;
font-weight: 500;
}
.wave-no-value {
color: #409eff;
font-weight: 600;
font-size: 16px;
font-family: 'Courier New', monospace;
}
.barcode-image-container {
margin-top: 8px;
padding: 12px;
background-color: #ffffff;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.barcode-image {
max-width: 300px;
max-height: 150px;
display: block;
}
/* 新的波次条形码展示样式 */
.barcode-display-section {
margin-top: 12px;
padding: 16px;
background-color: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px;
text-align: center;
}
.barcode-wave-no {
margin-bottom: 12px;
font-size: 14px;
color: #606266;
}
.barcode-wave-no strong {
color: #409eff;
font-family: 'Courier New', monospace;
font-size: 16px;
}
.barcode-img-wrapper {
background: #ffffff;
padding: 12px;
border-radius: 4px;
display: inline-block;
}
.barcode-img-wrapper img {
max-width: 300px;
max-height: 150px;
display: block;
}
</style>
<style>
/** 成功提示样式 */
.el-message.scan-success-message {
top: 15% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
display: flex;
flex-direction: row;
align-items: center;
min-width: 600px;
min-height: 160px;
padding: 12px 20px;
font-size: 15px;
border-radius: 10px;
background-color: #67C23A;
opacity: 0.9;
border: 1px solid #b7eb8f;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
justify-content: center;
}
.el-message.scan-success-message .el-message__icon {
color: #ffffff;
font-size: 18px;
}
.el-message.scan-success-message .el-message__content {
font-size: 30px;
color: #ffffff;
font-weight: bold;
}
/** 错误提示样式 */
.el-message.scan-error-message {
top: 15% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
display: flex;
flex-direction: row;
align-items: center;
min-width: 600px;
min-height: 160px;
padding: 12px 20px;
font-size: 15px;
border-radius: 10px;
background-color: #F56C6C;
opacity: 0.9;
border: 1px solid #fbc4c4;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
justify-content: center;
}
.el-message.scan-error-message .el-message__icon {
color: #ffffff;
font-size: 18px;
}
.el-message.scan-error-message .el-message__content {
font-size: 30px;
color: #ffffff;
font-weight: bold;
}
/** 警告提示样式 */
.el-message.scan-warning-message {
top: 15% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
display: flex;
flex-direction: row;
align-items: center;
min-width: 600px;
min-height: 160px;
padding: 12px 20px;
font-size: 15px;
border-radius: 10px;
background-color: #E6A23C;
opacity: 0.9;
border: 1px solid #f5c6a5;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
justify-content: center;
}
.el-message.scan-warning-message .el-message__icon {
color: #ffffff;
font-size: 18px;
}
.el-message.scan-warning-message .el-message__content {
font-size: 30px;
color: #ffffff;
font-weight: bold;
}
</style>