update customer package

This commit is contained in:
Timi
2026-09-06 15:49:20 +08:00
parent b8b3ecbf5d
commit 4c6920be85
19 changed files with 383 additions and 285 deletions
+169 -97
View File
@@ -10,7 +10,7 @@
<h1 class="no" v-text="order.orderNo" />
<div class="meta">
<pass-time-label class="time" :timestamp="order.purchasedAt" underline />
<div class="amount" :class="getPaymentAmountClass(order.paymentStatus)">
<div class="amount red text-bold">
<span>¥</span>
<strong class="value" v-text="formatPackageAmount(order.saleAmount)" />
</div>
@@ -66,63 +66,81 @@
</div>
</t-form-item>
</t-form>
<t-cell-group
<t-form
v-if="order.itemList?.length"
class="product empty-cell-title-margin"
class="form product"
title="订单明细"
theme="card"
label-width="0"
v-entrance-transition="140"
>
<t-cell
class="item"
v-for="item in order.itemList"
:key="item.id"
>
<template #leftIcon>
<t-image
v-if="getProductPhotoURL(item)"
:src="getProductPhotoURL(item)"
:alt="item.productNameSnapshot || '产品图片'"
fit="cover"
class="avatar"
/>
<div
v-else
class="avatar fallback"
v-text="(item.productNameSnapshot || '产').slice(0, 1)"
/>
</template>
<template #title>
<div class="title">
<span v-text="getItemTitle(item)"></span>
<t-tag
:content="getItemFulfillmentStatus(item).text"
:theme="getItemFulfillmentStatus(item).theme"
variant="light-outline"
/>
<t-form-item class="form-item" v-for="item in order.itemList" :key="item.id">
<div class="item">
<div class="header">
<div class="left">
<t-image
v-if="getProductPhotoURL(item)"
:src="getProductPhotoURL(item)"
:alt="item.productNameSnapshot || '产品图片'"
fit="cover"
class="avatar"
/>
<div
v-else
class="avatar fallback"
v-text="(item.productNameSnapshot || '产').slice(0, 1)"
/>
</div>
<div class="main">
<div class="title">
<span class="name text-bold" v-text="item.productNameSnapshot" />
<t-tag
:content="getItemFulfillmentStatus(item).text"
:theme="getItemFulfillmentStatus(item).theme"
variant="light-outline"
/>
</div>
<div class="description">
<strong
v-if="item.lineAmount !== null && item.lineAmount !== undefined"
class="red"
v-text="`¥${formatPackageAmount(item.lineAmount)}`"
/>
<t-tag v-else size="small" content="套餐权益" />
<t-tag size="small" theme="primary" variant="light" :content="item.unitSnapshot" />
</div>
</div>
</div>
</template>
<template #description>
<div class="description">
<span>
<span>¥</span>
<strong class="red" v-text="formatPackageAmount(item.lineAmount)" />
</span>
<span>
<span>已履约</span>
<div class="content">
<div class="metric">
<strong v-text="getPackageQuantityText(item.quantity)" />
<span>总数</span>
</div>
<div class="metric">
<strong :class="getAvailableQuantityClass(item)" v-text="getPackageQuantityText(item.availableQuantity)" />
<span>可履约</span>
</div>
<div class="metric">
<strong v-text="getPackageQuantityText(item.pendingQuantity)" />
<span>待核销</span>
</div>
<div class="metric">
<strong :class="getFulfilledQuantityClass(item)" v-text="getPackageQuantityText(item.fulfilledQuantity)" />
</span>
<span>
<span>剩余履约</span>
<strong :class="getRemainingQuantityClass(item)" v-text="getRemainingQuantityText(item)" />
</span>
<span>已履约</span>
</div>
<div class="metric">
<strong :class="getRemainingQuantityClass(item)" v-text="getPackageQuantityText(item.remainingQuantity)" />
<span>剩余</span>
</div>
</div>
</template>
</t-cell>
<t-cell title="履约记录" note="查看本单所有履约单" arrow @click="toFulfillmentList" />
</t-cell-group>
</div>
</t-form-item>
<t-form-item label="履约记录" arrow @click="toFulfillmentList">
查看本单所有履约单
</t-form-item>
</t-form>
<t-button
v-if="canCreateFulfillment && canFulfillByPayment && order.orderType === 'DIRECT_PRODUCT' && order.status === 'ACTIVE'"
v-if="canCreateFulfillment && canFulfillByPayment && order.status === 'ACTIVE'"
class="cell-button"
theme="primary"
content="履约产品"
@@ -149,6 +167,11 @@
<t-cell title="退款状态" :note="getSalesRefundStatusText(order.refundStatus)" />
<t-cell v-if="canReadFund" title="查看全部资金流水" arrow @click="toFundRecords" />
</t-cell-group>
<t-cell-group v-if="order.status === 'CANCELLED'" class="cancel-info" title="取消记录" theme="card">
<t-cell title="取消时间" :note="order.cancelledAt ? Time.toDateTime(order.cancelledAt) : '未知'" />
<t-cell title="取消人" :note="order.cancelOperator?.name || order.cancelOperatorUserId || '未知'" />
<t-cell title="取消原因" :note="order.cancelReason || '未填写'" />
</t-cell-group>
<section class="actions" v-entrance-transition="280">
<t-button
v-if="canReceive"
@@ -179,11 +202,12 @@
<script setup lang="ts">
import { PassTimeLabel, Text, Time } from "timi-web";
import { type Attachment, useUserStore } from "timi-web-api";
import FulfillmentAPI from "@/api/FulfillmentAPI";
import SalesOrderAPI from "@/api/SalesOrderAPI";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { SalesOrder, SalesOrderItem, SalesPaymentStatus } from "@/types/SalesOrder";
import type { GaoSalesOrderItem, GaoSalesPaymentStatus, SalesOrder } from "@/types/SalesOrder";
import type { PhotoItem } from "@/types/Photo";
import { formatPackageAmount, getPackageQuantityText } from "@/utils/package";
import { getAttachmentReadURL } from "@/utils/attachment";
@@ -205,6 +229,7 @@ const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const order = ref<SalesOrder>();
const hasPendingFulfillment = ref(false);
const loading = ref(false);
const isEditing = ref(false);
const isSaving = ref(false);
@@ -217,7 +242,7 @@ const canOperateStock = computed(() => userStore.hasPermissions(permissionCodes.
const canReceive = computed(() => !!order.value && order.value.status !== "CANCELLED" && order.value.paymentStatus !== "PAID" && userStore.hasPermissions(permissionCodes.fundReceive.create));
const canRefund = computed(() => !!order.value && order.value.status === "CANCELLED" && order.value.refundStatus === "PENDING" && userStore.hasPermissions(permissionCodes.fund.refund));
const canConfirmNoRefund = computed(() => !!order.value && order.value.status === "CANCELLED" && order.value.refundStatus === "PENDING" && userStore.hasPermissions(permissionCodes.fund.refund));
const canCancel = computed(() => !!order.value && order.value.status === "ACTIVE" && userStore.hasPermissions(permissionCodes.salesOrder.cancel));
const canCancel = computed(() => !!order.value && order.value.status === "ACTIVE" && !hasPendingFulfillment.value && userStore.hasPermissions(permissionCodes.salesOrder.cancel));
const canFulfillByPayment = computed(() => order.value?.paymentStatus === "PAID" || (order.value?.paymentStatus === "PARTIALLY_PAID" && canOperateStock.value));
const photoURLList = computed(() => photoItems.value.map(item => item.url));
@@ -246,7 +271,7 @@ function getPaidAtText(paidAt?: number | null): string {
return paidAt ? Time.toDateTime(paidAt) : "待后端回填";
}
function getPaymentAmountClass(status?: SalesPaymentStatus): "green" | "orange" | "red" | "gray" {
function getPaymentAmountClass(status?: GaoSalesPaymentStatus): "green" | "orange" | "red" | "gray" {
if (status === "PAID") return "green";
if (status === "PARTIALLY_PAID") return "orange";
if (status === "UNPAID") return "red";
@@ -263,37 +288,22 @@ function getRemainingAmountClass(amount?: number | null): "green" | "red" | "gra
return amount === 0 ? "green" : "red";
}
function getItemTitle(item: SalesOrderItem): string {
const productName = item.productNameSnapshot || "未命名产品";
const quantity = getPackageQuantityText(item.quantity);
const unit = item.unitSnapshot ? ` ${item.unitSnapshot}` : "";
return `${productName} ${quantity}${unit}`;
function getAvailableQuantityClass(item: GaoSalesOrderItem): "green" | "orange" {
return Number(item.availableQuantity || 0) === 0 ? "green" : "orange";
}
function getRemainingQuantity(item: SalesOrderItem): number {
const quantity = Number(item.quantity || 0);
function getFulfilledQuantityClass(item: GaoSalesOrderItem): "green" | "orange" {
return Number(item.remainingQuantity || 0) === 0 ? "green" : "orange";
}
function getRemainingQuantityClass(item: GaoSalesOrderItem): "green" | "orange" {
return Number(item.remainingQuantity || 0) === 0 ? "green" : "orange";
}
function getItemFulfillmentStatus(item: GaoSalesOrderItem): { text: string; theme: "success" | "warning" | "danger" } {
const fulfilledQuantity = Number(item.fulfilledQuantity || 0);
return Math.max(0, quantity - fulfilledQuantity);
}
function getRemainingQuantityText(item: SalesOrderItem): string {
return getPackageQuantityText(getRemainingQuantity(item).toFixed(3));
}
function getFulfilledQuantityClass(item: SalesOrderItem): "green" | "orange" {
const quantity = Number(item.quantity || 0);
const fulfilledQuantity = Number(item.fulfilledQuantity || 0);
return fulfilledQuantity < quantity ? "orange" : "green";
}
function getRemainingQuantityClass(item: SalesOrderItem): "green" | "orange" {
return getRemainingQuantity(item) === 0 ? "green" : "orange";
}
function getItemFulfillmentStatus(item: SalesOrderItem): { text: string; theme: "success" | "warning" | "danger" } {
const quantity = Number(item.quantity || 0);
const fulfilledQuantity = Number(item.fulfilledQuantity || 0);
if (0 < quantity && (fulfilledQuantity === quantity || quantity < fulfilledQuantity)) {
const remainingQuantity = Number(item.remainingQuantity || 0);
if (0 < fulfilledQuantity && remainingQuantity === 0) {
return { text: "已完成", theme: "success" };
}
if (0 < fulfilledQuantity) {
@@ -308,12 +318,25 @@ async function loadData(): Promise<void> {
navBarStore.clearRight();
if (!id) {
order.value = undefined;
hasPendingFulfillment.value = false;
photoItems.value = [];
return;
}
loading.value = true;
try {
order.value = await SalesOrderAPI.detail(id);
hasPendingFulfillment.value = false;
if (userStore.hasPermissions(permissionCodes.orderFulfillment.read)) {
try {
const [pendingResult, waitingStockResult] = await Promise.all([
FulfillmentAPI.list({ salesOrderId: id, status: "PENDING", index: 0, size: 1 }),
FulfillmentAPI.list({ salesOrderId: id, status: "WAITING_STOCK", index: 0, size: 1 })
]);
hasPendingFulfillment.value = !!pendingResult.list?.length || !!waitingStockResult.list?.length;
} catch {
// 取消接口仍会在后端校验待处理履约,查询失败时不阻断订单详情展示。
}
}
editRemark.value = order.value.remark || "";
photoItems.value = toPhotoItems(order.value.attachmentList);
} catch (error) {
@@ -464,7 +487,6 @@ onUnmounted(() => navBarStore.clearRight());
gap: .75rem;
display: flex;
margin-top: .6rem;
color: var(--td-text-color-secondary);
align-items: end;
justify-content: space-between;
@@ -513,27 +535,77 @@ onUnmounted(() => navBarStore.clearRight());
.product {
.item {
.form-item {
padding: 10px;
.avatar {
width: 3.5rem;
height: 3.5rem;
}
.title {
flex: 1;
.item {
width: 100%;
display: flex;
justify-content: space-between;
}
flex-direction: column;
.description {
gap: .75rem;
display: flex;
> span {
gap: .25rem;
.header {
width: 100%;
display: flex;
padding-bottom: .5rem;
border-bottom: 1px solid rgba(155, 155, 155, .2);
.left {
.avatar {
width: 3.5rem;
height: 3.5rem;
}
}
.main {
flex: 1;
display: flex;
padding-left: .5rem;
flex-direction: column;
justify-content: space-between;
.title {
display: flex;
align-items: center;
justify-content: space-between;
}
.description {
gap: .5rem;
display: flex;
}
}
}
.content {
display: flex;
padding-top: 5px;
justify-content: space-around;
.metric {
gap: .25rem;
flex: 1;
display: flex;
position: relative;
align-items: center;
flex-direction: column;
&:not(:last-child):after {
content: "";
top: 50%;
right: 0;
width: 1px;
height: 50%;
transform: translateY(calc(-50% + 2.5px));
position: absolute;
background: rgba(155, 155, 155, .2);
}
span {
color: var(--td-text-color-secondary);
font-size: .65rem;
}
}
}
}
}