This commit is contained in:
2025-09-29 21:38:32 +08:00
parent ed26244cdb
commit 19117de6c8
182 changed files with 11590 additions and 2156 deletions

View File

@@ -1,18 +1,21 @@
<template>
<view class="auth-page">
<view class="tabs">
<view :class="['tab', tab==='login'?'active':'']" @click="tab='login'">登录</view>
<view :class="['tab', tab==='register'?'active':'']" @click="tab='register'">注册</view>
<view :class="['tab', tab==='reset'?'active':'']" @click="tab='reset'">忘记密码</view>
<view class="login-hero">
<image class="login-hero-img" :src="authLoginTopImage" mode="widthFix" />
</view>
<view class="header"><text class="title">邮箱密码登录</text></view>
<view v-if="tab==='login'" class="panel">
<view v-if="tab==='login'" class="panel">
<input class="input" type="text" v-model.trim="loginForm.email" placeholder="输入邮箱" />
<input class="input" type="password" v-model="loginForm.password" placeholder="输入密码" />
<button class="btn primary" :disabled="loading" @click="onLogin">登录</button>
<view class="quick-inline">
<button class="quick-link" @click="gotoRegister">注册</button>
<button class="quick-link" @click="gotoReset">忘记密码</button>
</view>
</view>
<view v-else-if="tab==='register'" class="panel">
<view class="panel minor" v-if="tab==='register'">
<input class="input" type="text" v-model.trim="regForm.name" placeholder="输入用户名" />
<input class="input" type="text" v-model.trim="regForm.email" placeholder="输入邮箱" />
<view class="row">
@@ -24,7 +27,7 @@
<button class="btn primary" :disabled="loading" @click="onRegister">注册新用户</button>
</view>
<view v-else class="panel">
<view class="panel minor" v-if="tab==='reset'">
<input class="input" type="text" v-model.trim="resetForm.email" placeholder="输入邮箱" />
<view class="row">
<input class="input flex1" type="text" v-model.trim="resetForm.code" placeholder="邮箱验证码" />
@@ -35,16 +38,19 @@
<button class="btn primary" :disabled="loading" @click="onReset">重置密码</button>
</view>
</view>
</template>
<script>
import { get, post } from '../../common/http.js'
import { AUTH_LOGIN_TOP_IMAGE } from '../../common/config.js'
export default {
data(){
data(){
return {
loading: false,
tab: 'login',
tab: 'login',
authLoginTopImage: AUTH_LOGIN_TOP_IMAGE,
loginForm: { email: '', password: '' },
regForm: { name: '', email: '', code: '', password: '', password2: '' },
resetForm: { email: '', code: '', password: '', password2: '' },
@@ -55,6 +61,8 @@ export default {
},
beforeUnmount(){ this._timers.forEach(t=>clearInterval(t)) },
methods: {
gotoRegister(){ this.tab='register' },
gotoReset(){ this.tab='reset' },
toast(msg){ try{ uni.showToast({ title: String(msg||'操作失败'), icon: 'none' }) } catch(_){} },
validateEmail(v){ return /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(String(v||'').trim()) },
startCountdown(key){
@@ -136,20 +144,39 @@ export default {
}catch(e){ this.toast(e.message) }
finally{ this.loading=false }
}
}
}
}
</script>
<style lang="scss">
.auth-page{ padding: 32rpx; display:flex; flex-direction: column; gap: 24rpx; }
.tabs{ display:flex; gap: 24rpx; }
.tab{ padding: 12rpx 20rpx; border-radius: 999rpx; background:#f2f4f8; color:#5b6b80; font-weight:700; }
.tab.active{ background:#2d6be6; color:#fff; }
.panel{ display:flex; flex-direction: column; gap: 16rpx; background:#fff; padding: 24rpx; border-radius: 16rpx; border:2rpx solid #eef2f9; }
.input{ background:#f7f9ff; border:2rpx solid rgba(45,107,230,0.12); border-radius: 12rpx; padding: 22rpx 20rpx; font-size: 28rpx; }
@import '../../uni.scss';
.auth-page{ padding: 32rpx; display:flex; flex-direction: column; gap: 24rpx; position: relative; min-height: 100vh; }
.header{ display:flex; align-items:center; justify-content:center; padding: 8rpx 0 0; }
.title{ font-size: 34rpx; font-weight: 800; color: $uni-text-color; }
.login-hero{ display:flex; justify-content:center; padding: 16rpx 0 0; }
.login-hero-img{ width: 72%; max-width: 560rpx; border-radius: 8rpx; }
.panel{ display:flex; flex-direction: column; gap: 16rpx; background: transparent; padding: 0; border-radius: 0; border: none; }
.panel.minor{ margin-top: 12rpx; }
.input{ background:#ffffff; border:2rpx solid #e5e7eb; border-radius: 12rpx; padding: 22rpx 20rpx; font-size: 28rpx; }
.row{ display:flex; gap: 12rpx; align-items:center; }
.flex1{ flex:1; }
.btn{ padding: 22rpx 20rpx; border-radius: 12rpx; font-weight: 800; text-align:center; }
.btn.primary{ background: linear-gradient(135deg, #4788ff 0%, #2d6be6 100%); color:#fff; }
.btn.primary{ background: linear-gradient(135deg, #4788ff 0%, #2d6be6 100%); color:#fff; border: 1rpx solid rgba(45,107,230,0.25); width: 72%; margin: 0 auto; padding: 14rpx 16rpx; }
.btn.ghost{ background:#eef3ff; color:#2d6be6; }
/* 右下角快捷入口:贴着登录功能,无边框、无背景 */
.quick-inline{ display:flex; gap: 28rpx; justify-content:flex-end; align-items:center; margin-top: 10rpx; }
.quick-link{ background: transparent !important; color: #2d6be6; border: none !important; outline: none; padding: 0; font-size: 26rpx; font-weight: 700; box-shadow: none; line-height: 1.2; }
.quick-link::after{ border: none !important; }
/* 注册/重置页:验证码按钮与左侧输入框等高,且更紧凑 */
.panel.minor .row > .input{ height: $app-form-control-height; padding: 0 $app-form-control-padding-x; }
.panel.minor .row > .btn.ghost{
height: $app-form-control-height;
padding: 0 $app-form-control-padding-x;
border-radius: $app-form-control-border-radius;
display: inline-flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -12,28 +12,28 @@
<view class="kpi-item kpi-card">
<image :src="KPI_ICONS.todaySales" class="kpi-icon" mode="aspectFit"></image>
<view class="kpi-content">
<text class="kpi-label">今日销售额</text>
<text class="kpi-label">{{ KPI_LABELS.todaySales }}</text>
<text class="kpi-value">{{ kpi.todaySales }}</text>
</view>
</view>
<view class="kpi-item kpi-card">
<image :src="KPI_ICONS.monthSales" class="kpi-icon" mode="aspectFit"></image>
<view class="kpi-content">
<text class="kpi-label">本月销售额</text>
<text class="kpi-label">{{ KPI_LABELS.monthSales }}</text>
<text class="kpi-value">{{ kpi.monthSales }}</text>
</view>
</view>
<view class="kpi-item kpi-card">
<image :src="KPI_ICONS.monthProfit" class="kpi-icon" mode="aspectFit"></image>
<view class="kpi-content">
<text class="kpi-label">本月利润</text>
<text class="kpi-label">{{ KPI_LABELS.monthProfit }}</text>
<text class="kpi-value">{{ kpi.monthProfit }}</text>
</view>
</view>
<view class="kpi-item kpi-card">
<image :src="KPI_ICONS.stockCount" class="kpi-icon" mode="aspectFit"></image>
<view class="kpi-content">
<text class="kpi-label">库存商品数量</text>
<text class="kpi-label">{{ KPI_LABELS.stockCount }}</text>
<text class="kpi-value">{{ kpi.stockCount }}</text>
</view>
</view>
@@ -93,12 +93,13 @@
<script>
import { get, post, put } from '../../common/http.js'
import { ROUTES } from '../../common/constants.js'
import { ROUTES, KPI_LABELS } from '../../common/constants.js'
import { KPI_ICONS as KPI_ICON_MAP } from '../../common/config.js'
export default {
data() {
return {
KPI_ICONS: KPI_ICON_MAP,
KPI_LABELS,
kpi: { todaySales: '0.00', monthSales: '0.00', monthProfit: '0.00', stockCount: '0' },
activeTab: 'home',
notices: [],
@@ -238,6 +239,11 @@
// 报表非 tab 页,使用 navigateTo 进入
uni.navigateTo({ url: ROUTES.report })
return
}
if (item.key === 'vip') {
// 跳转“我的 - VIP会员”页面
uni.navigateTo({ url: '/pages/my/vip' })
return
}
if (item.key === 'otherPay') {
// 进入开单页并预选“其他支出”

View File

@@ -222,9 +222,16 @@ export default {
formatDisplay(value) {
if (!value) return '-'
const s = String(value)
// 简单规范化:只保留到分钟
const m = s.match(/^(\d{4}-\d{2}-\d{2})([ T](\d{2}:\d{2}))/)
if (m) return `${m[1]} ${m[3]}`
// 仅显示“YYYY-MM-DD”
const m = s.match(/^(\d{4}-\d{2}-\d{2})/)
if (m) return m[1]
const d = new Date(s)
if (!isNaN(d.getTime())) {
const y = d.getFullYear()
const mo = String(d.getMonth() + 1).padStart(2, '0')
const da = String(d.getDate()).padStart(2, '0')
return `${y}-${mo}-${da}`
}
return s
},
startLogin() {

View File

@@ -46,6 +46,17 @@
</view>
</view>
<!-- 已是VIP展示申请普通管理员入口 -->
<view v-if="isVip" class="apply-card">
<view class="apply-text">
<text class="apply-title">申请成为普通管理员</text>
<text class="apply-desc">在普通管理端参与配件审核</text>
</view>
<view role="button" :class="['apply-btn', { disabled: applyDisabled }]" @click="onApplyNormalAdmin">
<text>{{ applyBtnText }}</text>
</view>
</view>
<view v-if="!isVip" class="purchase-card">
<view class="purchase-text">
<text class="purchase-title">立即升级 VIP</text>
@@ -67,21 +78,59 @@ export default {
isVip: false,
expire: '',
price: 0,
benefits: []
benefits: [],
normalAdmin: { isNormalAdmin: false, applicationStatus: 'none' }
}
},
onShow(){
this.loadVip()
this.loadNormalAdminStatus()
this.composeBenefits()
},
computed: {
expireDisplay(){
const s = String(this.expire || '')
return s || '11年11月11日'
const v = this.expire
if (v === null || v === undefined) return ''
if (typeof v === 'number') {
const d = new Date(v)
if (!isNaN(d.getTime())) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${dd}`
}
return ''
}
const s = String(v)
const m = s.match(/^(\d{4}-\d{2}-\d{2})/)
if (m) return m[1]
const idx = s.search(/[ T]/)
if (idx > 0) {
const head = s.slice(0, idx)
if (head) return head
}
const d2 = new Date(s)
if (!isNaN(d2.getTime())) {
const y = d2.getFullYear()
const m2 = String(d2.getMonth() + 1).padStart(2, '0')
const dd2 = String(d2.getDate()).padStart(2, '0')
return `${y}-${m2}-${dd2}`
}
return s
},
priceDisplay(){
const n = Number(this.price)
return Number.isFinite(n) && n > 0 ? n.toFixed(2) : '0.00'
},
applyDisabled(){
const s = String(this.normalAdmin?.applicationStatus || 'none')
return !!(this.normalAdmin?.isNormalAdmin || s === 'approved' || s === 'pending')
},
applyBtnText(){
if (this.normalAdmin?.isNormalAdmin || this.normalAdmin?.applicationStatus === 'approved') return '已通过'
if (this.normalAdmin?.applicationStatus === 'pending') return '审核中'
if (!this.isVip) return '仅限VIP'
return '提交申请'
}
},
methods: {
@@ -103,6 +152,17 @@ export default {
this.isVip = false
}
},
async loadNormalAdminStatus(){
try {
const data = await get('/api/normal-admin/application/status')
this.normalAdmin = {
isNormalAdmin: !!data?.isNormalAdmin,
applicationStatus: String(data?.applicationStatus || 'none')
}
} catch(e) {
this.normalAdmin = { isNormalAdmin: false, applicationStatus: 'none' }
}
},
async onPay(){
try {
await post('/api/vip/pay', {})
@@ -111,7 +171,20 @@ export default {
} catch(e) {
uni.showToast({ title: String(e.message || '开通失败'), icon: 'none' })
}
}
},
async onApplyNormalAdmin(){
if (this.applyDisabled) {
const msg = this.normalAdmin?.isNormalAdmin || this.normalAdmin?.applicationStatus === 'approved' ? '已通过,无需重复申请' : (this.normalAdmin?.applicationStatus === 'pending' ? '审核中,请耐心等待' : '不可申请')
return uni.showToast({ title: msg, icon: 'none' })
}
try {
await post('/api/normal-admin/apply', { remark: '从我的-会员发起申请' })
uni.showToast({ title: '申请已提交', icon: 'success' })
await this.loadNormalAdminStatus()
} catch(e) {
uni.showToast({ title: String(e.message || '申请失败'), icon: 'none' })
}
}
}
}
</script>
@@ -186,6 +259,9 @@ page {
border-color: #4c8dff;
}
/* 指定 hero 内激活态徽标文本为黑色 */
.vip-hero .status-pill.active text { color: #000 !important; }
.vip-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0,1fr));
@@ -337,6 +413,45 @@ page {
opacity: 0.88;
}
.apply-card {
margin-top: 0;
background: linear-gradient(135deg, rgba(30,173,145,0.14) 0%, rgba(30,173,145,0.06) 100%);
border-radius: 28rpx;
padding: 30rpx 28rpx;
display: flex;
align-items: center;
gap: 24rpx;
border: 2rpx solid rgba(30,173,145,0.18);
box-shadow: 0 10rpx 24rpx rgba(30,173,145,0.15);
}
.apply-text { flex: 1; display:flex; flex-direction: column; gap: 10rpx; }
.apply-title { font-size: 32rpx; font-weight: 800; color: #1ead91; }
.apply-desc { font-size: 24rpx; color: #247a66; line-height: 34rpx; }
.apply-btn {
flex: 0 0 auto;
padding: 20rpx 36rpx;
border-radius: 999rpx;
border: none;
background-color: transparent;
background: linear-gradient(135deg, #1ead91 0%, #159b7e 100%);
color: #fff;
font-size: 28rpx;
font-weight: 700;
box-shadow: 0 10rpx 22rpx rgba(21,155,126,0.20);
}
.apply-btn::after { border: none; }
.apply-btn:active { opacity: .9; }
.apply-btn.disabled {
opacity: .5;
background: #c7e8df;
color: #fff;
box-shadow: none;
pointer-events: none;
}
@media (max-width: 375px) {
.vip-summary {
grid-template-columns: 1fr;

View File

@@ -31,41 +31,18 @@
<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>
@@ -94,14 +71,11 @@ export default {
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,
name: '', barcode: '', brand: '', model: '', spec: '',
categoryId: '',
images: [], remark: '',
platformStatus: '', sourceSubmissionId: ''
},
units: [],
categories: [],
keyboardHeight: 0
}
@@ -115,12 +89,7 @@ export default {
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 : '选择类别'
@@ -128,7 +97,7 @@ export default {
},
methods: {
async bootstrap() {
await Promise.all([this.fetchUnits(), this.fetchCategories()])
await Promise.all([this.fetchCategories()])
if (this.id) this.loadDetail()
},
initKeyboardListener() {
@@ -147,22 +116,12 @@ export default {
}
} 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 : ''
@@ -193,12 +152,8 @@ export default {
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,
barcode: data.barcode, brand: data.brand, model: data.model, spec: data.spec,
categoryId: data.categoryId,
images: (data.images || []).map(i => i.url || i),
remark: data.remark || '',
platformStatus: data.platformStatus || '',
@@ -208,21 +163,13 @@ export default {
},
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,
name: f.name, barcode: f.barcode, brand: f.brand, model: f.model, spec: f.spec,
categoryId: f.categoryId || null,
images: f.images,
remark: f.remark
}
@@ -236,7 +183,7 @@ export default {
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: '' }
this.form = { name: '', barcode: '', brand: '', model: '', spec: '', categoryId: '', images: [], remark: '', platformStatus: '', sourceSubmissionId: '' }
} else {
setTimeout(() => uni.navigateBack(), 400)
}

View File

@@ -2,32 +2,59 @@
<view class="page">
<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" :class="{active: tab==='search'}" @click="switchTab('search')">查询</view>
<view class="tab extra" @click="goMySubmissions">我的提交</view>
</view>
<view class="search">
<input v-model.trim="query.kw" placeholder="输入名称/条码/规格查询" @confirm="reload" />
<picker mode="selector" :range="categoryNames" v-if="tab==='category'" @change="onPickCategory">
<view class="picker">{{ categoryLabel }}</view>
</picker>
<view class="search" :class="{ 'template-mode': query.mode==='template' }" v-if="tab==='search'">
<view class="mode">
<picker mode="selector" :range="['直接查询','名称模糊查询','按模板参数查询']" @change="e => (query.mode = ['direct','nameLike','template'][Number(e.detail.value)] || 'direct')">
<view class="picker">{{ modeLabel }}</view>
</picker>
</view>
<block v-if="query.mode==='direct' || query.mode==='nameLike'">
<input v-model.trim="query.kw" placeholder="输入名称/条码/规格查询" @confirm="reload" />
</block>
<block v-if="query.mode==='template'">
<view class="picker-row">
<picker mode="selector" :range="categoryNames" @change="onPickCategory">
<view class="picker">{{ categoryLabel }}</view>
</picker>
<picker mode="selector" :range="templateNames" @change="onPickTemplate">
<view class="picker">{{ templateLabel }}</view>
</picker>
</view>
<view class="params-wrap">
<view class="param-row" v-for="(p,idx) in selectedTemplateParams" :key="p.fieldKey">
<input v-if="p.type==='string'" v-model.trim="paramValues[p.fieldKey]" :placeholder="'输入' + p.fieldLabel" />
<input v-else-if="p.type==='number'" type="number" v-model.number="paramValues[p.fieldKey]" :placeholder="'输入' + p.fieldLabel" />
<switch v-else-if="p.type==='boolean'" :checked="!!paramValues[p.fieldKey]" @change="onParamBoolChange(p, $event)" />
<picker v-else-if="p.type==='enum'" mode="selector" :range="p.enumOptions||[]" @change="onPickParamEnumWrapper(p, $event)">
<view class="picker">{{ displayParamEnum(p) }}</view>
</picker>
<picker v-else-if="p.type==='date'" mode="date" @change="onParamDateChange(p, $event)">
<view class="picker">{{ paramValues[p.fieldKey] || ('选择' + p.fieldLabel) }}</view>
</picker>
<input v-else v-model.trim="paramValues[p.fieldKey]" :placeholder="'输入' + p.fieldLabel" />
</view>
</view>
</block>
<button size="mini" @click="reload">查询</button>
</view>
<scroll-view scroll-y class="list" @scrolltolower="loadMore">
<block v-if="items.length">
<view class="item" v-for="it in items" :key="it.id" @click="openForm(it.id)">
<view class="item" v-for="it in items" :key="it.id" @click="openDetail(it.id)">
<image v-if="it.cover" :src="it.cover" class="thumb" mode="aspectFill" />
<view class="content">
<view class="name">
<text>{{ it.name }}</text>
<view class="name">
<text>{{ it.name }}</text>
<text v-if="it.deleted" class="tag-deleted">已删除</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>
</view>
<!-- 隐藏库存与价格展示按需求仅展示基础信息 -->
</view>
</view>
</block>
@@ -47,11 +74,13 @@ export default {
data() {
return {
items: [],
query: { kw: '', page: 1, size: 20, categoryId: '' },
query: { kw: '', page: 1, size: 20, categoryId: '', mode: 'direct', templateId: '', params: {} },
finished: false,
loading: false,
tab: 'all',
categories: []
categories: [],
templates: [],
paramValues: {}
}
},
onLoad() {
@@ -76,26 +105,59 @@ export default {
categoryLabel() {
const c = this.categories.find(x => String(x.id) === String(this.query.categoryId))
return c ? '类别:' + c.name : '选择类别'
}
},
modeLabel() {
const map = { direct: '直接查询', nameLike: '名称模糊查询', template: '按模板参数查询' }
return map[this.query.mode] || '直接查询'
},
templateNames() { return this.templates.map(t => t.name) },
templateLabel() {
const t = this.templates.find(x => String(x.id) === String(this.query.templateId))
return t ? '模板:' + t.name : '选择模板'
},
selectedTemplate() { return this.templates.find(t => String(t.id) === String(this.query.templateId)) || null },
selectedTemplateParams() { return (this.selectedTemplate && Array.isArray(this.selectedTemplate.params)) ? this.selectedTemplate.params : [] }
},
methods: {
switchTab(t) {
this.tab = t
this.query.categoryId = ''
this.query.templateId = ''
this.paramValues = {}
this.reload()
},
onPickCategory(e) {
const idx = Number(e.detail.value)
const c = this.categories[idx]
this.query.categoryId = c ? c.id : ''
this.reload()
this.fetchTemplates()
},
onPickTemplate(e) {
const idx = Number(e.detail.value)
const t = this.templates[idx]
this.query.templateId = t ? t.id : ''
this.paramValues = {}
},
onPickParamEnumWrapper(p, e) {
const idx = Number(e.detail.value)
const arr = p.enumOptions || []
this.paramValues[p.fieldKey] = arr[idx]
},
onParamBoolChange(p, e) { this.paramValues[p.fieldKey] = e?.detail?.value ? true : false },
onParamDateChange(p, e) { this.paramValues[p.fieldKey] = e?.detail?.value || '' },
async fetchCategories() {
try {
const res = await get('/api/product-categories', {})
this.categories = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch (_) {}
},
async fetchTemplates() {
try {
const res = await get('/api/product-templates', this.query.categoryId ? { categoryId: this.query.categoryId } : {})
const list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
this.templates = list
} catch (_) { this.templates = [] }
},
reload() {
this.items = []
this.query.page = 1
@@ -107,7 +169,16 @@ export default {
this.loading = true
try {
const params = { kw: this.query.kw, page: this.query.page, size: this.query.size }
if (this.tab === 'category' && this.query.categoryId) params.categoryId = this.query.categoryId
if (this.tab === 'search') {
if (this.query.categoryId) params.categoryId = this.query.categoryId
if (this.query.templateId) params.templateId = this.query.templateId
if (this.paramValues && Object.keys(this.paramValues).length) {
for (const k of Object.keys(this.paramValues)) {
const v = this.paramValues[k]
if (v !== undefined && v !== null && v !== '') params['param_' + k] = v
}
}
}
const res = await get('/api/products', params)
const list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
this.items = this.items.concat(list)
@@ -119,13 +190,26 @@ export default {
this.loading = false
}
},
openForm(id) {
const url = '/pages/product/form' + (id ? ('?id=' + id) : '')
uni.navigateTo({ url })
},
goMySubmissions() {
openDetail(id) {
uni.navigateTo({ url: '/pages/product/product-detail?id=' + id })
},
goMySubmissions() {
uni.navigateTo({ url: '/pages/product/submissions' })
}
},
async remove(it) {
try {
const r = await new Promise(resolve => {
uni.showModal({ content: '确认删除该货品?删除后可在后台恢复', success: resolve })
})
if (!r || !r.confirm) return
const { del } = require('../../common/http.js')
await del('/api/products/' + it.id)
uni.showToast({ title: '已删除', icon: 'success' })
this.reload()
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
}
</script>
@@ -139,6 +223,9 @@ export default {
.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; }
.template-mode { flex-direction: column; align-items: stretch; gap: 8rpx; }
.picker-row { display:flex; gap: 12rpx; }
.params-wrap { margin-top: 6rpx; background:$uni-bg-color-grey; border-radius: 12rpx; padding: 8rpx 8rpx; }
.list { flex:1; }
.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; }
@@ -146,6 +233,7 @@ export default {
.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; }
.tag-deleted { font-size: 22rpx; color:#fff; background:#909399; 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,142 @@
<template>
<scroll-view scroll-y class="page" v-if="detail">
<view class="header">
<text class="model">{{ detail.model }}</text>
<text v-if="detail.deleted" class="status deleted">已删除</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.model || '-' }}</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">{{ categoryName }}</text></view>
<view class="row"><text class="label">模板</text><text class="value">{{ templateName }}</text></view>
<view class="row" v-if="detail.externalCode"><text class="label">编号</text><text class="value">{{ detail.externalCode }}</text></view>
</view>
<view class="section">
<view class="block-title">参数</view>
<view v-if="labeledPairs.length" class="params">
<view class="param" v-for="item in labeledPairs" :key="item.key">
<text class="param-key">{{ item.label }}<text v-if="item.unit">{{ item.unit }}</text></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.url || 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="footer">
<button size="mini" @click="back">返回</button>
<button size="mini" type="warn" @click="remove">删除</button>
</view>
</scroll-view>
<view v-else class="loading">加载中...</view>
</template>
<script>
import { get, del } from '../../common/http.js'
export default {
data() {
return { id: '', detail: null, categoryName: '-', templateName: '-' }
},
async onLoad(query) {
this.id = query?.id || ''
if (!this.id) { uni.showToast({ title: '参数缺失', icon: 'none' }); return }
await this.preloadDictionaries()
await this.loadDetail()
},
methods: {
async preloadDictionaries() {
try {
const needCats = !Array.isArray(uni.getStorageSync('CACHE_CATEGORIES'))
const needTpls = !Array.isArray(uni.getStorageSync('CACHE_TEMPLATES'))
if (!needCats && !needTpls) return
const reqs = []
if (needCats) reqs.push(get('/api/product-categories'))
if (needTpls) reqs.push(get('/api/product-templates'))
const res = await Promise.all(reqs)
let idx = 0
if (needCats) { const r = res[idx++]; const list = Array.isArray(r?.list)?r.list:(Array.isArray(r)?r:[]); uni.setStorageSync('CACHE_CATEGORIES', list) }
if (needTpls) { const r = res[idx++]; const list = Array.isArray(r?.list)?r.list:(Array.isArray(r)?r:[]); uni.setStorageSync('CACHE_TEMPLATES', list) }
} catch (_) {}
},
async loadDetail() {
try {
const data = await get('/api/products/' + this.id)
this.detail = data
this.categoryName = this.categoryLookup(data.categoryId)
this.templateName = this.templateLookup(data.templateId)
} catch (e) { uni.showToast({ title: e?.message || '加载失败', icon: 'none' }) }
},
preview(idx) {
try { const list = (this.detail?.images||[]).map(i => i.url || i); uni.previewImage({ urls: list, current: idx }) } catch (_) {}
},
categoryLookup(id) {
try { const list = uni.getStorageSync('CACHE_CATEGORIES') || []; const f = list.find(x => String(x.id)===String(id)); return f?f.name:'-'} catch(_){return'-'}
},
templateLookup(id) {
try { const list = uni.getStorageSync('CACHE_TEMPLATES') || []; const f = list.find(x => String(x.id)===String(id)); return f?f.name:'-'} catch(_){return'-'}
},
async remove() {
try {
const r = await new Promise(resolve => { uni.showModal({ content: '确认删除该货品?删除后可在后台恢复', success: resolve }) })
if (!r || !r.confirm) return
await del('/api/products/' + this.id)
uni.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (e) { uni.showToast({ title: '删除失败', icon: 'none' }) }
},
back(){ uni.navigateBack({ delta: 1 }) }
},
computed: {
labeledPairs() {
const params = this.detail?.parameters
if (!params || typeof params !== 'object') return []
let labelMap = {}, unitMap = {}
try {
const templates = uni.getStorageSync('CACHE_TEMPLATES') || []
const tpl = templates.find(t => String(t.id) === String(this.detail?.templateId))
if (tpl && Array.isArray(tpl.params)) for (const p of tpl.params) { labelMap[p.fieldKey] = p.fieldLabel; unitMap[p.fieldKey] = p.unit }
} catch (_) {}
return Object.keys(params).map(k => ({ key: k, label: labelMap[k] || k, unit: unitMap[k] || '', value: params[k] }))
}
}
}
</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.deleted { font-size: 24rpx; padding: 6rpx 18rpx; border-radius: 999rpx; background: #c0c4cc; color: #fff; }
.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

@@ -9,18 +9,17 @@
<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 class="row"><text class="label">模板</text><text class="value">{{ templateName }}</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>
<view v-if="labeledPairs.length" class="params">
<view class="param" v-for="item in labeledPairs" :key="item.key">
<text class="param-key">{{ item.label }}</text>
<text class="param-val">{{ item.value }}</text>
</view>
</view>
@@ -63,7 +62,8 @@ export default {
id: '',
detail: null,
unitName: '-',
categoryName: '-'
categoryName: '-',
templateName: '-'
}
},
async onLoad(query) {
@@ -79,8 +79,9 @@ export default {
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)
this.templateName = this.templateLookup(data.templateId)
} catch (e) {
const msg = e?.message || '加载失败'
uni.showToast({ title: msg, icon: 'none' })
@@ -96,11 +97,7 @@ export default {
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 })
@@ -120,7 +117,7 @@ export default {
},
unitLookup(id) {
try {
const list = uni.getStorageSync('CACHE_UNITS') || []
const list = []
const found = list.find(x => String(x.id) === String(id))
return found ? found.name : '-'
} catch (_) { return '-' }
@@ -132,6 +129,13 @@ export default {
return found ? found.name : '-'
} catch (_) { return '-' }
},
templateLookup(id) {
try {
const list = uni.getStorageSync('CACHE_TEMPLATES') || []
const found = list.find(x => String(x.id) === String(id))
return found ? found.name : '-'
} catch (_) { return '-' }
},
back() {
uni.navigateBack({ delta: 1 })
},
@@ -160,6 +164,20 @@ export default {
if (min != null && max != null) return `${min} ~ ${max}`
if (min != null) return `${min}`
return `${max}`
},
labeledPairs() {
const params = this.detail?.parameters
if (!params || typeof params !== 'object') return []
// 从缓存模板中读取 label
let labelMap = {}
try {
const templates = uni.getStorageSync('CACHE_TEMPLATES') || []
const tpl = templates.find(t => String(t.id) === String(this.detail?.templateId))
if (tpl && Array.isArray(tpl.params)) {
for (const p of tpl.params) labelMap[p.fieldKey] = p.fieldLabel
}
} catch (_) {}
return Object.keys(params).map(k => ({ key: k, label: labelMap[k] || k, value: params[k] }))
}
}
}

View File

@@ -68,9 +68,10 @@ export default {
methods: {
async preloadDictionaries() {
try {
const [units, categories] = await Promise.all([
const [units, categories, templates] = await Promise.all([
this.cacheUnitsLoaded ? Promise.resolve(null) : get('/api/product-units'),
this.cacheCategoriesLoaded ? Promise.resolve(null) : get('/api/product-categories')
this.cacheCategoriesLoaded ? Promise.resolve(null) : get('/api/product-categories'),
get('/api/product-templates')
])
if (units) {
const list = Array.isArray(units?.list) ? units.list : (Array.isArray(units) ? units : [])
@@ -82,6 +83,10 @@ export default {
uni.setStorageSync('CACHE_CATEGORIES', list)
this.cacheCategoriesLoaded = true
}
if (templates) {
const list = Array.isArray(templates?.list) ? templates.list : (Array.isArray(templates) ? templates : [])
uni.setStorageSync('CACHE_TEMPLATES', list)
}
} catch (_) {
// 忽略缓存失败
}

View File

@@ -32,6 +32,13 @@
</view>
</view>
<view class="section">
<view class="row">
<text class="label">编号</text>
<input v-model.trim="form.externalCode" placeholder="内部/外部编号(可选)" />
</view>
</view>
<view class="section">
<view class="row">
<text class="label">模板</text>
@@ -43,6 +50,42 @@
</view>
</view>
<!-- 动态参数根据模板渲染必填/可选项 -->
<view class="section" v-if="selectedTemplate">
<view class="row">
<text class="label">参数</text>
</view>
<view class="param-list">
<view class="row" v-for="(p,idx) in (selectedTemplate.params||[])" :key="p.fieldKey">
<text class="label">
{{ p.fieldLabel }}<text v-if="p.unit">{{ p.unit }}</text><text v-if="p.required" style="color:#ff5b5b">*</text>
</text>
<block v-if="p.type==='string'">
<input v-model.trim="paramValues[p.fieldKey]" :placeholder="'请输入' + p.fieldLabel" />
</block>
<block v-else-if="p.type==='number'">
<input type="number" v-model.number="paramValues[p.fieldKey]" :placeholder="'请输入' + p.fieldLabel" />
</block>
<block v-else-if="p.type==='boolean'">
<switch :checked="!!paramValues[p.fieldKey]" @change="e => (paramValues[p.fieldKey]=e.detail.value)" />
</block>
<block v-else-if="p.type==='enum'">
<picker mode="selector" :range="p.enumOptions||[]" @change="onPickEnum(p, $event)">
<view class="picker">{{ displayEnum(p) }}</view>
</picker>
</block>
<block v-else-if="p.type==='date'">
<picker mode="date" @change="onPickDate(p, $event)">
<view class="picker">{{ paramValues[p.fieldKey] || ('选择' + p.fieldLabel) }}</view>
</picker>
</block>
<block v-else>
<input v-model.trim="paramValues[p.fieldKey]" :placeholder="'请输入' + p.fieldLabel" />
</block>
</view>
</view>
</view>
<view class="section">
<view class="row">
<text class="label">图片</text>
@@ -91,6 +134,7 @@ export default {
categoryId: '',
templateId: '',
externalCode: '',
parameters: {},
images: [],
remark: '',
@@ -172,11 +216,18 @@ export default {
this.form.templateId = t ? t.id : ''
this.paramValues = {}
},
onPickDate(p, e) {
this.paramValues[p.fieldKey] = e?.detail?.value || ''
},
onPickEnum(p, e) {
const idx = Number(e.detail.value)
const arr = p.enumOptions || []
this.paramValues[p.fieldKey] = arr[idx]
},
displayEnum(p) {
const v = this.paramValues[p.fieldKey]
return (v === undefined || v === null || v === '') ? ('选择' + p.fieldLabel) : String(v)
},
async scanBarcode() {
try {
const chooseRes = await uni.chooseImage({ count: 1, sourceType: ['camera','album'], sizeType: ['compressed'] })
@@ -253,6 +304,7 @@ export default {
model: this.form.model,
brand: this.form.brand,
barcode: this.form.barcode,
externalCode: this.form.externalCode || null,
categoryId: this.form.categoryId || null,
templateId: this.form.templateId || null,
parameters: paramsForSubmit,