Files
gao-customer-h5/src/pages/package/PackagePurchase.vue
T
2026-09-07 12:59:27 +08:00

644 lines
17 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
/>
<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">
<span class="name" v-text="getItemLabel(item)" />
<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)" />
</template>
</t-input>
</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="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 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 { getErrorMessage } from "@/utils/error";
import { formatPackageAmount } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo";
import { getProductPhotoURL } 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 config = ref<GaoPackageConfig>();
// ---------- 客户 ----------
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
});
function getItemLabel(item: PackagePurchaseItem): string {
const configItem = config.value?.itemList?.find(configItem => configItem.skuId === item.skuId && configItem.skuUnitId === item.skuUnitId);
return configItem ? `${configItem.productNameSnapshot || "未命名产品"} · ${configItem.skuNameSnapshot || configItem.specSummarySnapshot || "默认 SKU"}` : "产品权益";
}
function getItemName(item: PackagePurchaseItem): string {
return config.value?.itemList?.find(configItem => configItem.skuId === item.skuId && configItem.skuUnitId === item.skuUnitId)?.productNameSnapshot || "未命名产品";
}
function getItemPhotoURL(item: PackagePurchaseItem): string {
const configItem = config.value?.itemList?.find(configItem => configItem.skuId === item.skuId && configItem.skuUnitId === item.skuUnitId);
return getProductPhotoURL(configItem?.sku || {});
}
function getItemUnit(item: PackagePurchaseItem): string {
return config.value?.itemList?.find(configItem => configItem.skuId === item.skuId && configItem.skuUnitId === item.skuUnitId)?.unitSnapshot || "";
}
function updateItemQuantity(index: number, value: string | number): void {
packagePurchaseItemList.value = packagePurchaseItemList.value.map((item, itemIndex) => itemIndex === index ? {
...item,
totalQuantity: String(value)
} : item);
}
async function submitRecord(): Promise<void> {
if (isSaving.value) {
return;
}
if (!validatePurchaseForm()) {
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) {
return;
}
config.value = await PackageAPI.configDetail(configId.value);
applyConfig(config.value);
}
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;
.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;
}
.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>