add product sku
This commit is contained in:
@@ -11,47 +11,83 @@
|
||||
</t-form-item>
|
||||
<bottom-popup
|
||||
v-model="visible"
|
||||
title="选择产品"
|
||||
:title="activeProduct ? '选择 SKU 单位' : '选择产品'"
|
||||
height="80vh"
|
||||
right-button-text=""
|
||||
@left-button-click="visible = false"
|
||||
@left-button-click="close"
|
||||
>
|
||||
<div class="popup">
|
||||
<div class="search-wrap">
|
||||
<t-input
|
||||
v-model="keyword"
|
||||
class="search"
|
||||
borderless
|
||||
placeholder="搜索产品名称或编码"
|
||||
/>
|
||||
<div v-if="activeProduct" class="selection-header">
|
||||
<t-button variant="text" size="small" @click="backToProducts">返回产品</t-button>
|
||||
<strong v-text="activeProduct.name || '未命名产品'" />
|
||||
<span v-text="activeProduct.code || ''" />
|
||||
</div>
|
||||
<div class="items">
|
||||
<t-cell-group v-if="visibleProductList.length" theme="card">
|
||||
<t-cell
|
||||
v-for="product in visibleProductList"
|
||||
:key="product.id"
|
||||
class="item"
|
||||
:title="product.name"
|
||||
:description="`${product.code || '-'}`"
|
||||
@click="handleProductSelect(product)"
|
||||
>
|
||||
<template #leftIcon>
|
||||
<t-image
|
||||
v-if="getProductPhotoURL(product)"
|
||||
:src="getProductPhotoURL(product)"
|
||||
:alt="product.name || '产品图片'"
|
||||
fit="cover"
|
||||
class="avatar"
|
||||
<div v-if="activeProduct" class="selection">
|
||||
<div v-if="specList.length" class="specs">
|
||||
<div v-for="spec in specList" :key="spec.id || spec.name" class="spec">
|
||||
<strong v-text="spec.name" />
|
||||
<div class="values">
|
||||
<t-button
|
||||
v-for="value in enabledValues(spec)"
|
||||
:key="value.id"
|
||||
size="extra-small"
|
||||
:theme="selectedSpecValueMap[spec.id || ''] === value.id ? 'primary' : 'default'"
|
||||
:variant="selectedSpecValueMap[spec.id || ''] === value.id ? 'base' : 'outline'"
|
||||
:content="value.name"
|
||||
@click="selectSpecValue(spec.id || '', value.id || '')"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="avatar fallback"
|
||||
v-text="(product.name || product.code || '产').slice(0, 1)"
|
||||
/>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty" v-text="loading ? '正在加载可售产品' : '暂无可售产品'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="matchedSku" class="sku-card">
|
||||
<div class="sku-title">
|
||||
<strong v-text="matchedSku.name || matchedSku.specSummary || '默认 SKU'" />
|
||||
<span v-if="matchedSku.code" v-text="matchedSku.code" />
|
||||
</div>
|
||||
<div class="units">
|
||||
<t-button
|
||||
v-for="unit in saleableUnits(matchedSku)"
|
||||
:key="unit.id"
|
||||
size="small"
|
||||
:theme="props.modelValue?.skuId === matchedSku.id && props.modelValue?.skuUnitId === unit.id ? 'primary' : 'default'"
|
||||
:variant="props.modelValue?.skuId === matchedSku.id && props.modelValue?.skuUnitId === unit.id ? 'base' : 'outline'"
|
||||
@click="selectUnit(matchedSku, unit.id || '')"
|
||||
>
|
||||
<span v-text="unit.name" />
|
||||
<span v-if="unit.salePrice !== null && unit.salePrice !== undefined" class="price" v-text="`¥${formatProductAmount(unit.salePrice)}`" />
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty" v-text="specList.length ? '请选择每个规格项的规格值' : '暂无可售 SKU'" />
|
||||
</div>
|
||||
<div v-else class="products">
|
||||
<div class="search-wrap">
|
||||
<t-input v-model="keyword" class="search" borderless placeholder="搜索产品名称或编码" />
|
||||
</div>
|
||||
<div class="items">
|
||||
<t-cell-group v-if="visibleProductList.length" theme="card">
|
||||
<t-cell
|
||||
v-for="product in visibleProductList"
|
||||
:key="product.id"
|
||||
class="item"
|
||||
:title="product.name"
|
||||
:description="`${product.code || '-'} · ${product.skuList?.length || 0} 个 SKU`"
|
||||
@click="selectProduct(product)"
|
||||
>
|
||||
<template #leftIcon>
|
||||
<t-image
|
||||
v-if="getProductPhotoURL(product)"
|
||||
:src="getProductPhotoURL(product)"
|
||||
:alt="product.name || '产品图片'"
|
||||
fit="cover"
|
||||
class="avatar"
|
||||
/>
|
||||
<div v-else class="avatar fallback" v-text="(product.name || product.code || '产').slice(0, 1)" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty" v-text="loading ? '正在加载可售产品' : '暂无可售产品'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</bottom-popup>
|
||||
@@ -60,40 +96,54 @@
|
||||
<script setup lang="ts">
|
||||
import { BottomPopup } from "timi-tdesign-mobile";
|
||||
import ProductAPI from "@/api/ProductAPI";
|
||||
import type { Product } from "@/types/Product";
|
||||
import type { Product, ProductSku, ProductSkuUnit, ProductSpec, ProductSpecValue } from "@/types/Product";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { getProductPhotoURL } from "@/utils/product";
|
||||
import { formatProductAmount, getProductPhotoURL, getProductSkuText } from "@/utils/product";
|
||||
|
||||
interface Props {
|
||||
modelValue: { productId: string; productUnitId: string } | undefined;
|
||||
modelValue: { skuId: string; skuUnitId: string } | undefined;
|
||||
selectedProduct?: Product;
|
||||
label?: string;
|
||||
requiredMark?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { label: "产品", requiredMark: false, placeholder: "请选择产品单位", selectionMode: "product-unit" });
|
||||
const props = withDefaults(defineProps<Props>(), { label: "产品", requiredMark: false, placeholder: "请选择产品 SKU" });
|
||||
const emit = defineEmits<{ "update:modelValue": [value: Props["modelValue"]] }>();
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const keyword = ref("");
|
||||
const productList = ref<Product[]>([]);
|
||||
const activeProduct = ref<Product>();
|
||||
const selectedSpecValueMap = reactive<Record<string, string>>({});
|
||||
|
||||
const selectedText = computed(() => {
|
||||
if (!props.modelValue) {
|
||||
if (!props.modelValue?.skuId) {
|
||||
return "";
|
||||
}
|
||||
const product = props.selectedProduct || productList.value.find(item => item.id === props.modelValue?.productId);
|
||||
return product?.name || "";
|
||||
const product = props.selectedProduct || productList.value.find(item => item.skuList?.some(sku => sku.id === props.modelValue?.skuId));
|
||||
const sku = product?.skuList?.find(item => item.id === props.modelValue?.skuId);
|
||||
const unit = sku?.unitList?.find(item => item.id === props.modelValue?.skuUnitId);
|
||||
return `${getProductSkuText(product?.name, sku)}${unit?.name ? ` · ${unit.name}` : ""}`;
|
||||
});
|
||||
const filteredProductList = computed(() => {
|
||||
const text = keyword.value.toLocaleLowerCase();
|
||||
return productList.value.filter(item => !text || `${item.name}${item.code}`.toLocaleLowerCase().includes(text));
|
||||
});
|
||||
const visibleProductList = computed(() => filteredProductList.value);
|
||||
const specList = computed(() => activeProduct.value?.specList || []);
|
||||
const matchedSku = computed(() => {
|
||||
const product = activeProduct.value;
|
||||
if (!product) {
|
||||
return undefined;
|
||||
}
|
||||
return (product.skuList || []).find(sku => specList.value.every(spec => sku.specValueIdList?.includes(selectedSpecValueMap[spec.id || ""] || "")));
|
||||
});
|
||||
|
||||
async function open(): Promise<void> {
|
||||
visible.value = true;
|
||||
activeProduct.value = undefined;
|
||||
if (productList.value.length) {
|
||||
return;
|
||||
}
|
||||
@@ -107,15 +157,48 @@ async function open(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function handleProductSelect(product: Product): void {
|
||||
if (!product.id) {
|
||||
function close(): void {
|
||||
visible.value = false;
|
||||
activeProduct.value = undefined;
|
||||
}
|
||||
|
||||
function backToProducts(): void {
|
||||
activeProduct.value = undefined;
|
||||
}
|
||||
|
||||
function enabledValues(spec: ProductSpec): ProductSpecValue[] {
|
||||
return (spec.valueList || []).filter(value => value.enabled !== false && value.id);
|
||||
}
|
||||
|
||||
function saleableUnits(sku: ProductSku): ProductSkuUnit[] {
|
||||
return (sku.unitList || []).filter(unit => unit.allowSale !== false && unit.id);
|
||||
}
|
||||
|
||||
function selectProduct(product: Product): void {
|
||||
activeProduct.value = product;
|
||||
Object.keys(selectedSpecValueMap).forEach(key => delete selectedSpecValueMap[key]);
|
||||
const selectedSku = product.skuList?.find(sku => sku.id === props.modelValue?.skuId);
|
||||
for (const spec of product.specList || []) {
|
||||
const valueId = selectedSku?.specValueIdList?.find(id => (spec.valueList || []).some(value => value.id === id))
|
||||
|| enabledValues(spec)[0]?.id;
|
||||
if (spec.id && valueId) {
|
||||
selectedSpecValueMap[spec.id] = valueId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectSpecValue(specId: string, valueId: string): void {
|
||||
if (specId && valueId) {
|
||||
selectedSpecValueMap[specId] = valueId;
|
||||
}
|
||||
}
|
||||
|
||||
function selectUnit(sku: ProductSku, skuUnitId: string): void {
|
||||
if (!sku.id || !skuUnitId) {
|
||||
return;
|
||||
}
|
||||
emit("update:modelValue", {
|
||||
productId: product.id,
|
||||
productUnitId: product.id === props.modelValue?.productId ? props.modelValue?.productUnitId || "" : ""
|
||||
});
|
||||
visible.value = false;
|
||||
emit("update:modelValue", { skuId: sku.id, skuUnitId });
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -134,40 +217,114 @@ function handleProductSelect(product: Product): void {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.search-wrap {
|
||||
z-index: 1;
|
||||
padding: 1rem 0 .5rem 0;
|
||||
box-shadow: 0 4px 4px rgba(0, 0, 0, .05);
|
||||
.selection-header {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
padding: 0 1rem .75rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--td-component-border);
|
||||
|
||||
.search {
|
||||
--td-input-vertical-padding: 8px 14px;
|
||||
strong {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
flex: 0;
|
||||
margin: 0 1rem;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .875rem;
|
||||
span {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
.selection {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
|
||||
.item {
|
||||
padding: 10px;
|
||||
.specs {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.avatar {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
.spec {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.values,
|
||||
.units {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sku-card {
|
||||
margin-top: 1.25rem;
|
||||
padding: .75rem;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: .875rem;
|
||||
|
||||
.sku-title {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
|
||||
span {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.units {
|
||||
gap: .35rem;
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
margin-top: .75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
|
||||
.price {
|
||||
margin-left: .25rem;
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.products {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.search-wrap {
|
||||
z-index: 1;
|
||||
padding: 0 0 .75rem;
|
||||
|
||||
.search {
|
||||
--td-input-vertical-padding: 8px 14px;
|
||||
|
||||
margin: 0 1rem;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
|
||||
.item {
|
||||
padding: 10px;
|
||||
|
||||
.avatar {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user