3364 lines
106 KiB
Vue
3364 lines
106 KiB
Vue
<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" />
|
||
|
||
<!-- <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/purchase-order'
|
||
import type { GoodsInfo } from './goodsInfo.vue'
|
||
import SuitBookDialog from './suitBookDialog.vue'
|
||
import OcrResultDialog from './OcrResultDialog.vue'
|
||
import { ocrImage } from '@/api/product'
|
||
import { getAdminUserInfo } from '@/utils/auth'
|
||
import axios from 'axios'
|
||
|
||
// 定义 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
|
||
}
|
||
|
||
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' })
|
||
|
||
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('')
|
||
|
||
/**
|
||
* 检查是否已选择小车,若未选择则弹出警告并阻止后续操作
|
||
*/
|
||
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)
|
||
|
||
// ========== 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
|
||
}
|
||
const pendingGoodsQueryQueue: PendingGoodsQuery[] = []
|
||
|
||
/**
|
||
* 调用 goods/query 接口(直连核价器)
|
||
*/
|
||
async function queryGoodsApi(isbn: string, productId: number, quality: 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 formData = new URLSearchParams()
|
||
formData.append('isbn', isbn)
|
||
formData.append('out_id', productId.toString())
|
||
formData.append('quality', quality.toString())
|
||
formData.append('query_index', queryIndex.toString())
|
||
formData.append('user_id', userId.toString())
|
||
formData.append('placeholder_down_price', localStorage.getItem('placeholder_down_price') || '0.01')
|
||
formData.append('min_shipping_fee', localStorage.getItem('min_shipping_fee') || '5.00')
|
||
formData.append('min_price', localStorage.getItem('min_price') || '1.00')
|
||
|
||
console.log('[goods/query] 发送请求 - 直连:', { ip, port, isbn, productId, quality })
|
||
|
||
try {
|
||
// 直接请求核价器,避免 CORS 需服务端支持
|
||
const response = await axios.post(`http://${ip}:${port}/api/goods/query`, formData.toString(), {
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded'
|
||
},
|
||
timeout: 10000
|
||
})
|
||
|
||
console.log('[goods/query] 响应:', response.data)
|
||
} 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 {
|
||
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(() => injectedCarRef?.value?.quality ?? 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, isbn, pictureName)
|
||
|
||
// 第三步:上传拼多多图片空间
|
||
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
|
||
})
|
||
|
||
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, isbn, pictureName)
|
||
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) {
|
||
ElMessage.error({ message: `拍照失败`, duration: 1000, customClass: 'scan-error-message' })
|
||
console.warn('[PDD上传] 失败:', pddResult.error_response.error_msg || JSON.stringify(pddResult.error_response))
|
||
}
|
||
} catch (pddErr) {
|
||
ElMessage.error({ message: `拍照失败`, duration: 1000, customClass: 'scan-error-message' })
|
||
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 // 单位:分,整数类型
|
||
})
|
||
|
||
// 第五步:第二次调用 /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})`)
|
||
}
|
||
|
||
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
|
||
|
||
// 检查小车容量:当前已扫描数 >= 容量时阻止拍照
|
||
const capacity = injectedCarCapacity.value
|
||
if (capacity !== null && scannedCount.value >= capacity) {
|
||
ElMessage.error({
|
||
message: '小车容量已满,请创建波次上书或者创建新波次',
|
||
duration: 1000,
|
||
customClass: 'scan-error-message'
|
||
})
|
||
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, isbn, coverPictureName)
|
||
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, isbn, pictureName)
|
||
|
||
// 第四步:更新商品的 live_image 为实拍图片地址
|
||
await updateProductLiveImage(productId, pictureName, {
|
||
barcode: isbn,
|
||
name: bookInfo.value?.bookName || '',
|
||
price: productPrice
|
||
})
|
||
|
||
// 第四步半:第二次调用 /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 {
|
||
await ElMessageBox.confirm(
|
||
'确认要创建波次吗?创建后所有已扫描的书籍将提交到该波次中。',
|
||
'确认创建波次',
|
||
{
|
||
confirmButtonText: '确认',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
}
|
||
)
|
||
} catch {
|
||
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))
|
||
ElMessage.error({ message: '波次创建失败: ' + (err instanceof Error ? err.message : String(err)), duration: 1000, customClass: 'scan-error-message' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 向当前活跃波次追加商品
|
||
* 调用 /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 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))
|
||
ElMessage.error({ message: '追加波次失败: ' + (err instanceof Error ? err.message : String(err)), duration: 1000, customClass: 'scan-error-message' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建新波次:清空所有数据,重新开始
|
||
* 点击后会清空扫描列表、波次状态,用户需要重新扫描后创建新波次
|
||
*/
|
||
async function handleCreateNewWave() {
|
||
if (!checkCarSelected()) return
|
||
|
||
// 二次确认弹窗
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'确认要创建新波次吗?当前波次的数据将保留,并将清空该页面开始一个新的扫描批次。',
|
||
'确认创建新波次',
|
||
{
|
||
confirmButtonText: '确认',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
}
|
||
)
|
||
} catch {
|
||
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/wave-task')
|
||
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/wave-task')
|
||
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/wave-task')
|
||
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 request = (await import('@/utils/request')).default
|
||
const res = await request.post('/barcode/generate', {
|
||
content: waveNo.value
|
||
})
|
||
|
||
// res 已经被拦截器解包为 response.data,类型断言绕过 AxiosResponse 类型
|
||
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 {
|
||
ElMessage.error({ message: '条形码生成失败:' + (resData.msg || resData.message || '未知错误'), duration: 1000, customClass: 'scan-error-message' })
|
||
}
|
||
} catch (err) {
|
||
console.warn('[生成条形码] 失败:', err instanceof Error ? err.message : String(err))
|
||
ElMessage.error({ message: '条形码生成失败: ' + (err instanceof Error ? err.message : String(err)), duration: 1000, customClass: 'scan-error-message' })
|
||
} finally {
|
||
barcodeLoading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上传实拍图片到图片服务
|
||
* PUT http://:19000/living-picture/{pictureName}
|
||
* Content-Type: image/jpeg
|
||
* @param {string} base64Data - base64图片数据
|
||
* @param {string} isbn - ISBN(仅用于日志)
|
||
* @param {string} [pictureName] - 可选自定义文件名,默认 {isbn}.jpg
|
||
*/
|
||
async function uploadLivingPicture(base64Data: string, isbn: string, pictureName?: string) {
|
||
const fileName = pictureName || `${isbn}.jpg`
|
||
console.log('[上传实拍] 开始上传, 文件名:', fileName)
|
||
try {
|
||
// 去掉 base64 头,转为二进制
|
||
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '')
|
||
const binaryString = atob(base64)
|
||
const bytes = new Uint8Array(binaryString.length)
|
||
for (let i = 0; i < binaryString.length; i++) {
|
||
bytes[i] = binaryString.charCodeAt(i)
|
||
}
|
||
const blob = new Blob([bytes], { type: 'image/jpeg' })
|
||
|
||
const url = `https://shxy.image.yushutx.com/living-picture/${fileName}`
|
||
console.log('[上传实拍] PUT URL:', url, '| Blob大小:', blob.size)
|
||
|
||
const resp = await fetch(url, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'image/jpeg' },
|
||
body: blob
|
||
})
|
||
console.log('[上传实拍] 响应状态:', resp.status, resp.statusText)
|
||
|
||
if (!resp.ok) {
|
||
console.warn(`实拍图片上传失败 [${isbn}]: HTTP ${resp.status}`)
|
||
} else {
|
||
console.log(`实拍图片上传成功 [${isbn}]`)
|
||
}
|
||
} catch (err) {
|
||
console.warn('实拍图片上传异常:', err instanceof Error ? err.message : String(err))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理扫码输入变化
|
||
* 兼容不带 Enter 的扫码枪:输入变化时立即检测快捷键或长输入自动触发。
|
||
* 注意:带 Enter 的扫码枪仍会触发 @keyup.enter 的 handleScan,handleScan 内部
|
||
* 会清空输入框防重入,因此重复触发时第二次检测到空值直接 return。
|
||
*/
|
||
const handleScanInput = () => {
|
||
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) {
|
||
// 延迟一小段时间确保输入完整
|
||
setTimeout(() => {
|
||
if (scannedCode.value.trim().length >= 8) {
|
||
handleScan()
|
||
}
|
||
}, 50)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 扫码快捷键映射表
|
||
* 当扫码枪扫到这些文本时,执行对应的快捷键动作
|
||
*/
|
||
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() {
|
||
const code = scannedCode.value.trim()
|
||
if (!code) return
|
||
|
||
// 没选择小车的情况下,提示并阻止扫码操作
|
||
if (!checkCarSelected()) {
|
||
scannedCode.value = ''
|
||
return
|
||
}
|
||
|
||
// 立即清空输入框,防止 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) {
|
||
const normalizedCode = normalizeBarcode(code)
|
||
console.log('规范化后的条码:', normalizedCode)
|
||
console.log('条码长度:', normalizedCode.length)
|
||
|
||
// 清空照片预览(扫码只获取数据,不拍照)
|
||
const newFormData = {
|
||
...props.modelValue,
|
||
photoSrc: '', // 清空照片预览
|
||
isbn: '',
|
||
barcode: ''
|
||
}
|
||
emit('update:modelValue', newFormData)
|
||
|
||
// 尝试多种ISBN格式验证
|
||
if (isValidISBN13(normalizedCode) || isValidISBN10(normalizedCode)) {
|
||
// 使用规范化后的条码
|
||
const finalIsbn = isValidISBN13(normalizedCode) ? normalizedCode : convertISBN10to13(normalizedCode)
|
||
|
||
// 更新条码数据
|
||
emit('update:modelValue', {
|
||
...newFormData,
|
||
isbn: finalIsbn,
|
||
barcode: finalIsbn
|
||
})
|
||
|
||
// 加载书籍信息
|
||
loadBookInfo(finalIsbn)
|
||
|
||
// 保存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)
|
||
|
||
// 保存ISBN供拍照上传使用
|
||
lastScannedIsbn.value = normalizedCode
|
||
|
||
// 显示扫码失败状态(ISBN校验未通过,但仍在尝试加载)
|
||
barcodeStatus.value = `扫码失败:${normalizedCode}`
|
||
barcodeSuccess.value = false
|
||
barcodeError.value = true
|
||
ElMessage.error({ message: `扫码失败:${normalizedCode}`, duration: 1000, customClass: 'scan-error-message' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 校验 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) {
|
||
if (!isbn) {
|
||
bookInfo.value = null
|
||
return
|
||
}
|
||
|
||
// 同步保存 ISBN,供拍照上传使用(Car 搜索查询流程不走 handleScannedCode,需在此处设置)
|
||
lastScannedIsbn.value = isbn
|
||
|
||
try {
|
||
const request = (await import('@/utils/request')).default
|
||
const payload = await request.get('/getBookInfo', {
|
||
params: { 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 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.book_pic || undefined,
|
||
totalBook: 1,
|
||
ownPrice: 0,
|
||
salePrice: 0,
|
||
isbn: isbn
|
||
}
|
||
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: data.book_pic || undefined,
|
||
}
|
||
// 套装书弹窗打开时,立即同步数据到第三方平台(f_isbn=000,f_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
|
||
}
|
||
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.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?: string) {
|
||
// ---- 自定义项且有拍摄的照片:执行上传保存流程 ----
|
||
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
|
||
|
||
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
|
||
if (hasActiveWave.value) {
|
||
await queryGoodsApi(isbn, productId, String(injectedQuality.value))
|
||
} else {
|
||
pendingGoodsQueryQueue.push({
|
||
isbn, outId: productId.toString(), quality: String(injectedQuality.value), productId,
|
||
})
|
||
}
|
||
|
||
// 获取 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 bookData = {
|
||
bookName,
|
||
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,
|
||
book_pic: originalBookPic,
|
||
capturedPhoto: liveImgUrl,
|
||
}
|
||
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?: string
|
||
) {
|
||
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, isbn, pictureName)
|
||
|
||
// 第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
|
||
})
|
||
|
||
// 第5步:调用 goods/query
|
||
if (hasActiveWave.value) {
|
||
await queryGoodsApi(isbn, productId, String(injectedQuality.value))
|
||
} else {
|
||
pendingGoodsQueryQueue.push({
|
||
isbn,
|
||
outId: productId.toString(),
|
||
quality: String(injectedQuality.value),
|
||
productId
|
||
})
|
||
}
|
||
|
||
// 第7步:获取 salePrice(update: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?: string
|
||
) {
|
||
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
|
||
if (hasActiveWave.value) {
|
||
await queryGoodsApi(isbn, productId, String(injectedQuality.value))
|
||
} else {
|
||
pendingGoodsQueryQueue.push({
|
||
isbn,
|
||
outId: productId.toString(),
|
||
quality: String(injectedQuality.value),
|
||
productId
|
||
})
|
||
}
|
||
|
||
// 第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 request = (await import('@/utils/request')).default
|
||
// 传普通对象,拦截器会自动做:签名 → objectToFormData → 移除 Content-Type
|
||
const body: Record<string, any> = {}
|
||
if (params.book_name) body['book_name'] = params.book_name
|
||
if (params.author) body['author'] = params.author
|
||
if (params.publisher) body['publisher'] = params.publisher
|
||
if (params.publication_time) body['publication_time'] = params.publication_time
|
||
if (params.binding_layout) body['binding_layout'] = params.binding_layout
|
||
if (params.fix_price !== undefined) body['fix_price'] = params.fix_price
|
||
if (params.isbn) body['isbn'] = params.isbn
|
||
if (params.page_count) body['page_count'] = params.page_count
|
||
if (params.word_count) body['word_count'] = params.word_count
|
||
if (params.book_format) body['book_format'] = params.book_format
|
||
if (params.fid !== undefined) body['fid'] = params.fid
|
||
if (params.f_isbn) body['f_isbn'] = params.f_isbn
|
||
if (params.f_book_name) body['f_book_name'] = params.f_book_name
|
||
if (params.live_image) body['live_image[0]'] = params.live_image
|
||
if (params.type) body['type'] = params.type
|
||
const res = await request.post('/syncBook', body)
|
||
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 })
|
||
</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> |