add package

This commit is contained in:
Timi
2026-09-01 01:14:54 +08:00
parent 100d4c4c44
commit 2d94abfb32
39 changed files with 4425 additions and 59 deletions
@@ -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>