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, wind_power_default,
zoom_in_default, zoom_in_default,
zoom_out_default zoom_out_default
} from "./chunk-SD7HEPIX.js"; } from "./chunk-5GV5GKFP.js";
import "./chunk-JWX4TPXS.js"; import "./chunk-YDDUUUNR.js";
import "./chunk-S5KM4IGW.js"; import "./chunk-S5KM4IGW.js";
export { export {
add_location_default as AddLocation, add_location_default as AddLocation,

View File

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

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,7 @@ import {
createElementBlock, createElementBlock,
defineComponent, defineComponent,
openBlock openBlock
} from "./chunk-JWX4TPXS.js"; } from "./chunk-YDDUUUNR.js";
// node_modules/@element-plus/icons-vue/dist/index.js // node_modules/@element-plus/icons-vue/dist/index.js
var _sfc_main = defineComponent({ var _sfc_main = defineComponent({
@ -5465,4 +5465,4 @@ export {
zoom_out_default zoom_out_default
}; };
/*! Element Plus Icons Vue v2.3.2 */ /*! 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 * (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT * @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 { export {
setupDevtoolsPlugin 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, warning_filled_default,
zoom_in_default, zoom_in_default,
zoom_out_default zoom_out_default
} from "./chunk-SD7HEPIX.js"; } from "./chunk-5GV5GKFP.js";
import { import {
Comment, Comment,
Fragment, Fragment,
@ -130,7 +130,7 @@ import {
withDirectives, withDirectives,
withKeys, withKeys,
withModifiers withModifiers
} from "./chunk-JWX4TPXS.js"; } from "./chunk-YDDUUUNR.js";
import { import {
require_dayjs_min require_dayjs_min
} from "./chunk-YIAL6EWQ.js"; } 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 { import {
setupDevtoolsPlugin setupDevtoolsPlugin
} from "./chunk-FAQYB3BN.js"; } from "./chunk-YGWLVKKF.js";
import { import {
computed, computed,
effectScope, effectScope,
@ -20,7 +20,7 @@ import {
toRefs, toRefs,
unref, unref,
watch watch
} from "./chunk-JWX4TPXS.js"; } from "./chunk-YDDUUUNR.js";
import "./chunk-S5KM4IGW.js"; import "./chunk-S5KM4IGW.js";
// node_modules/vue-demi/lib/index.mjs // 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 { import {
setupDevtoolsPlugin setupDevtoolsPlugin
} from "./chunk-FAQYB3BN.js"; } from "./chunk-YGWLVKKF.js";
import { import {
computed, computed,
defineComponent, defineComponent,
@ -19,7 +19,7 @@ import {
unref, unref,
watch, watch,
watchEffect watchEffect
} from "./chunk-JWX4TPXS.js"; } from "./chunk-YDDUUUNR.js";
import "./chunk-S5KM4IGW.js"; import "./chunk-S5KM4IGW.js";
// node_modules/vue-router/dist/devtools-EWN81iOl.mjs // 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, withMemo,
withModifiers, withModifiers,
withScopeId withScopeId
} from "./chunk-JWX4TPXS.js"; } from "./chunk-YDDUUUNR.js";
import "./chunk-S5KM4IGW.js"; import "./chunk-S5KM4IGW.js";
export { export {
BaseTransition, 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 字段 * @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 normalizeListResponse = (payload) => {
const data = payload?.data const data = payload?.data
if (!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)) { 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 { return {
list: Array.isArray(data.list) ? data.list : [], 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 对象 * @returns {Promise} 接口返回的 Promise 对象
*/ */
export const saveProduct = async (product) => { export const saveProduct = async (product) => {
@ -93,6 +97,24 @@ export const updateProduct = async (product) => {
return request.post(`${API_BASE}/save`, 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 字段 * @param {Object} product - 要删除的商品对象需包含 id 字段
@ -125,7 +147,7 @@ export const updateProductLiveImage = async (id, pictureName, extra = {}) => {
name, name,
appearance: appearance ?? 85, appearance: appearance ?? 85,
price, 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, name,
appearance: appearance ?? 85, appearance: appearance ?? 85,
price, price,
live_image: [picUrl] liveimage: [picUrl]
}) })
} }
@ -179,7 +201,7 @@ export const updateProductLiveImageForTest = async (id, pictureName, extra = {})
name, name,
appearance: appearance ?? 85, appearance: appearance ?? 85,
price, 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() const formData = new FormData()
formData.append('product_id', productId) formData.append('product_id', product_id)
formData.append('shop_id', shopId) formData.append('shop_id', shopId)
formData.append('shop_type', shopType) formData.append('shop_type', shopType)
return request.post(`${API_BASE}/retry-out-task`, formData) 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) formData.append('file', file)
return request.post(`/goods/import-from-excel`, formData) 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) => { export const releaseWave = async (data) => {
return request.post('/wave/release', 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 page_size: pageSize
} }
const response = await request.get(`/wave/task/list`, { params: requestParams }) 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> <el-icon>
<Printer /> <Printer />
</el-icon> </el-icon>
<span>打印机管理</span> <span>系统硬件配置</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/pdaManage" @click="goTo('/pdaManage')"> <el-menu-item index="/pdaManage" @click="goTo('/pdaManage')">
<el-icon> <el-icon>

View File

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

View File

@ -15,16 +15,17 @@
<strong>{{ pagination.total }}</strong> 个库位 <strong>{{ pagination.total }}</strong> 个库位
总计 <strong>{{ totalAllQuantity }}</strong> 件商品 总计 <strong>{{ totalAllQuantity }}</strong> 件商品
</span> </span>
<el-button type="danger" :icon="Delete" :disabled="selectedRows.length === 0"
@click="handleBatchDestroy">
批量销毁{{ selectedRows.length }}
</el-button>
</div> </div>
<el-table <el-table ref="tableRef" :data="flatRows" v-loading="loading" border stripe style="width: 100%"
:data="flatRows" :span-method="mergeSpanMethod" :header-cell-style="{ background: '#f5f7fa' }"
v-loading="loading" row-key="rowId" @selection-change="handleSelectionChange">
border stripe <el-table-column type="selection" width="45" align="center"
style="width: 100%" :selectable="checkSelectable" :reserve-selection="true" />
:span-method="mergeSpanMethod"
:header-cell-style="{ background: '#f5f7fa' }"
>
<el-table-column prop="warehouse_name" label="所属仓库" width="120" align="center" show-overflow-tooltip> <el-table-column prop="warehouse_name" label="所属仓库" width="120" align="center" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row._isLocationRow" style="font-weight: 600">{{ row.warehouse_name }}</span> <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> <span v-if="!row._isLocationRow">{{ formatTimestamp(row.created_at) }}</span>
</template> </template>
</el-table-column> </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> </el-table>
<div class="pagination-wrapper"> <div class="pagination-wrapper">
@ -85,8 +93,8 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, reactive, computed, onMounted } from 'vue' import { defineComponent, ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue' import { Search, Refresh, Delete } from '@element-plus/icons-vue'
import { fetchGoodsListByLocation } from '@/api/inventory' import { fetchGoodsListByLocation } from '@/api/inventory'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -135,18 +143,24 @@ interface FlatRow {
_summaryText?: string _summaryText?: string
// //
product_id?: number
product_name?: string product_name?: string
warehouse_id?: number
location_id?: number
barcode?: string barcode?: string
quantity?: number quantity?: number
sale_price?: number sale_price?: number
batch_no?: string batch_no?: string
created_at?: number created_at?: number
name?: string
} }
export default defineComponent({ export default defineComponent({
name: 'ProductByLocation', name: 'ProductByLocation',
setup() { setup() {
const loading = ref<boolean>(false) const loading = ref<boolean>(false)
const tableRef = ref()
const selectedRows = ref<FlatRow[]>([])
const sourceGroups = ref<LocationGroup[]>([]) const sourceGroups = ref<LocationGroup[]>([])
const searchParams = reactive<{ keyword: string }>({ keyword: '' }) const searchParams = reactive<{ keyword: string }>({ keyword: '' })
@ -180,7 +194,11 @@ export default defineComponent({
_isLocationRow: false, _isLocationRow: false,
_locationId: group.location_id, _locationId: group.location_id,
_detailCount: 0, _detailCount: 0,
product_id: detail.product_id,
product_name: detail.product_name, product_name: detail.product_name,
name: detail.product_name,
warehouse_id: group.warehouse_id,
location_id: group.location_id,
barcode: detail.barcode, barcode: detail.barcode,
quantity: detail.quantity, quantity: detail.quantity,
locked_quantity: detail.locked_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 => { const mergeSpanMethod = ({ rowIndex, columnIndex }: { row: any; column: any; rowIndex: number; columnIndex: number }): { rowspan: number; colspan: number } | null => {
// 2 // 2columnIndex 0=selection, 1=, 2=
if (columnIndex > 1) return { rowspan: 1, colspan: 1 } if (columnIndex > 2) return { rowspan: 1, colspan: 1 }
const row = flatRows.value[rowIndex] const row = flatRows.value[rowIndex]
if (!row) return { rowspan: 1, colspan: 1 } if (!row) return { rowspan: 1, colspan: 1 }
const span = locationSpanMap.value.get(row.rowId) const span = locationSpanMap.value.get(row.rowId)
@ -271,21 +289,56 @@ export default defineComponent({
void loadData() 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(() => { onMounted(() => {
void loadData() void loadData()
}) })
return { return {
loading, loading,
tableRef,
selectedRows,
flatRows, flatRows,
sourceGroups, sourceGroups,
searchParams, pagination, searchParams, pagination,
totalAllQuantity, totalAllQuantity,
Search, Refresh, Search, Refresh, Delete,
mergeSpanMethod, mergeSpanMethod,
checkSelectable,
formatTimestamp, formatPrice, formatTimestamp, formatPrice,
handleSearch, resetSearch, 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 { queryGoodsPrice } from '@/api/config'
import { getAdminUserInfo } from '@/utils/auth' import { getAdminUserInfo } from '@/utils/auth'
import axios from 'axios' import axios from 'axios'
import { uploadLivingPicture } from '@/utils/uploadLivingPicture'
// BookInfo // BookInfo
interface BookInfo { interface BookInfo {
@ -698,7 +699,7 @@ const retakePhoto = async () => {
const pictureName = productId ? `${productId}-${isbn}-${random5}.jpg` : null const pictureName = productId ? `${productId}-${isbn}-${random5}.jpg` : null
if (pictureName) { if (pictureName) {
lastLivingPictureName.value = pictureName lastLivingPictureName.value = pictureName
await uploadLivingPicture(base64Data, isbn, pictureName) await uploadLivingPicture(base64Data, pictureName, isbn)
// : // :
let pddUrl = '' let pddUrl = ''
@ -885,7 +886,7 @@ const takePhotoPreview = async () => {
const pictureName = productId ? `${productId}-${isbn}-${random5}.jpg` : null const pictureName = productId ? `${productId}-${isbn}-${random5}.jpg` : null
if (pictureName) { if (pictureName) {
lastLivingPictureName.value = 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) console.log('[实拍] 图片名:', pictureName, '| 地址: https://shxy.image.yushutx.com/living-picture/' + pictureName)
// :, pddUrl // :, pddUrl
@ -1270,7 +1271,7 @@ const handleOcrAssign = async (assignments: Record<string, string>) => {
// {barcode}.jpg // {barcode}.jpg
const coverPictureName = `${isbn}.jpg` const coverPictureName = `${isbn}.jpg`
console.log('[OCR上传封面] 开始上传, 文件名:', coverPictureName) console.log('[OCR上传封面] 开始上传, 文件名:', coverPictureName)
await uploadLivingPicture(base64Data, isbn, coverPictureName) await uploadLivingPicture(base64Data, coverPictureName, isbn)
const coverImageUrl = `https://shxy.image.yushutx.com/living-picture/${coverPictureName}` const coverImageUrl = `https://shxy.image.yushutx.com/living-picture/${coverPictureName}`
console.log('[OCR上传封面] 完成, URL:', coverImageUrl) console.log('[OCR上传封面] 完成, URL:', coverImageUrl)
@ -1294,7 +1295,7 @@ const handleOcrAssign = async (assignments: Record<string, string>) => {
if (pictureName) { if (pictureName) {
lastLivingPictureName.value = pictureName lastLivingPictureName.value = pictureName
await uploadLivingPicture(base64Data, isbn, pictureName) await uploadLivingPicture(base64Data, pictureName, isbn)
// live_image // live_image
await updateProductLiveImage(productId, pictureName, { 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: // 2:
const random5 = String(Math.floor(Math.random() * 100000)).padStart(5, '0') const random5 = String(Math.floor(Math.random() * 100000)).padStart(5, '0')
const pictureName = `${productId}-${isbn}-${random5}.jpg` const pictureName = `${productId}-${isbn}-${random5}.jpg`
await uploadLivingPicture(base64Data, isbn, pictureName) await uploadLivingPicture(base64Data, pictureName, isbn)
// 3: // 3:
let pddUrl = '' let pddUrl = ''

View File

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

View File

@ -271,7 +271,7 @@ const routes = [
name: 'printer-manager', name: 'printer-manager',
component: () => import('@/views/printerManager/printerManager.vue'), component: () => import('@/views/printerManager/printerManager.vue'),
meta: { meta: {
title: '打印机管理', title: '系统硬件配置',
requiresAuth: true 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> </span>
</template> </template>
<el-input v-model="dir" placeholder="如 C:\\verifyTool" clearable @clear="dir = ''" /> <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-tooltip content="通过自定义协议启动本机程序" placement="top" trigger="click">
<el-button <el-button type="primary" @click="handleOpenExe" size="small">打开程序</el-button>
type="primary"
:icon="VideoPlay"
style="margin-left: 10px"
:disabled="!dir"
@click="handleOpenExe"
>
打开程序
</el-button>
</el-tooltip> </el-tooltip>
</div>
</el-form-item> </el-form-item>
</div> </div>
<div class="config-row"> <div class="config-row">
@ -59,7 +57,8 @@
<template #label> <template #label>
<span class="label-with-icon"> <span class="label-with-icon">
<span>端口</span> <span>端口</span>
<el-tooltip content="默认是8080 但是由于每台电脑的环境都不同 可能会出现端口冲突 在出现问题时候 请第一时间联系网管" placement="top" trigger="click"> <el-tooltip content="默认是8080 但是由于每台电脑的环境都不同 可能会出现端口冲突 在出现问题时候 请第一时间联系网管" placement="top"
trigger="click">
<el-icon style="cursor: pointer;"> <el-icon style="cursor: pointer;">
<QuestionFilled /> <QuestionFilled />
</el-icon> </el-icon>
@ -226,8 +225,7 @@
autocomplete="new-password" /> autocomplete="new-password" />
</el-form-item> </el-form-item>
<el-tooltip v-if="appendAccounts.length > 1" content="删除此条账号输入" placement="top" trigger="click"> <el-tooltip v-if="appendAccounts.length > 1" content="删除此条账号输入" placement="top" trigger="click">
<el-button type="danger" :icon="Delete" circle <el-button type="danger" :icon="Delete" circle @click="removeAppendAccount(index)" />
@click="removeAppendAccount(index)" />
</el-tooltip> </el-tooltip>
</div> </div>
</div> </div>
@ -255,8 +253,7 @@
<el-input v-model="item.password" type="password" placeholder="请输入密码" clearable show-password <el-input v-model="item.password" type="password" placeholder="请输入密码" clearable show-password
autocomplete="new-password" /> autocomplete="new-password" />
<el-tooltip v-if="accounts.length > 1" content="删除此条账号输入" placement="top" trigger="click"> <el-tooltip v-if="accounts.length > 1" content="删除此条账号输入" placement="top" trigger="click">
<el-button type="danger" :icon="Delete" circle <el-button type="danger" :icon="Delete" circle @click="removeAccount(index)" />
@click="removeAccount(index)" />
</el-tooltip> </el-tooltip>
</div> </div>
</div> </div>
@ -711,7 +708,7 @@ export default {
? minPriceNum ? minPriceNum
: 1.00 : 1.00
try { try {
await saveNewPrice(ip.value, port.value, sendNewPrice, sendPlaceholderDownPrice, sendMinShippingFee, sendMinPrice,verifyIndex.value) await saveNewPrice(ip.value, port.value, sendNewPrice, sendPlaceholderDownPrice, sendMinShippingFee, sendMinPrice, verifyIndex.value)
console.log('核价价格相关配置已保存') console.log('核价价格相关配置已保存')
} catch (err: any) { } catch (err: any) {
console.log('核价价格相关配置保存失败:', err.message) console.log('核价价格相关配置保存失败:', err.message)

View File

@ -76,6 +76,33 @@
<el-empty v-if="!loading && printers.length === 0" description="未检测到可用打印机" /> <el-empty v-if="!loading && printers.length === 0" description="未检测到可用打印机" />
</el-card> </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> </div>
</template> </template>
@ -89,6 +116,7 @@ import JsBarcode from 'jsbarcode'
const STORAGE_KEY_BARCODE = 'printer_barcode' const STORAGE_KEY_BARCODE = 'printer_barcode'
const STORAGE_KEY_EXPRESS = 'printer_express' const STORAGE_KEY_EXPRESS = 'printer_express'
const STORAGE_KEY_PAPER_SIZE = 'printer_paper_size' const STORAGE_KEY_PAPER_SIZE = 'printer_paper_size'
const STORAGE_KEY_CAMERA = 'printer_camera'
const loading = ref(false) const loading = ref(false)
const printers = ref([]) const printers = ref([])
@ -96,6 +124,10 @@ const barcodePrinter = ref('')
const expressPrinter = ref('') const expressPrinter = ref('')
const paperSize = 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 shortcutItems = ['Alt+c', 'Alt+a', 'Alt+x', 'Alt+b']
const barcodeImages = reactive({}) 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 () => { const fetchPrinters = async () => {
loading.value = true loading.value = true
try { try {
@ -193,7 +255,9 @@ onMounted(() => {
barcodePrinter.value = localStorage.getItem(STORAGE_KEY_BARCODE) || '' barcodePrinter.value = localStorage.getItem(STORAGE_KEY_BARCODE) || ''
expressPrinter.value = localStorage.getItem(STORAGE_KEY_EXPRESS) || '' expressPrinter.value = localStorage.getItem(STORAGE_KEY_EXPRESS) || ''
paperSize.value = localStorage.getItem(STORAGE_KEY_PAPER_SIZE) || '' paperSize.value = localStorage.getItem(STORAGE_KEY_PAPER_SIZE) || ''
selectedCamera.value = localStorage.getItem(STORAGE_KEY_CAMERA) || ''
fetchPrinters() fetchPrinters()
fetchCameras()
shortcutItems.forEach(key => fetchBarcodeImage(key)) shortcutItems.forEach(key => fetchBarcodeImage(key))
}) })

View File

@ -5,11 +5,7 @@
</template> </template>
<el-tabs v-model="activeTab" type="border-card"> <el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="按商品查看" name="byGoods"> <el-tab-pane label="按商品查看" name="byGoods">
<ProductList <ProductList ref="productListRef" @edit="handleEdit" @delete="handleDelete" />
ref="productListRef"
@edit="handleEdit"
@delete="handleDelete"
/>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="按库位查看" name="byLocation"> <el-tab-pane label="按库位查看" name="byLocation">
<ProductByLocation /> <ProductByLocation />
@ -17,37 +13,109 @@
</el-tabs> </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-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-form-item label="商品名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入商品名称" /> <el-input v-model="formData.name" placeholder="请输入商品名称" />
</el-form-item> </el-form-item>
<el-form-item label="条码" prop="barcode"> </el-col>
<el-input v-model="formData.barcode" placeholder="请输入条码" /> <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>
<el-form-item label="价格(分)" prop="price"> </el-col>
<el-input-number v-model="formData.price" :min="0" :step="100" controls-position="right" </el-row>
style="width: 100%" />
<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>
<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>
<el-form-item label="标品ID" prop="standard_product_id"> </el-col>
<el-input-number v-model="formData.standard_product_id" :min="0" controls-position="right" </el-row>
style="width: 100%" />
<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-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-form-item label="批次管理" prop="is_batch_managed">
<el-switch v-model="formData.is_batch_managed" :active-value="1" :inactive-value="0" active-text="" <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-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="效期管理" prop="is_shelf_life_managed"> <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="" <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-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="状态" prop="status"> <el-form-item label="状态" prop="status">
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" <el-switch v-model="formData.status" :active-value="1" :inactive-value="0" active-text="启用"
active-color="#67C23A" /> inactive-text="禁用" active-color="#67C23A" disabled />
</el-form-item> </el-form-item>
</el-col>
</el-row>
</el-form> </el-form>
<template #footer> <template #footer>
<span class="dialog-footer"> <span class="dialog-footer">
@ -60,11 +128,28 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, reactive } from 'vue' import { defineComponent, ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, CircleClose } from '@element-plus/icons-vue'
import ProductList from '@/components/product/byGoods/index.vue' import ProductList from '@/components/product/byGoods/index.vue'
import ProductByLocation from '@/components/product/byLocation/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 { interface ProductItem {
id: number id: number
@ -85,20 +170,10 @@ interface ProductItem {
warehouse_name: string warehouse_name: string
location_id: number location_id: number
location_code: string location_code: string
appearance: number
created_at: number created_at: number
updated_at: number updated_at: number
shop_list?: Array<{ shop_list?: 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 ProductFormData { interface ProductFormData {
@ -108,11 +183,25 @@ interface ProductFormData {
name: string name: string
barcode: string barcode: string
price: number price: number
live_image: any live_image: string[]
is_batch_managed: number is_batch_managed: number
is_shelf_life_managed: number is_shelf_life_managed: number
status: number status: number
appearance: 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({ export default defineComponent({
@ -125,6 +214,8 @@ export default defineComponent({
const activeTab = ref<string>('byGoods') const activeTab = ref<string>('byGoods')
const productListRef = ref<InstanceType<typeof ProductList> | null>(null) const productListRef = ref<InstanceType<typeof ProductList> | null>(null)
const submitLoading = ref<boolean>(false) const submitLoading = ref<boolean>(false)
const imageUploading = ref<boolean>(false)
const maxImageCount = 10
const dialogVisible = ref<boolean>(false) const dialogVisible = ref<boolean>(false)
const dialogTitle = ref<string>('') const dialogTitle = ref<string>('')
@ -140,67 +231,157 @@ export default defineComponent({
is_batch_managed: 0, is_batch_managed: 0,
is_shelf_life_managed: 0, is_shelf_life_managed: 0,
status: 1, status: 1,
appearance: 85 appearance: 85,
quantity: 0,
warehouse_id: null,
location_id: null,
shop_list: []
}) })
const formRules = { const formRules = {
name: [ name: [
{ required: true, message: '商品名称不能为空', trigger: 'blur' }, { required: true, message: '商品名称不能为空', trigger: 'blur' },
{ min: 1, max: 255, message: '长度在1到255个字符', 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 => { const refreshList = (): void => {
productListRef.value?.refreshList?.() productListRef.value?.refreshList?.()
} }
const handleEdit = async (row: ProductItem): Promise<void> => { const handleEdit = (row: ProductItem): void => {
dialogTitle.value = '编辑商品' dialogTitle.value = '编辑商品'
try { //
const detail = await fetchProductDetail(row.id) loadWarehouses()
if (detail) {
formData.id = detail.id ?? row.id // row
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 {
formData.id = row.id formData.id = row.id
formData.category_id = row.category_id formData.category_id = row.category_id
formData.standard_product_id = row.standard_product_id formData.standard_product_id = row.standard_product_id
formData.name = row.name formData.name = row.name
formData.barcode = row.barcode formData.barcode = row.barcode
formData.price = row.price 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
}
} 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.is_batch_managed = row.is_batch_managed formData.is_batch_managed = row.is_batch_managed
formData.is_shelf_life_managed = row.is_shelf_life_managed formData.is_shelf_life_managed = row.is_shelf_life_managed
formData.status = row.status 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 dialogVisible.value = true
setTimeout(() => { formRef.value?.clearValidate() }, 0) 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 => { const handleDelete = (row: ProductItem): void => {
ElMessageBox.confirm(`确定要删除商品 "${row.name}" 吗?`, '删除确认', { ElMessageBox.confirm(`确定要删除商品 "${row.name}" 吗?`, '删除确认', {
confirmButtonText: '确定删除', confirmButtonText: '确定删除',
@ -221,29 +402,22 @@ export default defineComponent({
try { try {
await formRef.value?.validate() await formRef.value?.validate()
submitLoading.value = true submitLoading.value = true
const payload = { const payload: Record<string, any> = {
category_id: formData.category_id,
standard_product_id: formData.standard_product_id,
name: formData.name, name: formData.name,
barcode: formData.barcode, liveimage: formData.live_image || [],
price: formData.price, product_id: formData.id
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
} }
if (formData.id === null) { if (formData.id === null) {
await createProduct(payload) await createProduct(payload)
ElMessage.success({ message: '新增商品成功', customClass: 'scan-success-message' }) ElMessage.success({ message: '新增商品成功', customClass: 'scan-success-message' })
} else { } 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' }) ElMessage.success({ message: '编辑商品成功', customClass: 'scan-success-message' })
} }
dialogVisible.value = false dialogVisible.value = false
refreshList() refreshList()
} catch (error) { } catch (error) {
// ElMessage.error('') //
} finally { } finally {
submitLoading.value = false submitLoading.value = false
} }
@ -261,14 +435,30 @@ export default defineComponent({
formData.is_batch_managed = 0 formData.is_batch_managed = 0
formData.is_shelf_life_managed = 0 formData.is_shelf_life_managed = 0
formData.status = 1 formData.status = 1
formData.appearance = 85
formData.quantity = 0
formData.warehouse_id = null
formData.location_id = null
formData.shop_list = []
locationOptions.value = []
} }
return { return {
activeTab, activeTab,
productListRef, productListRef,
submitLoading, submitLoading,
imageUploading,
maxImageCount,
dialogVisible, dialogTitle, formRef, formData, formRules, 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) { :deep(.el-dialog__body) {
padding-top: 16px; padding-top: 16px;
max-height: 70vh;
overflow-y: auto;
overflow-x: hidden;
} }
.dialog-footer { .dialog-footer {
@ -294,4 +487,67 @@ export default defineComponent({
justify-content: flex-end; justify-content: flex-end;
gap: 12px; 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> </style>

View File

@ -20,6 +20,7 @@
</el-select> </el-select>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button> <el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button :icon="Refresh" @click="resetSearch">重置</el-button> <el-button :icon="Refresh" @click="resetSearch">重置</el-button>
<el-button type="primary" @click="handleExport">导出</el-button>
</div> </div>
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%" @expand-change="handleExpandChange"> <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 { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus, Edit, Delete, View, Loading } from '@element-plus/icons-vue' import { Search, Refresh, Plus, Edit, Delete, View, Loading } from '@element-plus/icons-vue'
import dayjs from 'dayjs' 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 { fetchSupplierList } from '@/api/supplier'
import { fetchWarehouseList } from '@/api/warehouse' import { fetchWarehouseList } from '@/api/warehouse'
@ -334,6 +335,36 @@ export default defineComponent({
}).catch(() => {}) }).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 => { const resetForm = (): void => {
formRef.value?.resetFields() formRef.value?.resetFields()
formData.id = null formData.id = null
@ -363,7 +394,7 @@ export default defineComponent({
statusLabel, statusTagType, statusLabel, statusTagType,
formatTimestamp, formatAmount, formatTimestamp, formatAmount,
handleSearch, resetSearch, handleCurrentChange, handleSizeChange, handleSearch, resetSearch, handleCurrentChange, handleSizeChange,
handleExpandChange, handleView, handleDelete, resetForm handleExpandChange, handleView, handleDelete, handleExport, resetForm
} }
} }
}) })

View File

@ -55,14 +55,48 @@
{{ row.name || '-' }} {{ row.name || '-' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="实拍图" width="80" align="center"> <el-table-column label="实拍图" width="100" align="center">
<template #default="{ row }"> <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)" :preview-src-list="getImageList(row.live_image)"
style="width: 40px; height: 40px; border-radius: 4px" fit="cover" :initial-index="0"
preview-teleported /> class="image-thumb-single"
<span v-else fit="cover"
style="width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; color: #c0c4cc; font-size: 12px">暂无</span> 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> </template>
</el-table-column> </el-table-column>
<el-table-column label="ISBN" min-width="130" show-overflow-tooltip align="center"> <el-table-column label="ISBN" min-width="130" show-overflow-tooltip align="center">
@ -115,10 +149,12 @@
{{ row.msg || '-' }} {{ row.msg || '-' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="80" align="center"> <el-table-column label="操作" width="160" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link size="small" <el-button type="primary" link size="small"
@click="handleRetry(activeShopId, row)">重试</el-button> @click="handleRetry(activeShopId, row)">重试</el-button>
<el-button type="danger" link size="small"
>删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -224,20 +260,48 @@ export default defineComponent({
return dayjs.unix(Number(timestamp)).format('YYYY-MM-DD HH:mm:ss') 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[] => { const getImageList = (liveImage: any): string[] => {
if (!liveImage) return [] if (!liveImage) return []
if (Array.isArray(liveImage)) return liveImage.filter((url: string) => typeof url === 'string') if (Array.isArray(liveImage)) {
if (typeof liveImage === 'string') return [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 [] 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> => { const loadList = async (): Promise<void> => {
if (!searchParams.shop_type) { if (!searchParams.shop_type) {
tableData.value = [] tableData.value = []
@ -482,4 +546,43 @@ export default defineComponent({
align-items: center; 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> </style>

View File

@ -37,6 +37,33 @@
<el-button :icon="Refresh" @click="resetSearch">重置</el-button> <el-button :icon="Refresh" @click="resetSearch">重置</el-button>
</div> </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 <el-table
:data="filteredTableData" :data="filteredTableData"
v-loading="loading" v-loading="loading"
@ -152,7 +179,7 @@ import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus, Edit, Delete, View } from '@element-plus/icons-vue' import { Search, Refresh, Plus, Edit, Delete, View } from '@element-plus/icons-vue'
import dayjs from 'dayjs' 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 }> = { const TYPE_MAP: Record<number, { label: string; type: string }> = {
@ -216,6 +243,15 @@ export default defineComponent({
total: 0 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 dialogVisible = ref<boolean>(false)
const dialogTitle = ref<string>('') const dialogTitle = ref<string>('')
const formRef = ref<any>(null) const formRef = ref<any>(null)
@ -268,6 +304,17 @@ export default defineComponent({
return list return list
}) })
const loadStats = async (): Promise<void> => {
try {
const data = await fetchWaveTaskStats()
if (data) {
Object.assign(stats, data)
}
} catch {
//
}
}
const loadList = async (): Promise<void> => { const loadList = async (): Promise<void> => {
loading.value = true loading.value = true
expandedRowKeys.value = [] expandedRowKeys.value = []
@ -418,11 +465,12 @@ export default defineComponent({
searchParams.wave_no = route.query.wave_no as string searchParams.wave_no = route.query.wave_no as string
} }
void loadList() void loadList()
void loadStats()
}) })
return { return {
loading, submitLoading, tableData, filteredTableData, expandedRowKeys, loading, submitLoading, tableData, filteredTableData, expandedRowKeys,
typeOptions, statusOptions, searchParams, pagination, typeOptions, statusOptions, searchParams, pagination, stats,
dialogVisible, dialogTitle, formRef, formData, formRules, dialogVisible, dialogTitle, formRef, formData, formRules,
detailVisible, detailData, detailVisible, detailData,
Search, Refresh, Plus, Edit, Delete, View, Search, Refresh, Plus, Edit, Delete, View,
@ -459,6 +507,35 @@ export default defineComponent({
box-shadow: 0 1px 4px rgba(0,0,0,0.05); 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 { .pagination-wrapper {
margin-top: 20px; margin-top: 20px;
display: flex; display: flex;

View File

@ -134,7 +134,7 @@ module.exports = defineConfig({
try { try {
const userJson = JSON.parse(userInfoResponse.body) const userJson = JSON.parse(userInfoResponse.body)
if (userJson.status && userJson.data) displayName = userJson.data.nickname || username if (userJson.status && userJson.data) displayName = userJson.data.nickname || username
} catch {} } catch { }
res.setHeader('Content-Type', 'application/json') res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ res.end(JSON.stringify({
@ -177,6 +177,7 @@ module.exports = defineConfig({
// target: 'http://127.0.0.1:9090', // target: 'http://127.0.0.1:9090',
// target: 'http://192.168.101.213:9090', // target: 'http://192.168.101.213:9090',
target: 'https://psi.api.buzhiyushu.cn', target: 'https://psi.api.buzhiyushu.cn',
// target: 'https://psi.api.buzhiyushu.cn',
changeOrigin: true changeOrigin: true
}, },
'/api/print': { '/api/print': {