feat(web-admin): site public page brand qty chips entry
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
006ed6586c
commit
6dde2cec07
3 files changed
+465
-16
No files matched your search
@@ -1,5 +1,11 @@
|
||||
import { publicPlGet, publicPlPost, publicPlUpload } from '@/utils/http/public-pl'
|
||||
|
||||
export interface BrandQtyItem {
|
||||
brandId: string
|
||||
brandName?: string
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface PublicIoOrderItem {
|
||||
id: string
|
||||
productNo?: string
|
||||
@@ -10,6 +16,11 @@ export interface PublicIoOrderItem {
|
||||
actualQty?: number
|
||||
packageSpec?: string
|
||||
remark?: string
|
||||
brandMode?: string | null
|
||||
brandId?: string | null
|
||||
brandName?: string | null
|
||||
brandOptions?: { brandId: string; brandName: string }[]
|
||||
brandQtys?: BrandQtyItem[]
|
||||
}
|
||||
|
||||
export interface PublicIoOrderPage {
|
||||
@@ -50,7 +61,7 @@ export function createPublicIoOrderApi(kind: 'inbound' | 'outbound') {
|
||||
status: 'in_progress' | 'completed' | 'voided'
|
||||
remark?: string
|
||||
images?: string[]
|
||||
items: { id: string; actualQty: number }[]
|
||||
items: { id: string; actualQty: number; brandQtys?: BrandQtyItem[] }[]
|
||||
}
|
||||
) => publicPlPost<{ success: boolean }>(`${base}/${token}/feedback`, data),
|
||||
uploadAttachment: (token: string, file: File) =>
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<div v-if="brandMode" class="brand-qty-chips" :class="`brand-qty-chips--${variant}`">
|
||||
<div class="brand-qty-chips__head">
|
||||
<h4 class="brand-qty-chips__title">{{
|
||||
brandMode === 'required' ? '指定品牌填量' : '选择品牌并填量'
|
||||
}}</h4>
|
||||
<span
|
||||
class="brand-qty-chips__sum"
|
||||
:class="{ 'brand-qty-chips__sum--ok': sumMatch, 'brand-qty-chips__sum--bad': !sumMatch }"
|
||||
>
|
||||
{{ sumLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="brandMode === 'any'" class="brand-qty-chips__chips">
|
||||
<button
|
||||
v-for="opt in brandOptions"
|
||||
:key="opt.brandId"
|
||||
type="button"
|
||||
class="brand-qty-chips__chip"
|
||||
:class="{ 'brand-qty-chips__chip--on': isSelected(opt.brandId) }"
|
||||
:disabled="disabled"
|
||||
@click="toggleChip(opt)"
|
||||
>
|
||||
{{ opt.brandName }}
|
||||
<span v-if="isSelected(opt.brandId)" class="brand-qty-chips__chip-qty">
|
||||
{{ qtyOf(opt.brandId) || '' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="brandMode === 'required'" class="brand-qty-chips__chips">
|
||||
<span class="brand-qty-chips__chip brand-qty-chips__chip--on brand-qty-chips__chip--locked">
|
||||
{{ requiredBrandLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="brand-qty-chips__rows">
|
||||
<div v-for="row in activeRows" :key="row.brandId" class="brand-qty-chips__row">
|
||||
<span class="brand-qty-chips__row-label">{{ row.brandName }}</span>
|
||||
<template v-if="variant === 'admin'">
|
||||
<el-input-number
|
||||
:model-value="row.qty"
|
||||
:min="0"
|
||||
:precision="3"
|
||||
:step="0.001"
|
||||
:disabled="disabled"
|
||||
controls-position="right"
|
||||
class="brand-qty-chips__admin-input"
|
||||
@update:model-value="(val) => setQty(row.brandId, Number(val ?? 0))"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="brand-qty-chips__qty-input"
|
||||
:value="row.qty === 0 ? '' : row.qty"
|
||||
:disabled="disabled"
|
||||
placeholder="0"
|
||||
@input="onMobileQtyInput(row.brandId, $event)"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="unit" class="brand-qty-chips__unit">{{ unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface BrandQtyRow {
|
||||
brandId: string
|
||||
brandName: string
|
||||
qty: number
|
||||
}
|
||||
|
||||
export interface BrandOption {
|
||||
brandId: string
|
||||
brandName: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
brandMode?: 'any' | 'required' | null
|
||||
requiredBrandId?: string | null
|
||||
brandOptions: BrandOption[]
|
||||
modelValue: BrandQtyRow[]
|
||||
disabled?: boolean
|
||||
unit?: string
|
||||
variant?: 'mobile' | 'admin'
|
||||
actualQty?: number
|
||||
}>(),
|
||||
{
|
||||
brandMode: null,
|
||||
requiredBrandId: null,
|
||||
disabled: false,
|
||||
unit: '',
|
||||
variant: 'mobile',
|
||||
actualQty: undefined
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: BrandQtyRow[]]
|
||||
'update:actualQty': [value: number]
|
||||
}>()
|
||||
|
||||
const requiredBrandLabel = computed(() => {
|
||||
const fromOptions = props.brandOptions.find((o) => o.brandId === props.requiredBrandId)
|
||||
if (fromOptions?.brandName) return fromOptions.brandName
|
||||
const fromRows = props.modelValue.find((r) => r.brandId === props.requiredBrandId)
|
||||
return fromRows?.brandName || '指定品牌'
|
||||
})
|
||||
|
||||
const activeRows = computed(() => {
|
||||
if (props.brandMode === 'required') {
|
||||
const id = props.requiredBrandId || props.brandOptions[0]?.brandId || ''
|
||||
const existing = props.modelValue.find((r) => r.brandId === id)
|
||||
if (existing) return [existing]
|
||||
const opt = props.brandOptions.find((o) => o.brandId === id)
|
||||
return [{ brandId: id, brandName: opt?.brandName || requiredBrandLabel.value, qty: 0 }]
|
||||
}
|
||||
return props.modelValue
|
||||
})
|
||||
|
||||
function isSelected(brandId: string) {
|
||||
return props.modelValue.some((r) => r.brandId === brandId)
|
||||
}
|
||||
|
||||
function qtyOf(brandId: string) {
|
||||
return props.modelValue.find((r) => r.brandId === brandId)?.qty ?? 0
|
||||
}
|
||||
|
||||
function sumRows(rows: BrandQtyRow[]) {
|
||||
return rows.reduce((s, r) => s + (Number(r.qty) || 0), 0)
|
||||
}
|
||||
|
||||
const totalQty = computed(() => Number(sumRows(props.modelValue).toFixed(3)))
|
||||
|
||||
const sumMatch = computed(() => {
|
||||
if (props.actualQty == null) return true
|
||||
return Math.abs(totalQty.value - Number(props.actualQty)) < 0.0005
|
||||
})
|
||||
|
||||
const sumLabel = computed(() => {
|
||||
if (props.actualQty == null) {
|
||||
return `合计 ${totalQty.value}${props.unit ? ` ${props.unit}` : ''}`
|
||||
}
|
||||
if (sumMatch.value) {
|
||||
return `合计 ${totalQty.value} = 实际数量 ✓`
|
||||
}
|
||||
return `合计 ${totalQty.value} ≠ 实际数量 ${props.actualQty}`
|
||||
})
|
||||
|
||||
function emitRows(rows: BrandQtyRow[]) {
|
||||
emit('update:modelValue', rows)
|
||||
emit('update:actualQty', Number(sumRows(rows).toFixed(3)))
|
||||
}
|
||||
|
||||
function toggleChip(opt: BrandOption) {
|
||||
if (props.disabled || props.brandMode !== 'any') return
|
||||
const selected = isSelected(opt.brandId)
|
||||
if (selected) {
|
||||
emitRows(props.modelValue.filter((r) => r.brandId !== opt.brandId))
|
||||
return
|
||||
}
|
||||
emitRows([...props.modelValue, { brandId: opt.brandId, brandName: opt.brandName, qty: 0 }])
|
||||
}
|
||||
|
||||
function setQty(brandId: string, qty: number) {
|
||||
const next = props.modelValue.map((r) =>
|
||||
r.brandId === brandId ? { ...r, qty: Math.max(0, qty) } : r
|
||||
)
|
||||
emitRows(next)
|
||||
}
|
||||
|
||||
function onMobileQtyInput(brandId: string, e: Event) {
|
||||
const raw = (e.target as HTMLInputElement).value
|
||||
const qty = raw === '' ? 0 : Math.max(0, Number(raw) || 0)
|
||||
setQty(brandId, qty)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.brandMode, props.requiredBrandId, props.brandOptions] as const,
|
||||
() => {
|
||||
if (props.brandMode !== 'required') return
|
||||
const id = props.requiredBrandId || props.brandOptions[0]?.brandId
|
||||
if (!id) return
|
||||
const opt = props.brandOptions.find((o) => o.brandId === id)
|
||||
const existing = props.modelValue.find((r) => r.brandId === id)
|
||||
if (!existing) {
|
||||
emitRows([
|
||||
{
|
||||
brandId: id,
|
||||
brandName: opt?.brandName || requiredBrandLabel.value,
|
||||
qty: 0
|
||||
}
|
||||
])
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.brand-qty-chips {
|
||||
margin-top: 10px;
|
||||
border-top: 1px dashed #bbf7d0;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.brand-qty-chips__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.brand-qty-chips__title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #047857;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand-qty-chips__sum {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-qty-chips__sum--ok {
|
||||
color: #059669;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand-qty-chips__sum--bad {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand-qty-chips__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.brand-qty-chips__chip {
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.brand-qty-chips__chip:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.brand-qty-chips__chip--on {
|
||||
border-color: #059669;
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand-qty-chips__chip--locked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.brand-qty-chips__chip-qty {
|
||||
margin-left: 4px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.brand-qty-chips__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.brand-qty-chips__row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand-qty-chips__row-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding-left: 4px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.brand-qty-chips__qty-input {
|
||||
height: 36px;
|
||||
border: 1.5px solid #6ee7b7;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
background: #ecfdf5;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #059669;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-qty-chips__unit {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.brand-qty-chips--admin {
|
||||
.brand-qty-chips__row {
|
||||
grid-template-columns: 100px 1fr auto;
|
||||
}
|
||||
|
||||
.brand-qty-chips__admin-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -74,11 +74,7 @@
|
||||
</van-cell-group>
|
||||
|
||||
<van-cell-group inset class="section-card" title="产品实际数量">
|
||||
<div
|
||||
v-for="(item, idx) in editableItems"
|
||||
:key="item.id"
|
||||
class="product-card"
|
||||
>
|
||||
<div v-for="(item, idx) in editableItems" :key="item.id" class="product-card">
|
||||
<div class="product-card__head">
|
||||
<div class="product-card__name">{{ item.productName || '产品' }}</div>
|
||||
<span class="product-card__badge">产品 {{ idx + 1 }}/{{ editableItems.length }}</span>
|
||||
@@ -102,7 +98,8 @@
|
||||
<div class="actual-box">
|
||||
<div class="actual-box__label">
|
||||
<strong>实际数量</strong>
|
||||
<span>请按现场实装填写</span>
|
||||
<span v-if="item.brandMode">由品牌明细合计</span>
|
||||
<span v-else>请按现场实装填写</span>
|
||||
</div>
|
||||
<div class="actual-box__row">
|
||||
<van-field
|
||||
@@ -111,10 +108,22 @@
|
||||
class="actual-box__field"
|
||||
placeholder="请输入"
|
||||
:border="false"
|
||||
:readonly="isReadOnly"
|
||||
:readonly="isReadOnly || Boolean(item.brandMode)"
|
||||
/>
|
||||
<span class="actual-box__unit">{{ item.unit || '吨' }}</span>
|
||||
</div>
|
||||
<BrandQtyChipsField
|
||||
v-if="item.brandMode"
|
||||
v-model="item.brandQtyRows"
|
||||
:brand-mode="brandModeForField(item)"
|
||||
:required-brand-id="item.brandId"
|
||||
:brand-options="item.brandOptions || []"
|
||||
:disabled="isReadOnly"
|
||||
:unit="item.unit || '吨'"
|
||||
variant="mobile"
|
||||
:actual-qty="Number(item.actualQtyText || 0)"
|
||||
@update:actual-qty="(val) => syncItemActualQty(item, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-cell-group>
|
||||
@@ -175,9 +184,11 @@
|
||||
import { showConfirmDialog, showFailToast, showSuccessToast } from 'vant'
|
||||
import {
|
||||
createPublicIoOrderApi,
|
||||
type BrandQtyItem,
|
||||
type PublicIoOrderItem,
|
||||
type PublicIoOrderPage
|
||||
} from '@/api/pl/public-io-order'
|
||||
import BrandQtyChipsField from '@/components/business/brand-qty-chips-field/index.vue'
|
||||
import PublicMobileShell from '@/layouts/PublicMobileShell.vue'
|
||||
import { buildAmapNavigationUrl } from '@/utils/amap/navigation'
|
||||
|
||||
@@ -187,6 +198,11 @@
|
||||
|
||||
type IoOrderStatus = 'in_progress' | 'completed' | 'voided'
|
||||
|
||||
type EditableIoOrderItem = PublicIoOrderItem & {
|
||||
actualQtyText: string
|
||||
brandQtyRows: BrandQtyItem[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ kind: 'inbound' | 'outbound' }>()
|
||||
const api = createPublicIoOrderApi(props.kind)
|
||||
const route = useRoute()
|
||||
@@ -202,7 +218,7 @@
|
||||
const feedbackRemark = ref('')
|
||||
const feedbackImages = ref<string[]>([])
|
||||
const uploaderFiles = ref<UploaderFileListItem[]>([])
|
||||
const editableItems = ref<(PublicIoOrderItem & { actualQtyText: string })[]>([])
|
||||
const editableItems = ref<EditableIoOrderItem[]>([])
|
||||
const selectedStatus = ref<IoOrderStatus>('in_progress')
|
||||
const isReadOnly = computed(() => Boolean(page.value?.voided))
|
||||
|
||||
@@ -271,6 +287,54 @@
|
||||
})) as UploaderFileListItem[]
|
||||
}
|
||||
|
||||
function mapBrandQtyRows(it: PublicIoOrderItem): BrandQtyItem[] {
|
||||
const existing = (it.brandQtys || []).map((row) => ({
|
||||
brandId: row.brandId,
|
||||
brandName: row.brandName || '',
|
||||
qty: Number(row.qty ?? 0)
|
||||
}))
|
||||
if (existing.length) return existing
|
||||
|
||||
if (it.brandMode === 'required') {
|
||||
const brandId = it.brandId || it.brandOptions?.[0]?.brandId || ''
|
||||
const brandName =
|
||||
it.brandName || it.brandOptions?.find((o) => o.brandId === brandId)?.brandName || '指定品牌'
|
||||
const seedQty = it.actualQty != null ? Number(it.actualQty) : 0
|
||||
return brandId ? [{ brandId, brandName, qty: seedQty }] : []
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function brandModeForField(item: EditableIoOrderItem): 'any' | 'required' | null {
|
||||
if (item.brandMode === 'any' || item.brandMode === 'required') return item.brandMode
|
||||
return null
|
||||
}
|
||||
|
||||
function syncItemActualQty(item: EditableIoOrderItem, val: number) {
|
||||
item.actualQtyText = String(val)
|
||||
}
|
||||
|
||||
function brandQtySum(rows: BrandQtyItem[]) {
|
||||
return rows.reduce((sum, row) => sum + Number(row.qty || 0), 0)
|
||||
}
|
||||
|
||||
function validateBrandedItemsForCompleted(): string | null {
|
||||
for (const item of editableItems.value) {
|
||||
if (!item.brandMode) continue
|
||||
const positiveRows = (item.brandQtyRows || []).filter((r) => Number(r.qty) > 0)
|
||||
if (!positiveRows.length) {
|
||||
return `「${item.productName || '产品'}」请至少填写一个品牌数量`
|
||||
}
|
||||
const total = Number(brandQtySum(item.brandQtyRows).toFixed(3))
|
||||
const actual = Number(item.actualQtyText || 0)
|
||||
if (Math.abs(total - actual) >= 0.0005) {
|
||||
return `「${item.productName || '产品'}」品牌数量合计须等于实际数量`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
@@ -279,10 +343,22 @@
|
||||
page.value = data
|
||||
feedbackRemark.value = data.siteFeedbackRemark || ''
|
||||
feedbackImages.value = [...(data.siteFeedbackImages || [])]
|
||||
editableItems.value = (data.items || []).map(it => ({
|
||||
...it,
|
||||
actualQtyText: it.actualQty != null ? String(it.actualQty) : ''
|
||||
}))
|
||||
editableItems.value = (data.items || []).map((it) => {
|
||||
const brandQtyRows = mapBrandQtyRows(it)
|
||||
const actualFromBrand =
|
||||
it.brandMode && brandQtyRows.length ? Number(brandQtySum(brandQtyRows).toFixed(3)) : null
|
||||
return {
|
||||
...it,
|
||||
brandOptions: it.brandOptions || [],
|
||||
brandQtyRows,
|
||||
actualQtyText:
|
||||
actualFromBrand != null
|
||||
? String(actualFromBrand)
|
||||
: it.actualQty != null
|
||||
? String(it.actualQty)
|
||||
: ''
|
||||
}
|
||||
})
|
||||
selectedStatus.value = (
|
||||
['in_progress', 'completed', 'voided'].includes(data.status || '')
|
||||
? data.status
|
||||
@@ -324,7 +400,7 @@
|
||||
if (isReadOnly.value) return false
|
||||
const fileUrl = (item as UploaderFileListItem & { fileUrl?: string }).fileUrl
|
||||
if (fileUrl) {
|
||||
feedbackImages.value = feedbackImages.value.filter(u => u !== fileUrl)
|
||||
feedbackImages.value = feedbackImages.value.filter((u) => u !== fileUrl)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -341,15 +417,32 @@
|
||||
return
|
||||
}
|
||||
}
|
||||
if (selectedStatus.value === 'completed') {
|
||||
const brandErr = validateBrandedItemsForCompleted()
|
||||
if (brandErr) {
|
||||
showFailToast(brandErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.saveFeedback(token.value, {
|
||||
status: selectedStatus.value,
|
||||
remark: feedbackRemark.value,
|
||||
images: feedbackImages.value,
|
||||
items: editableItems.value.map(it => ({
|
||||
items: editableItems.value.map((it) => ({
|
||||
id: it.id,
|
||||
actualQty: Number(it.actualQtyText || 0)
|
||||
actualQty: Number(it.actualQtyText || 0),
|
||||
brandQtys: it.brandMode
|
||||
? (it.brandQtyRows || [])
|
||||
.filter((row) => Number(row.qty) > 0)
|
||||
.map((row) => ({
|
||||
brandId: row.brandId,
|
||||
brandName: row.brandName,
|
||||
qty: Number(row.qty)
|
||||
}))
|
||||
: undefined
|
||||
}))
|
||||
})
|
||||
showSuccessToast('提交成功')
|
||||
|
||||
Reference in new issue
Block a user