639 lines
17 KiB
Vue
639 lines
17 KiB
Vue
<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.productId}-${item.productUnitId}`"
|
||
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.productId === item.productId && configItem.productUnitId === item.productUnitId);
|
||
return configItem ? `${configItem.productNameSnapshot || "未命名产品"}` : "产品权益";
|
||
}
|
||
|
||
function getItemName(item: PackagePurchaseItem): string {
|
||
return config.value?.itemList?.find(configItem => configItem.productId === item.productId && configItem.productUnitId === item.productUnitId)?.productNameSnapshot || "未命名产品";
|
||
}
|
||
|
||
function getItemPhotoURL(item: PackagePurchaseItem): string {
|
||
const configItem = config.value?.itemList?.find(configItem => configItem.productId === item.productId && configItem.productUnitId === item.productUnitId);
|
||
return getProductPhotoURL(configItem?.product || {});
|
||
}
|
||
|
||
function getItemUnit(item: PackagePurchaseItem): string {
|
||
return config.value?.itemList?.find(configItem => configItem.productId === item.productId && configItem.productUnitId === item.productUnitId)?.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?.id) {
|
||
return;
|
||
}
|
||
await settingStore.playRegisterSuccessSound();
|
||
Message.success("套餐购买已提交");
|
||
await router.replace(`/management/customer-packages/${result.id}`);
|
||
} 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>
|