support gift

This commit is contained in:
Timi
2026-09-10 01:33:58 +08:00
parent 8a88df99fc
commit 33b1c0a432
9 changed files with 279 additions and 154 deletions
+257 -151
View File
@@ -44,20 +44,19 @@
:customer="selectedCustomer"
v-entrance-transition
/>
<t-form-item v-if="isCustomerPackageCreate" label="套餐" required-mark>
<t-input
:model-value="config?.name || ''"
readonly
borderless
placeholder="请选择套餐"
@click="configSelectorVisible = true"
/>
</t-form-item>
<t-form-item
v-if="isCustomerPackageCreate"
label="套餐"
required-mark
content-align="right"
arrow
@click="configSelectorVisible = true"
>选择套餐</t-form-item>
<div v-if="config && !isSaleable" class="saleable-tip red">套餐当前不可购买,请检查套餐状态和有效期</div>
<section class="package-purchase-form">
<div v-if="config" class="config-summary">
<t-form-item v-if="config" label-width="0">
<div class="config-summary">
<div class="header">
<div class="text-bold" v-text="config.name || '未命名套餐'" />
<div class="name text-bold clip-text" v-text="config.name || '未命名套餐'" />
<div class="price">
<span class="label gray">标准价:</span>
<span class="value red text-bold" v-text="formatPackageAmount(config.standardAmount)" />
@@ -65,80 +64,93 @@
</div>
<div class="description" v-text="config.description" />
</div>
<div v-else class="center-tips" v-text="'当前套餐未返回,无法完成购买。'" />
<t-form-item label="成交价(元)" label-width="6rem" required-mark>
<t-input
v-model.trim="saleAmountText"
type="number"
align="right"
borderless
:readonly="!canPriceOverride"
placeholder="请输入成交价"
/>
</t-form-item>
<t-form-item label-width="0">
<t-form class="items" label-width="2.5rem">
<div class="title">
<strong>购买权益明细</strong>
<span v-if="!canPriceOverride">当前账号无定制权限,仅可等值转换单位</span>
</div>
<t-form-item
v-for="(item, index) in packagePurchaseItemList"
:key="`${index}-${item.skuId}-${item.skuUnitId}`"
class="item"
>
<template #label>
<t-image
v-if="getItemPhotoURL(item)"
:src="getItemPhotoURL(item)"
:alt="getItemName(item)"
fit="cover"
class="avatar"
/>
<div
v-else
class="avatar fallback"
v-text="getItemName(item).slice(0, 1)"
/>
</template>
<div class="content">
</t-form-item>
<t-form-item label="成交价(元)" label-width="6rem" required-mark>
<t-input
v-model.trim="saleAmountText"
type="number"
align="right"
borderless
:readonly="!canPriceOverride"
placeholder="请输入成交价"
/>
</t-form-item>
<t-form-item v-if="config" label="购买权益明细" label-align="top">
<t-form class="items" label-width="2.5rem">
<span v-if="!canPriceOverride">当前账号无定制权限,仅可等值转换单位</span>
<t-form-item
v-for="(item, index) in packagePurchaseItemList"
:key="`${index}-${item.skuId}-${item.skuUnitId}-${item.gifted}`"
class="item"
>
<template #label>
<t-image
v-if="getItemPhotoURL(item)"
:src="getItemPhotoURL(item)"
:alt="getItemName(item)"
fit="cover"
class="avatar"
/>
<div
v-else
class="avatar fallback"
v-text="getItemName(item).slice(0, 1)"
/>
</template>
<div class="content">
<div class="main">
<span class="name" v-text="getItemLabel(item)" />
<t-tag v-if="item.gifted" size="small" theme="success" content="赠" />
<t-input
class="input"
:model-value="item.totalQuantity"
type="number"
:readonly="!canPriceOverride"
:readonly="!canEditItem(item)"
placeholder="总权益数量"
borderless
align="right"
@update:model-value="updateItemQuantity(index, $event)"
>
<template #suffix-icon>
<div class="actions">
<t-button
size="extra-small"
variant="outline"
:content="getItemUnit(item)?.name || '选择单位'"
@click="openItemUnitPicker(index)"
/>
<t-button
v-if="canPriceOverride"
class="remove"
size="extra-small"
theme="danger"
variant="text"
content="移除"
@click="removePurchaseItem(index)"
/>
</div>
</template>
</t-input>
<div class="actions">
<t-button
size="extra-small"
variant="outline"
:content="getItemUnit(item)?.name || '选择单位'"
@click="openItemUnitPicker(index)"
/>
<t-button
v-if="canRemoveItem(item)"
class="remove"
size="extra-small"
theme="danger"
variant="text"
content="移除"
@click="removePurchaseItem(index)"
/>
</div>
</div>
</t-form-item>
<div v-if="!packagePurchaseItemList.length" class="center-tips" v-text="'当前套餐暂无权益明细'" />
</t-form>
</t-form-item>
</section>
<t-input
v-if="item.gifted"
class="gift-remark"
:model-value="item.remark || ''"
align="right"
borderless
placeholder="赠送说明(选填)"
@update:model-value="updateItemRemark(index, $event)"
/>
</div>
</t-form-item>
<product-selector
v-if="canGiftCreate"
:model-value="undefined"
label=""
placeholder="添加可售赠送产品"
@update:model-value="addGiftProduct"
/>
<div v-if="!packagePurchaseItemList.length" class="center-tips" v-text="'当前套餐暂无权益明细'" />
</t-form>
</t-form-item>
<t-form-item label="购买时间" content-align="right">
<div class="time-actions">
<t-button
@@ -254,9 +266,14 @@ import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { PhotoItem } from "@/types/Photo";
import type { GaoPackageConfig, PackageAttachment, PackagePurchaseItem } from "@/types/Package";
import type { ProductSku, ProductSkuUnit } from "@/types/Product";
import type { Product, ProductSku, ProductSkuUnit } from "@/types/Product";
import { getErrorMessage } from "@/utils/error";
import { addPackageQuantity, convertPackageQuantity, formatPackageAmount, normalizePackageQuantity } from "@/utils/package";
import {
addPackageQuantity,
convertPackageQuantity,
formatPackageAmount,
normalizePackageQuantity
} from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo";
@@ -280,6 +297,7 @@ const config = ref<GaoPackageConfig>();
const configList = ref<GaoPackageConfig[]>([]);
const configSelectorVisible = ref(false);
const skuMap = reactive<Record<string, ProductSku>>({});
const giftProductMap = reactive<Record<string, Product>>({});
const unitPickerVisible = ref(false);
const unitPickerIndex = ref(-1);
const unitPickerDraft = ref<Array<string | number>>([]);
@@ -417,6 +435,7 @@ function buildAttachments(): PackageAttachment[] {
const {
isSaleable,
canPriceOverride,
canGiftCreate,
saleAmountText,
packagePurchaseItemList,
applyConfig,
@@ -439,16 +458,19 @@ const unitPickerOptions = computed(() => {
function getItemLabel(item: PackagePurchaseItem): string {
const configItem = config.value?.itemList?.find(configItem => configItem.skuId === item.skuId);
if (!configItem) {
return "产品权益";
}
const productName = configItem.productNameSnapshot || "未命名产品";
const skuText = getProductSkuSnapshotText(productName, configItem.skuNameSnapshot, configItem.specSummarySnapshot);
const product = item.gifted ? giftProductMap[item.skuId] : undefined;
const productName = item.gifted
? product?.name || item.productNameSnapshot || "未命名产品"
: configItem?.productNameSnapshot || "未命名产品";
const skuName = item.gifted ? item.skuNameSnapshot || getItemSku(item)?.name : configItem?.skuNameSnapshot;
const specSummary = item.gifted ? item.specSummarySnapshot || getItemSku(item)?.specSummary : configItem?.specSummarySnapshot;
const skuText = getProductSkuSnapshotText(productName, skuName, specSummary);
return skuText ? `${productName} · ${skuText}` : productName;
}
function getItemName(item: PackagePurchaseItem): string {
return config.value?.itemList?.find(configItem => configItem.skuId === item.skuId)?.productNameSnapshot || "未命名产品";
return item.gifted ? giftProductMap[item.skuId]?.name || item.productNameSnapshot || "未命名产品"
: config.value?.itemList?.find(configItem => configItem.skuId === item.skuId)?.productNameSnapshot || "未命名产品";
}
function getItemPhotoURL(item: PackagePurchaseItem): string {
@@ -464,6 +486,9 @@ function getItemUnit(item: PackagePurchaseItem): ProductSkuUnit | undefined {
}
function getSelectableUnitList(item: PackagePurchaseItem): ProductSkuUnit[] {
if (item.gifted) {
return (getItemSku(item)?.unitList || []).filter(unit => unit.allowSale !== false && !!unit.id);
}
const maxBaseQuantityPerUnit = Math.max(...(config.value?.itemList || [])
.filter(configItem => configItem.skuId === item.skuId)
.map(configItem => Number(configItem.baseQuantityPerUnitSnapshot || 0)));
@@ -473,7 +498,7 @@ function getSelectableUnitList(item: PackagePurchaseItem): ProductSkuUnit[] {
function openItemUnitPicker(index: number): void {
const item = packagePurchaseItemList.value[index];
if (!item || !getSelectableUnitList(item).length) {
if (!item || !canEditItem(item) || !getSelectableUnitList(item).length) {
Message.warning("当前 SKU 没有可用于套餐权益的销售单位");
return;
}
@@ -488,7 +513,7 @@ function confirmItemUnitPicker(value: Array<string | number>): void {
const skuUnitId = String(value[0] || "");
const currentUnit = item && getItemUnit(item);
const targetUnit = item && getSelectableUnitList(item).find(unit => unit.id === skuUnitId);
if (!item || !currentUnit || !targetUnit || !skuUnitId || skuUnitId === item.skuUnitId) {
if (!item || !canEditItem(item) || !currentUnit || !targetUnit || !skuUnitId || skuUnitId === item.skuUnitId) {
unitPickerVisible.value = false;
return;
}
@@ -498,7 +523,7 @@ function confirmItemUnitPicker(value: Array<string | number>): void {
return;
}
const duplicateIndex = packagePurchaseItemList.value.findIndex((candidate, candidateIndex) => candidateIndex !== index
&& candidate.skuId === item.skuId && candidate.skuUnitId === skuUnitId);
&& candidate.skuId === item.skuId && candidate.skuUnitId === skuUnitId && Boolean(candidate.gifted) === Boolean(item.gifted));
if (duplicateIndex < 0) {
packagePurchaseItemList.value[index] = { ...item, skuUnitId, totalQuantity: convertedQuantity };
} else {
@@ -522,7 +547,63 @@ function confirmItemUnitPicker(value: Array<string | number>): void {
unitPickerVisible.value = false;
}
async function addGiftProduct(value?: { skuId: string; skuUnitId: string }): Promise<void> {
if (!value || !canGiftCreate.value) {
return;
}
try {
const sku = skuMap[value.skuId] || await ProductAPI.skuDetail(value.skuId);
skuMap[value.skuId] = sku;
const productId = sku.productId || "";
const product = productId ? giftProductMap[value.skuId] || await ProductAPI.detail(productId) : undefined;
if (!product) {
Message.warning("赠送产品信息加载失败,请重新选择");
return;
}
giftProductMap[value.skuId] = product;
const existingIndex = packagePurchaseItemList.value.findIndex(item => item.gifted
&& item.skuId === value.skuId && item.skuUnitId === value.skuUnitId);
if (0 <= existingIndex) {
const existingItem = packagePurchaseItemList.value[existingIndex];
const totalQuantity = addPackageQuantity(existingItem.totalQuantity, "1");
if (!totalQuantity) {
Message.warning("赠送数量合并后不能精确保留三位小数");
return;
}
packagePurchaseItemList.value[existingIndex] = { ...existingItem, totalQuantity };
} else {
const maxSort = Math.max(-1, ...packagePurchaseItemList.value.map(item => item.sort ?? -1));
packagePurchaseItemList.value.push({
skuId: value.skuId,
skuUnitId: value.skuUnitId,
totalQuantity: "1",
gifted: true,
sort: maxSort + 1,
remark: null,
productNameSnapshot: product.name,
skuNameSnapshot: sku.name,
specSummarySnapshot: sku.specSummary
});
}
resetRequestNo();
} catch (error) {
Message.error(getErrorMessage(error, "赠送产品加载失败"));
}
}
function canEditItem(item: PackagePurchaseItem): boolean {
return Boolean(item.gifted) ? canGiftCreate.value : canPriceOverride.value;
}
function canRemoveItem(item: PackagePurchaseItem): boolean {
return canEditItem(item);
}
function updateItemQuantity(index: number, value: string | number): void {
const item = packagePurchaseItemList.value[index];
if (!item || !canEditItem(item)) {
return;
}
packagePurchaseItemList.value = packagePurchaseItemList.value.map((item, itemIndex) => itemIndex === index ? {
...item,
totalQuantity: String(value)
@@ -530,8 +611,18 @@ function updateItemQuantity(index: number, value: string | number): void {
resetRequestNo();
}
function updateItemRemark(index: number, value: string | number): void {
const item = packagePurchaseItemList.value[index];
if (!item?.gifted || !canGiftCreate.value) {
return;
}
packagePurchaseItemList.value[index] = { ...item, remark: String(value) || null };
resetRequestNo();
}
function removePurchaseItem(index: number): void {
if (!canPriceOverride.value) {
const item = packagePurchaseItemList.value[index];
if (!item || !canRemoveItem(item)) {
return;
}
packagePurchaseItemList.value = packagePurchaseItemList.value.filter((_, itemIndex) => itemIndex !== index);
@@ -662,84 +753,86 @@ watch(config, value => {
margin: 1rem;
}
.saleable-tip {
padding: 0 1rem .5rem;
font-size: .8125rem;
line-height: 1.5;
}
.time-actions {
gap: .5rem;
.config-summary {
gap: .25rem;
width: 100%;
padding: .5rem .75rem;
display: flex;
align-items: center;
justify-content: flex-end;
}
background: var(--td-error-color-1);
border-radius: .75rem;
flex-direction: column;
.package-purchase-form {
gap: .75rem;
display: grid;
margin-top: 1rem;
.config-summary {
gap: .25rem;
margin: 0 1rem;
padding: .5rem .75rem;
.header {
display: flex;
background: var(--td-error-color-1);
border-radius: .75rem;
flex-direction: column;
justify-content: space-between;
.header {
.name {
flex: 1;
}
.price {
flex: none;
display: flex;
justify-content: space-between;
align-items: baseline;
.price {
display: flex;
align-items: baseline;
.label {
font-size: .75rem;
}
.label {
font-size: .75rem;
}
.value {
font-size: 1.75rem;
line-height: 1;
}
.value {
font-size: 1.75rem;
line-height: 1;
}
}
}
.items {
gap: .5rem;
margin: 0;
display: grid;
.description {
width: 100%;
font-size: .75rem;
min-width: 0;
max-width: 100%;
word-break: break-word;
white-space: normal;
overflow-wrap: anywhere;
}
}
.title {
font-size: .8125rem;
margin-bottom: .5rem;
.items {
gap: .5rem;
flex: 1;
margin: 0;
display: grid;
.title {
font-size: .8125rem;
margin-bottom: .5rem;
}
.item {
padding: 10px;
box-shadow: .25rem .25rem 1rem rgba(0, 0, 0, .08);
border-radius: 1rem;
.avatar {
width: 2rem;
height: 2rem;
flex: none;
border-radius: .5rem;
}
.item {
padding: 10px;
box-shadow: .25rem .25rem 1rem rgba(0, 0, 0, .08);
border-radius: 1rem;
&::after {
display: none;
}
.avatar {
width: 2rem;
height: 2rem;
flex: none;
border-radius: .5rem;
}
.content {
gap: .25rem;
display: flex;
flex-direction: column;
&::after {
display: none;
}
.content {
.main {
gap: .25rem;
display: flex;
height: 2rem;
align-items: center;
justify-content: space-between;
.name {
min-width: 0;
@@ -749,13 +842,19 @@ watch(config, value => {
}
.input {
min-width: 0;
flex: 1;
min-width: 0;
margin-top: .2rem;
}
.actions {
gap: .25rem;
display: flex;
}
.actions {
gap: .25rem;
margin: .1rem 0 0 .75rem;
display: flex;
}
.gift-remark {
padding: 0;
}
}
}
@@ -763,6 +862,13 @@ watch(config, value => {
}
}
.time-actions {
gap: .5rem;
display: flex;
align-items: center;
justify-content: flex-end;
}
.file-input {
display: none;
}