This commit is contained in:
2025-09-27 22:57:59 +08:00
parent 8a458ff0a4
commit ed26244cdb
12585 changed files with 1914308 additions and 3474 deletions

View File

@@ -5,20 +5,22 @@
<text class="title">编辑货品</text>
<text class="sub">完善基础信息与价格</text>
</view>
<view class="card">
<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 v-model.trim="form.barcode" placeholder="可扫码或输入" />
<!-- #ifdef APP-PLUS -->
<button size="mini" @click="scan"></button>
<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">
<text class="label">品牌/型号/规格/产地</text>
</view>
<view class="row">
<input v-model.trim="form.brand" placeholder="品牌" />
@@ -42,7 +44,7 @@
</view>
</view>
<view class="card">
<view class="section">
<view class="row">
<text class="label">库存与安全库存</text>
</view>
@@ -53,7 +55,7 @@
</view>
</view>
<view class="card">
<view class="section">
<view class="row">
<text class="label">价格进价/零售/批发/大单</text>
</view>
@@ -65,17 +67,17 @@
</view>
</view>
<view class="card">
<view class="section">
<text class="label">图片</text>
<ImageUploader v-model="form.images" :formData="{ ownerType: 'product' }" />
</view>
<view class="card">
<view class="section">
<text class="label">备注</text>
<textarea v-model.trim="form.remark" placeholder="可选" auto-height />
</view>
<view class="fixed">
<view class="fixed" :style="{ bottom: (keyboardHeight || 0) + 'px' }">
<button class="ghost" @click="save(false)">保存</button>
<button class="primary" @click="save(true)">保存并继续</button>
</view>
@@ -84,7 +86,7 @@
<script>
import ImageUploader from '../../components/ImageUploader.vue'
import { get, post, put } from '../../common/http.js'
import { get, post, put, upload } from '../../common/http.js'
export default {
components: { ImageUploader },
@@ -96,15 +98,21 @@ export default {
categoryId: '', unitId: '',
stock: null, safeMin: null, safeMax: null,
purchasePrice: null, retailPrice: null, wholesalePrice: null, bigClientPrice: null,
images: [], remark: ''
images: [], remark: '',
platformStatus: '', sourceSubmissionId: ''
},
units: [],
categories: []
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) },
@@ -123,6 +131,22 @@ export default {
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')
@@ -143,11 +167,27 @@ export default {
const idx = Number(e.detail.value); const c = this.categories[idx]
this.form.categoryId = c ? c.id : ''
},
scan() {
uni.scanCode({ onlyFromCamera: false, success: (res) => {
this.form.barcode = res.result
}})
},
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)
@@ -159,7 +199,10 @@ export default {
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)
images: (data.images || []).map(i => i.url || i),
remark: data.remark || '',
platformStatus: data.platformStatus || '',
sourceSubmissionId: data.sourceSubmissionId || ''
})
} catch (_) {}
},
@@ -185,19 +228,20 @@ export default {
}
},
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' })
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: '' }
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' })
uni.showToast({ title: '保存失败', icon: 'none', mask: false })
}
}
}
@@ -205,19 +249,28 @@ export default {
</script>
<style lang="scss">
.page { background:$uni-bg-color; height: 100vh; }
.hero.small { margin: 16rpx; padding: 16rpx; background: #ffffff; border: 2rpx solid $uni-border-color; border-radius: 16rpx; }
.hero.small .title { font-size: 32rpx; font-weight: 800; color: $uni-text-color; }
.hero.small .sub { margin-left: 12rpx; color: $uni-text-color-grey; font-size: 24rpx; }
.card { background:#ffffff; margin: 16rpx; padding: 16rpx; border-radius: 16rpx; border: 2rpx solid $uni-border-color; }
.row { display:flex; gap: 12rpx; align-items: center; margin-bottom: 12rpx; }
.label { width: 180rpx; color:$uni-text-color-grey; }
.row input { flex:1; background:$uni-bg-color-hover; border-radius: 12rpx; padding: 14rpx; color:$uni-text-color; border: 2rpx solid $uni-border-color; }
.picker { padding: 10rpx 14rpx; background:$uni-bg-color-hover; border-radius: 12rpx; color:$uni-text-color-grey; margin-left: 8rpx; border: 2rpx solid $uni-border-color; }
.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%; }
.fixed { position: fixed; left: 0; right: 0; bottom: 0; background:#ffffff; padding: 12rpx 16rpx; display:flex; gap: 16rpx; border-top: 2rpx solid $uni-border-color; }
.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>

View File

@@ -3,6 +3,7 @@
<view class="tabs">
<view class="tab" :class="{active: tab==='all'}" @click="switchTab('all')">全部</view>
<view class="tab" :class="{active: tab==='category'}" @click="switchTab('category')">按类别</view>
<view class="tab extra" @click="goMySubmissions">我的提交</view>
</view>
<view class="search">
@@ -18,7 +19,11 @@
<view class="item" v-for="it in items" :key="it.id" @click="openForm(it.id)">
<image v-if="it.cover" :src="it.cover" class="thumb" mode="aspectFill" />
<view class="content">
<view class="name">{{ it.name }}</view>
<view class="name">
<text>{{ it.name }}</text>
<text v-if="it.platformStatus==='platform'" class="tag-platform">平台推荐</text>
<text v-else-if="it.sourceSubmissionId" class="tag-custom">我的提交</text>
</view>
<view class="meta">{{ it.brand || '-' }} {{ it.model || '' }} {{ it.spec || '' }}</view>
<view class="meta">库存{{ it.stock ?? 0 }}
<text class="price">零售价¥{{ (it.retailPrice ?? it.price ?? 0).toFixed(2) }}</text>
@@ -31,7 +36,7 @@
</view>
</scroll-view>
<view class="fab" @click="openForm()"></view>
<!-- 保留我的提交页的此处不显示 -->
</view>
</template>
@@ -117,6 +122,9 @@ export default {
openForm(id) {
const url = '/pages/product/form' + (id ? ('?id=' + id) : '')
uni.navigateTo({ url })
},
goMySubmissions() {
uni.navigateTo({ url: '/pages/product/submissions' })
}
}
}
@@ -127,6 +135,7 @@ export default {
.tabs { display:flex; background:$uni-bg-color-grey; }
.tab { flex:1; text-align:center; padding: 20rpx 0; color:$uni-text-color-grey; }
.tab.active { color:$uni-color-primary; font-weight: 600; }
.tab.extra { flex: 0 0 180rpx; color:$uni-color-primary; font-weight: 600; }
.search { display:flex; gap: 12rpx; padding: 16rpx; background:$uni-bg-color-grey; align-items: center; }
.search input { flex:1; background:$uni-bg-color-hover; border-radius: 12rpx; padding: 12rpx; color: $uni-text-color; }
.picker { padding: 8rpx 12rpx; background:$uni-bg-color-hover; border-radius: 10rpx; color:$uni-text-color-grey; }
@@ -134,7 +143,9 @@ export default {
.item { display:flex; padding: 20rpx; background:$uni-bg-color-grey; border-bottom: 1rpx solid $uni-border-color; }
.thumb { width: 120rpx; height: 120rpx; border-radius: 12rpx; margin-right: 16rpx; background:$uni-bg-color-hover; }
.content { flex:1; }
.name { color:$uni-text-color; margin-bottom: 6rpx; font-weight: 600; }
.name { color:$uni-text-color; margin-bottom: 6rpx; font-weight: 600; display:flex; align-items:center; gap: 12rpx; }
.tag-platform { font-size: 22rpx; color:#fff; background:#2d8cf0; padding: 4rpx 10rpx; border-radius: 8rpx; }
.tag-custom { font-size: 22rpx; color:#fff; background:#67c23a; padding: 4rpx 10rpx; border-radius: 8rpx; }
.meta { color:$uni-text-color-grey; font-size: 24rpx; }
.price { margin-left: 20rpx; color:$uni-color-primary; }
.empty { height: 60vh; display:flex; align-items:center; justify-content:center; color:$uni-text-color-grey; }

View File

@@ -0,0 +1,190 @@
<template>
<scroll-view scroll-y class="page" v-if="detail">
<view class="header">
<text class="model">{{ detail.model }}</text>
<text :class="['status', statusClass(detail.status)]">{{ statusLabel(detail.status) }}</text>
</view>
<view class="section">
<view class="row"><text class="label">名称</text><text class="value">{{ detail.name || '-' }}</text></view>
<view class="row"><text class="label">品牌</text><text class="value">{{ detail.brand || '-' }}</text></view>
<view class="row"><text class="label">规格</text><text class="value">{{ detail.spec || '-' }}</text></view>
<view class="row"><text class="label">产地</text><text class="value">{{ detail.origin || '-' }}</text></view>
<view class="row"><text class="label">条码</text><text class="value">{{ detail.barcode || '-' }}</text></view>
<view class="row"><text class="label">单位</text><text class="value">{{ unitName }}</text></view>
<view class="row"><text class="label">类别</text><text class="value">{{ categoryName }}</text></view>
<view class="row"><text class="label">安全库存</text><text class="value">{{ stockRange }}</text></view>
</view>
<view class="section">
<view class="block-title">参数</view>
<view v-if="parameterPairs.length" class="params">
<view class="param" v-for="item in parameterPairs" :key="item.key">
<text class="param-key">{{ item.key }}</text>
<text class="param-val">{{ item.value }}</text>
</view>
</view>
<view v-else class="placeholder">未填写参数</view>
</view>
<view class="section">
<view class="block-title">图片</view>
<view v-if="detail.images && detail.images.length" class="images">
<image v-for="(img, idx) in detail.images" :key="idx" :src="img" class="image" mode="aspectFill" @click="preview(idx)" />
</view>
<view v-else class="placeholder">未上传图片</view>
</view>
<view class="section">
<view class="block-title">备注</view>
<view class="placeholder">{{ detail.remark || '无' }}</view>
</view>
<view class="section">
<view class="row"><text class="label">提交时间</text><text class="value">{{ formatTime(detail.createdAt) }}</text></view>
<view class="row"><text class="label">审核时间</text><text class="value">{{ formatTime(detail.reviewedAt) }}</text></view>
<view class="row" v-if="detail.reviewRemark"><text class="label">审核说明</text><text class="value">{{ detail.reviewRemark }}</text></view>
</view>
<view class="footer">
<button size="mini" @click="back">返回</button>
<button size="mini" type="warn" v-if="detail.status === 'rejected'" @click="resubmit">重新提交</button>
</view>
</scroll-view>
<view v-else class="loading">加载中...</view>
</template>
<script>
import { get } from '../../common/http.js'
export default {
data() {
return {
id: '',
detail: null,
unitName: '-',
categoryName: '-'
}
},
async onLoad(query) {
this.id = query?.id || ''
if (!this.id) {
uni.showToast({ title: '参数缺失', icon: 'none' })
return
}
await this.loadDetail()
},
methods: {
async loadDetail() {
try {
const data = await get(`/api/products/submissions/${this.id}`)
this.detail = data
this.unitName = this.unitLookup(data.unitId)
this.categoryName = this.categoryLookup(data.categoryId)
} catch (e) {
const msg = e?.message || '加载失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
statusLabel(s) {
if (s === 'approved') return '已通过'
if (s === 'rejected') return '已驳回'
return '待审核'
},
statusClass(s) {
if (s === 'approved') return 'approved'
if (s === 'rejected') return 'rejected'
return 'pending'
},
parameterPairs() {
const params = this.detail?.parameters
if (!params || typeof params !== 'object') return []
return Object.keys(params).map(k => ({ key: k, value: params[k] }))
},
preview(idx) {
if (!this.detail?.images || !this.detail.images.length) return
uni.previewImage({ urls: this.detail.images, current: idx })
},
formatTime(value) {
if (!value) return '-'
try {
const d = new Date(value)
if (!Number.isFinite(d.getTime())) return value
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}`
} catch (_) { return value }
},
unitLookup(id) {
try {
const list = uni.getStorageSync('CACHE_UNITS') || []
const found = list.find(x => String(x.id) === String(id))
return found ? found.name : '-'
} catch (_) { return '-' }
},
categoryLookup(id) {
try {
const list = uni.getStorageSync('CACHE_CATEGORIES') || []
const found = list.find(x => String(x.id) === String(id))
return found ? found.name : '-'
} catch (_) { return '-' }
},
back() {
uni.navigateBack({ delta: 1 })
},
resubmit() {
const payload = {
model: this.detail.model,
name: this.detail.name,
brand: this.detail.brand,
spec: this.detail.spec,
origin: this.detail.origin,
unitId: this.detail.unitId,
categoryId: this.detail.categoryId,
remark: this.detail.remark,
barcode: this.detail.barcode,
parameters: this.detail.parameters
}
const query = encodeURIComponent(JSON.stringify(payload))
uni.navigateTo({ url: `/pages/product/submit?prefill=${query}` })
}
},
computed: {
stockRange() {
const min = this.detail?.safeMin
const max = this.detail?.safeMax
if (min == null && max == null) return '-'
if (min != null && max != null) return `${min} ~ ${max}`
if (min != null) return `${min}`
return `${max}`
}
}
}
</script>
<style lang="scss">
.page { padding: 24rpx 24rpx 160rpx; background: #f6f7fb; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
.model { font-size: 36rpx; font-weight: 700; color: #2d3a4a; }
.status { font-size: 26rpx; padding: 6rpx 18rpx; border-radius: 999rpx; }
.status.pending { background: rgba(246, 190, 0, 0.15); color: #c47f00; }
.status.approved { background: rgba(103,194,58,0.15); color: #409eff; }
.status.rejected { background: rgba(255,87,115,0.18); color: #f56c6c; }
.section { background: #fff; border-radius: 16rpx; padding: 20rpx 22rpx; margin-bottom: 24rpx; box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.04); }
.row { display: flex; justify-content: space-between; padding: 12rpx 0; border-bottom: 1rpx solid #f1f2f5; }
.row:last-child { border-bottom: none; }
.label { width: 160rpx; font-size: 26rpx; color: #7a8899; }
.value { flex: 1; text-align: right; font-size: 26rpx; color: #2d3a4a; word-break: break-all; }
.block-title { font-size: 28rpx; font-weight: 600; color: #2d3a4a; margin-bottom: 12rpx; }
.placeholder { font-size: 26rpx; color: #7a8899; }
.params { display: flex; flex-direction: column; gap: 12rpx; }
.param { display: flex; justify-content: space-between; font-size: 26rpx; color: #2d3a4a; }
.param-key { color: #7a8899; }
.images { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12rpx; }
.image { width: 100%; height: 200rpx; border-radius: 16rpx; background: #f0f2f5; }
.footer { display: flex; justify-content: flex-end; gap: 20rpx; }
.loading { height: 100vh; display: flex; align-items: center; justify-content: center; color: #7a8899; }
</style>

View File

@@ -0,0 +1,194 @@
<template>
<view class="page">
<view class="hero">
<text class="title">我的配件提交</text>
<text class="desc">查看待审核已通过已驳回的记录</text>
</view>
<view class="tabs">
<view class="tab" :class="{ active: status === '' }" @click="switchStatus('')">全部</view>
<view class="tab" :class="{ active: status === 'pending' }" @click="switchStatus('pending')">待审核</view>
<view class="tab" :class="{ active: status === 'approved' }" @click="switchStatus('approved')">已通过</view>
<view class="tab" :class="{ active: status === 'rejected' }" @click="switchStatus('rejected')">已驳回</view>
</view>
<scroll-view scroll-y class="list" @scrolltolower="loadMore">
<view v-if="items.length" class="cards">
<view class="card" v-for="item in items" :key="item.id">
<view class="card-header">
<text class="model">{{ item.model || '-' }}</text>
<text :class="['status', statusClass(item.status)]">{{ statusLabel(item.status) }}</text>
</view>
<view class="card-body">
<text class="name">{{ item.name || '未填写名称' }}</text>
<text class="brand">品牌{{ item.brand || '-' }}</text>
<text class="time">提交{{ formatTime(item.createdAt) }}</text>
<text class="time" v-if="item.reviewedAt">审核{{ formatTime(item.reviewedAt) }}</text>
</view>
<view class="card-footer">
<button size="mini" @click="viewDetail(item.id)">详情</button>
<button size="mini" type="primary" v-if="item.status === 'pending'" @click="notifyPending">等待审核</button>
<button size="mini" type="warn" v-else-if="item.status === 'rejected'" @click="resubmit(item)">重新提交</button>
</view>
</view>
</view>
<view v-else class="empty">
<text>暂无提交记录快去提交新的配件吧</text>
<button size="mini" class="primary" @click="goSubmit">立即提交</button>
</view>
<view v-if="loading" class="loading">加载中...</view>
<view v-else-if="finished && items.length" class="finished">没有更多了</view>
</scroll-view>
<view class="fab" @click="goSubmit"></view>
</view>
</template>
<script>
import { get } from '../../common/http.js'
export default {
data() {
return {
status: '',
items: [],
page: 1,
size: 20,
total: 0,
loading: false,
finished: false,
cacheUnitsLoaded: false,
cacheCategoriesLoaded: false
}
},
onShow() {
this.preloadDictionaries()
this.reload()
},
methods: {
async preloadDictionaries() {
try {
const [units, categories] = await Promise.all([
this.cacheUnitsLoaded ? Promise.resolve(null) : get('/api/product-units'),
this.cacheCategoriesLoaded ? Promise.resolve(null) : get('/api/product-categories')
])
if (units) {
const list = Array.isArray(units?.list) ? units.list : (Array.isArray(units) ? units : [])
uni.setStorageSync('CACHE_UNITS', list)
this.cacheUnitsLoaded = true
}
if (categories) {
const list = Array.isArray(categories?.list) ? categories.list : (Array.isArray(categories) ? categories : [])
uni.setStorageSync('CACHE_CATEGORIES', list)
this.cacheCategoriesLoaded = true
}
} catch (_) {
// 忽略缓存失败
}
},
switchStatus(s) {
if (this.status === s) return
this.status = s
this.reload()
},
async reload() {
this.page = 1
this.items = []
this.finished = false
await this.loadMore()
},
async loadMore() {
if (this.loading || this.finished) return
this.loading = true
try {
const params = { page: this.page, size: this.size }
if (this.status) params.status = this.status
const res = await get('/api/products/submissions', params)
const list = Array.isArray(res?.list) ? res.list : []
this.items = this.items.concat(list)
this.total = Number(res?.total || this.items.length)
if (list.length < this.size) this.finished = true
this.page += 1
} catch (e) {
console.warn('加载提交记录失败', e)
const msg = e?.message || '加载失败'
uni.showToast({ title: msg, icon: 'none' })
} finally {
this.loading = false
}
},
statusLabel(s) {
if (s === 'approved') return '已通过'
if (s === 'rejected') return '已驳回'
return '待审核'
},
statusClass(s) {
if (s === 'approved') return 'approved'
if (s === 'rejected') return 'rejected'
return 'pending'
},
formatTime(value) {
if (!value) return '-'
try {
const d = new Date(value)
if (!Number.isFinite(d.getTime())) return value
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}`
} catch (_) { return value }
},
viewDetail(id) {
uni.navigateTo({ url: `/pages/product/submission-detail?id=${id}` })
},
notifyPending() {
uni.showToast({ title: '审核中,请耐心等待', icon: 'none' })
},
resubmit(item) {
const payload = {
model: item.model,
name: item.name,
brand: item.brand,
spec: item.spec,
origin: item.origin,
unitId: item.unitId,
categoryId: item.categoryId,
remark: item.remark
}
const query = encodeURIComponent(JSON.stringify(payload))
uni.navigateTo({ url: `/pages/product/submit?prefill=${query}` })
},
goSubmit() {
uni.navigateTo({ url: '/pages/product/submit' })
}
}
}
</script>
<style lang="scss">
.page { display: flex; flex-direction: column; height: 100vh; background: #f6f7fb; padding-bottom: 140rpx; }
.hero { padding: 24rpx; background: #fff; box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.04); }
.title { font-size: 34rpx; font-weight: 700; color: #2d3a4a; }
.desc { font-size: 24rpx; color: #7a8899; margin-top: 8rpx; }
.tabs { display: flex; background: #fff; margin: 16rpx; border-radius: 999rpx; overflow: hidden; box-shadow: inset 0 0 0 1rpx rgba(76,141,255,0.1); }
.tab { flex: 1; text-align: center; padding: 20rpx 0; font-size: 28rpx; color: #7a8899; }
.tab.active { background: linear-gradient(135deg, #4c8dff, #6ab7ff); color: #fff; font-weight: 600; }
.list { flex: 1; padding: 0 20rpx; }
.cards { display: flex; flex-direction: column; gap: 20rpx; padding-bottom: 40rpx; }
.card { background: #fff; border-radius: 18rpx; padding: 22rpx; box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.05); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.model { font-size: 30rpx; font-weight: 700; color: #2d3a4a; }
.status { font-size: 24rpx; padding: 6rpx 18rpx; border-radius: 999rpx; }
.status.pending { background: rgba(246, 190, 0, 0.15); color: #c47f00; }
.status.approved { background: rgba(103,194,58,0.15); color: #409eff; }
.status.rejected { background: rgba(255,87,115,0.18); color: #f56c6c; }
.card-body { display: flex; flex-direction: column; gap: 6rpx; color: #4f5969; font-size: 26rpx; }
.name { font-weight: 600; color: #2d3a4a; }
.card-footer { display: flex; gap: 12rpx; margin-top: 16rpx; }
.empty { height: 60vh; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #8894a3; gap: 20rpx; }
.empty .primary { background: #4c8dff; color: #fff; border-radius: 999rpx; padding: 12rpx 30rpx; }
.loading, .finished { text-align: center; padding: 20rpx 0; color: #7a8899; }
.fab { position: fixed; right: 30rpx; bottom: 120rpx; width: 100rpx; height: 100rpx; background: linear-gradient(135deg, #4c8dff, #6ab7ff); color: #fff; border-radius: 50rpx; display: flex; align-items: center; justify-content: center; font-size: 48rpx; box-shadow: 0 20rpx 40rpx rgba(0,0,0,0.2); }
</style>

View File

@@ -0,0 +1,299 @@
<template>
<scroll-view scroll-y class="page">
<view class="hero">
<text class="title">提交配件</text>
<text class="desc">填写型号名称参数与图片提交后进入待审核状态</text>
</view>
<view class="section">
<view class="row required">
<text class="label">型号</text>
<input v-model.trim="form.model" placeholder="请输入型号(必填)" />
</view>
<view class="row">
<text class="label">品牌</text>
<input v-model.trim="form.brand" placeholder="品牌/厂商" />
</view>
<view class="row">
<text class="label">条码</text>
<input v-model.trim="form.barcode" placeholder="可选,建议扫码录入" />
<button size="mini" class="picker-btn" @click="scanBarcode">识码</button>
</view>
</view>
<view class="section">
<view class="row">
<text class="label">类别</text>
<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">
<picker mode="selector" :range="templateNames" @change="onPickTemplate">
<view class="picker">{{ templateLabel }}</view>
</picker>
</view>
</view>
<view class="section">
<view class="row">
<text class="label">图片</text>
</view>
<ImageUploader v-model="form.images" :max="9" :formData="{ ownerType: 'submission' }" />
</view>
<view class="section">
<view class="row">
<text class="label">备注</text>
</view>
<textarea class="textarea" v-model.trim="form.remark" placeholder="选填:补充说明" />
</view>
<view class="section">
<view class="row">
<text class="label">安全库存</text>
</view>
<view class="row triple">
<input type="number" v-model.number="form.safeMin" placeholder="下限" />
<input type="number" v-model.number="form.safeMax" placeholder="上限" />
</view>
</view>
<view class="fixed">
<button class="primary" :loading="submitting" @click="submit">提交审核</button>
<button class="primary" style="margin-top:16rpx;background:#7aa9ff" :loading="checking" @click="checkModel">查重</button>
</view>
</scroll-view>
</template>
<script>
import ImageUploader from '../../components/ImageUploader.vue'
import { get, post, upload } from '../../common/http.js'
export default {
components: { ImageUploader },
data() {
return {
form: {
model: '',
brand: '',
barcode: '',
categoryId: '',
templateId: '',
parameters: {},
images: [],
remark: '',
safeMin: null,
safeMax: null
},
templates: [],
paramValues: {},
checking: false,
parameterText: '',
categories: [],
submitting: false,
paramPlaceholder: '可输入 JSON如 {"颜色":"黑","材质":"钢"}'
}
},
computed: {
categoryNames() { return this.categories.map(c => c.name) },
templateNames() { return this.templates.map(t => t.name) },
categoryLabel() {
const c = this.categories.find(x => String(x.id) === String(this.form.categoryId))
return c ? c.name : '选择类别'
},
selectedTemplate() {
return this.templates.find(t => String(t.id) === String(this.form.templateId))
},
templateLabel() {
const t = this.selectedTemplate
return t ? `${t.name}` : '选择模板'
}
},
onLoad(options) {
this.bootstrap()
if (options && options.prefill) {
try {
const data = JSON.parse(decodeURIComponent(options.prefill))
Object.assign(this.form, {
model: data.model || '',
brand: data.brand || '',
barcode: data.barcode || '',
categoryId: data.categoryId || '',
remark: data.remark || ''
})
if (data.parameters && typeof data.parameters === 'object') {
this.parameterText = JSON.stringify(data.parameters, null, 2)
}
} catch (_) {}
}
},
methods: {
async bootstrap() {
await Promise.all([ this.fetchCategories()])
await this.fetchTemplates()
},
async fetchCategories() {
try {
const res = await get('/api/product-categories')
this.categories = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch (_) {
this.categories = []
}
},
async fetchTemplates() {
try {
const res = await get('/api/product-templates', this.form.categoryId ? { categoryId: this.form.categoryId } : {})
const list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
this.templates = list
} catch (_) { this.templates = [] }
},
onPickCategory(e) {
const idx = Number(e.detail.value)
const c = this.categories[idx]
this.form.categoryId = c ? c.id : ''
this.fetchTemplates()
},
onPickTemplate(e) {
const idx = Number(e.detail.value)
const t = this.templates[idx]
this.form.templateId = t ? t.id : ''
this.paramValues = {}
},
onPickEnum(p, e) {
const idx = Number(e.detail.value)
const arr = p.enumOptions || []
this.paramValues[p.fieldKey] = arr[idx]
},
async scanBarcode() {
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 (_) {}
const data = await upload('/api/barcode/scan', filePath, {}, 'file')
const barcode = data?.barcode || data?.data?.barcode
if (barcode) {
this.form.barcode = barcode
uni.showToast({ title: '识别成功', icon: 'success' })
} else {
uni.showToast({ title: '未识别到条码', icon: 'none' })
}
} catch (e) {
const msg = e?.message || '识码失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
async checkModel() {
if (!this.form.model) return uni.showToast({ title: '请填写型号', icon: 'none' })
try {
this.checking = true
const res = await post('/api/products/submissions/check-model', { templateId: this.form.templateId, model: this.form.model })
if (res && res.available) {
uni.showToast({ title: '可用,无重复', icon: 'success' })
} else {
uni.showToast({ title: '已存在相同型号提交', icon: 'none' })
}
} catch (e) {
const msg = e?.message || '校验失败'
uni.showToast({ title: msg, icon: 'none' })
} finally { this.checking = false }
},
async submit() {
if (this.submitting) return
if (!this.form.model) {
return uni.showToast({ title: '请填写型号', icon: 'none' })
}
let paramsObj = null
if (this.parameterText) {
try {
paramsObj = JSON.parse(this.parameterText)
} catch (e) {
return uni.showToast({ title: '参数 JSON 不合法', icon: 'none' })
}
}
if (this.form.safeMin != null && this.form.safeMax != null && Number(this.form.safeMin) > Number(this.form.safeMax)) {
return uni.showToast({ title: '安全库存区间不合法', icon: 'none' })
}
// 模板必填校验与参数整理
let paramsForSubmit = paramsObj
if (this.selectedTemplate) {
// 校验必填
for (const p of (this.selectedTemplate.params || [])) {
if (p.required && (this.paramValues[p.fieldKey] === undefined || this.paramValues[p.fieldKey] === null || this.paramValues[p.fieldKey] === '')) {
return uni.showToast({ title: `请填写 ${p.fieldLabel}`, icon: 'none' })
}
}
// 类型规范化
const shaped = {}
for (const p of (this.selectedTemplate.params || [])) {
let v = this.paramValues[p.fieldKey]
if (p.type === 'number' && v !== undefined && v !== null && v !== '') v = Number(v)
if (p.type === 'boolean') v = !!v
shaped[p.fieldKey] = v
}
paramsForSubmit = shaped
}
const payload = {
model: this.form.model,
brand: this.form.brand,
barcode: this.form.barcode,
categoryId: this.form.categoryId || null,
templateId: this.form.templateId || null,
parameters: paramsForSubmit,
images: this.form.images,
remark: this.form.remark,
safeMin: this.form.safeMin,
safeMax: this.form.safeMax
}
this.submitting = true
try {
await post('/api/products/submissions', payload)
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => {
uni.redirectTo({ url: '/pages/product/submissions' })
}, 400)
} catch (e) {
const msg = e?.message || '提交失败'
uni.showToast({ title: msg, icon: 'none' })
} finally {
this.submitting = false
}
}
}
}
</script>
<style lang="scss">
.page { padding: 24rpx 24rpx 120rpx; background: #f6f7fb; }
.hero { padding: 24rpx; background: linear-gradient(135deg, #4c8dff, #6ab7ff); border-radius: 20rpx; color: #fff; margin-bottom: 24rpx; }
.title { font-size: 36rpx; font-weight: 700; }
.desc { font-size: 26rpx; margin-top: 8rpx; opacity: 0.9; }
.section { background: #fff; border-radius: 16rpx; padding: 20rpx 22rpx; margin-bottom: 24rpx; box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.04); }
.row { display: flex; align-items: center; gap: 16rpx; padding: 16rpx 0; border-bottom: 1rpx solid #f1f2f5; }
.row:last-child { border-bottom: none; }
.row.required .label::after { content: '*'; color: #ff5b5b; margin-left: 6rpx; }
.label { width: 130rpx; font-size: 28rpx; color: #2d3a4a; }
input { flex: 1; background: #f8f9fb; border-radius: 12rpx; padding: 16rpx 18rpx; font-size: 28rpx; color: #222; }
.textarea { width: 100%; min-height: 160rpx; background: #f8f9fb; border-radius: 12rpx; padding: 18rpx; font-size: 28rpx; color: #222; }
.picker { flex: 1; background: #f8f9fb; border-radius: 12rpx; padding: 18rpx; font-size: 28rpx; color: #222; }
.picker-btn { background: #4c8dff; color: #fff; border-radius: 999rpx; padding: 10rpx 22rpx; }
.triple input { flex: 1; }
.fixed { position: fixed; left: 0; right: 0; bottom: 0; padding: 20rpx 24rpx 40rpx; background: rgba(255,255,255,0.96); box-shadow: 0 -6rpx 20rpx rgba(0,0,0,0.08); }
.primary { width: 100%; height: 88rpx; border-radius: 999rpx; background: #4c8dff; color: #fff; font-size: 32rpx; font-weight: 600; }
</style>