add package
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.customerPackage.cancel" class="package-operation-edit">
|
||||
<t-cell-group v-if="customerPackage" title="待取消套餐" theme="card" v-entrance-transition>
|
||||
<t-cell title="客户" :note="customerPackage.customer?.name || customerPackage.customerId || '未知客户'" />
|
||||
<t-cell title="套餐" :note="customerPackage.packageNameSnapshot || '未命名套餐'" />
|
||||
<t-cell title="当前状态" :note="getCustomerPackageStatusText(customerPackage.status)" />
|
||||
</t-cell-group>
|
||||
<t-form class="form" label-align="left" v-entrance-transition="70">
|
||||
<t-form-item label="取消原因" required-mark>
|
||||
<t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入取消原因" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<t-empty v-if="!customerPackage" :description="isLoading ? '正在加载套餐' : '套餐不存在'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { GaoCustomerPackage } from "@/types/Package";
|
||||
import { getCustomerPackageStatusText } from "@/utils/package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "CustomerPackageCancel" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const navBarStore = useNavBarStore();
|
||||
const customerPackage = ref<GaoCustomerPackage>();
|
||||
const reason = ref("");
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
customerPackage.value = await PackageAPI.customerPackageDetail(id);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "套餐加载失败"));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
if (!customerPackage.value?.id || !reason.value.trim()) {
|
||||
Message.warning("请输入取消原因");
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await PackageAPI.cancel(customerPackage.value.id, reason.value.trim());
|
||||
Message.success("客户套餐已取消");
|
||||
await router.replace(`/management/customer-packages/${customerPackage.value.id}`);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "套餐取消失败,请刷新后确认状态"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (userStore.hasPermissions(permissionCodes.customerPackage.cancel)) {
|
||||
navBarStore.setRightText("确认取消", "var(--td-danger-color)", save);
|
||||
}
|
||||
await loadData();
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-operation-edit {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.packageDelivery.create" class="package-operation-edit">
|
||||
<t-cell-group v-if="customerPackage" title="发放前状态(以提交时后端校验为准)" theme="card" v-entrance-transition>
|
||||
<t-cell title="客户套餐" :note="customerPackage.packageNameSnapshot || '未命名套餐'" />
|
||||
<t-cell title="收款状态" :note="getPaymentStatusText(customerPackage.paymentStatus)" />
|
||||
</t-cell-group>
|
||||
<section v-if="customerPackage" class="items" v-entrance-transition="70">
|
||||
<div class="section-title"><strong>本次发放权益</strong><span>只能提交不超过可发数量的产品</span></div>
|
||||
<div v-for="item in customerPackage.itemList || []" :key="item.id" class="item-card">
|
||||
<div class="item-title"><strong v-text="`${item.productNameSnapshot}(${item.unitSnapshot})`" /><span v-text="`可发 ${getPackageQuantityText(item.availableQuantity)}`" /></div>
|
||||
<t-input v-model.trim="quantityMap[item.id || '']" type="number" borderless placeholder="本次发放数量,不填表示不发放" />
|
||||
</div>
|
||||
</section>
|
||||
<t-form v-if="customerPackage" class="form" label-align="left" label-width="6rem" v-entrance-transition="140">
|
||||
<t-form-item label="发放时间" arrow @click="deliveredPickerVisible = true">
|
||||
<span class="picker-text" v-text="deliveredAtText || '使用当前时间'" />
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="发放备注" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<package-photo-field v-if="customerPackage" v-model="photoItems" required title="发放照片" />
|
||||
<t-empty v-if="!customerPackage" :description="isLoading ? '正在加载套餐' : '套餐不存在'" />
|
||||
<t-popup v-model="deliveredPickerVisible" placement="bottom">
|
||||
<t-date-time-picker v-model="deliveredDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="发放时间" @cancel="deliveredPickerVisible = false" @confirm="confirmDeliveredAt" />
|
||||
</t-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import PackagePhotoField from "@/components/PackagePhotoField.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { GaoCustomerPackage, PackageAttachment } from "@/types/Package";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import { comparePackageQuantity, createPackageRequestNo, getPackageQuantityText, getPaymentStatusText, normalizePackageQuantity } from "@/utils/package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "CustomerPackageDelivery" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const navBarStore = useNavBarStore();
|
||||
const customerPackage = ref<GaoCustomerPackage>();
|
||||
const quantityMap = reactive<Record<string, string>>({});
|
||||
const photoItems = ref<PhotoItem[]>([]);
|
||||
const remark = ref("");
|
||||
const deliveredAtText = ref("");
|
||||
const deliveredDraft = ref("");
|
||||
const deliveredPickerVisible = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
|
||||
function confirmDeliveredAt(value: string | number): void {
|
||||
deliveredAtText.value = String(value);
|
||||
deliveredPickerVisible.value = false;
|
||||
}
|
||||
|
||||
function resolveTimestamp(value: string): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = new Date(value.replace(" ", "T")).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
function buildAttachments(): PackageAttachment[] {
|
||||
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO", name: item.attachment?.name }] : []);
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
customerPackage.value = await PackageAPI.customerPackageDetail(id);
|
||||
for (const item of customerPackage.value.itemList || []) {
|
||||
if (item.id) {
|
||||
quantityMap[item.id] = "";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "套餐加载失败"));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateItems(): Array<{ customerPackageItemId: string; quantity: string }> | undefined {
|
||||
const result: Array<{ customerPackageItemId: string; quantity: string }> = [];
|
||||
for (const item of customerPackage.value?.itemList || []) {
|
||||
if (!item.id) {
|
||||
continue;
|
||||
}
|
||||
const text = quantityMap[item.id] || "";
|
||||
if (!text.trim()) {
|
||||
continue;
|
||||
}
|
||||
const quantity = normalizePackageQuantity(text);
|
||||
if (!quantity || quantity === "0") {
|
||||
Message.warning(`${item.productNameSnapshot} 的数量必须是正数,最多三位小数`);
|
||||
return undefined;
|
||||
}
|
||||
if (comparePackageQuantity(quantity, item.availableQuantity) > 0) {
|
||||
Message.warning(`${item.productNameSnapshot} 的发放数量超过当前可发数量,请刷新后重新确认`);
|
||||
return undefined;
|
||||
}
|
||||
result.push({ customerPackageItemId: item.id, quantity });
|
||||
}
|
||||
if (!result.length) {
|
||||
Message.warning("请至少填写一项本次发放数量");
|
||||
return undefined;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
const customerPackageId = customerPackage.value?.id;
|
||||
const itemList = validateItems();
|
||||
if (!customerPackageId || !itemList) {
|
||||
return;
|
||||
}
|
||||
if (customerPackage.value?.paymentStatus !== "PAID") {
|
||||
Message.warning("套餐未付清,不能提交发放");
|
||||
return;
|
||||
}
|
||||
if (!photoItems.value.some(item => item.tempFileId)) {
|
||||
Message.warning("发放必须上传至少一张照片");
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const result = await PackageAPI.createDelivery({
|
||||
requestNo: createPackageRequestNo(),
|
||||
customerPackageId,
|
||||
deliveredAt: resolveTimestamp(deliveredAtText.value),
|
||||
itemList,
|
||||
attachmentList: buildAttachments(),
|
||||
remark: remark.value.trim() || null
|
||||
});
|
||||
Message.success("发放单已提交,等待核销");
|
||||
await PackageAPI.customerPackageDetail(customerPackageId);
|
||||
if (result.id) {
|
||||
await router.replace(`/management/package-deliveries/${result.id}`);
|
||||
return;
|
||||
}
|
||||
await router.replace(`/management/customer-packages/${customerPackageId}`);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "发放单提交失败,请刷新后确认状态"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (userStore.hasPermissions(permissionCodes.packageDelivery.create)) {
|
||||
navBarStore.setRightText("提交发放", "var(--td-brand-color)", save);
|
||||
}
|
||||
await loadData();
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-operation-edit {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.picker-text {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.items,
|
||||
.package-photo-field {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: var(--td-text-color-secondary);
|
||||
margin-bottom: .5rem;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.section-title span {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
gap: .5rem;
|
||||
padding: .75rem;
|
||||
margin-bottom: .75rem;
|
||||
display: grid;
|
||||
background: var(--td-bg-color-container);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.item-title span {
|
||||
color: var(--td-brand-color);
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.customerPackage.read" class="customer-package-detail">
|
||||
<template v-if="customerPackage">
|
||||
<customer-profile-card class="customer" :customer="customerPackage.customer" v-entrance-transition />
|
||||
<section class="hero" v-entrance-transition="40">
|
||||
<div class="title-row">
|
||||
<h2 v-text="customerPackage.packageNameSnapshot || '未命名套餐'" />
|
||||
<t-tag :content="getCustomerPackageStatusText(customerPackage.status)" variant="light-outline" :theme="getStatusTheme(customerPackage.status)" />
|
||||
</div>
|
||||
<div class="meta" v-text="customerPackage.purchasedAt ? `购买于 ${Time.toDateTime(customerPackage.purchasedAt)}` : '购买时间未知'" />
|
||||
</section>
|
||||
<t-cell-group title="金额与状态" theme="card" v-entrance-transition="70">
|
||||
<t-cell title="标准价快照" :note="formatPackageAmount(customerPackage.standardAmountSnapshot)" />
|
||||
<t-cell title="成交价" :note="formatPackageAmount(customerPackage.saleAmount)" />
|
||||
<t-cell title="已核销收款" :note="formatPackageAmount(customerPackage.paidAmount)" />
|
||||
<t-cell title="待核销收款" :note="formatPackageAmount(customerPackage.pendingPaidAmount)" />
|
||||
<t-cell title="有效退款" :note="formatPackageAmount(customerPackage.refundedAmount)" />
|
||||
<t-cell title="收款状态" :note="getPaymentStatusText(customerPackage.paymentStatus)" />
|
||||
<t-cell title="退款状态" :note="getRefundStatusText(customerPackage.refundStatus)" />
|
||||
</t-cell-group>
|
||||
<t-cell-group title="产品权益" theme="card" v-entrance-transition="140">
|
||||
<t-cell v-for="item in customerPackage.itemList || []" :key="item.id" :title="item.productNameSnapshot" :description="`${getPackageQuantityText(item.deliveredQuantity)} ${item.unitSnapshot} 已核销`">
|
||||
<template #note><div class="quantity" v-text="`可发 ${getPackageQuantityText(item.availableQuantity)}|待核销 ${getPackageQuantityText(item.pendingQuantity)}`" /></template>
|
||||
</t-cell>
|
||||
<div v-if="!customerPackage.itemList?.length" class="empty" v-text="'暂无产品权益'" />
|
||||
</t-cell-group>
|
||||
<t-cell-group v-if="customerPackage.purchaseEventRecord" title="购买记录照片" theme="card" v-entrance-transition="210">
|
||||
<t-cell
|
||||
v-if="canReadEventRecord && customerPackage.purchaseEventRecord.id"
|
||||
title="购买登记记录"
|
||||
note="查看关联的事件登记记录"
|
||||
arrow
|
||||
hover
|
||||
@click="toPurchaseEventRecord"
|
||||
/>
|
||||
<photo-scroller :model-value="purchasePhotoURLList" :photo-items="purchasePhotoItems" title="购买照片" :show-add="false" :show-remove="false" :show-viewer-actions="false" />
|
||||
</t-cell-group>
|
||||
<section class="actions" v-entrance-transition="240">
|
||||
<t-button v-if="canReceivePayment" theme="primary" variant="outline" @click="toPayment">登记收款</t-button>
|
||||
<t-button v-if="canCreateDelivery" theme="primary" variant="outline" @click="toDelivery">创建发放单</t-button>
|
||||
<t-button v-if="canCancelPackage" theme="default" variant="outline" @click="toCancel">取消套餐</t-button>
|
||||
<t-button v-if="canRefund" theme="danger" variant="outline" @click="toRefund">处理退款</t-button>
|
||||
</section>
|
||||
<t-cell-group title="业务记录" theme="card" v-entrance-transition="280">
|
||||
<t-cell v-if="canReadDelivery" title="发放历史" :note="`${customerPackage.deliveryList?.length || 0} 条`" arrow hover @click="toDeliveryList" />
|
||||
<t-cell v-if="canReadFund" title="资金历史" :note="`${customerPackage.fundRecordList?.length || 0} 条`" arrow hover @click="toFundList" />
|
||||
</t-cell-group>
|
||||
<t-cell-group v-if="customerPackage.deliveryList?.length" title="最近发放记录" theme="card" v-entrance-transition="320">
|
||||
<t-cell
|
||||
v-for="item in customerPackage.deliveryList"
|
||||
:key="item.id"
|
||||
:title="`${getDeliveryOperationText(item.operationType)} · ${getDeliveryStatusText(item.status)}`"
|
||||
:description="item.deliveredAt ? Time.toDateTime(item.deliveredAt) : '时间未知'"
|
||||
arrow
|
||||
hover
|
||||
@click="toDeliveryDetail(item)"
|
||||
>
|
||||
<template #note><span v-text="getDeliveryItemSummary(item)" /></template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<t-cell-group v-if="customerPackage.fundRecordList?.length" title="最近资金记录" theme="card" v-entrance-transition="360">
|
||||
<t-cell
|
||||
v-for="item in customerPackage.fundRecordList"
|
||||
:key="item.id"
|
||||
:title="`${getFundOperationText(item.operationType)} · ${getFundStatusText(item.status)}`"
|
||||
:description="item.occurredAt ? Time.toDateTime(item.occurredAt) : '时间未知'"
|
||||
arrow
|
||||
hover
|
||||
@click="toFundDetail(item)"
|
||||
>
|
||||
<template #note><span v-text="formatPackageAmount(item.amount)" /></template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
</template>
|
||||
<t-empty v-else :description="isLoading ? '正在加载客户套餐' : '客户套餐不存在'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Time } from "timi-web";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||
import type { GaoCustomerPackage, GaoFundRecord, GaoPackageDelivery, CustomerPackageStatus } from "@/types/Package";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import { getAttachmentReadURL } from "@/utils/attachment";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import {
|
||||
formatPackageAmount,
|
||||
getCustomerPackageStatusText,
|
||||
getDeliveryOperationText,
|
||||
getDeliveryStatusText,
|
||||
getFundOperationText,
|
||||
getFundStatusText,
|
||||
getPackageQuantityText,
|
||||
getPaymentStatusText,
|
||||
getRefundStatusText
|
||||
} from "@/utils/package";
|
||||
import { Message } from "@/utils/message";
|
||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "CustomerPackageDetail" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
const customerPackage = ref<GaoCustomerPackage>();
|
||||
const isLoading = ref(false);
|
||||
const canReceivePayment = computed(() => customerPackage.value?.status === "ACTIVE" && customerPackage.value.paymentStatus !== "PAID" && userStore.hasPermissions(permissionCodes.fundReceive.create));
|
||||
const canCreateDelivery = computed(() => customerPackage.value?.status === "ACTIVE" && customerPackage.value.paymentStatus === "PAID" && userStore.hasPermissions(permissionCodes.packageDelivery.create));
|
||||
const canCancelPackage = computed(() => customerPackage.value?.status === "ACTIVE" && userStore.hasPermissions(permissionCodes.customerPackage.cancel));
|
||||
const canRefund = computed(() => customerPackage.value?.status === "CANCELLED" && customerPackage.value.refundStatus === "PENDING" && userStore.hasPermissions(permissionCodes.fund.refund));
|
||||
const canReadEventRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.read));
|
||||
const canReadDelivery = computed(() => userStore.hasPermissions(permissionCodes.packageDelivery.read));
|
||||
const canReadFund = computed(() => userStore.hasPermissions(permissionCodes.fundRecord.read));
|
||||
const purchasePhotoItems = computed<PhotoItem[]>(() => (customerPackage.value?.purchaseEventRecord?.attachmentList || []).filter(item => !!item.id).map(item => ({
|
||||
attachment: item,
|
||||
attachType: item.attachType || "PHOTO",
|
||||
previewUrl: getAttachmentReadURL(item.id || ""),
|
||||
url: getAttachmentReadURL(item.id || "")
|
||||
})));
|
||||
const purchasePhotoURLList = computed(() => purchasePhotoItems.value.map(item => item.url));
|
||||
|
||||
function getStatusTheme(status?: CustomerPackageStatus): "success" | "default" {
|
||||
return status === "ACTIVE" ? "success" : "default";
|
||||
}
|
||||
|
||||
function getDeliveryItemSummary(item: GaoPackageDelivery): string {
|
||||
return (item.itemList || []).map(detail => `${detail.productNameSnapshot || "产品"} ${getPackageQuantityText(detail.quantity)} ${detail.unitSnapshot || ""}`).join("、") || "无明细";
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
if (userStore.hasRoles(gaoRoleCodes.globalManager)) {
|
||||
await currentStoreStore.loadStoreList();
|
||||
} else {
|
||||
await currentStoreStore.loadLoginStore();
|
||||
}
|
||||
customerPackage.value = await PackageAPI.customerPackageDetail(id);
|
||||
} catch (error) {
|
||||
customerPackage.value = undefined;
|
||||
Message.error(getErrorMessage(error, "客户套餐加载失败"));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toPayment(): Promise<void> {
|
||||
if (customerPackage.value?.id) {
|
||||
await router.push(`/management/customer-packages/${customerPackage.value.id}/payment`);
|
||||
}
|
||||
}
|
||||
|
||||
async function toPurchaseEventRecord(): Promise<void> {
|
||||
const id = customerPackage.value?.purchaseEventRecord?.id;
|
||||
if (id && canReadEventRecord.value) {
|
||||
await router.push(`/management/registration-event-records/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function toDelivery(): Promise<void> {
|
||||
if (customerPackage.value?.id) {
|
||||
await router.push(`/management/customer-packages/${customerPackage.value.id}/delivery/create`);
|
||||
}
|
||||
}
|
||||
|
||||
async function toCancel(): Promise<void> {
|
||||
if (customerPackage.value?.id) {
|
||||
await router.push(`/management/customer-packages/${customerPackage.value.id}/cancel`);
|
||||
}
|
||||
}
|
||||
|
||||
async function toRefund(): Promise<void> {
|
||||
if (customerPackage.value?.id) {
|
||||
await router.push(`/management/customer-packages/${customerPackage.value.id}/refund`);
|
||||
}
|
||||
}
|
||||
|
||||
async function toDeliveryList(): Promise<void> {
|
||||
if (customerPackage.value?.id) {
|
||||
await router.push({ path: "/management/package-deliveries", query: { customerPackageId: customerPackage.value.id } });
|
||||
}
|
||||
}
|
||||
|
||||
async function toFundList(): Promise<void> {
|
||||
if (customerPackage.value?.id) {
|
||||
await router.push({ path: "/management/fund-records", query: { customerPackageId: customerPackage.value.id } });
|
||||
}
|
||||
}
|
||||
|
||||
async function toDeliveryDetail(item: GaoPackageDelivery): Promise<void> {
|
||||
if (item.id) {
|
||||
await router.push(`/management/package-deliveries/${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function toFundDetail(item: GaoFundRecord): Promise<void> {
|
||||
if (item.id) {
|
||||
await router.push(`/management/fund-records/${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
watch(() => route.params.id, loadData);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.customer-package-detail {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.customer {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin: 0 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--td-bg-color-container);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1rem;
|
||||
|
||||
.title-row {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--td-text-color-secondary);
|
||||
margin-top: .5rem;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.quantity {
|
||||
color: var(--td-brand-color);
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: .5rem;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.t-button {
|
||||
flex: 1 1 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.photo-scroller) {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.customerPackage.read" class="customer-package-list">
|
||||
<div class="filters">
|
||||
<t-input v-model.trim="keyword" class="keyword" borderless clearable placeholder="搜索客户、编码或套餐名称" @enter="reload" />
|
||||
<t-button theme="primary" size="small" @click="reload">搜索</t-button>
|
||||
</div>
|
||||
<t-dropdown-menu>
|
||||
<t-dropdown-item v-model="status" label="套餐状态" :options="statusOptions" @change="selectStatus" />
|
||||
<t-dropdown-item v-model="paymentStatus" label="收款状态" :options="paymentOptions" @change="selectPaymentStatus" />
|
||||
</t-dropdown-menu>
|
||||
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
|
||||
<t-cell-group v-if="packageList.length" theme="card" class="list">
|
||||
<t-cell v-for="(item, index) in packageList" :key="item.id" class="item" :arrow="!!item.id" :hover="!!item.id" v-entrance-transition="index * 30" @click="toDetail(item)">
|
||||
<template #title>
|
||||
<div class="title-row">
|
||||
<strong v-text="item.packageNameSnapshot || '未命名套餐'" />
|
||||
<t-tag :content="getCustomerPackageStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="description">
|
||||
<span v-text="item.customer?.name || item.customer?.code || item.customerId || '未知客户'" />
|
||||
<span v-text="formatPackageAmount(item.saleAmount)" />
|
||||
<span v-text="getPaymentStatusText(item.paymentStatus)" />
|
||||
<span v-text="item.purchasedAt ? Time.toPassedDateTime(item.purchasedAt) : '未知时间'" />
|
||||
</div>
|
||||
</template>
|
||||
<template #note>
|
||||
<div class="paid" v-text="`已收 ${formatPackageAmount(item.paidAmount)}`" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty" v-text="loading ? '正在加载客户套餐' : '暂无客户套餐'" />
|
||||
<div class="load-state" v-text="loadStateText" />
|
||||
</t-pull-down-refresh>
|
||||
</div>
|
||||
<nav-bar-action-menu v-if="canCreate" :actions="actionList" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type DropdownValue } from "tdesign-mobile-vue";
|
||||
import { Time } from "timi-web";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { CustomerPackageStatus, GaoCustomerPackage, PaymentStatus } from "@/types/Package";
|
||||
import type { NavBarAction } from "@/types/NavBarAction";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { formatPackageAmount, getCustomerPackageStatusText, getPaymentStatusText } from "@/utils/package";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "CustomerPackageList" });
|
||||
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.customerPackage.create));
|
||||
const actionList = computed<NavBarAction[]>(() => canCreate.value ? [{
|
||||
key: "purchase",
|
||||
text: "购买套餐",
|
||||
icon: "add",
|
||||
theme: "primary",
|
||||
action: toPurchase
|
||||
}] : []);
|
||||
const keyword = ref("");
|
||||
const status = ref("ALL");
|
||||
const paymentStatus = ref("ALL");
|
||||
const packageList = ref<GaoCustomerPackage[]>([]);
|
||||
const pageIndex = ref(0);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const isRefreshing = ref(false);
|
||||
const noMore = ref(false);
|
||||
const statusOptions = [
|
||||
{ label: "全部状态", value: "ALL" },
|
||||
{ label: "进行中", value: "ACTIVE" },
|
||||
{ label: "已履约", value: "FULFILLED" },
|
||||
{ label: "已取消", value: "CANCELLED" }
|
||||
];
|
||||
const paymentOptions = [
|
||||
{ label: "全部收款", value: "ALL" },
|
||||
{ label: "未收款", value: "UNPAID" },
|
||||
{ label: "部分收款", value: "PARTIALLY_PAID" },
|
||||
{ label: "已收款", value: "PAID" }
|
||||
];
|
||||
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (loading.value || noMore.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await PackageAPI.customerPackageList({
|
||||
index: pageIndex.value,
|
||||
size: 15,
|
||||
keyword: keyword.value || undefined,
|
||||
status: status.value === "ALL" ? undefined : status.value as CustomerPackageStatus,
|
||||
paymentStatus: paymentStatus.value === "ALL" ? undefined : paymentStatus.value as PaymentStatus
|
||||
});
|
||||
const list = result.list || [];
|
||||
packageList.value = packageList.value.concat(list);
|
||||
total.value = result.total || 0;
|
||||
pageIndex.value += 1;
|
||||
noMore.value = list.length < 15 || total.value <= packageList.value.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
pageIndex.value = 0;
|
||||
packageList.value = [];
|
||||
total.value = 0;
|
||||
noMore.value = false;
|
||||
await loadMore();
|
||||
}
|
||||
|
||||
async function selectStatus(value: DropdownValue): Promise<void> {
|
||||
if (Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
status.value = String(value);
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function selectPaymentStatus(value: DropdownValue): Promise<void> {
|
||||
if (Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
paymentStatus.value = String(value);
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function toPurchase(): Promise<void> {
|
||||
await router.push("/management/customer-packages/purchase");
|
||||
}
|
||||
|
||||
async function toDetail(item: GaoCustomerPackage): Promise<void> {
|
||||
if (item.id) {
|
||||
await router.push(`/management/customer-packages/${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusTheme(statusValue?: CustomerPackageStatus): "success" | "warning" | "default" {
|
||||
return statusValue === "ACTIVE" ? "success" : statusValue === "CANCELLED" ? "warning" : "default";
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.clearRight();
|
||||
navBarStore.setCustomRightVisible(canCreate.value);
|
||||
try {
|
||||
await reload();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "客户套餐加载失败"));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.customer-package-list {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
|
||||
|
||||
.filters {
|
||||
gap: .5rem;
|
||||
margin: 0 1rem .75rem;
|
||||
display: flex;
|
||||
|
||||
.keyword {
|
||||
flex: 1;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
.title-row,
|
||||
.description {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--td-text-color-secondary);
|
||||
margin-top: .35rem;
|
||||
font-size: .8125rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.paid {
|
||||
color: var(--td-brand-color);
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.fundReceive.create" class="package-operation-edit">
|
||||
<t-cell-group v-if="customerPackage" title="收款信息" theme="card" v-entrance-transition>
|
||||
<t-cell title="客户套餐" :note="customerPackage.packageNameSnapshot || '未命名套餐'" />
|
||||
<t-cell title="成交价" :note="formatPackageAmount(customerPackage.saleAmount)" />
|
||||
<t-cell title="已核销收款" :note="formatPackageAmount(customerPackage.paidAmount)" />
|
||||
<t-cell title="待核销收款" :note="formatPackageAmount(customerPackage.pendingPaidAmount)" />
|
||||
</t-cell-group>
|
||||
<t-form v-if="customerPackage" class="form" label-align="left" label-width="6rem" v-entrance-transition="70">
|
||||
<t-form-item label="本次收款(元)" required-mark>
|
||||
<t-input v-model.trim="amountText" type="number" align="right" borderless placeholder="请输入本次实际收款金额" />
|
||||
</t-form-item>
|
||||
<t-form-item label="发生时间" arrow @click="occurredPickerVisible = true">
|
||||
<span class="picker-text" v-text="occurredAtText || '使用当前时间'" />
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="收款备注" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<package-photo-field v-if="customerPackage" v-model="photoItems" required title="收款凭证照片" />
|
||||
<t-empty v-if="!customerPackage" :description="isLoading ? '正在加载套餐' : '套餐不存在'" />
|
||||
<t-popup v-model="occurredPickerVisible" placement="bottom">
|
||||
<t-date-time-picker v-model="occurredDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="发生时间" @cancel="occurredPickerVisible = false" @confirm="confirmOccurredAt" />
|
||||
</t-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import PackagePhotoField from "@/components/PackagePhotoField.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { GaoCustomerPackage, PackageAttachment } from "@/types/Package";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import { createPackageRequestNo, formatPackageAmount, parsePackageAmount } from "@/utils/package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "CustomerPackagePayment" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const navBarStore = useNavBarStore();
|
||||
const customerPackage = ref<GaoCustomerPackage>();
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const amountText = ref("");
|
||||
const occurredAtText = ref("");
|
||||
const occurredDraft = ref("");
|
||||
const occurredPickerVisible = ref(false);
|
||||
const remark = ref("");
|
||||
const photoItems = ref<PhotoItem[]>([]);
|
||||
|
||||
function resolveTimestamp(value: string): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = new Date(value.replace(" ", "T")).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
function confirmOccurredAt(value: string | number): void {
|
||||
occurredAtText.value = String(value);
|
||||
occurredPickerVisible.value = false;
|
||||
}
|
||||
|
||||
function buildAttachments(): PackageAttachment[] {
|
||||
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO", name: item.attachment?.name }] : []);
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
customerPackage.value = await PackageAPI.customerPackageDetail(id);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "套餐加载失败"));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
const customerPackageId = customerPackage.value?.id;
|
||||
const amount = parsePackageAmount(amountText.value);
|
||||
if (!customerPackageId || amount === undefined || 0 >= amount) {
|
||||
Message.warning("请输入大于 0 的合法收款金额");
|
||||
return;
|
||||
}
|
||||
if (!photoItems.value.some(item => item.tempFileId)) {
|
||||
Message.warning("收款必须上传至少一张凭证照片");
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await PackageAPI.receivePayment({
|
||||
requestNo: createPackageRequestNo(),
|
||||
customerPackageId,
|
||||
amount,
|
||||
occurredAt: resolveTimestamp(occurredAtText.value),
|
||||
attachmentList: buildAttachments(),
|
||||
remark: remark.value.trim() || null
|
||||
});
|
||||
Message.success("收款已提交,等待核销");
|
||||
await router.replace(`/management/customer-packages/${customerPackageId}`);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "收款提交失败,请刷新后确认状态"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (userStore.hasPermissions(permissionCodes.fundReceive.create)) {
|
||||
navBarStore.setRightText("提交收款", "var(--td-brand-color)", save);
|
||||
}
|
||||
await loadData();
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-operation-edit {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.picker-text {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.package-photo-field {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.customerPackage.create" class="customer-package-purchase">
|
||||
<t-form class="form" label-align="left" label-width="6rem" v-entrance-transition>
|
||||
<t-form-item v-if="isGlobalManager" label="所属门店" arrow @click="openStorePicker">
|
||||
<span class="picker-text" v-text="selectedStoreName" />
|
||||
</t-form-item>
|
||||
<t-form-item label="客户" required-mark arrow @click="customerSelectorVisible = true">
|
||||
<span class="picker-text" v-text="selectedCustomer?.name || selectedCustomer?.code || '请选择客户'" />
|
||||
</t-form-item>
|
||||
<customer-profile-card v-if="selectedCustomer" class="customer-profile" :customer="selectedCustomer" />
|
||||
<t-form-item label="购买事件" required-mark arrow @click="openEventPicker">
|
||||
<span class="picker-text" v-text="selectedEvent?.name || '请选择 PACKAGE_PURCHASE 事件'" />
|
||||
</t-form-item>
|
||||
<div v-if="selectedConfig" class="config-summary">
|
||||
<strong v-text="selectedConfig.name || '未命名套餐'" />
|
||||
<span v-text="`标准价:${formatPackageAmount(selectedConfig.standardAmount)}|${getPackageConfigStatusText(selectedConfig.status)}`" />
|
||||
</div>
|
||||
<div v-if="!eventList.length" class="option-tip">
|
||||
<div v-if="!eventList.length">当前门店没有可用的 PACKAGE_PURCHASE 购买事件,请先新增并启用购买事件。</div>
|
||||
</div>
|
||||
<div v-if="selectedEvent && !selectedConfig" class="option-tip">当前购买事件未返回绑定套餐,无法购买,请联系管理员检查事件配置。</div>
|
||||
<div v-if="selectedConfig && !isSelectedConfigSaleable" class="option-tip">事件绑定的套餐当前不可购买,请检查套餐状态和有效期。</div>
|
||||
<t-form-item label="成交价(元)" required-mark>
|
||||
<t-input v-model.trim="saleAmountText" type="number" align="right" borderless :readonly="!canPriceOverride" placeholder="请输入成交价" />
|
||||
</t-form-item>
|
||||
<t-form-item label="购买时间" arrow @click="openPurchaseTime">
|
||||
<span class="picker-text" v-text="purchasedAtText || '使用当前时间'" />
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="购买备注" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<section class="items" v-entrance-transition="70">
|
||||
<div class="section-title"><strong>购买权益明细</strong><span v-if="!canPriceOverride">当前账号无定制权限,明细不可修改</span></div>
|
||||
<div v-for="(item, index) in purchaseItems" :key="item.clientKey" class="item-card">
|
||||
<strong v-text="`权益 ${index + 1}`" />
|
||||
<t-input v-model.trim="item.productName" borderless readonly placeholder="产品名称" />
|
||||
<t-input v-model.trim="item.unit" borderless readonly placeholder="单位" />
|
||||
<t-input v-model.trim="item.totalQuantity" type="number" borderless :readonly="!canPriceOverride" placeholder="总权益数量" />
|
||||
<span v-if="item.remark" class="item-remark" v-text="`备注:${item.remark}`" />
|
||||
</div>
|
||||
<div v-if="!purchaseItems.length" class="empty" v-text="'选择购买事件后加载绑定套餐权益明细'" />
|
||||
</section>
|
||||
<package-photo-field v-model="photoItems" :required="selectedEvent?.requirePhoto === true" title="购买照片" />
|
||||
<customer-selector v-model="selectedCustomerId" v-model:visible="customerSelectorVisible" :store-id="formStoreId" @change="handleCustomerChange" />
|
||||
<t-popup v-model="storePickerVisible" placement="bottom">
|
||||
<t-picker v-model="storeDraft" :columns="storePickerColumns" title="选择门店" @cancel="storePickerVisible = false" @confirm="confirmStore" />
|
||||
</t-popup>
|
||||
<t-popup v-model="eventPickerVisible" placement="bottom">
|
||||
<t-picker v-model="eventDraft" :columns="eventPickerColumns" title="选择购买事件" @cancel="eventPickerVisible = false" @confirm="confirmEvent" />
|
||||
</t-popup>
|
||||
<t-popup v-model="purchasePickerVisible" placement="bottom">
|
||||
<t-date-time-picker v-model="purchaseDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="购买时间" @cancel="purchasePickerVisible = false" @confirm="confirmPurchaseTime" />
|
||||
</t-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Time } from "timi-web";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import EventAPI from "@/api/EventAPI";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import PackagePhotoField from "@/components/PackagePhotoField.vue";
|
||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { EventItem } from "@/types/EventItem";
|
||||
import type { GaoPackageConfig, PackageAttachment, PackagePurchaseItem } from "@/types/Package";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import { createPackageRequestNo, formatPackageAmount, formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
|
||||
type PickerValue = string | number;
|
||||
type PurchaseFormItem = PackagePurchaseItem & { clientKey: string };
|
||||
|
||||
defineOptions({ name: "CustomerPackagePurchase" });
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const navBarStore = useNavBarStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
||||
const canPriceOverride = computed(() => userStore.hasPermissions(permissionCodes.customerPackage.priceOverride));
|
||||
const formStoreId = ref("");
|
||||
const selectedCustomerId = ref("");
|
||||
const selectedCustomer = ref<Customer>();
|
||||
const selectedEventId = ref("");
|
||||
const eventList = ref<EventItem[]>([]);
|
||||
const purchaseItems = ref<PurchaseFormItem[]>([]);
|
||||
const selectedEvent = computed(() => eventList.value.find(item => item.id === selectedEventId.value));
|
||||
const selectedConfig = computed<GaoPackageConfig | undefined>(() => selectedEvent.value?.packageConfig);
|
||||
const isSelectedConfigSaleable = computed(() => {
|
||||
const config = selectedConfig.value;
|
||||
if (!config || config.status !== "ACTIVE") {
|
||||
return false;
|
||||
}
|
||||
const now = Date.now();
|
||||
const beginAt = config.beginAt;
|
||||
const endAt = config.endAt;
|
||||
return (beginAt === undefined || beginAt === null || beginAt <= now)
|
||||
&& (endAt === undefined || endAt === null || now <= endAt);
|
||||
});
|
||||
const saleAmountText = ref("");
|
||||
const purchasedAtText = ref("");
|
||||
const purchaseDraft = ref("");
|
||||
const remark = ref("");
|
||||
const photoItems = ref<PhotoItem[]>([]);
|
||||
const isSaving = ref(false);
|
||||
const customerSelectorVisible = ref(false);
|
||||
const storePickerVisible = ref(false);
|
||||
const eventPickerVisible = ref(false);
|
||||
const purchasePickerVisible = ref(false);
|
||||
const storeDraft = ref<PickerValue[]>([]);
|
||||
const eventDraft = ref<PickerValue[]>([]);
|
||||
const storePickerColumns = computed(() => [[...currentStoreStore.storeList].map(item => ({ label: item.name || item.code || item.id || "未知门店", value: item.id || "" }))]);
|
||||
const eventPickerColumns = computed(() => [[...eventList.value].map(item => ({ label: item.name || "未命名事件", value: item.id || "" }))]);
|
||||
const selectedStoreName = computed(() => currentStoreStore.storeList.find(item => item.id === formStoreId.value)?.name || formStoreId.value || "请选择门店");
|
||||
|
||||
async function loadOptions(): Promise<void> {
|
||||
eventList.value = formStoreId.value
|
||||
? (await EventAPI.listValid(formStoreId.value)).filter(item => item.bizType === "PACKAGE_PURCHASE" && !!item.packageConfigId)
|
||||
: [];
|
||||
if (!eventList.value.some(item => item.id === selectedEventId.value)) {
|
||||
selectedEventId.value = "";
|
||||
resetPurchaseConfig();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCustomerChange(id: string): Promise<void> {
|
||||
selectedCustomerId.value = id;
|
||||
selectedCustomer.value = id ? await CustomerAPI.detail(id) : undefined;
|
||||
}
|
||||
|
||||
function openStorePicker(): void {
|
||||
storeDraft.value = [formStoreId.value];
|
||||
storePickerVisible.value = true;
|
||||
}
|
||||
|
||||
function openEventPicker(): void {
|
||||
if (!eventList.value.length) {
|
||||
Message.warning("当前门店没有 PACKAGE_PURCHASE 类型购买事件,请先启用购买事件");
|
||||
return;
|
||||
}
|
||||
|
||||
eventDraft.value = [selectedEventId.value];
|
||||
eventPickerVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmStore(value: Array<string | number>): Promise<void> {
|
||||
const storeId = String(value[0] || "");
|
||||
if (storeId && storeId !== formStoreId.value) {
|
||||
formStoreId.value = storeId;
|
||||
selectedCustomerId.value = "";
|
||||
selectedCustomer.value = undefined;
|
||||
await loadOptions();
|
||||
}
|
||||
storePickerVisible.value = false;
|
||||
}
|
||||
|
||||
function resetPurchaseConfig(): void {
|
||||
saleAmountText.value = "";
|
||||
purchaseItems.value = [];
|
||||
}
|
||||
|
||||
function applySelectedEvent(): void {
|
||||
const config = selectedConfig.value;
|
||||
if (!config) {
|
||||
resetPurchaseConfig();
|
||||
return;
|
||||
}
|
||||
saleAmountText.value = formatPackageAmountInput(config.standardAmount);
|
||||
purchaseItems.value = (config.itemList || []).map((item, index) => ({
|
||||
clientKey: window.crypto.randomUUID(),
|
||||
productName: item.productName,
|
||||
unit: item.unit,
|
||||
totalQuantity: normalizePackageQuantity(item.defaultQuantity) || "",
|
||||
sort: item.sort ?? index,
|
||||
remark: item.remark ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
function confirmEvent(value: Array<string | number>): void {
|
||||
selectedEventId.value = String(value[0] || "");
|
||||
applySelectedEvent();
|
||||
eventPickerVisible.value = false;
|
||||
}
|
||||
|
||||
function openPurchaseTime(): void {
|
||||
purchaseDraft.value = purchasedAtText.value || Time.formatDateTime(new Date());
|
||||
purchasePickerVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmPurchaseTime(value: string | number): void {
|
||||
purchasedAtText.value = String(value);
|
||||
purchasePickerVisible.value = false;
|
||||
}
|
||||
|
||||
function resolveTimestamp(value: string): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = new Date(value.replace(" ", "T")).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (!selectedCustomerId.value) {
|
||||
Message.warning("请选择客户");
|
||||
return false;
|
||||
}
|
||||
if (!selectedEventId.value || !selectedEvent.value) {
|
||||
Message.warning("请选择 PACKAGE_PURCHASE 类型购买事件");
|
||||
return false;
|
||||
}
|
||||
if (!selectedConfig.value) {
|
||||
Message.warning("当前购买事件未绑定有效套餐配置");
|
||||
return false;
|
||||
}
|
||||
if (!isSelectedConfigSaleable.value) {
|
||||
Message.warning("事件绑定的套餐当前不可购买,请检查套餐状态和有效期");
|
||||
return false;
|
||||
}
|
||||
if (parsePackageAmount(saleAmountText.value) === undefined) {
|
||||
Message.warning("成交价必须是合法的元金额,最多两位小数");
|
||||
return false;
|
||||
}
|
||||
if (!purchaseItems.value.length) {
|
||||
Message.warning("购买权益至少需要一项");
|
||||
return false;
|
||||
}
|
||||
for (const item of purchaseItems.value) {
|
||||
if (!item.productName.trim() || !item.unit.trim() || !normalizePackageQuantity(item.totalQuantity) || normalizePackageQuantity(item.totalQuantity) === "0") {
|
||||
Message.warning("产品名称、单位和正数量不能为空");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (selectedEvent.value.requirePhoto && !photoItems.value.some(item => item.tempFileId)) {
|
||||
Message.warning("当前购买事件要求上传照片");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildAttachments(): PackageAttachment[] {
|
||||
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO", name: item.attachment?.name }] : []);
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (isSaving.value || !validateForm()) {
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const result = await PackageAPI.purchase({
|
||||
requestNo: createPackageRequestNo(),
|
||||
eventId: selectedEventId.value,
|
||||
customerId: selectedCustomerId.value,
|
||||
saleAmount: parsePackageAmount(saleAmountText.value)!,
|
||||
purchasedAt: resolveTimestamp(purchasedAtText.value),
|
||||
itemList: purchaseItems.value.map((item, index) => ({
|
||||
productName: item.productName.trim(),
|
||||
unit: item.unit.trim(),
|
||||
totalQuantity: normalizePackageQuantity(item.totalQuantity)!,
|
||||
sort: item.sort ?? index,
|
||||
remark: item.remark ?? null
|
||||
})),
|
||||
attachmentList: buildAttachments(),
|
||||
remark: remark.value.trim() || null
|
||||
});
|
||||
Message.success("套餐购买已提交");
|
||||
if (result.id) {
|
||||
await router.replace(`/management/customer-packages/${result.id}`);
|
||||
return;
|
||||
}
|
||||
await router.replace("/management/customer-packages");
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "套餐购买失败,请刷新后重试"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
if (isGlobalManager.value) {
|
||||
await currentStoreStore.loadStoreList();
|
||||
} else {
|
||||
await currentStoreStore.loadLoginStore();
|
||||
}
|
||||
formStoreId.value = currentStoreStore.currentStoreId;
|
||||
if (!formStoreId.value) {
|
||||
const store = await currentStoreStore.loadLoginStore();
|
||||
formStoreId.value = store?.id || "";
|
||||
}
|
||||
await loadOptions();
|
||||
const queryEventId = String(route.query.eventId || "");
|
||||
if (queryEventId && eventList.value.some(item => item.id === queryEventId)) {
|
||||
selectedEventId.value = queryEventId;
|
||||
applySelectedEvent();
|
||||
}
|
||||
const queryCustomerId = String(route.query.customerId || "");
|
||||
if (queryCustomerId) {
|
||||
await handleCustomerChange(queryCustomerId);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (userStore.hasPermissions(permissionCodes.customerPackage.create)) {
|
||||
navBarStore.setRightText("提交购买", "var(--td-brand-color)", save);
|
||||
}
|
||||
try {
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "购买页面数据加载失败"));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.customer-package-purchase {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.picker-text {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.customer-profile {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.items,
|
||||
.package-photo-field {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: var(--td-text-color-secondary);
|
||||
margin-bottom: .5rem;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.section-title span {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
gap: .5rem;
|
||||
padding: .75rem;
|
||||
margin-bottom: .75rem;
|
||||
display: grid;
|
||||
background: var(--td-bg-color-container);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.config-summary {
|
||||
gap: .25rem;
|
||||
padding: .75rem 1rem;
|
||||
display: flex;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .8125rem;
|
||||
flex-direction: column;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
|
||||
strong {
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: .9375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.item-remark {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.option-tip {
|
||||
padding: .75rem 1rem;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .8125rem;
|
||||
line-height: 1.5;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.fund.refund" class="package-operation-edit">
|
||||
<t-cell-group v-if="customerPackage" title="退款信息" theme="card" v-entrance-transition>
|
||||
<t-cell title="客户套餐" :note="customerPackage.packageNameSnapshot || '未命名套餐'" />
|
||||
<t-cell title="已核销收款" :note="formatPackageAmount(customerPackage.paidAmount)" />
|
||||
<t-cell title="当前退款状态" :note="getRefundStatusText(customerPackage.refundStatus)" />
|
||||
</t-cell-group>
|
||||
<t-form v-if="customerPackage" class="form" label-align="left" label-width="6rem" v-entrance-transition="70">
|
||||
<t-form-item label="确认无需退款" content-align="right">
|
||||
<t-switch v-model="confirmNone" />
|
||||
</t-form-item>
|
||||
<t-form-item v-if="!confirmNone" label="退款金额(元)" required-mark>
|
||||
<t-input v-model.trim="amountText" type="number" align="right" borderless placeholder="请输入实际退款金额,不自动填充" />
|
||||
</t-form-item>
|
||||
<t-form-item label="退款原因" required-mark>
|
||||
<t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入原因" />
|
||||
</t-form-item>
|
||||
<t-form-item label="发生时间" arrow @click="occurredPickerVisible = true">
|
||||
<span class="picker-text" v-text="occurredAtText || '使用当前时间'" />
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="备注(可选)" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<package-photo-field v-if="customerPackage && !confirmNone" v-model="photoItems" required title="退款凭证照片" />
|
||||
<t-empty v-if="!customerPackage" :description="isLoading ? '正在加载套餐' : '套餐不存在'" />
|
||||
<t-popup v-model="occurredPickerVisible" placement="bottom">
|
||||
<t-date-time-picker v-model="occurredDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="发生时间" @cancel="occurredPickerVisible = false" @confirm="confirmOccurredAt" />
|
||||
</t-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import PackagePhotoField from "@/components/PackagePhotoField.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { GaoCustomerPackage, PackageAttachment } from "@/types/Package";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import { createPackageRequestNo, formatPackageAmount, parsePackageAmount, getRefundStatusText } from "@/utils/package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "CustomerPackageRefund" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const navBarStore = useNavBarStore();
|
||||
const customerPackage = ref<GaoCustomerPackage>();
|
||||
const confirmNone = ref(false);
|
||||
const amountText = ref("");
|
||||
const reason = ref("");
|
||||
const remark = ref("");
|
||||
const occurredAtText = ref("");
|
||||
const occurredDraft = ref("");
|
||||
const occurredPickerVisible = ref(false);
|
||||
const photoItems = ref<PhotoItem[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
|
||||
function confirmOccurredAt(value: string | number): void {
|
||||
occurredAtText.value = String(value);
|
||||
occurredPickerVisible.value = false;
|
||||
}
|
||||
|
||||
function resolveTimestamp(value: string): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = new Date(value.replace(" ", "T")).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
function buildAttachments(): PackageAttachment[] {
|
||||
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO", name: item.attachment?.name }] : []);
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
customerPackage.value = await PackageAPI.customerPackageDetail(id);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "套餐加载失败"));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
const customerPackageId = customerPackage.value?.id;
|
||||
if (!customerPackageId || !reason.value.trim()) {
|
||||
Message.warning("请输入退款原因");
|
||||
return;
|
||||
}
|
||||
const amount = confirmNone.value ? undefined : parsePackageAmount(amountText.value);
|
||||
if (!confirmNone.value && (amount === undefined || 0 >= amount)) {
|
||||
Message.warning("请输入大于 0 的合法退款金额");
|
||||
return;
|
||||
}
|
||||
if (!confirmNone.value && !photoItems.value.some(item => item.tempFileId)) {
|
||||
Message.warning("退款必须上传至少一张凭证照片");
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
if (confirmNone.value) {
|
||||
await PackageAPI.confirmNoRefund({
|
||||
requestNo: createPackageRequestNo(),
|
||||
customerPackageId,
|
||||
occurredAt: resolveTimestamp(occurredAtText.value),
|
||||
reason: reason.value.trim(),
|
||||
remark: remark.value.trim() || null
|
||||
});
|
||||
} else {
|
||||
await PackageAPI.refund({
|
||||
requestNo: createPackageRequestNo(),
|
||||
customerPackageId,
|
||||
amount: amount!,
|
||||
occurredAt: resolveTimestamp(occurredAtText.value),
|
||||
reason: reason.value.trim(),
|
||||
attachmentList: buildAttachments(),
|
||||
remark: remark.value.trim() || null
|
||||
});
|
||||
}
|
||||
Message.success(confirmNone.value ? "已确认无需退款" : "退款已登记");
|
||||
await router.replace(`/management/customer-packages/${customerPackageId}`);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "退款操作失败,请刷新后确认状态"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (userStore.hasPermissions(permissionCodes.fund.refund)) {
|
||||
navBarStore.setRightText("提交处理", "var(--td-brand-color)", save);
|
||||
}
|
||||
await loadData();
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-operation-edit {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.picker-text {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.package-photo-field {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.fundRecord.read" class="package-record-detail">
|
||||
<template v-if="record">
|
||||
<t-cell-group title="资金记录" theme="card" v-entrance-transition>
|
||||
<t-cell title="操作类型" :note="getFundOperationText(record.operationType)" />
|
||||
<t-cell title="状态"><template #note><t-tag :content="getFundStatusText(record.status)" :theme="getStatusTheme(record.status)" variant="light-outline" /></template></t-cell>
|
||||
<t-cell title="金额" :note="formatPackageAmount(record.amount)" />
|
||||
<t-cell title="客户套餐" :note="record.customerPackageId || record.bizId || '未知'" />
|
||||
<t-cell title="发生时间" :note="record.occurredAt ? Time.toDateTime(record.occurredAt) : '未知'" />
|
||||
<t-cell title="提交人" :note="record.operator?.name || record.operator?.nick || record.operatorUserId || '未知'" />
|
||||
<t-cell title="处理人" :note="record.verifier?.name || record.verifier?.nick || record.verifierUserId || '未处理'" />
|
||||
<t-cell v-if="record.sourceRecordId" title="原资金记录" :note="record.sourceRecordId" />
|
||||
<t-cell v-if="record.reason" title="原因" :note="record.reason" />
|
||||
<t-cell v-if="record.remark" title="备注" :note="record.remark" />
|
||||
</t-cell-group>
|
||||
<photo-scroller :model-value="photoURLList" :photo-items="photoItems" title="业务照片" :show-add="false" :show-remove="false" :show-viewer-actions="false" />
|
||||
<section v-if="isPendingReceive || canRevoke" class="actions" v-entrance-transition="70">
|
||||
<t-button v-if="canVerifyReceive" theme="primary" @click="verifyReceive">核销收款</t-button>
|
||||
<t-button v-if="canVerifyReceive" theme="default" variant="outline" @click="openReasonDialog('reject')">拒绝收款</t-button>
|
||||
<t-button v-if="canCancelReceive" theme="default" variant="outline" @click="openReasonDialog('cancel')">撤销收款</t-button>
|
||||
<t-button v-if="canRevoke" theme="danger" variant="outline" @click="toRevoke">资金冲正</t-button>
|
||||
</section>
|
||||
</template>
|
||||
<t-empty v-else :description="isLoading ? '正在加载资金记录' : '资金记录不存在'" />
|
||||
<t-dialog v-model:visible="reasonDialogVisible" :title="reasonTitle" :confirm-btn="reasonActionText" @confirm="submitReason">
|
||||
<t-textarea v-model.trim="reasonText" borderless autosize placeholder="请输入原因" />
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogPlugin } from "tdesign-mobile-vue";
|
||||
import { Time } from "timi-web";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { FundRecordStatus, GaoFundRecord } from "@/types/Package";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import { getAttachmentReadURL } from "@/utils/attachment";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { formatPackageAmount, getFundOperationText, getFundStatusText } from "@/utils/package";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
type ReasonAction = "reject" | "cancel";
|
||||
|
||||
defineOptions({ name: "FundRecordDetail" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const navBarStore = useNavBarStore();
|
||||
const record = ref<GaoFundRecord>();
|
||||
const isLoading = ref(false);
|
||||
const isOperating = ref(false);
|
||||
const reasonDialogVisible = ref(false);
|
||||
const reasonText = ref("");
|
||||
const reasonAction = ref<ReasonAction>("reject");
|
||||
const isPendingReceive = computed(() => record.value?.operationType === "RECEIVE" && record.value.status === "PENDING");
|
||||
const canVerifyReceive = computed(() => isPendingReceive.value && userStore.hasPermissions(permissionCodes.fundReceive.verify));
|
||||
const canCancelReceive = computed(() => isPendingReceive.value && userStore.hasPermissions(permissionCodes.fundReceive.create));
|
||||
const canRevoke = computed(() => record.value?.status === "VERIFIED" && record.value.operationType !== "REVOKE" && userStore.hasPermissions(permissionCodes.fund.revoke));
|
||||
const reasonTitle = computed(() => reasonAction.value === "reject" ? "拒绝收款" : "撤销收款");
|
||||
const reasonActionText = computed(() => reasonAction.value === "reject" ? "拒绝" : "撤销");
|
||||
const photoItems = computed<PhotoItem[]>(() => (record.value?.attachmentList || [])
|
||||
.filter(item => !!item.id)
|
||||
.map(item => ({
|
||||
attachment: item,
|
||||
attachType: item.attachType || "PHOTO",
|
||||
url: getAttachmentReadURL(item.id || "")
|
||||
})));
|
||||
const photoURLList = computed(() => photoItems.value.map(item => item.url));
|
||||
|
||||
function getStatusTheme(status?: FundRecordStatus): "success" | "danger" | "default" {
|
||||
return status === "VERIFIED" ? "success" : status === "REJECTED" ? "danger" : "default";
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
record.value = await PackageAPI.fundRecordDetail(id);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "资金记录加载失败"));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRelatedPackage(): Promise<void> {
|
||||
const customerPackageId = record.value?.customerPackageId || record.value?.bizId;
|
||||
if (customerPackageId) {
|
||||
await PackageAPI.customerPackageDetail(customerPackageId);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyReceive(): Promise<void> {
|
||||
if (!record.value?.id || !canVerifyReceive.value || isOperating.value) {
|
||||
return;
|
||||
}
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "核销收款",
|
||||
content: "核销后该金额才会计入套餐的已收款。确定继续?",
|
||||
confirmBtn: "核销",
|
||||
onConfirm: async () => {
|
||||
isOperating.value = true;
|
||||
try {
|
||||
await PackageAPI.verifyPayment(record.value!.id!);
|
||||
await refreshRelatedPackage();
|
||||
Message.success("收款已核销");
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "收款核销失败,请刷新后确认状态"));
|
||||
} finally {
|
||||
isOperating.value = false;
|
||||
dialog.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openReasonDialog(action: ReasonAction): void {
|
||||
if (!isPendingReceive.value || isOperating.value) {
|
||||
return;
|
||||
}
|
||||
reasonAction.value = action;
|
||||
reasonText.value = "";
|
||||
reasonDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitReason(): Promise<void> {
|
||||
if (!record.value?.id || !reasonText.value.trim() || isOperating.value) {
|
||||
Message.warning("请输入原因");
|
||||
return;
|
||||
}
|
||||
isOperating.value = true;
|
||||
try {
|
||||
if (reasonAction.value === "reject") {
|
||||
await PackageAPI.rejectPayment(record.value.id, reasonText.value.trim());
|
||||
} else {
|
||||
await PackageAPI.cancelPayment(record.value.id, reasonText.value.trim());
|
||||
}
|
||||
reasonDialogVisible.value = false;
|
||||
await refreshRelatedPackage();
|
||||
Message.success(reasonAction.value === "reject" ? "收款已拒绝" : "收款已撤销");
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "收款状态操作失败,请刷新后确认状态"));
|
||||
} finally {
|
||||
isOperating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toRevoke(): Promise<void> {
|
||||
if (record.value?.id && canRevoke.value) {
|
||||
await router.push(`/management/fund-records/${record.value.id}/revoke`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-record-detail {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.photo-scroller) {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: .5rem;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.t-button {
|
||||
flex: 1 1 8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.fundRecord.read" class="package-record-list">
|
||||
<t-dropdown-menu>
|
||||
<t-dropdown-item v-model="operationType" label="操作类型" :options="operationOptions" @change="selectOperation" />
|
||||
<t-dropdown-item v-model="status" label="状态" :options="statusOptions" @change="selectStatus" />
|
||||
</t-dropdown-menu>
|
||||
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
|
||||
<t-cell-group v-if="recordList.length" theme="card">
|
||||
<t-cell
|
||||
v-for="(item, index) in recordList"
|
||||
:key="item.id"
|
||||
:title="`${getFundOperationText(item.operationType)} · ${getFundStatusText(item.status)}`"
|
||||
:description="getDescription(item)"
|
||||
arrow
|
||||
hover
|
||||
v-entrance-transition="index * 30"
|
||||
@click="toDetail(item)"
|
||||
>
|
||||
<template #title>
|
||||
<div class="title-row">
|
||||
<span v-text="getFundOperationText(item.operationType)" />
|
||||
<t-tag :content="getFundStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
|
||||
</div>
|
||||
</template>
|
||||
<template #note><span class="amount" v-text="formatPackageAmount(item.amount)" /></template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty" v-text="loading ? '正在加载资金流水' : '暂无资金流水'" />
|
||||
<div class="load-state" v-text="loadStateText" />
|
||||
</t-pull-down-refresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type DropdownValue } from "tdesign-mobile-vue";
|
||||
import { Time } from "timi-web";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { FundOperationType, FundRecordStatus, GaoFundRecord } from "@/types/Package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { formatPackageAmount, getFundOperationText, getFundStatusText } from "@/utils/package";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "FundRecordList" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const recordList = ref<GaoFundRecord[]>([]);
|
||||
const operationType = ref("ALL");
|
||||
const status = ref("ALL");
|
||||
const pageIndex = ref(0);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const isRefreshing = ref(false);
|
||||
const noMore = ref(false);
|
||||
const operationOptions = [
|
||||
{ label: "全部类型", value: "ALL" },
|
||||
{ label: "线下收款", value: "RECEIVE" },
|
||||
{ label: "退款", value: "REFUND" },
|
||||
{ label: "确认无需退款", value: "CONFIRM_NO_REFUND" },
|
||||
{ label: "资金冲正", value: "REVOKE" }
|
||||
];
|
||||
const statusOptions = [
|
||||
{ label: "全部状态", value: "ALL" },
|
||||
{ label: "待核销", value: "PENDING" },
|
||||
{ label: "已核销", value: "VERIFIED" },
|
||||
{ label: "已拒绝", value: "REJECTED" },
|
||||
{ label: "已撤销", value: "CANCELLED" }
|
||||
];
|
||||
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (loading.value || noMore.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await PackageAPI.fundRecordList({
|
||||
index: pageIndex.value,
|
||||
size: 15,
|
||||
customerPackageId: String(route.query.customerPackageId || "") || undefined,
|
||||
operationType: operationType.value === "ALL" ? undefined : operationType.value as FundOperationType,
|
||||
status: status.value === "ALL" ? undefined : status.value as FundRecordStatus
|
||||
});
|
||||
const list = result.list || [];
|
||||
recordList.value = recordList.value.concat(list);
|
||||
total.value = result.total || 0;
|
||||
pageIndex.value += 1;
|
||||
noMore.value = list.length < 15 || total.value <= recordList.value.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
pageIndex.value = 0;
|
||||
recordList.value = [];
|
||||
total.value = 0;
|
||||
noMore.value = false;
|
||||
await loadMore();
|
||||
}
|
||||
|
||||
async function selectOperation(value: DropdownValue): Promise<void> {
|
||||
if (!Array.isArray(value)) {
|
||||
operationType.value = String(value);
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectStatus(value: DropdownValue): Promise<void> {
|
||||
if (!Array.isArray(value)) {
|
||||
status.value = String(value);
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
function getDescription(item: GaoFundRecord): string {
|
||||
return `${item.customer?.name || item.customerId || "未知客户"}|${item.occurredAt ? Time.toDateTime(item.occurredAt) : "时间未知"}`;
|
||||
}
|
||||
|
||||
function getStatusTheme(statusValue?: FundRecordStatus): "success" | "warning" | "danger" | "default" {
|
||||
return statusValue === "VERIFIED" ? "success" : statusValue === "PENDING" ? "warning" : statusValue === "REJECTED" ? "danger" : "default";
|
||||
}
|
||||
|
||||
async function toDetail(item: GaoFundRecord): Promise<void> {
|
||||
if (item.id) {
|
||||
await router.push(`/management/fund-records/${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.clearRight();
|
||||
try {
|
||||
await reload();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "资金流水加载失败"));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-record-list {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
|
||||
|
||||
.title-row {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.fund.revoke" class="package-operation-edit">
|
||||
<t-cell-group v-if="record" title="原资金记录" theme="card" v-entrance-transition>
|
||||
<t-cell title="操作类型" :note="getFundOperationText(record.operationType)" />
|
||||
<t-cell title="金额" :note="formatPackageAmount(record.amount)" />
|
||||
<t-cell title="原状态" :note="getFundStatusText(record.status)" />
|
||||
<t-cell title="客户套餐" :note="record.customerPackageId || record.bizId || '未知'" />
|
||||
</t-cell-group>
|
||||
<t-form v-if="record" class="form" label-align="left" label-width="6rem" v-entrance-transition="70">
|
||||
<t-form-item label="冲正原因" required-mark><t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入冲正原因" /></t-form-item>
|
||||
<t-form-item label="冲正时间" arrow @click="pickerVisible = true"><span class="picker-text" v-text="occurredAtText || '使用当前时间'" /></t-form-item>
|
||||
<t-form-item label="备注"><t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="备注(可选)" /></t-form-item>
|
||||
</t-form>
|
||||
<package-photo-field v-if="record" v-model="photoItems" required title="冲正凭证照片" />
|
||||
<t-empty v-if="!record" :description="isLoading ? '正在加载资金记录' : '资金记录不存在'" />
|
||||
<t-popup v-model="pickerVisible" placement="bottom">
|
||||
<t-date-time-picker v-model="draftTime" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="冲正时间" @cancel="pickerVisible = false" @confirm="confirmTime" />
|
||||
</t-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import PackagePhotoField from "@/components/PackagePhotoField.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { GaoFundRecord, PackageAttachment } from "@/types/Package";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import { createPackageRequestNo, formatPackageAmount, getFundOperationText, getFundStatusText } from "@/utils/package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "FundRecordRevoke" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const navBarStore = useNavBarStore();
|
||||
const record = ref<GaoFundRecord>();
|
||||
const reason = ref("");
|
||||
const remark = ref("");
|
||||
const occurredAtText = ref("");
|
||||
const draftTime = ref("");
|
||||
const pickerVisible = ref(false);
|
||||
const photoItems = ref<PhotoItem[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
|
||||
function confirmTime(value: string | number): void {
|
||||
occurredAtText.value = String(value);
|
||||
pickerVisible.value = false;
|
||||
}
|
||||
|
||||
function resolveTimestamp(value: string): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = new Date(value.replace(" ", "T")).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
function buildAttachments(): PackageAttachment[] {
|
||||
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
record.value = await PackageAPI.fundRecordDetail(id);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "资金记录加载失败"));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
if (!record.value?.id || record.value.status !== "VERIFIED" || record.value.operationType === "REVOKE") {
|
||||
Message.warning("只有已核销且非冲正的资金记录可以冲正");
|
||||
return;
|
||||
}
|
||||
if (!reason.value.trim()) {
|
||||
Message.warning("请输入冲正原因");
|
||||
return;
|
||||
}
|
||||
if (!photoItems.value.some(item => item.tempFileId)) {
|
||||
Message.warning("资金冲正必须上传至少一张照片");
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const result = await PackageAPI.revokeFundRecord({
|
||||
requestNo: createPackageRequestNo(),
|
||||
fundRecordId: record.value.id,
|
||||
occurredAt: resolveTimestamp(occurredAtText.value),
|
||||
reason: reason.value.trim(),
|
||||
attachmentList: buildAttachments(),
|
||||
remark: remark.value.trim() || null
|
||||
});
|
||||
Message.success("资金记录已冲正");
|
||||
const customerPackageId = record.value.customerPackageId || record.value.bizId;
|
||||
if (customerPackageId) {
|
||||
await router.replace(`/management/customer-packages/${customerPackageId}`);
|
||||
return;
|
||||
}
|
||||
await router.replace(`/management/fund-records/${result.id || record.value.id}`);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "资金冲正失败,请刷新后确认状态"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (userStore.hasPermissions(permissionCodes.fund.revoke)) {
|
||||
navBarStore.setRightText("提交冲正", "var(--td-danger-color)", save);
|
||||
}
|
||||
await loadData();
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-operation-edit {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.picker-text {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.package-photo-field {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.packageConfig.read" class="package-config-detail">
|
||||
<template v-if="config">
|
||||
<section class="hero" v-entrance-transition>
|
||||
<div class="title-row"><h2 v-text="config.name || '未命名套餐'" /><t-tag :content="getPackageConfigStatusText(config.status)" variant="light-outline" :theme="getStatusTheme(config.status)" /></div>
|
||||
<p v-text="config.description || '暂无套餐说明'" />
|
||||
</section>
|
||||
<t-cell-group title="基本信息" theme="card" v-entrance-transition="70">
|
||||
<t-cell title="所属门店" :note="storeName" />
|
||||
<t-cell title="标准价" :note="formatPackageAmount(config.standardAmount)" />
|
||||
<t-cell title="有效期" :note="formatValidity" />
|
||||
<t-cell title="最后修改人" :note="config.updater?.name || config.updater?.nick || '未知'" />
|
||||
</t-cell-group>
|
||||
<t-cell-group title="产品权益" theme="card" v-entrance-transition="140">
|
||||
<t-cell
|
||||
v-for="item in config.itemList || []"
|
||||
:key="item.id || `${item.productName}-${item.unit}`"
|
||||
:title="item.productName"
|
||||
:note="`${item.defaultQuantity} ${item.unit}${item.remark ? `|${item.remark}` : ''}`"
|
||||
/>
|
||||
<div v-if="!config.itemList?.length" class="empty" v-text="'暂无产品权益'" />
|
||||
</t-cell-group>
|
||||
<t-button v-if="canDelete" class="delete-button" theme="danger" variant="outline" @click="removeConfig">删除配置</t-button>
|
||||
</template>
|
||||
<t-empty v-else :description="isLoading ? '正在加载套餐配置' : '套餐配置不存在'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogPlugin } from "tdesign-mobile-vue";
|
||||
import { Time } from "timi-web";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { GaoPackageConfig, PackageConfigStatus } from "@/types/Package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { formatPackageAmount, getPackageConfigStatusText } from "@/utils/package";
|
||||
import { Message } from "@/utils/message";
|
||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PackageConfigDetail" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
const config = ref<GaoPackageConfig>();
|
||||
const isLoading = ref(false);
|
||||
const canUpdate = computed(() => !!config.value && userStore.hasPermissions(permissionCodes.packageConfig.update));
|
||||
const canDelete = computed(() => !!config.value && userStore.hasPermissions(permissionCodes.packageConfig.delete));
|
||||
const storeName = computed(() => {
|
||||
const storeId = config.value?.storeId;
|
||||
return currentStoreStore.storeList.find(item => item.id === storeId)?.name || storeId || "未绑定门店";
|
||||
});
|
||||
const formatValidity = computed(() => {
|
||||
const begin = config.value?.beginAt ? Time.toDateTime(config.value.beginAt) : "不限起始";
|
||||
const end = config.value?.endAt ? Time.toDateTime(config.value.endAt) : "不限结束";
|
||||
return `${begin} 至 ${end}`;
|
||||
});
|
||||
|
||||
function getStatusTheme(status?: PackageConfigStatus): "success" | "warning" | "default" {
|
||||
return status === "ACTIVE" ? "success" : status === "INACTIVE" ? "warning" : "default";
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
if (userStore.hasRoles(gaoRoleCodes.globalManager)) {
|
||||
await currentStoreStore.loadStoreList();
|
||||
} else {
|
||||
await currentStoreStore.loadLoginStore();
|
||||
}
|
||||
config.value = await PackageAPI.configDetail(id);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "套餐配置加载失败"));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toEdit(): Promise<void> {
|
||||
if (config.value?.id && canUpdate.value) {
|
||||
await router.push(`/management/package-configs/${config.value.id}/edit`);
|
||||
}
|
||||
}
|
||||
|
||||
function removeConfig(): void {
|
||||
if (!config.value?.id || !canDelete.value) {
|
||||
return;
|
||||
}
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "删除套餐配置",
|
||||
content: "删除后该配置不再出现在可购买列表中,已购买的客户套餐不受影响。",
|
||||
confirmBtn: { content: "删除", theme: "danger" },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await PackageAPI.configRemove(config.value!.id!);
|
||||
Message.success("套餐配置已删除");
|
||||
await router.replace("/management/package-configs");
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "套餐配置删除失败"));
|
||||
} finally {
|
||||
dialog.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
navBarStore.clearRight();
|
||||
if (canUpdate.value) {
|
||||
navBarStore.setRightText("编辑", "var(--td-brand-color)", toEdit);
|
||||
}
|
||||
});
|
||||
|
||||
watch(canUpdate, value => {
|
||||
navBarStore.clearRight();
|
||||
if (value) {
|
||||
navBarStore.setRightText("编辑", "var(--td-brand-color)", toEdit);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-config-detail {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.hero {
|
||||
margin: 0 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--td-bg-color-container);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1rem;
|
||||
|
||||
.title-row {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--td-text-color-secondary);
|
||||
margin: .5rem 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<div v-can="savePermissionCode" class="package-config-edit">
|
||||
<t-form class="form" label-align="left" label-width="6rem" v-entrance-transition>
|
||||
<t-form-item v-if="isGlobalManager" label="所属门店" arrow @click="openStorePicker">
|
||||
<span class="picker-text" v-text="selectedStoreName" />
|
||||
</t-form-item>
|
||||
<t-form-item label="套餐名称" required-mark>
|
||||
<t-input v-model="form.name" borderless maxlength="100" placeholder="请输入套餐名称" />
|
||||
</t-form-item>
|
||||
<t-form-item label="套餐说明">
|
||||
<t-textarea v-model="form.description" borderless maxlength="500" autosize placeholder="可填写套餐说明" />
|
||||
</t-form-item>
|
||||
<t-form-item label="标准价(元)" required-mark>
|
||||
<t-input v-model.trim="standardAmountText" type="number" align="right" borderless placeholder="例如 1288.00" />
|
||||
</t-form-item>
|
||||
<t-form-item label="状态" arrow @click="openStatusPicker">
|
||||
<span class="picker-text" v-text="getPackageConfigStatusText(form.status)" />
|
||||
</t-form-item>
|
||||
<t-form-item label="开始时间" arrow @click="openBeginPicker">
|
||||
<span class="picker-text" v-text="beginAtText || '不限'" />
|
||||
</t-form-item>
|
||||
<t-form-item label="结束时间" arrow @click="openEndPicker">
|
||||
<span class="picker-text" v-text="endAtText || '不限'" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<section class="items" v-entrance-transition="70">
|
||||
<div class="section-title"><strong>产品权益</strong><span>至少一项,数量最多三位小数</span></div>
|
||||
<div v-for="(item, index) in form.itemList" :key="item.clientKey" class="item-card">
|
||||
<div class="item-head"><span v-text="`权益 ${index + 1}`" /><t-button theme="danger" variant="text" size="small" @click="removeItem(index)">删除</t-button></div>
|
||||
<t-input v-model="item.productName" borderless placeholder="产品名称" />
|
||||
<t-input v-model.trim="item.unit" borderless placeholder="单位,例如 盒、次" />
|
||||
<t-input v-model.trim="item.defaultQuantity" type="number" borderless placeholder="默认数量" />
|
||||
<t-input v-model="item.remark" borderless placeholder="备注(可选)" />
|
||||
</div>
|
||||
<t-button class="add-item" theme="primary" variant="dashed" block @click="addItem">添加产品权益</t-button>
|
||||
</section>
|
||||
<t-popup v-model="storePickerVisible" placement="bottom">
|
||||
<t-picker v-model="storePickerDraft" :columns="storePickerColumns" title="选择门店" @cancel="storePickerVisible = false" @confirm="confirmStore" />
|
||||
</t-popup>
|
||||
<t-popup v-model="statusPickerVisible" placement="bottom">
|
||||
<t-picker v-model="statusPickerDraft" :columns="statusOptions" title="状态" @cancel="statusPickerVisible = false" @confirm="confirmStatus" />
|
||||
</t-popup>
|
||||
<t-popup v-model="beginPickerVisible" placement="bottom">
|
||||
<t-date-time-picker v-model="beginDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="开始时间" @cancel="beginPickerVisible = false" @confirm="confirmBegin" />
|
||||
</t-popup>
|
||||
<t-popup v-model="endPickerVisible" placement="bottom">
|
||||
<t-date-time-picker v-model="endDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="结束时间" @cancel="endPickerVisible = false" @confirm="confirmEnd" />
|
||||
</t-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Time } from "timi-web";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
|
||||
import { Message } from "@/utils/message";
|
||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
|
||||
type PickerValue = string | number;
|
||||
type PackageConfigFormItem = PackageConfigItem & { clientKey: string };
|
||||
|
||||
defineOptions({ name: "PackageConfigEdit" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const navBarStore = useNavBarStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
const routeId = computed(() => String(route.params.id || ""));
|
||||
const isCreateMode = computed(() => !routeId.value);
|
||||
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.packageConfig.create : permissionCodes.packageConfig.update);
|
||||
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
||||
const isSaving = ref(false);
|
||||
const form = reactive({
|
||||
id: "",
|
||||
storeId: "",
|
||||
name: "",
|
||||
description: "",
|
||||
status: "DRAFT" as PackageConfigStatus,
|
||||
beginAt: undefined as number | undefined,
|
||||
endAt: undefined as number | undefined,
|
||||
itemList: [] as PackageConfigFormItem[]
|
||||
});
|
||||
const standardAmountText = ref("");
|
||||
const beginAtText = ref("");
|
||||
const endAtText = ref("");
|
||||
const storePickerVisible = ref(false);
|
||||
const storePickerDraft = ref<PickerValue[]>([]);
|
||||
const statusPickerVisible = ref(false);
|
||||
const statusPickerDraft = ref<PickerValue[]>([]);
|
||||
const beginPickerVisible = ref(false);
|
||||
const endPickerVisible = ref(false);
|
||||
const beginDraft = ref("");
|
||||
const endDraft = ref("");
|
||||
const statusOptions = [
|
||||
{ label: "草稿", value: "DRAFT" },
|
||||
{ label: "启用", value: "ACTIVE" },
|
||||
{ label: "停用", value: "INACTIVE" }
|
||||
];
|
||||
const storePickerColumns = computed(() => [[...currentStoreStore.storeList].map(item => ({ label: item.name || item.code || item.id || "未知门店", value: item.id || "" }))]);
|
||||
const selectedStoreName = computed(() => currentStoreStore.storeList.find(item => item.id === form.storeId)?.name || form.storeId || "请选择门店");
|
||||
|
||||
function createItem(item?: Partial<PackageConfigItem>): PackageConfigFormItem {
|
||||
return {
|
||||
...item,
|
||||
clientKey: window.crypto.randomUUID(),
|
||||
productName: item?.productName || "",
|
||||
unit: item?.unit || "",
|
||||
defaultQuantity: item?.defaultQuantity ?? "",
|
||||
remark: item?.remark || ""
|
||||
};
|
||||
}
|
||||
|
||||
function addItem(): void {
|
||||
form.itemList.push(createItem());
|
||||
}
|
||||
|
||||
function openStorePicker(): void {
|
||||
storePickerDraft.value = [form.storeId];
|
||||
storePickerVisible.value = true;
|
||||
}
|
||||
|
||||
function openStatusPicker(): void {
|
||||
statusPickerDraft.value = [form.status];
|
||||
statusPickerVisible.value = true;
|
||||
}
|
||||
|
||||
function removeItem(index: number): void {
|
||||
if (1 < form.itemList.length) {
|
||||
form.itemList.splice(index, 1);
|
||||
return;
|
||||
}
|
||||
Message.warning("套餐至少需要一项产品权益");
|
||||
}
|
||||
|
||||
function confirmStore(value: Array<string | number>): void {
|
||||
const storeId = String(value[0] || "");
|
||||
if (storeId) {
|
||||
form.storeId = storeId;
|
||||
}
|
||||
storePickerVisible.value = false;
|
||||
}
|
||||
|
||||
function confirmStatus(value: Array<string | number>): void {
|
||||
const status = String(value[0] || "");
|
||||
if (status === "DRAFT" || status === "ACTIVE" || status === "INACTIVE") {
|
||||
form.status = status;
|
||||
}
|
||||
statusPickerVisible.value = false;
|
||||
}
|
||||
|
||||
function openBeginPicker(): void {
|
||||
beginDraft.value = beginAtText.value || Time.formatDateTime(new Date());
|
||||
beginPickerVisible.value = true;
|
||||
}
|
||||
|
||||
function openEndPicker(): void {
|
||||
endDraft.value = endAtText.value || Time.formatDateTime(new Date());
|
||||
endPickerVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmBegin(value: string | number): void {
|
||||
beginAtText.value = String(value);
|
||||
beginPickerVisible.value = false;
|
||||
}
|
||||
|
||||
function confirmEnd(value: string | number): void {
|
||||
endAtText.value = String(value);
|
||||
endPickerVisible.value = false;
|
||||
}
|
||||
|
||||
function resolveTimestamp(value: string): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = new Date(value.replace(" ", "T")).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (isGlobalManager.value && !form.storeId) {
|
||||
Message.warning("请选择所属门店");
|
||||
return false;
|
||||
}
|
||||
if (!form.name.trim()) {
|
||||
Message.warning("请输入套餐名称");
|
||||
return false;
|
||||
}
|
||||
if (parsePackageAmount(standardAmountText.value) === undefined) {
|
||||
Message.warning("标准价必须是合法的元金额,最多两位小数");
|
||||
return false;
|
||||
}
|
||||
if (!form.itemList.length) {
|
||||
Message.warning("请至少添加一项产品权益");
|
||||
return false;
|
||||
}
|
||||
const keySet = new Set<string>();
|
||||
for (const item of form.itemList) {
|
||||
const quantity = normalizePackageQuantity(item.defaultQuantity);
|
||||
const key = `${item.productName.trim()}\u0000${item.unit.trim()}`;
|
||||
if (!item.productName.trim() || !item.unit.trim() || !quantity || quantity === "0") {
|
||||
Message.warning("产品名称、单位和正数量不能为空");
|
||||
return false;
|
||||
}
|
||||
if (keySet.has(key)) {
|
||||
Message.warning("产品名称和单位组合不能重复");
|
||||
return false;
|
||||
}
|
||||
keySet.add(key);
|
||||
}
|
||||
form.beginAt = resolveTimestamp(beginAtText.value);
|
||||
form.endAt = resolveTimestamp(endAtText.value);
|
||||
if (form.beginAt !== undefined && form.endAt !== undefined && form.endAt < form.beginAt) {
|
||||
Message.warning("结束时间不能早于开始时间");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (isSaving.value || !validateForm()) {
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const payload: GaoPackageConfig = {
|
||||
id: form.id || undefined,
|
||||
storeId: form.storeId || undefined,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
standardAmount: parsePackageAmount(standardAmountText.value)!,
|
||||
status: form.status,
|
||||
beginAt: form.beginAt ?? null,
|
||||
endAt: form.endAt ?? null,
|
||||
itemList: form.itemList.map((item, index) => ({
|
||||
productName: item.productName.trim(),
|
||||
unit: item.unit.trim(),
|
||||
defaultQuantity: normalizePackageQuantity(item.defaultQuantity)!,
|
||||
sort: index,
|
||||
remark: item.remark?.trim() || null
|
||||
}))
|
||||
};
|
||||
if (isCreateMode.value) {
|
||||
const id = await PackageAPI.configCreate(payload);
|
||||
Message.success("套餐配置已创建");
|
||||
await router.replace(`/management/package-configs/${id}`);
|
||||
return;
|
||||
}
|
||||
await PackageAPI.configUpdate(payload);
|
||||
Message.success("套餐配置已保存");
|
||||
await router.replace(`/management/package-configs/${routeId.value}`);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "套餐配置保存失败"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
if (isGlobalManager.value) {
|
||||
await currentStoreStore.loadStoreList();
|
||||
} else {
|
||||
await currentStoreStore.loadLoginStore();
|
||||
}
|
||||
if (!form.storeId) {
|
||||
form.storeId = currentStoreStore.currentStoreId;
|
||||
}
|
||||
if (isCreateMode.value) {
|
||||
addItem();
|
||||
return;
|
||||
}
|
||||
const result = await PackageAPI.configDetail(routeId.value);
|
||||
Object.assign(form, {
|
||||
id: result.id || routeId.value,
|
||||
storeId: result.storeId || form.storeId,
|
||||
name: result.name || "",
|
||||
description: result.description || "",
|
||||
status: result.status || "DRAFT",
|
||||
beginAt: result.beginAt || undefined,
|
||||
endAt: result.endAt || undefined,
|
||||
itemList: (result.itemList || []).map(createItem)
|
||||
});
|
||||
standardAmountText.value = formatPackageAmountInput(result.standardAmount);
|
||||
beginAtText.value = result.beginAt ? Time.toDateTime(result.beginAt) : "";
|
||||
endAtText.value = result.endAt ? Time.toDateTime(result.endAt) : "";
|
||||
if (!form.itemList.length) {
|
||||
addItem();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (userStore.hasPermissions(savePermissionCode.value)) {
|
||||
navBarStore.setRightText("保存", "var(--td-brand-color)", save);
|
||||
}
|
||||
try {
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "套餐配置加载失败"));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-config-edit {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 calc(1rem + env(safe-area-inset-bottom, 0));
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.picker-text {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.items {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-title,
|
||||
.item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: var(--td-text-color-secondary);
|
||||
margin-bottom: .5rem;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
gap: .5rem;
|
||||
padding: .75rem;
|
||||
margin-bottom: .75rem;
|
||||
display: grid;
|
||||
background: var(--td-bg-color-container);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.item-head {
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.add-item {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.packageConfig.read" class="package-config-list">
|
||||
<div class="filters">
|
||||
<t-input
|
||||
v-model.trim="keyword"
|
||||
class="keyword"
|
||||
borderless
|
||||
clearable
|
||||
placeholder="搜索套餐名称或说明"
|
||||
@enter="reload"
|
||||
/>
|
||||
<t-button theme="primary" size="small" @click="reload">搜索</t-button>
|
||||
</div>
|
||||
<t-dropdown-menu>
|
||||
<t-dropdown-item v-model="status" label="状态" :options="statusOptions" @change="selectStatus" />
|
||||
</t-dropdown-menu>
|
||||
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
|
||||
<t-cell-group v-if="configList.length" theme="card" class="list">
|
||||
<t-swipe-cell v-for="(item, index) in configList" :key="item.id" v-entrance-transition="index * 30">
|
||||
<t-cell class="item" :arrow="!!item.id" :hover="!!item.id" @click="toDetail(item)">
|
||||
<template #title>
|
||||
<div class="title-row">
|
||||
<strong v-text="item.name || '未命名套餐'" />
|
||||
<t-tag :content="getPackageConfigStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="description">
|
||||
<span v-text="formatPackageAmount(item.standardAmount)" />
|
||||
<span v-text="`${item.itemList?.length || 0} 项权益`" />
|
||||
<span v-text="formatValidity(item)" />
|
||||
</div>
|
||||
</template>
|
||||
</t-cell>
|
||||
<template v-if="canDelete && item.id" #right>
|
||||
<t-button class="delete" theme="danger" @click.stop="confirmRemove(item)">删除</t-button>
|
||||
</template>
|
||||
</t-swipe-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty" v-text="loading ? '正在加载套餐配置' : '暂无套餐配置'" />
|
||||
<div class="load-state" v-text="loadStateText" />
|
||||
</t-pull-down-refresh>
|
||||
</div>
|
||||
<nav-bar-action-menu v-if="canCreate" :actions="actionList" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogPlugin, type DropdownValue } from "tdesign-mobile-vue";
|
||||
import { Time } from "timi-web";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import type { GaoPackageConfig, PackageConfigStatus } from "@/types/Package";
|
||||
import type { NavBarAction } from "@/types/NavBarAction";
|
||||
import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { formatPackageAmount, getPackageConfigStatusText } from "@/utils/package";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PackageConfigList" });
|
||||
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.packageConfig.create));
|
||||
const canDelete = computed(() => userStore.hasPermissions(permissionCodes.packageConfig.delete));
|
||||
const actionList = computed<NavBarAction[]>(() => canCreate.value ? [{
|
||||
key: "create",
|
||||
text: "新增套餐配置",
|
||||
icon: "add",
|
||||
theme: "primary",
|
||||
action: toCreate
|
||||
}] : []);
|
||||
|
||||
const keyword = ref("");
|
||||
const status = ref("ALL");
|
||||
const configList = ref<GaoPackageConfig[]>([]);
|
||||
const pageIndex = ref(0);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const isRefreshing = ref(false);
|
||||
const noMore = ref(false);
|
||||
const statusOptions = [
|
||||
{ label: "全部状态", value: "ALL" },
|
||||
{ label: "草稿", value: "DRAFT" },
|
||||
{ label: "启用", value: "ACTIVE" },
|
||||
{ label: "停用", value: "INACTIVE" }
|
||||
];
|
||||
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (loading.value || noMore.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await PackageAPI.configList({
|
||||
index: pageIndex.value,
|
||||
size: 15,
|
||||
keyword: keyword.value || undefined,
|
||||
status: status.value === "ALL" ? undefined : status.value as PackageConfigStatus
|
||||
});
|
||||
const list = result.list || [];
|
||||
configList.value = configList.value.concat(list);
|
||||
total.value = result.total || 0;
|
||||
pageIndex.value += 1;
|
||||
noMore.value = list.length < 15 || total.value <= configList.value.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
pageIndex.value = 0;
|
||||
configList.value = [];
|
||||
total.value = 0;
|
||||
noMore.value = false;
|
||||
await loadMore();
|
||||
}
|
||||
|
||||
async function selectStatus(value: DropdownValue): Promise<void> {
|
||||
if (Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
status.value = String(value);
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function toCreate(): Promise<void> {
|
||||
await router.push("/management/package-configs/create");
|
||||
}
|
||||
|
||||
async function toDetail(item: GaoPackageConfig): Promise<void> {
|
||||
if (item.id) {
|
||||
await router.push(`/management/package-configs/${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusTheme(statusValue?: PackageConfigStatus): "success" | "warning" | "default" {
|
||||
return statusValue === "ACTIVE" ? "success" : statusValue === "INACTIVE" ? "warning" : "default";
|
||||
}
|
||||
|
||||
function formatValidity(item: GaoPackageConfig): string {
|
||||
const begin = item.beginAt ? Time.toDateTime(item.beginAt) : "不限起始";
|
||||
const end = item.endAt ? Time.toDateTime(item.endAt) : "不限结束";
|
||||
return `${begin} 至 ${end}`;
|
||||
}
|
||||
|
||||
function confirmRemove(item: GaoPackageConfig): void {
|
||||
if (!item.id || !canDelete.value) {
|
||||
return;
|
||||
}
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "删除套餐配置",
|
||||
content: `确定删除“${item.name || "未命名套餐"}”?删除后不可再用于购买。`,
|
||||
confirmBtn: { content: "删除", theme: "danger" },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await PackageAPI.configRemove(item.id!);
|
||||
configList.value = configList.value.filter(config => config.id !== item.id);
|
||||
total.value = Math.max(0, total.value - 1);
|
||||
Message.success("套餐配置已删除");
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "套餐配置删除失败"));
|
||||
} finally {
|
||||
dialog.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.clearRight();
|
||||
navBarStore.setCustomRightVisible(canCreate.value);
|
||||
try {
|
||||
await reload();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "套餐配置加载失败"));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-config-list {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
|
||||
|
||||
.filters {
|
||||
gap: .5rem;
|
||||
margin: 0 1rem .75rem;
|
||||
display: flex;
|
||||
|
||||
.keyword {
|
||||
flex: 1;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
.title-row,
|
||||
.description {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--td-text-color-secondary);
|
||||
margin-top: .35rem;
|
||||
font-size: .8125rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.delete {
|
||||
height: 100%;
|
||||
padding: 0 1rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.packageDelivery.read" class="package-record-detail">
|
||||
<template v-if="delivery">
|
||||
<t-cell-group title="发放单信息" theme="card" v-entrance-transition>
|
||||
<t-cell title="操作类型" :note="getDeliveryOperationText(delivery.operationType)" />
|
||||
<t-cell title="状态"><template #note><t-tag :content="getDeliveryStatusText(delivery.status)" :theme="getStatusTheme(delivery.status)" variant="light-outline" /></template></t-cell>
|
||||
<t-cell title="客户套餐" :note="delivery.customerPackageId || '未知'" />
|
||||
<t-cell title="发放时间" :note="delivery.deliveredAt ? Time.toDateTime(delivery.deliveredAt) : '未知'" />
|
||||
<t-cell title="提交人" :note="delivery.operator?.name || delivery.operator?.nick || delivery.operatorUserId || '未知'" />
|
||||
<t-cell title="处理人" :note="delivery.verifier?.name || delivery.verifier?.nick || delivery.verifierUserId || '未处理'" />
|
||||
<t-cell v-if="delivery.sourceDeliveryId" title="原发放单" :note="delivery.sourceDeliveryId" />
|
||||
<t-cell v-if="delivery.reason" title="原因" :note="delivery.reason" />
|
||||
</t-cell-group>
|
||||
<t-cell-group title="本单明细" theme="card" v-entrance-transition="70">
|
||||
<t-cell
|
||||
v-for="item in delivery.itemList || []"
|
||||
:key="item.customerPackageItemId || item.productNameSnapshot"
|
||||
:title="item.productNameSnapshot || '产品'"
|
||||
:note="`${getPackageQuantityText(item.quantity)} ${item.unitSnapshot || ''}`"
|
||||
/>
|
||||
<div v-if="!delivery.itemList?.length" class="empty" v-text="'暂无明细'" />
|
||||
</t-cell-group>
|
||||
<photo-scroller :model-value="photoURLList" :photo-items="photoItems" title="业务照片" :show-add="false" :show-remove="false" :show-viewer-actions="false" />
|
||||
<section class="actions" v-if="canVerify || canCancel || canRevoke" v-entrance-transition="140">
|
||||
<t-button v-if="canVerify && isPending" theme="primary" @click="verify">核销发放</t-button>
|
||||
<t-button v-if="canVerify && isPending" theme="default" variant="outline" @click="openReasonDialog('reject')">拒绝发放</t-button>
|
||||
<t-button v-if="canCancel && isPending" theme="default" variant="outline" @click="openReasonDialog('cancel')">撤销发放</t-button>
|
||||
<t-button v-if="canRevoke && isVerifiedDelivery" theme="danger" variant="outline" @click="toRevoke">整单冲正</t-button>
|
||||
</section>
|
||||
</template>
|
||||
<t-empty v-else :description="isLoading ? '正在加载发放单' : '发放单不存在'" />
|
||||
<t-dialog v-model:visible="reasonDialogVisible" :title="reasonTitle" :confirm-btn="reasonActionText" @confirm="submitReason">
|
||||
<t-textarea v-model.trim="reasonText" borderless autosize placeholder="请输入原因" />
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogPlugin } from "tdesign-mobile-vue";
|
||||
import { Time } from "timi-web";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { DeliveryStatus, GaoPackageDelivery } from "@/types/Package";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import { getAttachmentReadURL } from "@/utils/attachment";
|
||||
import { getDeliveryOperationText, getDeliveryStatusText, getPackageQuantityText } from "@/utils/package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
type ReasonAction = "reject" | "cancel";
|
||||
|
||||
defineOptions({ name: "PackageDeliveryDetail" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const navBarStore = useNavBarStore();
|
||||
const delivery = ref<GaoPackageDelivery>();
|
||||
const isLoading = ref(false);
|
||||
const isOperating = ref(false);
|
||||
const reasonDialogVisible = ref(false);
|
||||
const reasonText = ref("");
|
||||
const reasonAction = ref<ReasonAction>("reject");
|
||||
const isPending = computed(() => delivery.value?.operationType === "DELIVER" && delivery.value.status === "PENDING");
|
||||
const isVerifiedDelivery = computed(() => delivery.value?.operationType === "DELIVER" && delivery.value.status === "VERIFIED");
|
||||
const canVerify = computed(() => userStore.hasPermissions(permissionCodes.packageDelivery.verify));
|
||||
const canCancel = computed(() => userStore.hasPermissions(permissionCodes.packageDelivery.create));
|
||||
const canRevoke = computed(() => userStore.hasPermissions(permissionCodes.packageDelivery.revoke));
|
||||
const reasonTitle = computed(() => reasonAction.value === "reject" ? "拒绝发放" : "撤销发放");
|
||||
const reasonActionText = computed(() => reasonAction.value === "reject" ? "拒绝" : "撤销");
|
||||
const photoItems = computed<PhotoItem[]>(() => (delivery.value?.attachmentList || [])
|
||||
.filter(item => !!item.id)
|
||||
.map(item => ({
|
||||
attachment: item,
|
||||
attachType: item.attachType || "PHOTO",
|
||||
url: getAttachmentReadURL(item.id || "")
|
||||
})));
|
||||
const photoURLList = computed(() => photoItems.value.map(item => item.url));
|
||||
|
||||
function getStatusTheme(status?: DeliveryStatus): "success" | "danger" | "default" {
|
||||
return status === "VERIFIED" ? "success" : status === "REJECTED" ? "danger" : "default";
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
delivery.value = await PackageAPI.deliveryDetail(id);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "发放单加载失败"));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRelatedPackage(): Promise<void> {
|
||||
if (delivery.value?.customerPackageId) {
|
||||
await PackageAPI.customerPackageDetail(delivery.value.customerPackageId);
|
||||
}
|
||||
}
|
||||
|
||||
async function verify(): Promise<void> {
|
||||
if (!delivery.value?.id || !isPending.value || !canVerify.value || isOperating.value) {
|
||||
return;
|
||||
}
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "核销发放",
|
||||
content: "核销后会增加已发数量,所有权益完成核销后套餐可能变为已履约。确定继续?",
|
||||
confirmBtn: "核销",
|
||||
onConfirm: async () => {
|
||||
isOperating.value = true;
|
||||
try {
|
||||
await PackageAPI.verifyDelivery(delivery.value!.id!);
|
||||
await refreshRelatedPackage();
|
||||
Message.success("发放单已核销");
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "发放核销失败,请刷新后确认状态"));
|
||||
} finally {
|
||||
isOperating.value = false;
|
||||
dialog.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openReasonDialog(action: ReasonAction): void {
|
||||
if (isOperating.value || !isPending.value) {
|
||||
return;
|
||||
}
|
||||
reasonAction.value = action;
|
||||
reasonText.value = "";
|
||||
reasonDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitReason(): Promise<void> {
|
||||
if (!delivery.value?.id || !reasonText.value.trim() || isOperating.value) {
|
||||
Message.warning("请输入原因");
|
||||
return;
|
||||
}
|
||||
isOperating.value = true;
|
||||
try {
|
||||
if (reasonAction.value === "reject") {
|
||||
await PackageAPI.rejectDelivery(delivery.value.id, reasonText.value.trim());
|
||||
} else {
|
||||
await PackageAPI.cancelDelivery(delivery.value.id, reasonText.value.trim());
|
||||
}
|
||||
await refreshRelatedPackage();
|
||||
reasonDialogVisible.value = false;
|
||||
Message.success(reasonAction.value === "reject" ? "发放单已拒绝" : "发放单已撤销");
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "发放状态操作失败,请刷新后确认状态"));
|
||||
} finally {
|
||||
isOperating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toRevoke(): Promise<void> {
|
||||
if (delivery.value?.id && isVerifiedDelivery.value && canRevoke.value) {
|
||||
await router.push(`/management/package-deliveries/${delivery.value.id}/revoke`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-record-detail {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.photo-scroller) {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: .5rem;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.t-button {
|
||||
flex: 1 1 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.packageDelivery.read" class="package-record-list">
|
||||
<div class="filters">
|
||||
<t-dropdown-menu>
|
||||
<t-dropdown-item v-model="status" label="状态" :options="statusOptions" @change="selectStatus" />
|
||||
<t-dropdown-item v-model="operationType" label="操作类型" :options="operationOptions" @change="selectOperation" />
|
||||
</t-dropdown-menu>
|
||||
</div>
|
||||
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
|
||||
<t-cell-group v-if="deliveryList.length" theme="card">
|
||||
<t-cell
|
||||
v-for="(item, index) in deliveryList"
|
||||
:key="item.id"
|
||||
:title="`${getDeliveryOperationText(item.operationType)} · ${getDeliveryStatusText(item.status)}`"
|
||||
:description="getDescription(item)"
|
||||
arrow
|
||||
hover
|
||||
v-entrance-transition="index * 30"
|
||||
@click="toDetail(item)"
|
||||
>
|
||||
<template #note><span v-text="getItemSummary(item)" /></template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty" v-text="loading ? '正在加载发放记录' : '暂无发放记录'" />
|
||||
<div class="load-state" v-text="loadStateText" />
|
||||
</t-pull-down-refresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type DropdownValue } from "tdesign-mobile-vue";
|
||||
import { Time } from "timi-web";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { DeliveryOperationType, DeliveryStatus, GaoPackageDelivery } from "@/types/Package";
|
||||
import { getDeliveryOperationText, getDeliveryStatusText, getPackageQuantityText } from "@/utils/package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PackageDeliveryList" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const deliveryList = ref<GaoPackageDelivery[]>([]);
|
||||
const pageIndex = ref(0);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const isRefreshing = ref(false);
|
||||
const noMore = ref(false);
|
||||
const status = ref("ALL");
|
||||
const operationType = ref("ALL");
|
||||
const statusOptions = [
|
||||
{ label: "全部状态", value: "ALL" },
|
||||
{ label: "待核销", value: "PENDING" },
|
||||
{ label: "已核销", value: "VERIFIED" },
|
||||
{ label: "已拒绝", value: "REJECTED" },
|
||||
{ label: "已撤销", value: "CANCELLED" },
|
||||
{ label: "已冲正", value: "REVOKED" }
|
||||
];
|
||||
const operationOptions = [
|
||||
{ label: "全部类型", value: "ALL" },
|
||||
{ label: "正常发放", value: "DELIVER" },
|
||||
{ label: "发放冲正", value: "REVOKE" }
|
||||
];
|
||||
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (loading.value || noMore.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await PackageAPI.deliveryList({
|
||||
index: pageIndex.value,
|
||||
size: 15,
|
||||
customerPackageId: String(route.query.customerPackageId || "") || undefined,
|
||||
status: status.value === "ALL" ? undefined : status.value as DeliveryStatus,
|
||||
operationType: operationType.value === "ALL" ? undefined : operationType.value as DeliveryOperationType
|
||||
});
|
||||
const list = result.list || [];
|
||||
deliveryList.value = deliveryList.value.concat(list);
|
||||
total.value = result.total || 0;
|
||||
pageIndex.value += 1;
|
||||
noMore.value = list.length < 15 || total.value <= deliveryList.value.length;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
pageIndex.value = 0;
|
||||
deliveryList.value = [];
|
||||
total.value = 0;
|
||||
noMore.value = false;
|
||||
await loadMore();
|
||||
}
|
||||
|
||||
async function selectStatus(value: DropdownValue): Promise<void> {
|
||||
if (!Array.isArray(value)) {
|
||||
status.value = String(value);
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectOperation(value: DropdownValue): Promise<void> {
|
||||
if (!Array.isArray(value)) {
|
||||
operationType.value = String(value);
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
function getDescription(item: GaoPackageDelivery): string {
|
||||
return `${item.customerPackageId || "未知客户套餐"}|${item.deliveredAt ? Time.toDateTime(item.deliveredAt) : "时间未知"}`;
|
||||
}
|
||||
|
||||
function getItemSummary(item: GaoPackageDelivery): string {
|
||||
return (item.itemList || []).map(detail => `${detail.productNameSnapshot || "产品"} ${getPackageQuantityText(detail.quantity)} ${detail.unitSnapshot || ""}`).join("、") || "无明细";
|
||||
}
|
||||
|
||||
async function toDetail(item: GaoPackageDelivery): Promise<void> {
|
||||
if (item.id) {
|
||||
await router.push(`/management/package-deliveries/${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.clearRight();
|
||||
try {
|
||||
await reload();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "发放记录加载失败"));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-record-list {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
|
||||
|
||||
.filters {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.packageDelivery.revoke" class="package-operation-edit">
|
||||
<t-cell-group v-if="delivery" title="原发放单" theme="card" v-entrance-transition>
|
||||
<t-cell title="发放单号" :note="delivery.id" />
|
||||
<t-cell title="客户套餐" :note="delivery.customerPackageId || '未知'" />
|
||||
<t-cell title="原状态" :note="getDeliveryStatusText(delivery.status)" />
|
||||
<t-cell title="原明细" :note="getItemSummary" />
|
||||
</t-cell-group>
|
||||
<t-form v-if="delivery" class="form" label-align="left" label-width="6rem" v-entrance-transition="70">
|
||||
<t-form-item label="冲正原因" required-mark><t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入冲正原因" /></t-form-item>
|
||||
<t-form-item label="冲正时间" arrow @click="pickerVisible = true"><span class="picker-text" v-text="occurredAtText || '使用当前时间'" /></t-form-item>
|
||||
<t-form-item label="备注"><t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="备注(可选)" /></t-form-item>
|
||||
</t-form>
|
||||
<package-photo-field v-if="delivery" v-model="photoItems" required title="冲正凭证照片" />
|
||||
<t-empty v-if="!delivery" :description="isLoading ? '正在加载发放单' : '发放单不存在'" />
|
||||
<t-popup v-model="pickerVisible" placement="bottom">
|
||||
<t-date-time-picker v-model="draftTime" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="冲正时间" @cancel="pickerVisible = false" @confirm="confirmTime" />
|
||||
</t-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import PackageAPI from "@/api/PackageAPI";
|
||||
import PackagePhotoField from "@/components/PackagePhotoField.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { GaoPackageDelivery, PackageAttachment } from "@/types/Package";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import { createPackageRequestNo, getDeliveryStatusText, getPackageQuantityText } from "@/utils/package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PackageDeliveryRevoke" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const navBarStore = useNavBarStore();
|
||||
const delivery = ref<GaoPackageDelivery>();
|
||||
const reason = ref("");
|
||||
const remark = ref("");
|
||||
const occurredAtText = ref("");
|
||||
const draftTime = ref("");
|
||||
const pickerVisible = ref(false);
|
||||
const photoItems = ref<PhotoItem[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const getItemSummary = computed(() => (delivery.value?.itemList || []).map(item => `${item.productNameSnapshot || "产品"} ${getPackageQuantityText(item.quantity)} ${item.unitSnapshot || ""}`).join("、") || "无明细");
|
||||
|
||||
function confirmTime(value: string | number): void {
|
||||
occurredAtText.value = String(value);
|
||||
pickerVisible.value = false;
|
||||
}
|
||||
|
||||
function resolveTimestamp(value: string): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = new Date(value.replace(" ", "T")).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
function buildAttachments(): PackageAttachment[] {
|
||||
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
delivery.value = await PackageAPI.deliveryDetail(id);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "发放单加载失败"));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
if (!delivery.value?.id || delivery.value.status !== "VERIFIED" || delivery.value.operationType !== "DELIVER") {
|
||||
Message.warning("只有已核销的正常发放单可以冲正");
|
||||
return;
|
||||
}
|
||||
if (!reason.value.trim()) {
|
||||
Message.warning("请输入冲正原因");
|
||||
return;
|
||||
}
|
||||
if (!photoItems.value.some(item => item.tempFileId)) {
|
||||
Message.warning("冲正必须上传至少一张照片");
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const result = await PackageAPI.revokeDelivery({
|
||||
requestNo: createPackageRequestNo(),
|
||||
deliveryId: delivery.value.id,
|
||||
deliveredAt: resolveTimestamp(occurredAtText.value),
|
||||
reason: reason.value.trim(),
|
||||
attachmentList: buildAttachments(),
|
||||
remark: remark.value.trim() || null
|
||||
});
|
||||
Message.success("发放单已冲正");
|
||||
if (result.customerPackageId) {
|
||||
await router.replace(`/management/customer-packages/${result.customerPackageId}`);
|
||||
return;
|
||||
}
|
||||
await router.replace(`/management/package-deliveries/${delivery.value.id}`);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "发放冲正失败,请刷新后确认状态"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (userStore.hasPermissions(permissionCodes.packageDelivery.revoke)) {
|
||||
navBarStore.setRightText("提交冲正", "var(--td-danger-color)", save);
|
||||
}
|
||||
await loadData();
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.package-operation-edit {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.picker-text {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.package-photo-field {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user