Files
gao-customer-h5/src/pages/package/PackagePurchase.vue
T

833 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div v-can="permissionCodes.customerPackage.create" class="package-purchase">
<t-form class="form" label-align="left" v-entrance-transition>
<t-form-item label="客户" required-mark>
<t-input
:model-value="customerDisplayText"
class="customer"
readonly
borderless
placeholder="选择或扫码客户"
@click="openSelectedCustomerList()"
>
<template #suffix-icon>
<div class="actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
@click.stop="selectorVisible = true"
>选择</t-button>
<t-button
theme="primary"
size="extra-small"
@click.stop="toggleScanner"
>
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
</t-button>
</div>
</template>
</t-input>
</t-form-item>
<div v-if="scannerVisible" class="code-scanner-wrapper">
<code-scanner
visible
:status-text="scanStatus"
active-text="正在识别客户编码,请将条码或二维码移入取景框。"
@decode="handleDecodedText"
@status-change="handleScanStatusChange"
/>
</div>
<customer-profile-card
v-if="selectedCustomer"
class="customer-profile"
: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>
<div v-if="config && !isSaleable" class="saleable-tip red">套餐当前不可购买,请检查套餐状态和有效期</div>
<section class="package-purchase-form">
<div v-if="config" class="config-summary">
<div class="header">
<div class="text-bold" v-text="config.name || '未命名套餐'" />
<div class="price">
<span class="label gray">标准价:</span>
<span class="value red text-bold" v-text="formatPackageAmount(config.standardAmount)" />
</div>
</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">
<div class="item-title">
<span class="name" v-text="getItemLabel(item)" />
<t-button
size="extra-small"
variant="outline"
:content="getItemUnit(item)?.name || '选择单位'"
@click="openItemUnitPicker(index)"
/>
</div>
<t-input
:model-value="item.totalQuantity"
type="number"
:readonly="!canPriceOverride"
placeholder="总权益数量"
borderless
align="right"
@update:model-value="updateItemQuantity(index, $event)"
>
<template #suffix-icon>
<span class="unit" v-text="getItemUnit(item)?.name || ''" />
</template>
</t-input>
<t-button
v-if="canPriceOverride"
class="remove"
size="extra-small"
theme="danger"
variant="text"
content="移除"
@click="removePurchaseItem(index)"
/>
</div>
</t-form-item>
<div v-if="!packagePurchaseItemList.length" class="center-tips" v-text="'当前套餐暂无权益明细'" />
</t-form>
</t-form-item>
</section>
<t-form-item label="购买时间" content-align="right">
<div class="time-actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
:content="purchasedAtText || '此刻'"
@click="openPurchasedAtPicker"
/>
<t-button
v-if="purchasedAtText"
theme="primary"
variant="text"
size="extra-small"
content="此刻"
@click="resetPurchasedAt"
/>
</div>
</t-form-item>
<t-form-item label="购买备注">
<t-textarea
v-model.trim="note"
class="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="购买说明"
/>
</t-form-item>
<t-form-item label-width="0">
<photo-scroller
:model-value="photoUrlList"
v-model:photo-items="photoItems"
:upload="uploadPhotoList"
:edit-mode="true"
title="订单照片"
/>
</t-form-item>
</t-form>
<customer-selector
v-model="selectedCustomerId"
v-model:visible="selectorVisible"
selection-mode="single"
@change="handleCustomerChange"
/>
<t-popup v-model="configSelectorVisible" placement="bottom">
<div class="config-selector bg-white">
<div class="popup-header">
<t-button variant="text" size="small" @click="configSelectorVisible = false">关闭</t-button>
<h3>选择套餐</h3>
<span />
</div>
<t-cell-group v-if="configList.length">
<t-cell
v-for="item in configList"
:key="item.id"
:title="item.name || '未命名套餐'"
:note="`标准价:${formatPackageAmount(item.standardAmount)}`"
arrow
@click="selectConfig(item)"
/>
</t-cell-group>
<div v-else class="center-tips" v-text="'暂无可购买套餐'" />
</div>
</t-popup>
<t-popup v-model="unitPickerVisible" placement="bottom">
<t-picker v-model="unitPickerDraft" :columns="unitPickerOptions" title="调整权益单位" @cancel="unitPickerVisible = false" @confirm="confirmItemUnitPicker" />
</t-popup>
<t-popup v-model="selectedCustomerListVisible" placement="bottom">
<div class="selected-customer-popup bg-white">
<div class="popup-header">
<t-button variant="text" size="small" @click="selectedCustomerListVisible = false">关闭</t-button>
<h3>已选客户</h3>
<t-button theme="primary" variant="text" size="small" @click="openCustomerSelector">选择</t-button>
</div>
<customer-list
class="list"
:customer-list="selectedCustomerList"
removable
empty-text="暂未选择客户"
@remove="removeSelectedCustomer"
/>
</div>
</t-popup>
</div>
<t-popup v-model="purchasedAtPickerVisible" placement="bottom">
<t-date-time-picker
v-if="purchasedAtPickerVisible"
v-model="purchasedAtDraftText"
:start="minimumDateTimeText"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="购买时间"
@cancel="purchasedAtPickerVisible = false"
@confirm="confirmPurchasedAt"
/>
</t-popup>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI";
import PackageAPI from "@/api/PackageAPI";
import ProductAPI from "@/api/ProductAPI";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerList from "@/components/CustomerList.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { usePackagePurchase } from "@/pages/package/usePackagePurchase";
import { useNavBarStore } from "@/store/navBarStore";
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 { getErrorMessage } from "@/utils/error";
import { addPackageQuantity, convertPackageQuantity, formatPackageAmount, normalizePackageQuantity } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo";
import { getProductPhotoURL, getProductSkuSnapshotText } from "@/utils/product";
defineOptions({
name: "PackagePurchase"
});
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const settingStore = useSettingStore();
const userStore = useUserStore();
// ---------- 套餐配置 ----------
const configId = computed(() => String(route.params.id || ""));
const isCustomerPackageCreate = computed(() => route.name === "CustomerPackageCreate");
const config = ref<GaoPackageConfig>();
const configList = ref<GaoPackageConfig[]>([]);
const configSelectorVisible = ref(false);
const skuMap = reactive<Record<string, ProductSku>>({});
const unitPickerVisible = ref(false);
const unitPickerIndex = ref(-1);
const unitPickerDraft = ref<Array<string | number>>([]);
// ---------- 客户 ----------
const customerCode = ref("");
const selectorVisible = ref(false);
const selectedCustomerListVisible = ref(false);
const selectedCustomerId = ref("");
const selectedCustomerList = ref<Customer[]>([]);
const selectedCustomer = computed(() => selectedCustomerList.value[0]);
const customerDisplayText = computed(() => selectedCustomer.value?.name || selectedCustomer.value?.code || customerCode.value);
async function handleCustomerChange(value: string | string[]): Promise<void> {
selectedCustomerId.value = Array.isArray(value) ? value[0] || "" : value;
resetRequestNo();
await loadSelectedCustomer();
}
async function loadSelectedCustomer(): Promise<void> {
if (!selectedCustomerId.value) {
selectedCustomerList.value = [];
return;
}
const customer = await CustomerAPI.detail(selectedCustomerId.value);
selectedCustomerList.value = customer?.id ? [customer] : [];
}
function openSelectedCustomerList(): void {
selectedCustomerListVisible.value = true;
}
async function openCustomerSelector(): Promise<void> {
selectedCustomerListVisible.value = false;
await nextTick();
selectorVisible.value = true;
}
async function removeSelectedCustomer(): Promise<void> {
selectedCustomerId.value = "";
selectedCustomerList.value = [];
}
// ---------- 扫码 ----------
const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。");
let latestDecodedText = "";
async function toggleScanner(): Promise<void> {
scannerVisible.value = !scannerVisible.value;
if (scannerVisible.value) {
latestDecodedText = "";
}
}
async function handleDecodedText(rawText: string): Promise<void> {
const normalizedText = rawText.trim();
if (!normalizedText || normalizedText === latestDecodedText) {
return;
}
latestDecodedText = normalizedText;
await settingStore.playScanSuccessSound();
try {
const customer = await CustomerAPI.findByCode(normalizedText);
if (!customer?.id) {
scanStatus.value = `客户 ${normalizedText} 不存在,请先建档。`;
return;
}
selectedCustomerId.value = customer.id;
await loadSelectedCustomer();
resetRequestNo();
scanStatus.value = `已识别 ${customer.code || normalizedText},已选中 ${customer.name || "未命名客户"}。`;
scannerVisible.value = false;
} catch {
scanStatus.value = "客户查询失败,请重试。";
}
}
function handleScanStatusChange(value: string): void {
scanStatus.value = value;
}
// ---------- 购买时间 ----------
const minimumDateTimeText = "1970-01-01 00:00";
const purchasedAtText = ref("");
const purchasedAtDraftText = ref("");
const purchasedAtPickerVisible = ref(false);
function openPurchasedAtPicker(): void {
purchasedAtDraftText.value = purchasedAtText.value || Time.formatDateTime(new Date());
purchasedAtPickerVisible.value = true;
}
function confirmPurchasedAt(value?: string | number): void {
purchasedAtText.value = String(value || purchasedAtDraftText.value);
purchasedAtPickerVisible.value = false;
}
function resetPurchasedAt(): void {
purchasedAtText.value = "";
purchasedAtDraftText.value = "";
}
function buildPurchasedAt(): number {
return Time.toTimestamp(parseDateTime(purchasedAtText.value));
}
function parseDateTime(value: string): Date {
if (!value) {
return new Date();
}
const [dateText, timeText = "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, 0, 0);
}
// ---------- 购买 ----------
const note = ref("");
const isSaving = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const photoUrlList = computed(() => photoItems.value.map(item => item.url));
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{
tempFileId: item.tempFileId,
attachType: item.attachType || "PHOTO"
}] : []);
}
const {
isSaleable,
canPriceOverride,
saleAmountText,
packagePurchaseItemList,
applyConfig,
resetRequestNo,
submitPurchase,
validatePurchaseForm
} = usePackagePurchase({
config,
selectedCustomerList,
note,
hasPermission: permissionCode => userStore.hasPermissions(permissionCode),
buildRegisteredAt: buildPurchasedAt,
buildAttachments
});
const unitPickerOptions = computed(() => {
const item = packagePurchaseItemList.value[unitPickerIndex.value];
return item ? getSelectableUnitList(item).map(unit => ({ label: unit.name, value: unit.id || "" })) : [];
});
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);
return skuText ? `${productName} · ${skuText}` : productName;
}
function getItemName(item: PackagePurchaseItem): string {
return config.value?.itemList?.find(configItem => configItem.skuId === item.skuId)?.productNameSnapshot || "未命名产品";
}
function getItemPhotoURL(item: PackagePurchaseItem): string {
return getProductPhotoURL(getItemSku(item) || {});
}
function getItemSku(item: PackagePurchaseItem): ProductSku | undefined {
return skuMap[item.skuId] || config.value?.itemList?.find(configItem => configItem.skuId === item.skuId)?.sku;
}
function getItemUnit(item: PackagePurchaseItem): ProductSkuUnit | undefined {
return getItemSku(item)?.unitList?.find(unit => unit.id === item.skuUnitId);
}
function getSelectableUnitList(item: PackagePurchaseItem): ProductSkuUnit[] {
const maxBaseQuantityPerUnit = Math.max(...(config.value?.itemList || [])
.filter(configItem => configItem.skuId === item.skuId)
.map(configItem => Number(configItem.baseQuantityPerUnitSnapshot || 0)));
return (getItemSku(item)?.unitList || []).filter(unit => unit.allowSale !== false && !!unit.id
&& Number(unit.baseQuantityPerUnit) <= maxBaseQuantityPerUnit);
}
function openItemUnitPicker(index: number): void {
const item = packagePurchaseItemList.value[index];
if (!item || !getSelectableUnitList(item).length) {
Message.warning("当前 SKU 没有可用于套餐权益的销售单位");
return;
}
unitPickerIndex.value = index;
unitPickerDraft.value = [item.skuUnitId];
unitPickerVisible.value = true;
}
function confirmItemUnitPicker(value: Array<string | number>): void {
const index = unitPickerIndex.value;
const item = packagePurchaseItemList.value[index];
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) {
unitPickerVisible.value = false;
return;
}
const convertedQuantity = convertPackageQuantity(item.totalQuantity, currentUnit.baseQuantityPerUnit, targetUnit.baseQuantityPerUnit);
if (!convertedQuantity) {
Message.warning("调整单位后数量不能精确保留三位小数");
return;
}
const duplicateIndex = packagePurchaseItemList.value.findIndex((candidate, candidateIndex) => candidateIndex !== index
&& candidate.skuId === item.skuId && candidate.skuUnitId === skuUnitId);
if (duplicateIndex < 0) {
packagePurchaseItemList.value[index] = { ...item, skuUnitId, totalQuantity: convertedQuantity };
} else {
const duplicateItem = packagePurchaseItemList.value[duplicateIndex];
const totalQuantity = addPackageQuantity(duplicateItem.totalQuantity, convertedQuantity);
if (!totalQuantity) {
Message.warning("合并同单位权益后数量不能精确保留三位小数");
return;
}
packagePurchaseItemList.value = packagePurchaseItemList.value.flatMap((candidate, candidateIndex) => {
if (candidateIndex === index) {
return [];
}
if (candidateIndex === duplicateIndex) {
return [{ ...candidate, totalQuantity, sort: Math.min(candidate.sort ?? candidateIndex, item.sort ?? index) }];
}
return [candidate];
});
}
resetRequestNo();
unitPickerVisible.value = false;
}
function updateItemQuantity(index: number, value: string | number): void {
packagePurchaseItemList.value = packagePurchaseItemList.value.map((item, itemIndex) => itemIndex === index ? {
...item,
totalQuantity: String(value)
} : item);
resetRequestNo();
}
function removePurchaseItem(index: number): void {
if (!canPriceOverride.value) {
return;
}
packagePurchaseItemList.value = packagePurchaseItemList.value.filter((_, itemIndex) => itemIndex !== index);
resetRequestNo();
}
function validateItemDecimalQuantity(): boolean {
for (const item of packagePurchaseItemList.value) {
const quantity = normalizePackageQuantity(item.totalQuantity);
if (quantity && quantity !== "0" && quantity.includes(".") && getItemSku(item)?.allowDecimalQuantity === false) {
Message.warning("该 SKU 不允许小数数量");
return false;
}
}
return true;
}
async function submitRecord(): Promise<void> {
if (isSaving.value) {
return;
}
if (!validatePurchaseForm() || !validateItemDecimalQuantity()) {
return;
}
isSaving.value = true;
try {
const result = await submitPurchase();
if (!result?.salesOrderId) {
Message.error("套餐购买成功但未返回完整订单关联,请联系管理员");
return;
}
await settingStore.playRegisterSuccessSound();
Message.success("套餐购买已提交");
await router.replace({
name: "SalesOrderDetail",
params: { id: result.salesOrderId },
query: { transitionDepth: String(Number(route.meta.depth ?? 0) + 1) }
});
} catch (error) {
Message.error(getErrorMessage(error, "套餐购买提交失败"));
} finally {
isSaving.value = false;
}
}
// ---------- 页面数据 ----------
async function loadPageData(): Promise<void> {
if (!configId.value) {
configList.value = await PackageAPI.configListValid();
return;
}
config.value = await PackageAPI.configDetail(configId.value);
applyConfig(config.value);
await loadConfigSkuList(config.value);
}
async function selectConfig(item: GaoPackageConfig): Promise<void> {
if (!item.id) {
return;
}
config.value = await PackageAPI.configDetail(item.id);
await loadConfigSkuList(config.value);
configSelectorVisible.value = false;
}
async function loadConfigSkuList(value?: GaoPackageConfig): Promise<void> {
const skuIdList = [...new Set((value?.itemList || []).map(item => item.skuId).filter(Boolean))];
await Promise.all(skuIdList.map(async skuId => {
try {
skuMap[skuId] = await ProductAPI.skuDetail(skuId);
} catch {
// 后端会校验最终权益单位,单个 SKU 的单位加载失败不影响套餐查看
}
}));
}
onMounted(async () => {
navBarStore.clearRight();
if (userStore.hasPermissions(permissionCodes.customerPackage.create)) {
navBarStore.setRightText("提交", "var(--td-brand-color)", submitRecord);
}
try {
await loadPageData();
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
watch(config, value => {
if (value) {
applyConfig(value);
}
});
</script>
<style scoped lang="less">
.package-purchase {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
flex-direction: column;
.form {
padding: .5rem 0;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.4rem;
.customer .actions {
gap: .5rem;
display: flex;
}
.code-scanner-wrapper,
.photo-part {
padding: 1rem;
}
.textarea {
width: 100%;
}
.customer-profile {
margin: 1rem;
}
.saleable-tip {
padding: 0 1rem .5rem;
font-size: .8125rem;
line-height: 1.5;
}
.time-actions {
gap: .5rem;
display: flex;
align-items: center;
justify-content: flex-end;
}
.package-purchase-form {
gap: .75rem;
display: grid;
margin-top: 1rem;
.config-summary {
gap: .25rem;
margin: 0 1rem;
padding: .5rem .75rem;
display: flex;
background: var(--td-error-color-1);
border-radius: .75rem;
flex-direction: column;
.header {
display: flex;
justify-content: space-between;
.price {
display: flex;
align-items: baseline;
.label {
font-size: .75rem;
}
.value {
font-size: 1.75rem;
line-height: 1;
}
}
}
}
.items {
gap: .5rem;
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;
}
&::after {
display: none;
}
.content {
display: flex;
height: 2rem;
align-items: center;
justify-content: space-between;
.item-title {
gap: .375rem;
min-width: 0;
display: flex;
align-items: center;
}
.t-input {
min-width: 0;
flex: 1;
}
.remove {
flex: none;
}
.name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.unit {
font-size: 1rem;
}
}
}
}
}
.file-input {
display: none;
}
}
:deep(.package-purchase-form .avatar .t-image__img) {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.config-selector,
.selected-customer-popup {
height: 70vh;
overflow: auto;
padding-top: 1rem;
border-radius: 1.25rem 1.25rem 0 0;
.popup-header {
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
}
}
.list {
margin-top: 1rem;
}
}
:global(html.theme-dark) {
.package-purchase {
:deep(.form-item .t-form__label) {
color: #94a3b8;
}
.form {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
:deep(.textarea .t-textarea__inner) {
color: #e2e8f0;
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
.saleable-tip {
color: #fbbf24;
}
}
}
</style>