6.22lct bug 修改
Some checks failed
CI / build (18.x) (push) Failing after 1m34s
CI / build (20.x) (push) Failing after 34s
CI / deploy-preview (push) Has been skipped
CI / lint (push) Failing after 34s
CI / test (push) Failing after 34s
CI / security (push) Failing after 35s

This commit is contained in:
97694731 2026-06-22 17:10:51 +08:00
parent e3e3204354
commit 939d55c950
40 changed files with 1124 additions and 319 deletions

View File

@ -292,8 +292,8 @@ import {
wind_power_default,
zoom_in_default,
zoom_out_default
} from "./chunk-SD7HEPIX.js";
import "./chunk-JWX4TPXS.js";
} from "./chunk-5GV5GKFP.js";
import "./chunk-YDDUUUNR.js";
import "./chunk-S5KM4IGW.js";
export {
add_location_default as AddLocation,

View File

@ -1,83 +1,83 @@
{
"hash": "7d67b94b",
"browserHash": "5af76578",
"hash": "a0d5344e",
"browserHash": "906d96e7",
"optimized": {
"@element-plus/icons-vue": {
"src": "../../@element-plus/icons-vue/dist/index.js",
"file": "@element-plus_icons-vue.js",
"fileHash": "47eb7f77",
"fileHash": "b0bff00e",
"needsInterop": false
},
"axios": {
"src": "../../axios/index.js",
"file": "axios.js",
"fileHash": "2743dcc7",
"fileHash": "3661d772",
"needsInterop": false
},
"crypto-js": {
"src": "../../crypto-js/index.js",
"file": "crypto-js.js",
"fileHash": "c0aed2f0",
"fileHash": "42456c05",
"needsInterop": true
},
"dayjs": {
"src": "../../dayjs/dayjs.min.js",
"file": "dayjs.js",
"fileHash": "4a44116f",
"fileHash": "00d3ce60",
"needsInterop": true
},
"element-plus": {
"src": "../../element-plus/es/index.mjs",
"file": "element-plus.js",
"fileHash": "cc941865",
"fileHash": "8289c005",
"needsInterop": false
},
"element-plus/es/locale/lang/zh-cn": {
"src": "../../element-plus/es/locale/lang/zh-cn.mjs",
"file": "element-plus_es_locale_lang_zh-cn.js",
"fileHash": "8c2e2f2e",
"fileHash": "2b74d0fe",
"needsInterop": false
},
"jsbarcode": {
"src": "../../jsbarcode/bin/JsBarcode.js",
"file": "jsbarcode.js",
"fileHash": "4c77e9e6",
"fileHash": "5774375a",
"needsInterop": true
},
"json-bigint": {
"src": "../../json-bigint/index.js",
"file": "json-bigint.js",
"fileHash": "577a65f0",
"fileHash": "a76f358e",
"needsInterop": true
},
"pinia": {
"src": "../../pinia/dist/pinia.mjs",
"file": "pinia.js",
"fileHash": "ca9e1d82",
"fileHash": "3c37150a",
"needsInterop": false
},
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "211b1d8e",
"fileHash": "c01889f7",
"needsInterop": false
},
"vue-router": {
"src": "../../vue-router/dist/vue-router.mjs",
"file": "vue-router.js",
"fileHash": "132cec93",
"fileHash": "ced1274b",
"needsInterop": false
}
},
"chunks": {
"chunk-FAQYB3BN": {
"file": "chunk-FAQYB3BN.js"
"chunk-YGWLVKKF": {
"file": "chunk-YGWLVKKF.js"
},
"chunk-SD7HEPIX": {
"file": "chunk-SD7HEPIX.js"
"chunk-5GV5GKFP": {
"file": "chunk-5GV5GKFP.js"
},
"chunk-JWX4TPXS": {
"file": "chunk-JWX4TPXS.js"
"chunk-YDDUUUNR": {
"file": "chunk-YDDUUUNR.js"
},
"chunk-YIAL6EWQ": {
"file": "chunk-YIAL6EWQ.js"

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,7 @@ import {
createElementBlock,
defineComponent,
openBlock
} from "./chunk-JWX4TPXS.js";
} from "./chunk-YDDUUUNR.js";
// node_modules/@element-plus/icons-vue/dist/index.js
var _sfc_main = defineComponent({
@ -5465,4 +5465,4 @@ export {
zoom_out_default
};
/*! Element Plus Icons Vue v2.3.2 */
//# sourceMappingURL=chunk-SD7HEPIX.js.map
//# sourceMappingURL=chunk-5GV5GKFP.js.map

7
node_modules/.vite/deps/chunk-5GV5GKFP.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -12795,4 +12795,4 @@ export {
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/
//# sourceMappingURL=chunk-JWX4TPXS.js.map
//# sourceMappingURL=chunk-YDDUUUNR.js.map

7
node_modules/.vite/deps/chunk-YDDUUUNR.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -159,4 +159,4 @@ function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
export {
setupDevtoolsPlugin
};
//# sourceMappingURL=chunk-FAQYB3BN.js.map
//# sourceMappingURL=chunk-YGWLVKKF.js.map

7
node_modules/.vite/deps/chunk-YGWLVKKF.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -41,7 +41,7 @@ import {
warning_filled_default,
zoom_in_default,
zoom_out_default
} from "./chunk-SD7HEPIX.js";
} from "./chunk-5GV5GKFP.js";
import {
Comment,
Fragment,
@ -130,7 +130,7 @@ import {
withDirectives,
withKeys,
withModifiers
} from "./chunk-JWX4TPXS.js";
} from "./chunk-YDDUUUNR.js";
import {
require_dayjs_min
} from "./chunk-YIAL6EWQ.js";

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
node_modules/.vite/deps/pinia.js generated vendored
View File

@ -1,6 +1,6 @@
import {
setupDevtoolsPlugin
} from "./chunk-FAQYB3BN.js";
} from "./chunk-YGWLVKKF.js";
import {
computed,
effectScope,
@ -20,7 +20,7 @@ import {
toRefs,
unref,
watch
} from "./chunk-JWX4TPXS.js";
} from "./chunk-YDDUUUNR.js";
import "./chunk-S5KM4IGW.js";
// node_modules/vue-demi/lib/index.mjs

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
import {
setupDevtoolsPlugin
} from "./chunk-FAQYB3BN.js";
} from "./chunk-YGWLVKKF.js";
import {
computed,
defineComponent,
@ -19,7 +19,7 @@ import {
unref,
watch,
watchEffect
} from "./chunk-JWX4TPXS.js";
} from "./chunk-YDDUUUNR.js";
import "./chunk-S5KM4IGW.js";
// node_modules/vue-router/dist/devtools-EWN81iOl.mjs

File diff suppressed because one or more lines are too long

2
node_modules/.vite/deps/vue.js generated vendored
View File

@ -170,7 +170,7 @@ import {
withMemo,
withModifiers,
withScopeId
} from "./chunk-JWX4TPXS.js";
} from "./chunk-YDDUUUNR.js";
import "./chunk-S5KM4IGW.js";
export {
BaseTransition,

15
src/api/category.js Normal file
View File

@ -0,0 +1,15 @@
import request from '@/utils/request'
const API_BASE = '/category'
/**
* 获取分类列表
* @returns {Promise<Array>} 分类列表 [{ id, name }]
*/
export const fetchCategoryList = async () => {
const response = await request.get(`${API_BASE}/list`, { params: { page: 1, page_size: 9999 } })
const data = response?.data
if (Array.isArray(data)) return data
if (Array.isArray(data?.list)) return data.list
return []
}

View File

@ -6,19 +6,23 @@ const API_BASE = '/product'
/**
* 标准化列表接口返回的数据格式
* @param {Object} payload - 接口返回的原始响应对象通常包含 data 字段
* @returns {{ list: Array, total: number }} 标准化后的列表数据
* @returns {{ list: Array, total: number, located_count: number, unlocated_count: number, enabled_count: number, disabled_count: number }} 标准化后的列表数据
*/
const normalizeListResponse = (payload) => {
const data = payload?.data
if (!data) {
return { list: [], total: 0 }
return { list: [], total: 0, located_count: 0, unlocated_count: 0, enabled_count: 0, disabled_count: 0 }
}
if (Array.isArray(data)) {
return { list: data, total: data.length }
return { list: data, total: data.length, located_count: 0, unlocated_count: 0, enabled_count: 0, disabled_count: 0 }
}
return {
list: Array.isArray(data.list) ? data.list : [],
total: typeof data.total === 'number' ? data.total : Array.isArray(data.list) ? data.list.length : 0
total: typeof data.total === 'number' ? data.total : Array.isArray(data.list) ? data.list.length : 0,
located_count: data.located_count ?? 0,
unlocated_count: data.unlocated_count ?? 0,
enabled_count: data.enabled_count ?? 0,
disabled_count: data.disabled_count ?? 0
}
}
@ -77,7 +81,7 @@ export const createProduct = async (product) => {
/**
* 保存商品兼容旧接口
* @param {Object} product - 商品信息 { name, barcode, price, live_image }
* @param {Object} product - 商品信息 { name, barcode, price, liveimage }
* @returns {Promise} 接口返回的 Promise 对象
*/
export const saveProduct = async (product) => {
@ -93,6 +97,24 @@ export const updateProduct = async (product) => {
return request.post(`${API_BASE}/save`, product)
}
/**
* 更新商品名称和实拍图片
* @param {Object} params - 请求参数
* @param {string|number} params.id - 商品ID
* @param {string} params.name - 商品名称
* @param {Array<string>} params.liveimage - 实拍图片URL数组
* @param {string|number} [params.product_id] - 商品product_id
* @returns {Promise} 接口返回的 Promise 对象
*/
export const updateNameAndImages = async ({ id, name, liveimage, product_id }) => {
return request.post(`${API_BASE}/updateNameAndImages`, {
id,
name,
product_id,
liveimage: Array.isArray(liveimage) ? liveimage.join(',') : liveimage
})
}
/**
* 删除商品
* @param {Object} product - 要删除的商品对象需包含 id 字段
@ -125,7 +147,7 @@ export const updateProductLiveImage = async (id, pictureName, extra = {}) => {
name,
appearance: appearance ?? 85,
price,
live_image: [`https://shxy.image.yushutx.com/living-picture/${pictureName}`]
liveimage: [`https://shxy.image.yushutx.com/living-picture/${pictureName}`]
})
}
@ -152,7 +174,7 @@ export const updateProductLiveImageAsPddUrl = async (id, picUrl, extra = {}) =>
name,
appearance: appearance ?? 85,
price,
live_image: [picUrl]
liveimage: [picUrl]
})
}
@ -179,7 +201,7 @@ export const updateProductLiveImageForTest = async (id, pictureName, extra = {})
name,
appearance: appearance ?? 85,
price,
live_image: ["https://img.pddpic.com/open-gw/2025-12-01/181c6be7-c781-45ec-a2cc-397c5bbdd09e.jpeg"]
liveimage: ["https://img.pddpic.com/open-gw/2025-12-01/181c6be7-c781-45ec-a2cc-397c5bbdd09e.jpeg"]
})
}
@ -226,9 +248,9 @@ export const ocrImage = async (imageBlob) => {
/**
* 商品发布重试
*/
export const retryProductPublish = async (productId, shopId, shopType) => {
export const retryProductPublish = async (product_id, shopId, shopType) => {
const formData = new FormData()
formData.append('product_id', productId)
formData.append('product_id', product_id)
formData.append('shop_id', shopId)
formData.append('shop_type', shopType)
return request.post(`${API_BASE}/retry-out-task`, formData)
@ -312,3 +334,17 @@ export const importProductsByExcel = async ({ userId, warehouse_id, file }) => {
formData.append('file', file)
return request.post(`/goods/import-from-excel`, formData)
}
/**
* 上传商品实拍图片
* @param {File} file - 图片文件
* @returns {Promise<string>} 上传后的图片 URL
*/
export const uploadProductImage = async (file) => {
const formData = new FormData()
formData.append('file', file)
const res = await request.post('/upload/image', formData)
const url = res?.data?.url || res?.data?.file_path || res?.data
if (url && typeof url === 'string') return url
throw new Error('上传失败,未获取到图片地址')
}

View File

@ -113,3 +113,12 @@ export const createPurchaseOrderWithWave = async (data) => {
export const releaseWave = async (data) => {
return request.post('/wave/release', data)
}
/**
* 导出采购单到旺店通
* @param {Object} params - 请求参数与列表查询参数一致
* @returns {Promise<Blob>} 返回 Blob 响应
*/
export const exportPurchaseOrderToWdt = async (params) => {
return request.get('/purchase-order/export-to-wdt', { params, responseType: 'blob' })
}

View File

@ -40,7 +40,21 @@ export const fetchWaveTaskList = async (params) => {
page_size: pageSize
}
const response = await request.get(`/wave/task/list`, { params: requestParams })
return normalizeListResponse(response)
const result = normalizeListResponse(response)
const data = response?.data
if (data && typeof data === 'object' && !Array.isArray(data)) {
result.stats = {
today_inbound_waves: data.today_inbound_waves ?? 0,
today_inbound_quantity: data.today_inbound_quantity ?? 0,
today_outbound_quantity: data.today_outbound_quantity ?? 0,
yesterday_inbound_waves: data.yesterday_inbound_waves ?? 0,
yesterday_inbound_quantity: data.yesterday_inbound_quantity ?? 0,
yesterday_outbound_quantity: data.yesterday_outbound_quantity ?? 0
}
} else {
result.stats = null
}
return result
}
/**

View File

@ -87,7 +87,7 @@
<el-icon>
<Printer />
</el-icon>
<span>打印机管理</span>
<span>系统硬件配置</span>
</el-menu-item>
<el-menu-item index="/pdaManage" @click="goTo('/pdaManage')">
<el-icon>

View File

@ -17,10 +17,34 @@
<el-button :icon="Refresh" @click="resetSearch">重置</el-button>
</div>
<!-- 统计卡片栏 -->
<div v-if="loadedOnce" class="stats-row">
<div class="stat-card">
<div class="stat-card__value">{{ stats.total }}</div>
<div class="stat-card__label">总商品数</div>
</div>
<div class="stat-card">
<div class="stat-card__value">{{ stats.located_count }}</div>
<div class="stat-card__label">已落位</div>
</div>
<div class="stat-card">
<div class="stat-card__value">{{ stats.unlocated_count }}</div>
<div class="stat-card__label">未落位</div>
</div>
<div class="stat-card">
<div class="stat-card__value">{{ stats.enabled_count }}</div>
<div class="stat-card__label">启用中</div>
</div>
<div class="stat-card">
<div class="stat-card__value">{{ stats.disabled_count }}</div>
<div class="stat-card__label">已禁用</div>
</div>
</div>
<div style="margin-bottom: 16px; display: flex; gap: 8px; flex-wrap: wrap;">
<el-button type="primary">新增</el-button>
<el-button type="primary">修改</el-button>
<el-button type="primary">删除</el-button>
<!-- <el-button type="primary">修改</el-button>
<el-button type="primary">删除</el-button> -->
<el-button type="primary">导出</el-button>
<el-button type="primary">导出模板</el-button>
<el-button type="primary" @click="handleImportClick">导入</el-button>
@ -30,7 +54,6 @@
<el-button type="primary">批量修改货区</el-button>
</div>
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
@ -38,11 +61,28 @@
<el-table-column prop="appearance" label="品相" width="90" align="center">
<template #default="{ row }">{{ formatAppearance(row.appearance) }}</template>
</el-table-column>
<el-table-column label="实拍图" width="100" align="center">
<el-table-column label="实拍图" width="120" align="center">
<template #default="{ row }">
<el-image v-if="getFirstImage(row.live_image)" :src="getFirstImage(row.live_image)"
:preview-src-list="getImageList(row.live_image)" style="width: 50px; height: 50px; border-radius: 4px"
fit="cover" preview-teleported />
<template v-if="getImageList(row.live_image).length > 0">
<el-popover placement="right" :width="400" trigger="hover" :teleported="true">
<template #reference>
<el-image :src="getFirstImage(row.live_image)" :preview-src-list="getImageList(row.live_image)"
style="width: 48px; height: 48px; border-radius: 4px; cursor: pointer" fit="cover"
preview-teleported />
</template>
<div>
<div style="font-weight: 600; margin-bottom: 10px; font-size: 14px;">
{{ getImageList(row.live_image).length }}张图片
</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
<el-image v-for="(img, idx) in getImageList(row.live_image)" :key="idx" :src="img"
:preview-src-list="getImageList(row.live_image)" :initial-index="idx"
style="width: 64px; height: 64px; border-radius: 4px; cursor: pointer" fit="cover"
preview-teleported />
</div>
</div>
</el-popover>
</template>
<span v-else style="color: #c0c4cc">暂无</span>
</template>
</el-table-column>
@ -107,8 +147,7 @@
size="small">
{{ shop.shop_alias_name }}({{ shop.shop_type_name }}) - 任务已创建但发送到店铺失败
</el-tag>
<el-tag v-else-if="shop.out_task_log_id > 0 && shop.status == 2" effect="plain"
size="small">
<el-tag v-else-if="shop.out_task_log_id > 0 && shop.status == 2" effect="plain" size="small">
{{ shop.shop_alias_name }}({{ shop.shop_type_name }}) - 任务已创建但发送到店铺成功
</el-tag>
<el-tag v-else effect="plain" size="small">
@ -158,6 +197,16 @@
<el-table-column prop="updated_at" label="更新时间" width="170" align="center">
<template #default="{ row }">{{ formatTimestamp(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :icon="Edit" size="small" @click="handleEdit(row)">修改</el-button>
<el-tooltip v-if="row.warehouse_name" content="已落位商品不可删除" placement="top" :disabled="false">
<el-button type="danger" link :icon="Delete" size="small" disabled>删除</el-button>
</el-tooltip>
<el-button v-else type="danger" link :icon="Delete" size="small" @click="handleDelete(row)">删除</el-button>
<el-button type="danger" link :icon="Delete" size="small">销毁</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
@ -175,14 +224,8 @@
</el-select>
</el-form-item>
<el-form-item label="上传文件">
<el-upload
ref="uploadRef"
:auto-upload="false"
:show-file-list="true"
:limit="1"
:on-change="handleFileChange"
accept=".xlsx,.xls"
>
<el-upload ref="uploadRef" :auto-upload="false" :show-file-list="true" :limit="1"
:on-change="handleFileChange" accept=".xlsx,.xls">
<el-button type="primary" :icon="Upload">选择文件</el-button>
<template #tip>
<span style="font-size: 12px; color: #909399;">支持 .xlsx / .xls 格式</span>
@ -193,7 +236,8 @@
<template #footer>
<span class="dialog-footer">
<el-button @click="importDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="importLoading" :disabled="!importWarehouseId || !importFile" @click="handleImportSubmit">
<el-button type="primary" :loading="importLoading" :disabled="!importWarehouseId || !importFile"
@click="handleImportSubmit">
开始导入
</el-button>
</span>
@ -204,11 +248,11 @@
<script lang="ts">
import { defineComponent, ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh, MoreFilled, Upload } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, MoreFilled, Upload, Edit, Delete } from '@element-plus/icons-vue'
import GoodsPop from '@/components/goodsPop/index.vue'
import dayjs from 'dayjs'
import { fetchProductList, retryProductPublish, importProductsByExcel } from '@/api/product'
import { fetchProductList, retryProductPublish, importProductsByExcel, deleteProduct } from '@/api/product'
import { fetchWarehouseList } from '@/api/warehouse'
interface ShopListItem {
@ -245,6 +289,7 @@ interface ProductItem {
location_code: string
created_at: number
updated_at: number
appearance?: number | null
shop_list?: ShopListItem[]
}
@ -258,6 +303,7 @@ export default defineComponent({
const loading = ref<boolean>(false)
const tableData = ref<ProductItem[]>([])
const selectedRows = ref<ProductItem[]>([])
const loadedOnce = ref<boolean>(false)
const searchParams = reactive<{
keyword: string
@ -279,6 +325,15 @@ export default defineComponent({
100: '十品',
}
// ========== ==========
const stats = reactive({
total: 0,
located_count: 0,
unlocated_count: 0,
enabled_count: 0,
disabled_count: 0
})
const formatAppearance = (value?: number | null): string => {
if (value == null) return '-'
return CONDITION_MAP[value] ?? String(value)
@ -289,17 +344,45 @@ export default defineComponent({
return dayjs.unix(Number(timestamp)).format('YYYY-MM-DD HH:mm:ss')
}
/**
* 获取第一张图片 URL支持以下格式
* - 逗号分隔字符串: "url1,url2"
* - 数组: ["url1", "url2"]
* - 数组含逗号字符串: ["url1,url2"]
*/
const getFirstImage = (liveImage: any): string => {
if (!liveImage) return ''
if (Array.isArray(liveImage) && liveImage.length > 0) return liveImage[0]
if (typeof liveImage === 'string') return liveImage
if (Array.isArray(liveImage)) {
const flat = liveImage.flatMap((item: string) => {
if (typeof item === 'string') return item.split(',').map((s) => s.trim()).filter(Boolean)
return []
})
return flat[0] || ''
}
if (typeof liveImage === 'string') {
const parts = liveImage.split(',').map((s) => s.trim()).filter(Boolean)
return parts[0] || ''
}
return ''
}
/**
* 获取全部图片 URL 列表支持以下格式
* - 逗号分隔字符串: "url1,url2"
* - 数组: ["url1", "url2"]
* - 数组含逗号字符串: ["url1,url2", "url3"]
*/
const getImageList = (liveImage: any): string[] => {
if (!liveImage) return []
if (Array.isArray(liveImage)) return liveImage.filter((url: string) => typeof url === 'string')
if (typeof liveImage === 'string') return [liveImage]
if (Array.isArray(liveImage)) {
return liveImage.flatMap((item: string) => {
if (typeof item === 'string') return item.split(',').map((s) => s.trim()).filter(Boolean)
return []
})
}
if (typeof liveImage === 'string') {
return liveImage.split(',').map((s) => s.trim()).filter(Boolean)
}
return []
}
@ -324,6 +407,12 @@ export default defineComponent({
const data = res || {}
tableData.value = data.list || []
pagination.total = data.total || 0
stats.total = data.total
stats.located_count = data.located_count
stats.unlocated_count = data.unlocated_count
stats.enabled_count = data.enabled_count
stats.disabled_count = data.disabled_count
loadedOnce.value = true
} catch (error) {
ElMessage.error({ message: '获取商品列表失败', customClass: 'scan-error-message' })
} finally {
@ -455,9 +544,10 @@ export default defineComponent({
})
return {
loading, tableData,
loading, tableData, loadedOnce,
searchParams, pagination,
Search, Refresh, MoreFilled, Upload,
Search, Refresh, MoreFilled, Upload, Edit, Delete,
stats,
formatTimestamp, formatAppearance, getFirstImage, getImageList, showAllShopMsg,
handleSearch, resetSearch, handleCurrentChange, handleSizeChange,
handleEdit, handleDelete, reTryGoosTask,
@ -487,6 +577,42 @@ export default defineComponent({
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
/* ========== 统计卡片 ========== */
.stats-row {
display: flex;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.stat-card {
flex: 1;
min-width: 140px;
background: white;
border-radius: 8px;
padding: 20px 24px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
text-align: center;
transition: box-shadow 0.2s;
}
.stat-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stat-card__value {
font-size: 28px;
font-weight: 700;
color: #303133;
line-height: 1.2;
}
.stat-card__label {
margin-top: 8px;
font-size: 14px;
color: #909399;
}
.pagination-wrapper {
margin-top: 20px;
display: flex;

View File

@ -15,16 +15,17 @@
<strong>{{ pagination.total }}</strong> 个库位
总计 <strong>{{ totalAllQuantity }}</strong> 件商品
</span>
<el-button type="danger" :icon="Delete" :disabled="selectedRows.length === 0"
@click="handleBatchDestroy">
批量销毁{{ selectedRows.length }}
</el-button>
</div>
<el-table
:data="flatRows"
v-loading="loading"
border stripe
style="width: 100%"
:span-method="mergeSpanMethod"
:header-cell-style="{ background: '#f5f7fa' }"
>
<el-table ref="tableRef" :data="flatRows" v-loading="loading" border stripe style="width: 100%"
:span-method="mergeSpanMethod" :header-cell-style="{ background: '#f5f7fa' }"
row-key="rowId" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="45" align="center"
:selectable="checkSelectable" :reserve-selection="true" />
<el-table-column prop="warehouse_name" label="所属仓库" width="120" align="center" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row._isLocationRow" style="font-weight: 600">{{ row.warehouse_name }}</span>
@ -73,6 +74,13 @@
<span v-if="!row._isLocationRow">{{ formatTimestamp(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<template v-if="!row._isLocationRow">
<el-button type="danger" link :icon="Delete">销毁</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
@ -85,8 +93,8 @@
<script lang="ts">
import { defineComponent, ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Delete } from '@element-plus/icons-vue'
import { fetchGoodsListByLocation } from '@/api/inventory'
import dayjs from 'dayjs'
@ -135,18 +143,24 @@ interface FlatRow {
_summaryText?: string
//
product_id?: number
product_name?: string
warehouse_id?: number
location_id?: number
barcode?: string
quantity?: number
sale_price?: number
batch_no?: string
created_at?: number
name?: string
}
export default defineComponent({
name: 'ProductByLocation',
setup() {
const loading = ref<boolean>(false)
const tableRef = ref()
const selectedRows = ref<FlatRow[]>([])
const sourceGroups = ref<LocationGroup[]>([])
const searchParams = reactive<{ keyword: string }>({ keyword: '' })
@ -180,7 +194,11 @@ export default defineComponent({
_isLocationRow: false,
_locationId: group.location_id,
_detailCount: 0,
product_id: detail.product_id,
product_name: detail.product_name,
name: detail.product_name,
warehouse_id: group.warehouse_id,
location_id: group.location_id,
barcode: detail.barcode,
quantity: detail.quantity,
locked_quantity: detail.locked_quantity,
@ -210,8 +228,8 @@ export default defineComponent({
})
const mergeSpanMethod = ({ rowIndex, columnIndex }: { row: any; column: any; rowIndex: number; columnIndex: number }): { rowspan: number; colspan: number } | null => {
// 2
if (columnIndex > 1) return { rowspan: 1, colspan: 1 }
// 2columnIndex 0=selection, 1=, 2=
if (columnIndex > 2) return { rowspan: 1, colspan: 1 }
const row = flatRows.value[rowIndex]
if (!row) return { rowspan: 1, colspan: 1 }
const span = locationSpanMap.value.get(row.rowId)
@ -271,21 +289,56 @@ export default defineComponent({
void loadData()
}
/** 仅允许库位汇总行被选中 */
const checkSelectable = (row: FlatRow): boolean => {
return row._isLocationRow === true
}
const handleSelectionChange = (selection: FlatRow[]): void => {
selectedRows.value = selection
}
/** 批量销毁选中库位(仅前端标记,不调接口) */
const handleBatchDestroy = (): void => {
if (selectedRows.value.length === 0) {
ElMessage.warning({ message: '请先选择要销毁的库位', customClass: 'scan-error-message' })
return
}
const locationIds = selectedRows.value.map(row => row._locationId)
const codes = selectedRows.value.map(row => `"${row.location_code}"`).join('、')
ElMessageBox.confirm(
`确定要销毁以下 ${locationIds.length} 个库位吗?\n\n${codes}\n\n此操作将删除这些库位及其下的所有商品不可恢复`,
'批量销毁确认',
{
confirmButtonText: '确定销毁',
cancelButtonText: '取消',
type: 'error'
}
).then(() => {
ElMessage.success({ message: '销毁完成', customClass: 'scan-success-message' })
selectedRows.value = []
}).catch(() => { })
}
onMounted(() => {
void loadData()
})
return {
loading,
tableRef,
selectedRows,
flatRows,
sourceGroups,
searchParams, pagination,
totalAllQuantity,
Search, Refresh,
Search, Refresh, Delete,
mergeSpanMethod,
checkSelectable,
formatTimestamp, formatPrice,
handleSearch, resetSearch,
handleCurrentChange, handleSizeChange
handleCurrentChange, handleSizeChange,
handleSelectionChange, handleBatchDestroy
}
}
})

View File

@ -114,6 +114,7 @@ import { getBookInfo, syncBook } from '@/api/book'
import { queryGoodsPrice } from '@/api/config'
import { getAdminUserInfo } from '@/utils/auth'
import axios from 'axios'
import { uploadLivingPicture } from '@/utils/uploadLivingPicture'
// BookInfo
interface BookInfo {
@ -698,7 +699,7 @@ const retakePhoto = async () => {
const pictureName = productId ? `${productId}-${isbn}-${random5}.jpg` : null
if (pictureName) {
lastLivingPictureName.value = pictureName
await uploadLivingPicture(base64Data, isbn, pictureName)
await uploadLivingPicture(base64Data, pictureName, isbn)
// :
let pddUrl = ''
@ -885,7 +886,7 @@ const takePhotoPreview = async () => {
const pictureName = productId ? `${productId}-${isbn}-${random5}.jpg` : null
if (pictureName) {
lastLivingPictureName.value = pictureName
await uploadLivingPicture(base64Data, isbn, pictureName)
await uploadLivingPicture(base64Data, pictureName, isbn)
console.log('[实拍] 图片名:', pictureName, '| 地址: https://shxy.image.yushutx.com/living-picture/' + pictureName)
// :, pddUrl
@ -1270,7 +1271,7 @@ const handleOcrAssign = async (assignments: Record<string, string>) => {
// {barcode}.jpg
const coverPictureName = `${isbn}.jpg`
console.log('[OCR上传封面] 开始上传, 文件名:', coverPictureName)
await uploadLivingPicture(base64Data, isbn, coverPictureName)
await uploadLivingPicture(base64Data, coverPictureName, isbn)
const coverImageUrl = `https://shxy.image.yushutx.com/living-picture/${coverPictureName}`
console.log('[OCR上传封面] 完成, URL:', coverImageUrl)
@ -1294,7 +1295,7 @@ const handleOcrAssign = async (assignments: Record<string, string>) => {
if (pictureName) {
lastLivingPictureName.value = pictureName
await uploadLivingPicture(base64Data, isbn, pictureName)
await uploadLivingPicture(base64Data, pictureName, isbn)
// live_image
await updateProductLiveImage(productId, pictureName, {
@ -1857,46 +1858,7 @@ async function generateWaveBarcode() {
}
}
/**
* 上传实拍图片到图片服务
* 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))
}
}
/**
* 处理扫码输入变化
@ -2695,7 +2657,7 @@ async function processCustomItemUpload(
// 2:
const random5 = String(Math.floor(Math.random() * 100000)).padStart(5, '0')
const pictureName = `${productId}-${isbn}-${random5}.jpg`
await uploadLivingPicture(base64Data, isbn, pictureName)
await uploadLivingPicture(base64Data, pictureName, isbn)
// 3:
let pddUrl = ''

View File

@ -544,8 +544,6 @@ defineExpose({ getAllGoods, goodsList, clearAll, getUncommittedGoods, markAllCom
.book-card-remove {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.2s ease;
}
/* 售价样式:红色,紧邻信息区 */
@ -569,11 +567,6 @@ defineExpose({ getAllGoods, goodsList, clearAll, getUncommittedGoods, markAllCom
text-underline-offset: 3px;
}
.book-card:hover .book-card-remove {
opacity: 1;
}
.book-list-empty {
flex: 1;
display: flex;

View File

@ -271,7 +271,7 @@ const routes = [
name: 'printer-manager',
component: () => import('@/views/printerManager/printerManager.vue'),
meta: {
title: '打印机管理',
title: '系统硬件配置',
requiresAuth: true
}
},

View File

@ -0,0 +1,68 @@
/**
* living-picture
* PUT https://shxy.image.yushutx.com/living-picture/{pictureName}
*
* File base64
* src/components/wave/camera.vue便
*/
/**
* File base64
*/
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error('文件读取失败'))
reader.readAsDataURL(file)
})
}
/**
* living-picture
* @param file - File base64 data:image/...;base64,
* @param pictureName - 'abc123.jpg'
* @param isbn - ISBN
* @returns URL
*/
export async function uploadLivingPicture(
file: File | string,
pictureName: string,
isbn?: string
): Promise<string> {
// 若传入 File 对象,先转为 base64
let base64Data: string
if (typeof file === 'string') {
base64Data = file
} else {
base64Data = await fileToBase64(file)
}
console.log('[上传实拍] 开始上传, 文件名:', pictureName)
// 去掉 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/${pictureName}`
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) {
throw new Error(`实拍图片上传失败 [${isbn || 'unknown'}]: HTTP ${resp.status}`)
}
console.log(`实拍图片上传成功 [${isbn || 'unknown'}]`)
return url
}

View File

@ -26,17 +26,15 @@
</span>
</template>
<el-input v-model="dir" placeholder="如 C:\\verifyTool" clearable @clear="dir = ''" />
<div class="save-bar" style="margin-top: 18px;">
<el-tooltip content="通过自定义协议启动本机程序" placement="top" trigger="click">
<el-button
type="primary"
:icon="VideoPlay"
style="margin-left: 10px"
:disabled="!dir"
@click="handleOpenExe"
>
打开程序
</el-button>
<el-button type="primary" @click="handleOpenExe" size="small">打开程序</el-button>
</el-tooltip>
</div>
</el-form-item>
</div>
<div class="config-row">
@ -59,7 +57,8 @@
<template #label>
<span class="label-with-icon">
<span>端口</span>
<el-tooltip content="默认是8080 但是由于每台电脑的环境都不同 可能会出现端口冲突 在出现问题时候 请第一时间联系网管" placement="top" trigger="click">
<el-tooltip content="默认是8080 但是由于每台电脑的环境都不同 可能会出现端口冲突 在出现问题时候 请第一时间联系网管" placement="top"
trigger="click">
<el-icon style="cursor: pointer;">
<QuestionFilled />
</el-icon>
@ -226,8 +225,7 @@
autocomplete="new-password" />
</el-form-item>
<el-tooltip v-if="appendAccounts.length > 1" content="删除此条账号输入" placement="top" trigger="click">
<el-button type="danger" :icon="Delete" circle
@click="removeAppendAccount(index)" />
<el-button type="danger" :icon="Delete" circle @click="removeAppendAccount(index)" />
</el-tooltip>
</div>
</div>
@ -255,8 +253,7 @@
<el-input v-model="item.password" type="password" placeholder="请输入密码" clearable show-password
autocomplete="new-password" />
<el-tooltip v-if="accounts.length > 1" content="删除此条账号输入" placement="top" trigger="click">
<el-button type="danger" :icon="Delete" circle
@click="removeAccount(index)" />
<el-button type="danger" :icon="Delete" circle @click="removeAccount(index)" />
</el-tooltip>
</div>
</div>

View File

@ -76,6 +76,33 @@
<el-empty v-if="!loading && printers.length === 0" description="未检测到可用打印机" />
</el-card>
<el-card class="printer-card">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<span>摄像头配置</span>
<el-button type="primary" @click="refreshCameras" :loading="cameraLoading">
<el-icon>
<Refresh />
</el-icon>
刷新摄像头列表
</el-button>
</div>
</template>
<el-form label-width="140px" label-position="left" class="printer-form">
<el-form-item label="摄像头">
<div class="printer-row">
<el-select v-model="selectedCamera" placeholder="请选择摄像头" style="width: 400px" clearable>
<el-option v-for="c in cameras" :key="c.deviceId" :label="c.label" :value="c.deviceId" />
</el-select>
<el-button type="primary" @click="confirmCamera">确定</el-button>
</div>
</el-form-item>
</el-form>
<el-empty v-if="!cameraLoading && cameras.length === 0" description="未检测到可用摄像头" />
</el-card>
</div>
</template>
@ -89,6 +116,7 @@ import JsBarcode from 'jsbarcode'
const STORAGE_KEY_BARCODE = 'printer_barcode'
const STORAGE_KEY_EXPRESS = 'printer_express'
const STORAGE_KEY_PAPER_SIZE = 'printer_paper_size'
const STORAGE_KEY_CAMERA = 'printer_camera'
const loading = ref(false)
const printers = ref([])
@ -96,6 +124,10 @@ const barcodePrinter = ref('')
const expressPrinter = ref('')
const paperSize = ref('')
const cameraLoading = ref(false)
const cameras = ref([])
const selectedCamera = ref('')
const shortcutItems = ['Alt+c', 'Alt+a', 'Alt+x', 'Alt+b']
const barcodeImages = reactive({})
@ -172,6 +204,36 @@ const confirmExpressPrinter = () => {
}
}
const fetchCameras = async () => {
cameraLoading.value = true
try {
const devices = await navigator.mediaDevices.enumerateDevices()
cameras.value = devices.filter((d) => d.kind === 'videoinput').map((d) => ({
deviceId: d.deviceId,
label: d.label || `摄像头 ${d.deviceId.slice(0, 8)}`
}))
} catch {
ElMessage.error({ message: '获取摄像头列表失败', customClass: 'scan-error-message' })
cameras.value = []
} finally {
cameraLoading.value = false
}
}
const refreshCameras = () => {
fetchCameras()
}
const confirmCamera = () => {
if (selectedCamera.value) {
localStorage.setItem(STORAGE_KEY_CAMERA, selectedCamera.value)
ElMessage.success({ message: '摄像头已保存', duration: 1000, customClass: 'scan-success-message' })
} else {
localStorage.removeItem(STORAGE_KEY_CAMERA)
ElMessage.success({ message: '摄像头已清除', duration: 1000, customClass: 'scan-success-message' })
}
}
const fetchPrinters = async () => {
loading.value = true
try {
@ -193,7 +255,9 @@ onMounted(() => {
barcodePrinter.value = localStorage.getItem(STORAGE_KEY_BARCODE) || ''
expressPrinter.value = localStorage.getItem(STORAGE_KEY_EXPRESS) || ''
paperSize.value = localStorage.getItem(STORAGE_KEY_PAPER_SIZE) || ''
selectedCamera.value = localStorage.getItem(STORAGE_KEY_CAMERA) || ''
fetchPrinters()
fetchCameras()
shortcutItems.forEach(key => fetchBarcodeImage(key))
})

View File

@ -5,11 +5,7 @@
</template>
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="按商品查看" name="byGoods">
<ProductList
ref="productListRef"
@edit="handleEdit"
@delete="handleDelete"
/>
<ProductList ref="productListRef" @edit="handleEdit" @delete="handleDelete" />
</el-tab-pane>
<el-tab-pane label="按库位查看" name="byLocation">
<ProductByLocation />
@ -17,37 +13,109 @@
</el-tabs>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close @close="resetForm">
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close @close="resetForm">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="110px" label-position="right">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="商品名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入商品名称" />
</el-form-item>
<el-form-item label="条码" prop="barcode">
<el-input v-model="formData.barcode" placeholder="请输入条码" />
</el-col>
<el-col :span="12">
<el-form-item label="品相" prop="appearance">
<el-select v-model="formData.appearance" placeholder="请选择品相" style="width: 100%" disabled>
<el-option label="八五品" :value="85" />
<el-option label="九品" :value="90" />
<el-option label="十品" :value="100" />
</el-select>
</el-form-item>
<el-form-item label="价格(分)" prop="price">
<el-input-number v-model="formData.price" :min="0" :step="100" controls-position="right"
style="width: 100%" />
</el-col>
</el-row>
<el-form-item label="实拍图" prop="live_image">
<div class="live-image-upload">
<div v-for="(img, idx) in formData.live_image" :key="idx" class="live-image-item">
<el-image :src="img" :preview-src-list="formData.live_image" :initial-index="idx" fit="cover"
class="live-image-thumb" preview-teleported />
<el-icon class="live-image-delete" @click="removeLiveImage(idx)">
<CircleClose />
</el-icon>
</div>
<el-upload v-if="formData.live_image.length < maxImageCount" :show-file-list="false"
:http-request="handleImageUpload" :before-upload="beforeImageUpload"
accept="image/jpeg,image/png,image/webp,image/gif" class="live-image-uploader">
<el-icon class="live-image-uploader-icon">
<Plus />
</el-icon>
</el-upload>
</div>
<span class="upload-tip">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;支持 jpg/png/webp/gif最多 {{ maxImageCount }} </span>
</el-form-item>
<el-form-item label="分类ID" prop="category_id">
<el-input-number v-model="formData.category_id" :min="0" controls-position="right" style="width: 100%" />
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="分类" prop="category_id">
<el-select v-model="formData.category_id" placeholder="请选择分类" filterable clearable style="width: 100%"
disabled>
</el-select>
</el-form-item>
<el-form-item label="标品ID" prop="standard_product_id">
<el-input-number v-model="formData.standard_product_id" :min="0" controls-position="right"
style="width: 100%" />
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="仓库" prop="warehouse_id">
<el-select v-model="formData.warehouse_id" placeholder="请选择仓库" filterable clearable style="width: 100%"
disabled>
<el-option v-for="wh in warehouseOptions" :key="wh.id" :label="wh.name" :value="wh.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="库位" prop="location_id">
<el-select v-model="formData.location_id" placeholder="请选择库位" filterable clearable style="width: 100%"
disabled>
<el-option v-for="loc in locationOptions" :key="loc.id" :label="loc.code" :value="loc.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="库存" prop="quantity">
<el-input-number v-model="formData.quantity" :min="0" :step="1" controls-position="right"
style="width: 100%" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="价格(元)" prop="price">
<el-input-number v-model="priceYuan" :min="0" :step="0.01" :precision="2" controls-position="right"
style="width: 100%" disabled />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="批次管理" prop="is_batch_managed">
<el-switch v-model="formData.is_batch_managed" :active-value="1" :inactive-value="0" active-text=""
inactive-text="否" />
inactive-text="否" disabled />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="效期管理" prop="is_shelf_life_managed">
<el-switch v-model="formData.is_shelf_life_managed" :active-value="1" :inactive-value="0" active-text=""
inactive-text="否" />
inactive-text="否" disabled />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="状态" prop="status">
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用"
active-color="#67C23A" />
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" active-text="启用"
inactive-text="禁用" active-color="#67C23A" disabled />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
@ -60,11 +128,28 @@
</template>
<script lang="ts">
import { defineComponent, ref, reactive } from 'vue'
import { defineComponent, ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, CircleClose } from '@element-plus/icons-vue'
import ProductList from '@/components/product/byGoods/index.vue'
import ProductByLocation from '@/components/product/byLocation/index.vue'
import { fetchProductDetail, createProduct, updateProduct, deleteProduct } from '@/api/product'
import { fetchProductDetail, createProduct, updateProduct, deleteProduct, updateNameAndImages } from '@/api/product'
import { uploadLivingPicture } from '@/utils/uploadLivingPicture'
import { fetchWarehouseList } from '@/api/warehouse'
import { fetchLocationList } from '@/api/location'
interface ShopItem {
shop_alias_name: string
shop_type: number
shop_type_name: string
is_sent: boolean
out_task_log_id: number
out_task_id: number
status: number
msg: string
reset_num: number
created_at: number
}
interface ProductItem {
id: number
@ -85,20 +170,10 @@ interface ProductItem {
warehouse_name: string
location_id: number
location_code: string
appearance: number
created_at: number
updated_at: number
shop_list?: Array<{
shop_alias_name: string
shop_type: number
shop_type_name: string
is_sent: boolean
out_task_log_id: number
out_task_id: number
status: number
msg: string
reset_num: number
created_at: number
}>
shop_list?: ShopItem[]
}
interface ProductFormData {
@ -108,11 +183,25 @@ interface ProductFormData {
name: string
barcode: string
price: number
live_image: any
live_image: string[]
is_batch_managed: number
is_shelf_life_managed: number
status: number
appearance: number
quantity: number
warehouse_id: number | null
location_id: number | null
shop_list?: ShopItem[]
}
interface WarehouseOption {
id: number
name: string
}
interface LocationOption {
id: number
code: string
}
export default defineComponent({
@ -125,6 +214,8 @@ export default defineComponent({
const activeTab = ref<string>('byGoods')
const productListRef = ref<InstanceType<typeof ProductList> | null>(null)
const submitLoading = ref<boolean>(false)
const imageUploading = ref<boolean>(false)
const maxImageCount = 10
const dialogVisible = ref<boolean>(false)
const dialogTitle = ref<string>('')
@ -140,67 +231,157 @@ export default defineComponent({
is_batch_managed: 0,
is_shelf_life_managed: 0,
status: 1,
appearance: 85
appearance: 85,
quantity: 0,
warehouse_id: null,
location_id: null,
shop_list: []
})
const formRules = {
name: [
{ required: true, message: '商品名称不能为空', trigger: 'blur' },
{ min: 1, max: 255, message: '长度在1到255个字符', trigger: 'blur' }
],
barcode: [
{ max: 100, message: '条码长度不能超过100个字符', trigger: 'blur' }
]
}
// ()
const priceYuan = computed({
get: (): number => {
if (formData.price == null || formData.price === 0) return 0
return Number((formData.price / 100).toFixed(2))
},
set: (val: number) => {
formData.price = Math.round((val || 0) * 100)
}
})
//
const warehouseOptions = ref<WarehouseOption[]>([])
const loadWarehouses = async (): Promise<void> => {
try {
const res = await fetchWarehouseList({ page: 1, pageSize: 9999 })
warehouseOptions.value = res.list || []
} catch {
warehouseOptions.value = []
}
}
//
const locationOptions = ref<LocationOption[]>([])
const loadLocations = async (warehouseId: number): Promise<void> => {
if (!warehouseId) {
locationOptions.value = []
return
}
try {
const res = await fetchLocationList({ warehouseId, page: 1, pageSize: 9999 })
locationOptions.value = res.list || []
} catch {
locationOptions.value = []
}
}
const onWarehouseChange = (warehouseId: number | null): void => {
formData.location_id = null
if (warehouseId) {
loadLocations(warehouseId)
} else {
locationOptions.value = []
}
}
//
const removeLiveImage = (idx: number): void => {
formData.live_image.splice(idx, 1)
}
const beforeImageUpload = (file: File): boolean => {
const isImage = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type)
if (!isImage) {
ElMessage.error({ message: '仅支持 JPG/PNG/WebP/GIF 格式', customClass: 'scan-error-message' })
return false
}
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
ElMessage.error({ message: '图片大小不能超过 10MB', customClass: 'scan-error-message' })
return false
}
return true
}
const handleImageUpload = async (option: any): Promise<void> => {
imageUploading.value = true
try {
const pictureName = `${Date.now()}-${Math.random().toString(36).substring(2, 8)}.jpg`
const url = await uploadLivingPicture(option.file, pictureName)
formData.live_image.push(url)
} catch {
ElMessage.error({ message: '图片上传失败', customClass: 'scan-error-message' })
} finally {
imageUploading.value = false
}
}
const refreshList = (): void => {
productListRef.value?.refreshList?.()
}
const handleEdit = async (row: ProductItem): Promise<void> => {
const handleEdit = (row: ProductItem): void => {
dialogTitle.value = '编辑商品'
try {
const detail = await fetchProductDetail(row.id)
if (detail) {
formData.id = detail.id ?? row.id
formData.category_id = detail.category_id ?? row.category_id
formData.standard_product_id = detail.standard_product_id ?? row.standard_product_id
formData.name = detail.name || row.name
formData.barcode = detail.barcode || row.barcode
formData.price = detail.price ?? row.price
formData.live_image = detail.live_image || row.live_image || []
formData.is_batch_managed = detail.is_batch_managed ?? row.is_batch_managed
formData.is_shelf_life_managed = detail.is_shelf_life_managed ?? row.is_shelf_life_managed
formData.status = detail.status ?? row.status
} else {
//
loadWarehouses()
// row
formData.id = row.id
formData.category_id = row.category_id
formData.standard_product_id = row.standard_product_id
formData.name = row.name
formData.barcode = row.barcode
formData.price = row.price
formData.live_image = row.live_image || []
formData.is_batch_managed = row.is_batch_managed
formData.is_shelf_life_managed = row.is_shelf_life_managed
formData.status = row.status
}
} catch (error) {
console.warn('获取详情失败,使用当前行数据', error)
formData.id = row.id
formData.category_id = row.category_id
formData.standard_product_id = row.standard_product_id
formData.name = row.name
formData.barcode = row.barcode
formData.price = row.price
formData.live_image = row.live_image || []
formData.live_image = normalizeImageArray(row.live_image)
formData.is_batch_managed = row.is_batch_managed
formData.is_shelf_life_managed = row.is_shelf_life_managed
formData.status = row.status
formData.appearance = row.appearance ?? 85
formData.quantity = row.quantity ?? 0
formData.warehouse_id = row.warehouse_id ?? null
formData.location_id = row.location_id ?? null
formData.shop_list = row.shop_list || []
if (formData.warehouse_id) {
loadLocations(formData.warehouse_id)
}
dialogVisible.value = true
setTimeout(() => { formRef.value?.clearValidate() }, 0)
}
/** 兼容 live_image 多种格式字符串含逗号分隔多URL/ 数组 / 数组内含逗号分隔字符串 / null */
const normalizeImageArray = (val: any): string[] => {
if (!val) return []
if (Array.isArray(val)) {
const result: string[] = []
for (const item of val) {
if (typeof item === 'string' && item.length > 0) {
if (item.includes(',')) {
result.push(...item.split(',').map((u: string) => u.trim()).filter(Boolean))
} else {
result.push(item)
}
}
}
return result
}
if (typeof val === 'string' && val.length > 0) {
if (val.includes(',')) {
return val.split(',').map((u: string) => u.trim()).filter(Boolean)
}
return [val]
}
return []
}
const handleDelete = (row: ProductItem): void => {
ElMessageBox.confirm(`确定要删除商品 "${row.name}" 吗?`, '删除确认', {
confirmButtonText: '确定删除',
@ -221,29 +402,22 @@ export default defineComponent({
try {
await formRef.value?.validate()
submitLoading.value = true
const payload = {
category_id: formData.category_id,
standard_product_id: formData.standard_product_id,
const payload: Record<string, any> = {
name: formData.name,
barcode: formData.barcode,
price: formData.price,
live_image: formData.live_image || [],
is_batch_managed: formData.is_batch_managed,
is_shelf_life_managed: formData.is_shelf_life_managed,
status: formData.status,
appearance: formData.appearance ?? 85
liveimage: formData.live_image || [],
product_id: formData.id
}
if (formData.id === null) {
await createProduct(payload)
ElMessage.success({ message: '新增商品成功', customClass: 'scan-success-message' })
} else {
await updateProduct({ id: formData.id, ...payload })
await updateNameAndImages({ id: formData.id, name: formData.name, liveimage: formData.live_image, product_id: formData.id })
ElMessage.success({ message: '编辑商品成功', customClass: 'scan-success-message' })
}
dialogVisible.value = false
refreshList()
} catch (error) {
// ElMessage.error('')
//
} finally {
submitLoading.value = false
}
@ -261,14 +435,30 @@ export default defineComponent({
formData.is_batch_managed = 0
formData.is_shelf_life_managed = 0
formData.status = 1
formData.appearance = 85
formData.quantity = 0
formData.warehouse_id = null
formData.location_id = null
formData.shop_list = []
locationOptions.value = []
}
return {
activeTab,
productListRef,
submitLoading,
imageUploading,
maxImageCount,
dialogVisible, dialogTitle, formRef, formData, formRules,
handleEdit, handleDelete, submitForm, resetForm
priceYuan,
warehouseOptions,
locationOptions,
onWarehouseChange,
removeLiveImage,
beforeImageUpload,
handleImageUpload,
handleEdit, handleDelete, submitForm, resetForm,
Plus, CircleClose
}
}
})
@ -287,6 +477,9 @@ export default defineComponent({
:deep(.el-dialog__body) {
padding-top: 16px;
max-height: 70vh;
overflow-y: auto;
overflow-x: hidden;
}
.dialog-footer {
@ -294,4 +487,67 @@ export default defineComponent({
justify-content: flex-end;
gap: 12px;
}
/* 实拍图上传 */
.live-image-upload {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
.live-image-item {
position: relative;
width: 80px;
height: 80px;
border-radius: 6px;
border: 1px solid #d9d9d9;
}
.live-image-thumb {
width: 100%;
height: 100%;
border-radius: 6px;
overflow: hidden;
}
.live-image-delete {
position: absolute;
top: -6px;
right: -6px;
font-size: 18px;
color: #f56c6c;
cursor: pointer;
background: #fff;
border-radius: 50%;
z-index: 2;
}
.live-image-uploader {
width: 80px;
height: 80px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.2s;
}
.live-image-uploader:hover {
border-color: #409eff;
}
.live-image-uploader-icon {
font-size: 28px;
color: #8c939d;
}
.upload-tip {
display: block;
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>

View File

@ -20,6 +20,7 @@
</el-select>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button :icon="Refresh" @click="resetSearch">重置</el-button>
<el-button type="primary" @click="handleExport">导出</el-button>
</div>
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%" @expand-change="handleExpandChange">
@ -130,7 +131,7 @@ import { defineComponent, ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus, Edit, Delete, View, Loading } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import { fetchPurchaseOrderList, fetchPurchaseOrderDetail, createPurchaseOrder, updatePurchaseOrder, deletePurchaseOrder } from '@/api/purchaseOrder'
import { fetchPurchaseOrderList, fetchPurchaseOrderDetail, createPurchaseOrder, updatePurchaseOrder, deletePurchaseOrder, exportPurchaseOrderToWdt } from '@/api/purchaseOrder'
import { fetchSupplierList } from '@/api/supplier'
import { fetchWarehouseList } from '@/api/warehouse'
@ -334,6 +335,36 @@ export default defineComponent({
}).catch(() => {})
}
const handleExport = async (): Promise<void> => {
try {
const res = await exportPurchaseOrderToWdt({
keyword: searchParams.keyword,
status: searchParams.status !== null ? String(searchParams.status) : undefined
})
// Content-Disposition 使
const disposition = res.headers?.['content-disposition']
let fileName = '采购单导出.xlsx'
if (disposition) {
const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (match && match[1]) {
fileName = decodeURIComponent(match[1].replace(/['"]/g, ''))
}
}
// Blob
const url = window.URL.createObjectURL(new Blob([res.data]))
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success({ message: '导出成功', customClass: 'scan-success-message' })
} catch (error) {
ElMessage.error({ message: '导出失败', customClass: 'scan-error-message' })
}
}
const resetForm = (): void => {
formRef.value?.resetFields()
formData.id = null
@ -363,7 +394,7 @@ export default defineComponent({
statusLabel, statusTagType,
formatTimestamp, formatAmount,
handleSearch, resetSearch, handleCurrentChange, handleSizeChange,
handleExpandChange, handleView, handleDelete, resetForm
handleExpandChange, handleView, handleDelete, handleExport, resetForm
}
}
})

View File

@ -55,14 +55,48 @@
{{ row.name || '-' }}
</template>
</el-table-column>
<el-table-column label="实拍图" width="80" align="center">
<el-table-column label="实拍图" width="100" align="center">
<template #default="{ row }">
<el-image v-if="getFirstImage(row.live_image)" :src="getFirstImage(row.live_image)"
<template v-if="getImageList(row.live_image).length > 0">
<el-popover
placement="right"
:width="360"
trigger="hover"
:open-delay="300"
:close-delay="100"
:disabled="false"
:teleported="true"
>
<template #reference>
<el-image
:src="getImageList(row.live_image)[0]"
:preview-src-list="getImageList(row.live_image)"
style="width: 40px; height: 40px; border-radius: 4px" fit="cover"
preview-teleported />
<span v-else
style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; color: #c0c4cc; font-size: 12px">暂无</span>
:initial-index="0"
class="image-thumb-single"
fit="cover"
preview-teleported
/>
</template>
<div class="popover-image-gallery">
<div class="popover-gallery-title">
{{ getImageList(row.live_image).length }}张图片
</div>
<div class="popover-gallery-list">
<el-image
v-for="(img, idx) in getImageList(row.live_image)"
:key="idx"
:src="img"
:preview-src-list="getImageList(row.live_image)"
:initial-index="idx"
class="popover-gallery-item"
fit="cover"
preview-teleported
/>
</div>
</div>
</el-popover>
</template>
<span v-else style="color: #c0c4cc">暂无</span>
</template>
</el-table-column>
<el-table-column label="ISBN" min-width="130" show-overflow-tooltip align="center">
@ -115,10 +149,12 @@
{{ row.msg || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small"
@click="handleRetry(activeShopId, row)">重试</el-button>
<el-button type="danger" link size="small"
>删除</el-button>
</template>
</el-table-column>
</el-table>
@ -224,20 +260,48 @@ export default defineComponent({
return dayjs.unix(Number(timestamp)).format('YYYY-MM-DD HH:mm:ss')
}
const getFirstImage = (liveImage: any): string => {
if (!liveImage) return ''
if (Array.isArray(liveImage) && liveImage.length > 0) return liveImage[0]
if (typeof liveImage === 'string') return liveImage
return ''
}
const getImageList = (liveImage: any): string[] => {
if (!liveImage) return []
if (Array.isArray(liveImage)) return liveImage.filter((url: string) => typeof url === 'string')
if (typeof liveImage === 'string') return [liveImage]
if (Array.isArray(liveImage)) {
const result: string[] = []
for (const item of liveImage) {
if (typeof item === 'string') {
if (item.includes(',')) {
result.push(...item.split(',').map((u: string) => u.trim()).filter(Boolean))
} else {
result.push(item)
}
}
}
return result
}
if (typeof liveImage === 'string') {
if (liveImage.includes(',')) {
return liveImage.split(',').map((u: string) => u.trim()).filter(Boolean)
}
return [liveImage]
}
return []
}
const getFirstImage = (liveImage: any): string => {
if (!liveImage) return ''
if (Array.isArray(liveImage) && liveImage.length > 0) {
const first = String(liveImage[0])
if (first.includes(',')) {
return first.split(',')[0].trim()
}
return first
}
if (typeof liveImage === 'string') {
if (liveImage.includes(',')) {
return liveImage.split(',')[0].trim()
}
return liveImage
}
return ''
}
const loadList = async (): Promise<void> => {
if (!searchParams.shop_type) {
tableData.value = []
@ -482,4 +546,43 @@ export default defineComponent({
align-items: center;
/* 垂直居中 */
}
/* 实拍图 - 单张缩略图 */
.image-thumb-single {
width: 48px;
height: 48px;
border-radius: 4px;
cursor: pointer;
border: 1px solid #ebeef5;
flex-shrink: 0;
}
/* 悬浮画廊 */
.popover-image-gallery {
padding: 4px;
}
.popover-gallery-title {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.popover-gallery-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.popover-gallery-item {
width: 64px;
height: 64px;
border-radius: 4px;
cursor: pointer;
border: 1px solid #ebeef5;
flex-shrink: 0;
}
</style>

View File

@ -37,6 +37,33 @@
<el-button :icon="Refresh" @click="resetSearch">重置</el-button>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">今日入库波次数</div>
<div class="stat-value">{{ stats.today_inbound_waves }}</div>
</div>
<div class="stat-card">
<div class="stat-label">今日入库数量</div>
<div class="stat-value">{{ stats.today_inbound_quantity }}</div>
</div>
<div class="stat-card">
<div class="stat-label">今日出库数量</div>
<div class="stat-value">{{ stats.today_outbound_quantity }}</div>
</div>
<div class="stat-card">
<div class="stat-label">昨日入库波次数</div>
<div class="stat-value">{{ stats.yesterday_inbound_waves }}</div>
</div>
<div class="stat-card">
<div class="stat-label">昨日入库数量</div>
<div class="stat-value">{{ stats.yesterday_inbound_quantity }}</div>
</div>
<div class="stat-card">
<div class="stat-label">昨日出库数量</div>
<div class="stat-value">{{ stats.yesterday_outbound_quantity }}</div>
</div>
</div>
<el-table
:data="filteredTableData"
v-loading="loading"
@ -152,7 +179,7 @@ import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus, Edit, Delete, View } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import { fetchWaveTaskList, fetchWaveTaskDetail, deleteWaveTask } from '@/api/waveTask'
import { fetchWaveTaskList, fetchWaveTaskDetail, deleteWaveTask, } from '@/api/waveTask'
/** 任务类型映射 */
const TYPE_MAP: Record<number, { label: string; type: string }> = {
@ -216,6 +243,15 @@ export default defineComponent({
total: 0
})
const stats = reactive({
today_inbound_waves: 0,
today_inbound_quantity: 0,
today_outbound_quantity: 0,
yesterday_inbound_waves: 0,
yesterday_inbound_quantity: 0,
yesterday_outbound_quantity: 0
})
const dialogVisible = ref<boolean>(false)
const dialogTitle = ref<string>('')
const formRef = ref<any>(null)
@ -268,6 +304,17 @@ export default defineComponent({
return list
})
const loadStats = async (): Promise<void> => {
try {
const data = await fetchWaveTaskStats()
if (data) {
Object.assign(stats, data)
}
} catch {
//
}
}
const loadList = async (): Promise<void> => {
loading.value = true
expandedRowKeys.value = []
@ -418,11 +465,12 @@ export default defineComponent({
searchParams.wave_no = route.query.wave_no as string
}
void loadList()
void loadStats()
})
return {
loading, submitLoading, tableData, filteredTableData, expandedRowKeys,
typeOptions, statusOptions, searchParams, pagination,
typeOptions, statusOptions, searchParams, pagination, stats,
dialogVisible, dialogTitle, formRef, formData, formRules,
detailVisible, detailData,
Search, Refresh, Plus, Edit, Delete, View,
@ -459,6 +507,35 @@ export default defineComponent({
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
}
.stats-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
flex: 1;
min-width: 160px;
background: #fff;
border-radius: 8px;
padding: 20px 24px;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
text-align: center;
}
.stat-label {
font-size: 13px;
color: #909399;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #303133;
}
.pagination-wrapper {
margin-top: 20px;
display: flex;

View File

@ -177,6 +177,7 @@ module.exports = defineConfig({
// target: 'http://127.0.0.1:9090',
// target: 'http://192.168.101.213:9090',
target: 'https://psi.api.buzhiyushu.cn',
// target: 'https://psi.api.buzhiyushu.cn',
changeOrigin: true
},
'/api/print': {