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

@@ -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>