feat(web-admin): site public page brand qty chips entry

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-31 05:23:37 +08:00
1 parent 006ed6586c
commit 6dde2cec07
3 files changed
+465 -16

No files matched your search

+12 -1
View File
@@ -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('提交成功')