350 lines
9.8 KiB
Vue
350 lines
9.8 KiB
Vue
<template>
|
|
<t-form-item
|
|
class="product-selector"
|
|
:label="props.label"
|
|
:required-mark="props.requiredMark"
|
|
content-align="right"
|
|
arrow
|
|
@click="open"
|
|
>
|
|
<span class="picker-text" v-text="selectedProductName || props.placeholder" />
|
|
</t-form-item>
|
|
<product-sku-form-item :sku="selectedSku" :unit="selectedUnit" />
|
|
<bottom-popup
|
|
v-model="visible"
|
|
title="选择产品 SKU"
|
|
height="80vh"
|
|
right-button-text=""
|
|
@left-button-click="close"
|
|
>
|
|
<div class="popup">
|
|
<div class="search-wrap">
|
|
<t-input v-model="keyword" class="search" borderless placeholder="搜索产品名称或编码" />
|
|
</div>
|
|
<div class="items">
|
|
<t-collapse
|
|
v-if="visibleProductList.length"
|
|
v-model="expandedProductKeyList"
|
|
class="collapse"
|
|
theme="card"
|
|
:expand-mutex="true"
|
|
@change="handleProductChange"
|
|
>
|
|
<t-collapse-panel
|
|
class="item"
|
|
v-for="(product, productIndex) in visibleProductList"
|
|
:key="getProductKey(product, productIndex)"
|
|
:value="getProductKey(product, productIndex)"
|
|
placement="bottom"
|
|
>
|
|
<template #header>
|
|
<div class="header">
|
|
<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)" />
|
|
<strong class="clip-text" v-text="product.name || '未命名产品'" />
|
|
</div>
|
|
</template>
|
|
<div v-if="isExpandedProduct(product, productIndex)" 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>
|
|
</div>
|
|
</div>
|
|
<div v-if="matchedSku" class="sku-card">
|
|
<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>
|
|
</t-collapse-panel>
|
|
</t-collapse>
|
|
<div v-else class="empty" v-text="loading ? '正在加载可售产品' : '暂无可售产品'" />
|
|
</div>
|
|
</div>
|
|
</bottom-popup>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { BottomPopup } from "timi-tdesign-mobile";
|
|
import ProductAPI from "@/api/ProductAPI";
|
|
import ProductSkuFormItem from "@/components/ProductSkuFormItem.vue";
|
|
import type {
|
|
Product,
|
|
ProductSelection,
|
|
ProductSku,
|
|
ProductSkuUnit,
|
|
ProductSpec,
|
|
ProductSpecValue
|
|
} from "@/types/Product";
|
|
import { getErrorMessage } from "@/utils/error";
|
|
import { Message } from "@/utils/message";
|
|
import { formatProductAmount, getProductPhotoURL } from "@/utils/product";
|
|
|
|
interface Props {
|
|
modelValue: { skuId: string; skuUnitId: string } | undefined;
|
|
selectedProduct?: Product;
|
|
label?: string;
|
|
requiredMark?: boolean;
|
|
placeholder?: string;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), { label: "产品", requiredMark: false, placeholder: "请选择产品 SKU" });
|
|
const emit = defineEmits<{
|
|
"update:modelValue": [value: Props["modelValue"]];
|
|
select: [selection: ProductSelection];
|
|
}>();
|
|
const visible = ref(false);
|
|
const loading = ref(false);
|
|
const keyword = ref("");
|
|
const productList = ref<Product[]>([]);
|
|
const activeProduct = ref<Product>();
|
|
const expandedProductKeyList = ref<Array<string | number>>([]);
|
|
const selectedSpecValueMap = reactive<Record<string, string>>({});
|
|
|
|
const selectedProduct = computed(() => {
|
|
if (!props.modelValue?.skuId) {
|
|
return undefined;
|
|
}
|
|
return props.selectedProduct || productList.value.find(item => item.skuList?.some(sku => sku.id === props.modelValue?.skuId));
|
|
});
|
|
const selectedProductName = computed(() => selectedProduct.value?.name || "");
|
|
const selectedSku = computed(() => selectedProduct.value?.skuList?.find(sku => sku.id === props.modelValue?.skuId));
|
|
const selectedUnit = computed(() => selectedSku.value?.unitList?.find(unit => unit.id === props.modelValue?.skuUnitId));
|
|
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;
|
|
expandedProductKeyList.value = [];
|
|
if (productList.value.length) {
|
|
return;
|
|
}
|
|
loading.value = true;
|
|
try {
|
|
productList.value = await ProductAPI.sellableList({ index: 0, size: 1000 });
|
|
} catch (error) {
|
|
Message.error(getErrorMessage(error, "可售产品加载失败"));
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function close(): void {
|
|
visible.value = false;
|
|
activeProduct.value = undefined;
|
|
expandedProductKeyList.value = [];
|
|
}
|
|
|
|
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 getProductKey(product: Product, index: number): string {
|
|
return product.id || product.code || `product-${index}`;
|
|
}
|
|
|
|
function isExpandedProduct(product: Product, index: number): boolean {
|
|
return expandedProductKeyList.value.includes(getProductKey(product, index));
|
|
}
|
|
|
|
function handleProductChange(value: Array<string | number>): void {
|
|
const productIndex = visibleProductList.value.findIndex((product, index) => getProductKey(product, index) === value[0]);
|
|
const product = visibleProductList.value[productIndex];
|
|
if (product) {
|
|
selectProduct(product);
|
|
return;
|
|
}
|
|
activeProduct.value = undefined;
|
|
}
|
|
|
|
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;
|
|
}
|
|
const product = activeProduct.value;
|
|
const unit = sku.unitList?.find(item => item.id === skuUnitId);
|
|
if (product && unit) {
|
|
emit("select", { product, sku, unit });
|
|
}
|
|
emit("update:modelValue", { skuId: sku.id, skuUnitId });
|
|
close();
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.product-selector {
|
|
|
|
.picker-text {
|
|
color: var(--td-text-color-primary);
|
|
}
|
|
}
|
|
|
|
.popup {
|
|
height: 100%;
|
|
padding: 1rem 0;
|
|
display: flex;
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
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);
|
|
|
|
.collapse {
|
|
margin: 0;
|
|
|
|
.item {
|
|
--td-collapse-content-padding: .25rem 1rem .75rem 1rem;
|
|
|
|
.header {
|
|
gap: .75rem;
|
|
display: flex;
|
|
min-width: 0;
|
|
align-items: center;
|
|
|
|
.avatar {
|
|
flex: 0 0 auto;
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
border-radius: .75rem !important;
|
|
}
|
|
}
|
|
|
|
.selection {
|
|
overflow-y: auto;
|
|
|
|
.specs {
|
|
gap: .5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
.spec {
|
|
gap: .5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
.values,
|
|
.units {
|
|
gap: .5rem;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
}
|
|
|
|
.sku-card {
|
|
padding: .5rem;
|
|
border: 1px dashed var(--td-brand-color);
|
|
margin-top: .5rem;
|
|
border-radius: .875rem;
|
|
|
|
.units {
|
|
gap: .5rem;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
|
|
.price {
|
|
margin-left: .25rem;
|
|
font-size: .75rem;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.empty {
|
|
color: var(--td-text-color-secondary);
|
|
padding: 2rem 1rem;
|
|
text-align: center;
|
|
}
|
|
}
|
|
|
|
:deep(.avatar .t-image__img) {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
border-radius: inherit;
|
|
}
|
|
</style>
|