Files
PartsInquiry/frontend/pages/product/form.vue
2025-09-27 22:57:59 +08:00

277 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<scroll-view scroll-y class="page">
<!-- 顶部标题与操作提示 -->
<view class="hero small">
<text class="title">编辑货品</text>
<text class="sub">完善基础信息与价格</text>
</view>
<view v-if="form.platformStatus==='platform'" class="tip platform">平台推荐货品建议谨慎修改核心字段</view>
<view v-else-if="form.sourceSubmissionId" class="tip custom">此货品源于我的提交审核通过后已入库</view>
<view class="section">
<view class="row">
<text class="label">商品名称</text>
<input v-model.trim="form.name" placeholder="必填" />
</view>
<view class="row">
<text class="label">条形码</text>
<input class="input-long" v-model.trim="form.barcode" placeholder="可扫码或输入" />
<!-- #ifdef MP-WEIXIN -->
<button size="mini" class="picker-btn" @click="chooseAndScanBarcode">图片识码</button>
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<button size="mini" class="picker-btn" @click="chooseAndScanBarcode">图片识码</button>
<!-- #endif -->
</view>
<view class="row">
<input v-model.trim="form.brand" placeholder="品牌" />
</view>
<view class="row">
<input v-model.trim="form.model" placeholder="型号" />
</view>
<view class="row">
<input v-model.trim="form.spec" placeholder="规格" />
</view>
<view class="row">
<input v-model.trim="form.origin" placeholder="产地" />
</view>
<view class="row">
<picker mode="selector" :range="unitNames" @change="onPickUnit">
<view class="picker">主单位{{ unitLabel }}</view>
</picker>
<picker mode="selector" :range="categoryNames" @change="onPickCategory">
<view class="picker">类别{{ categoryLabel }}</view>
</picker>
</view>
</view>
<view class="section">
<view class="row">
<text class="label">库存与安全库存</text>
</view>
<view class="row">
<input type="number" v-model.number="form.stock" placeholder="当前库存" />
<input type="number" v-model.number="form.safeMin" placeholder="安全库存下限" />
<input type="number" v-model.number="form.safeMax" placeholder="安全库存上限" />
</view>
</view>
<view class="section">
<view class="row">
<text class="label">价格进价/零售/批发/大单</text>
</view>
<view class="row prices">
<input type="number" v-model.number="form.purchasePrice" placeholder="进货价" />
<input type="number" v-model.number="form.retailPrice" placeholder="零售价" />
<input type="number" v-model.number="form.wholesalePrice" placeholder="批发价" />
<input type="number" v-model.number="form.bigClientPrice" placeholder="大单价" />
</view>
</view>
<view class="section">
<text class="label">图片</text>
<ImageUploader v-model="form.images" :formData="{ ownerType: 'product' }" />
</view>
<view class="section">
<text class="label">备注</text>
<textarea v-model.trim="form.remark" placeholder="可选" auto-height />
</view>
<view class="fixed" :style="{ bottom: (keyboardHeight || 0) + 'px' }">
<button class="ghost" @click="save(false)">保存</button>
<button class="primary" @click="save(true)">保存并继续</button>
</view>
</scroll-view>
</template>
<script>
import ImageUploader from '../../components/ImageUploader.vue'
import { get, post, put, upload } from '../../common/http.js'
export default {
components: { ImageUploader },
data() {
return {
id: '',
form: {
name: '', barcode: '', brand: '', model: '', spec: '', origin: '',
categoryId: '', unitId: '',
stock: null, safeMin: null, safeMax: null,
purchasePrice: null, retailPrice: null, wholesalePrice: null, bigClientPrice: null,
images: [], remark: '',
platformStatus: '', sourceSubmissionId: ''
},
units: [],
categories: [],
keyboardHeight: 0
}
},
onLoad(query) {
this.id = query?.id || ''
this.bootstrap()
this.initKeyboardListener()
},
onUnload() {
this.disposeKeyboardListener()
},
computed: {
unitNames() { return this.units.map(u => u.name) },
categoryNames() { return this.categories.map(c => c.name) },
unitLabel() {
const u = this.units.find(x => String(x.id) === String(this.form.unitId))
return u ? u.name : '选择单位'
},
categoryLabel() {
const c = this.categories.find(x => String(x.id) === String(this.form.categoryId))
return c ? c.name : '选择类别'
}
},
methods: {
async bootstrap() {
await Promise.all([this.fetchUnits(), this.fetchCategories()])
if (this.id) this.loadDetail()
},
initKeyboardListener() {
try {
this.__keyboardListener = (e) => {
const h = (e && (e.height || e.targetHeight || 0)) || 0
this.keyboardHeight = h
}
uni.onKeyboardHeightChange && uni.onKeyboardHeightChange(this.__keyboardListener)
} catch (_) {}
},
disposeKeyboardListener() {
try {
if (this.__keyboardListener && uni.offKeyboardHeightChange) {
uni.offKeyboardHeightChange(this.__keyboardListener)
}
} catch (_) {}
},
async fetchUnits() {
try {
const res = await get('/api/product-units')
this.units = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch (_) {}
},
async fetchCategories() {
try {
const res = await get('/api/product-categories')
this.categories = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch (_) {}
},
onPickUnit(e) {
const idx = Number(e.detail.value); const u = this.units[idx]
this.form.unitId = u ? u.id : ''
},
onPickCategory(e) {
const idx = Number(e.detail.value); const c = this.categories[idx]
this.form.categoryId = c ? c.id : ''
},
async chooseAndScanBarcode() {
try {
const chooseRes = await uni.chooseImage({ count: 1, sourceType: ['camera','album'], sizeType: ['compressed'] })
let filePath = chooseRes.tempFilePaths[0]
try {
const comp = await uni.compressImage({ src: filePath, quality: 80 })
filePath = comp.tempFilePath || filePath
} catch (e) {}
const data = await upload('/api/barcode/scan', filePath, {}, 'file')
if (data && data.success && data.barcode) {
this.form.barcode = data.barcode
uni.showToast({ title: '识别成功', icon: 'success', mask: false })
return
}
const msg = (data && (data.message || data.error || data.msg)) || '未识别'
uni.showToast({ title: msg, icon: 'none', mask: false })
} catch (e) {
const msg = (e && e.message) ? String(e.message) : '网络异常或服务不可用'
uni.showToast({ title: msg, icon: 'none', mask: false })
}
},
async loadDetail() {
try {
const data = await get('/api/products/' + this.id)
Object.assign(this.form, {
name: data.name,
barcode: data.barcode, brand: data.brand, model: data.model, spec: data.spec, origin: data.origin,
categoryId: data.categoryId, unitId: data.unitId,
stock: data.stock,
safeMin: data.safeMin, safeMax: data.safeMax,
purchasePrice: data.purchasePrice, retailPrice: data.retailPrice,
wholesalePrice: data.wholesalePrice, bigClientPrice: data.bigClientPrice,
images: (data.images || []).map(i => i.url || i),
remark: data.remark || '',
platformStatus: data.platformStatus || '',
sourceSubmissionId: data.sourceSubmissionId || ''
})
} catch (_) {}
},
validate() {
if (!this.form.name) { uni.showToast({ title: '请填写名称', icon: 'none' }); return false }
if (this.form.safeMin != null && this.form.safeMax != null && Number(this.form.safeMin) > Number(this.form.safeMax)) {
uni.showToast({ title: '安全库存区间不合法', icon: 'none' }); return false
}
return true
},
buildPayload() {
const f = this.form
return {
name: f.name, barcode: f.barcode, brand: f.brand, model: f.model, spec: f.spec, origin: f.origin,
categoryId: f.categoryId || null, unitId: f.unitId,
safeMin: f.safeMin, safeMax: f.safeMax,
prices: {
purchasePrice: f.purchasePrice, retailPrice: f.retailPrice, wholesalePrice: f.wholesalePrice, bigClientPrice: f.bigClientPrice
},
stock: f.stock,
images: f.images,
remark: f.remark
}
},
async save(goOn) {
try { uni.hideKeyboard && uni.hideKeyboard() } catch (_) {}
if (!this.validate()) return
const payload = this.buildPayload()
try {
if (this.id) await put('/api/products/' + this.id, payload)
else await post('/api/products', payload)
uni.showToast({ title: '保存成功', icon: 'success', mask: false })
if (goOn && !this.id) {
this.form = { name: '', barcode: '', brand: '', model: '', spec: '', origin: '', categoryId: '', unitId: '', stock: null, safeMin: null, safeMax: null, purchasePrice: null, retailPrice: null, wholesalePrice: null, bigClientPrice: null, images: [], remark: '', platformStatus: '', sourceSubmissionId: '' }
} else {
setTimeout(() => uni.navigateBack(), 400)
}
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none', mask: false })
}
}
}
}
</script>
<style lang="scss">
.page { background:$uni-bg-color; min-height: 100vh; padding-bottom: 160rpx; box-sizing: border-box; }
.hero.small { margin: 22rpx 24rpx 12rpx; padding: 0 4rpx 18rpx; color: $uni-text-color; border-bottom: 2rpx solid rgba(94,124,174,0.12); }
.hero.small .title { font-size: 34rpx; font-weight: 800; }
.hero.small .sub { display: block; margin-top: 6rpx; color: $uni-text-color-grey; font-size: 24rpx; }
.section { margin: 0 24rpx 28rpx; padding-bottom: 6rpx; border-bottom: 2rpx solid rgba(94,124,174,0.10); }
.section:last-of-type { border-bottom: 0; margin-bottom: 0; }
.section .row:first-child .label { font-weight: 700; color: $uni-text-color; }
.row { display:flex; gap: 8rpx; align-items: center; margin-top: 18rpx; }
.row .input-long { flex: 1.2; }
.row:first-child { margin-top: 0; }
.label { width: 150rpx; color:$uni-text-color-grey; font-size: 26rpx; }
.row input { flex:1; background:#f7f9fc; border-radius: 14rpx; padding: 18rpx 20rpx; color:$uni-text-color; border: 0; box-shadow: inset 0 0 0 2rpx rgba(134,155,191,0.06); }
.picker-btn { background:#ffffff; border: 2rpx solid rgba($uni-color-primary, .45); color:$uni-color-primary; padding: 0 24rpx; border-radius: 999rpx; font-size: 24rpx; }
.picker { padding: 16rpx 22rpx; background:#f7f9fc; border-radius: 14rpx; color:$uni-text-color-grey; margin-left: 8rpx; border: 0; box-shadow: inset 0 0 0 2rpx rgba(134,155,191,0.06); }
.prices input { width: 30%; }
.section textarea { width: 100%; min-height: 160rpx; background:#f7f9fc; border-radius: 14rpx; padding: 20rpx 22rpx; box-sizing: border-box; color:$uni-text-color; border: 0; box-shadow: inset 0 0 0 2rpx rgba(134,155,191,0.06); }
.fixed { position: fixed; left: 0; right: 0; bottom: env(safe-area-inset-bottom); background:#ffffff; padding: 16rpx 16rpx calc(16rpx + constant(safe-area-inset-bottom)) 16rpx; display:flex; gap: 16rpx; box-shadow: 0 -6rpx 18rpx rgba(24,55,105,0.08); z-index: 999; }
.fixed .primary { flex:1; background: $uni-color-primary; color:#fff; border-radius: 999rpx; padding: 18rpx 0; font-weight: 700; }
.fixed .ghost { flex:1; background:#ffffff; color:$uni-color-primary; border: 2rpx solid rgba($uni-color-primary, .45); border-radius: 999rpx; padding: 18rpx 0; }
.tip { margin: 0 30rpx 20rpx; padding: 16rpx 20rpx; border-radius: 16rpx; font-size: 24rpx; }
.tip.platform { background: rgba(45,140,240,0.12); color: #2d8cf0; }
.tip.custom { background: rgba(103,194,58,0.12); color: #67c23a; }
</style>