fix OrderFulfillmentList.vue style

This commit is contained in:
Timi
2026-09-05 23:36:53 +08:00
parent 813d511d30
commit 3698f7151f
4 changed files with 172 additions and 102 deletions
@@ -1,7 +1,7 @@
<template> <template>
<div v-can="permissionCodes.orderFulfillment.create" class="fulfillment-create"> <div v-can="permissionCodes.orderFulfillment.create" class="fulfillment-create">
<t-cell-group title="履约来源" theme="card"> <t-cell-group title="履约来源" theme="card">
<t-cell title="销售订单" :note="order?.orderNo || order?.id || '请选择直接产品订单'" arrow @click="openOrderPicker" /> <t-cell title="销售订单" :note="order?.orderNo || order?.id || '未找到来源订单'" />
<t-cell title="客户" :note="order?.customer?.name || order?.customerId || '未选择'" /> <t-cell title="客户" :note="order?.customer?.name || order?.customerId || '未选择'" />
<t-cell title="来源类型" :note="isPackage ? '套餐权益' : '直接产品'" /> <t-cell title="来源类型" :note="isPackage ? '套餐权益' : '直接产品'" />
</t-cell-group> </t-cell-group>
@@ -51,7 +51,7 @@
</div> </div>
</t-form-item> </t-form-item>
</t-form> </t-form>
<div v-else class="empty" v-text="order ? '该订单暂无可履约明细,请从客户套餐或订单详情进入' : '请先选择销售订单'" /> <div v-else class="empty" v-text="'暂无可履约明细,请返回订单详情或客户套餐详情检查'" />
<t-form class="form" label-align="left" label-width="6rem"> <t-form class="form" label-align="left" label-width="6rem">
<t-form-item label="履约时间" content-align="right"> <t-form-item label="履约时间" content-align="right">
<t-button <t-button
@@ -80,23 +80,6 @@
<t-popup v-model="pickerVisible" placement="bottom"> <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-date-time-picker v-model="draftTime" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="履约时间" @cancel="pickerVisible = false" @confirm="confirmTime" />
</t-popup> </t-popup>
<t-popup v-model="orderPickerVisible" placement="bottom" :style="{height: '75vh'}">
<div class="order-picker">
<div class="picker-title" v-text="'选择进行中的直接产品订单'" />
<t-cell-group v-if="orderOptions.length" theme="card">
<t-cell
v-for="item in orderOptions"
:key="item.id"
:title="item.orderNo || item.id"
:description="item.customer?.name || item.customerId || '未知客户'"
:note="formatPackageAmount(item.saleAmount)"
arrow
@click="selectOrder(item)"
/>
</t-cell-group>
<div v-else class="empty" v-text="orderLoading ? '正在加载订单' : '暂无可履约的直接产品订单'" />
</div>
</t-popup>
</div> </div>
</template> </template>
@@ -111,7 +94,7 @@ import type { OrderFulfillmentItemRequest } from "@/types/Fulfillment";
import type { SalesOrder, SalesOrderItem } from "@/types/SalesOrder"; import type { SalesOrder, SalesOrderItem } from "@/types/SalesOrder";
import type { PhotoItem } from "@/types/Photo"; import type { PhotoItem } from "@/types/Photo";
import type { Attachment } from "timi-web-api"; import type { Attachment } from "timi-web-api";
import { createPackageRequestNo, formatPackageAmount, getPackageQuantityText, normalizePackageQuantity } from "@/utils/package"; import { createPackageRequestNo, getPackageQuantityText, normalizePackageQuantity } from "@/utils/package";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
@@ -134,16 +117,17 @@ const requestNo = ref("");
const isSaving = ref(false); const isSaving = ref(false);
const photoItems = ref<PhotoItem[]>([]); const photoItems = ref<PhotoItem[]>([]);
const photoUrlList = computed(() => photoItems.value.map(item => item.url)); const photoUrlList = computed(() => photoItems.value.map(item => item.url));
const orderPickerVisible = ref(false);
const orderLoading = ref(false);
const orderOptions = ref<SalesOrder[]>([]);
const isPackage = computed(() => !!customerPackage.value); const isPackage = computed(() => !!customerPackage.value);
const sourceItems = computed<SourceItem[]>(() => customerPackage.value?.itemList || order.value?.itemList || []); const sourceItems = computed<SourceItem[]>(() => customerPackage.value?.itemList || order.value?.itemList || []);
async function loadData(): Promise<void> { async function loadData(): Promise<void> {
const salesOrderId = String(route.query.salesOrderId || ""); const salesOrderId = String(route.query.salesOrderId || "");
const customerPackageId = String(route.query.customerPackageId || ""); const customerPackageId = String(route.query.customerPackageId || "");
if (!salesOrderId) return; if (!salesOrderId) {
Message.warning("请从订单详情或客户套餐详情进入履约创建");
await router.replace("/management/order-fulfillments");
return;
}
try { try {
order.value = await SalesOrderAPI.detail(salesOrderId); order.value = await SalesOrderAPI.detail(salesOrderId);
if (customerPackageId) customerPackage.value = await PackageAPI.customerPackageDetail(customerPackageId); if (customerPackageId) customerPackage.value = await PackageAPI.customerPackageDetail(customerPackageId);
@@ -191,7 +175,7 @@ function buildAttachments(): Partial<Attachment>[] {
function validate(): boolean { function validate(): boolean {
if (!order.value?.id) { if (!order.value?.id) {
Message.warning("请先从订单详情进入履约创建"); Message.warning("请从订单详情或客户套餐详情进入履约创建");
return false; return false;
} }
if (!buildAttachments().length) { if (!buildAttachments().length) {
@@ -240,46 +224,6 @@ async function save(): Promise<void> {
} }
} }
async function openOrderPicker(): Promise<void> {
if (order.value?.id) {
await router.push({
path: `/management/sales-orders/${order.value.id}`,
query: { transitionDepth: String(getCurrentRouteDepth() + 1) }
});
return;
}
orderPickerVisible.value = true;
if (orderOptions.value.length) return;
orderLoading.value = true;
try {
const result = await SalesOrderAPI.list({ index: 0, size: 100, orderType: "DIRECT_PRODUCT", status: "ACTIVE" });
orderOptions.value = result.list || [];
} catch (error) {
Message.error(getErrorMessage(error, "可履约订单加载失败"));
} finally {
orderLoading.value = false;
}
}
async function selectOrder(item: SalesOrder): Promise<void> {
if (!item.id) return;
order.value = item;
orderPickerVisible.value = false;
for (const key of Object.keys(quantityMap)) delete quantityMap[key];
await router.replace({ query: { salesOrderId: item.id } });
}
function getCurrentRouteDepth(): number {
const transitionDepth = Array.isArray(route.query.transitionDepth)
? route.query.transitionDepth[0]
: route.query.transitionDepth;
const depth = Number(transitionDepth);
if (transitionDepth && Number.isInteger(depth) && 0 <= depth && depth <= 20) {
return depth;
}
return Number(route.meta.depth ?? 0);
}
onMounted(async () => { onMounted(async () => {
navBarStore.setRightText("提交", "var(--td-brand-color)", save); navBarStore.setRightText("提交", "var(--td-brand-color)", save);
await loadData(); await loadData();
@@ -347,15 +291,5 @@ onUnmounted(() => navBarStore.clearRight());
text-align: center; text-align: center;
} }
.order-picker {
height: 100%;
overflow: auto;
padding: 1rem 0;
}
.picker-title {
padding: 0 1rem 1rem;
font-weight: 700;
}
} }
</style> </style>
+139 -18
View File
@@ -2,32 +2,82 @@
<div v-can="permissionCodes.orderFulfillment.read" class="fulfillment-detail"> <div v-can="permissionCodes.orderFulfillment.read" class="fulfillment-detail">
<template v-if="fulfillment"> <template v-if="fulfillment">
<t-cell-group title="履约单信息" theme="card" v-entrance-transition> <t-cell-group title="履约单信息" theme="card" v-entrance-transition>
<t-cell title="履约单号" :note="fulfillment.id" /> <t-cell title="销售订单" arrow @click="toOrder" />
<t-cell title="销售订单" :note="fulfillment.salesOrderId || '未知'" arrow @click="toOrder" />
<t-cell title="履约类型" :note="getFulfillmentTypeText(fulfillment.fulfillmentType)" /> <t-cell title="履约类型" :note="getFulfillmentTypeText(fulfillment.fulfillmentType)" />
<t-cell title="状态"><template #note><t-tag :content="getFulfillmentStatusText(fulfillment.status)" variant="light-outline" :theme="getStatusTheme(fulfillment.status)" /></template></t-cell> <t-cell title="状态"><template #note><t-tag :content="getFulfillmentStatusText(fulfillment.status)" variant="light-outline" :theme="getStatusTheme(fulfillment.status)" /></template></t-cell>
<t-cell title="履约时间" :note="fulfillment.deliveredAt ? Time.toDateTime(fulfillment.deliveredAt) : '未知'" /> <t-cell title="履约时间">
<template #note>
<pass-time-label :timestamp="fulfillment.deliveredAt" underline />
</template>
</t-cell>
<t-cell title="提交人" :note="fulfillment.operator?.name || fulfillment.operatorUserId || '未知'" /> <t-cell title="提交人" :note="fulfillment.operator?.name || fulfillment.operatorUserId || '未知'" />
<t-cell title="处理人" :note="fulfillment.verifier?.name || fulfillment.verifierUserId || '未处理'" /> <t-cell title="处理人" :note="fulfillment.verifier?.name || fulfillment.verifierUserId || '未处理'" />
<t-cell v-if="fulfillment.reason" title="原因" :note="fulfillment.reason" /> <t-cell v-if="fulfillment.reason" title="原因" :note="fulfillment.reason" />
<t-cell v-if="fulfillment.remark" title="备注" :note="fulfillment.remark" /> <t-cell v-if="fulfillment.remark" title="备注" :note="fulfillment.remark" />
</t-cell-group> </t-cell-group>
<t-cell-group title="履约明细" theme="card" v-entrance-transition="70"> <div class="photos" v-entrance-transition="35">
<photo-scroller
:model-value="fulfillmentPhotoURLList"
:photo-items="fulfillmentPhotoItems"
:show-add="false"
:show-remove="false"
:show-viewer-actions="false"
title="履约照片"
/>
</div>
<t-cell-group class="items" title="履约明细" theme="card" v-entrance-transition="70">
<t-cell <t-cell
class="item"
v-for="item in fulfillment.itemList || []" v-for="item in fulfillment.itemList || []"
:key="item.id" :key="item.id"
:title="`${item.productNameSnapshot || '未命名产品'} · ${item.unitSnapshot || ''}`" :title="item.productNameSnapshot"
:description="`本次 ${item.quantity || 0} · 基础单位 ${item.baseQuantity || 0}`" :description="`${item.quantity} ${item.unitSnapshot}`"
:note="`待履约 ${item.reservedBefore || 0} → ${item.reservedAfter || 0}`" :note="`待履约 ${item.reservedBefore || 0} → ${item.reservedAfter || 0}`"
/> >
<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>
</t-cell>
<t-cell v-if="!fulfillment.itemList?.length" title="暂无履约明细" /> <t-cell v-if="!fulfillment.itemList?.length" title="暂无履约明细" />
</t-cell-group> </t-cell-group>
<section class="actions" v-entrance-transition="140"> <section
<t-button v-if="isWaitingStock && canVerify" theme="primary" @click="prepareStock">准备库存</t-button> v-if="(isWaitingStock && canVerify) || (isPending && (canVerify || canCreate)) || (isVerified && canRevoke)"
<t-button v-if="isPending && canVerify" theme="primary" @click="verify">核销履约</t-button> class="actions"
<t-button v-if="isPending && canVerify" theme="default" variant="outline" @click="openReason('reject')">拒绝履约</t-button> v-entrance-transition="140"
<t-button v-if="isPending && canCreate" theme="default" variant="outline" @click="openReason('cancel')">撤销履约</t-button> >
<t-button v-if="isVerified && canRevoke" theme="danger" variant="outline" @click="openRevoke">履约冲正</t-button> <t-button
v-if="isWaitingStock && canVerify"
theme="primary"
content="准备库存"
@click="prepareStock"
/>
<div v-if="isPending && canVerify" class="pair">
<t-button theme="primary" content="核销履约" @click="verify" />
<t-button theme="danger" content="拒绝履约" @click="openReason('reject')" />
</div>
<t-button
v-if="isPending && canCreate"
theme="default"
content="撤销履约"
@click="openReason('cancel')"
/>
<t-button
v-if="isVerified && canRevoke"
theme="danger"
content="履约冲正"
@click="openRevoke"
/>
</section> </section>
</template> </template>
<t-empty v-else :description="loading ? '正在加载履约单' : '履约单不存在'" /> <t-empty v-else :description="loading ? '正在加载履约单' : '履约单不存在'" />
@@ -66,7 +116,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile"; import { BottomPopup } from "timi-tdesign-mobile";
import { Time } from "timi-web"; import { PassTimeLabel } from "timi-web";
import { useUserStore } from "timi-web-api"; import { useUserStore } from "timi-web-api";
import FulfillmentAPI from "@/api/FulfillmentAPI"; import FulfillmentAPI from "@/api/FulfillmentAPI";
import PhotoScroller from "@/components/PhotoScroller.vue"; import PhotoScroller from "@/components/PhotoScroller.vue";
@@ -75,7 +125,9 @@ import type { OrderFulfillment } from "@/types/Fulfillment";
import type { PhotoItem } from "@/types/Photo"; import type { PhotoItem } from "@/types/Photo";
import type { Attachment } from "timi-web-api"; import type { Attachment } from "timi-web-api";
import { createPackageRequestNo } from "@/utils/package"; import { createPackageRequestNo } from "@/utils/package";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { getProductPhotoURL } from "@/utils/product";
import { getFulfillmentStatusText, getFulfillmentTypeText, getStatusTheme } from "@/utils/salesOrder"; import { getFulfillmentStatusText, getFulfillmentTypeText, getStatusTheme } from "@/utils/salesOrder";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
@@ -99,6 +151,15 @@ const returnedToStore = ref(true);
const revokeRequestNo = ref(""); const revokeRequestNo = ref("");
const photoItems = ref<PhotoItem[]>([]); const photoItems = ref<PhotoItem[]>([]);
const photoUrlList = computed(() => photoItems.value.map(item => item.url)); const photoUrlList = computed(() => photoItems.value.map(item => item.url));
const fulfillmentPhotoItems = computed<PhotoItem[]>(() => (fulfillment.value?.attachmentList || [])
.filter(item => !!item.id)
.map(item => ({
attachment: item,
attachType: item.attachType || "PHOTO",
previewUrl: getAttachmentReadURL(item.id || ""),
url: getAttachmentReadURL(item.id || "", 90)
})));
const fulfillmentPhotoURLList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
const isWaitingStock = computed(() => fulfillment.value?.status === "WAITING_STOCK"); const isWaitingStock = computed(() => fulfillment.value?.status === "WAITING_STOCK");
const isPending = computed(() => fulfillment.value?.status === "PENDING"); const isPending = computed(() => fulfillment.value?.status === "PENDING");
const isVerified = computed(() => fulfillment.value?.status === "VERIFIED"); const isVerified = computed(() => fulfillment.value?.status === "VERIFIED");
@@ -114,7 +175,22 @@ async function loadData(): Promise<void> {
finally { loading.value = false; } finally { loading.value = false; }
} }
async function toOrder(): Promise<void> { async function toOrder(): Promise<void> {
if (fulfillment.value?.salesOrderId) await router.push(`/management/sales-orders/${fulfillment.value.salesOrderId}`); if (fulfillment.value?.salesOrderId) {
await router.push({
path: `/management/sales-orders/${fulfillment.value.salesOrderId}`,
query: { transitionDepth: String(getCurrentRouteDepth() + 1) }
});
}
}
function getCurrentRouteDepth(): number {
const transitionDepth = Array.isArray(route.query.transitionDepth)
? route.query.transitionDepth[0]
: route.query.transitionDepth;
const depth = Number(transitionDepth);
if (transitionDepth && Number.isInteger(depth) && 0 <= depth && depth <= 20) {
return depth;
}
return Number(route.meta.depth ?? 0);
} }
async function prepareStock(): Promise<void> { async function prepareStock(): Promise<void> {
if (!fulfillment.value?.id || operating.value) return; if (!fulfillment.value?.id || operating.value) return;
@@ -178,8 +254,53 @@ onUnmounted(() => navBarStore.clearRight());
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.fulfillment-detail { gap: 1rem; padding: calc(var(--app-nav-height) + 1rem) 0 2rem; display: flex; flex-direction: column; .fulfillment-detail {
.actions { gap: .5rem; padding: 0 1rem; display: flex; flex-wrap: wrap; } gap: 1rem;
.actions .t-button { flex: 1 1 8rem; } padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.actions {
--td-button-border-radius: 1rem;
gap: .5rem;
padding: 0 1rem;
display: flex;
flex-wrap: wrap;
.pair {
gap: .5rem;
width: 100%;
display: flex;
.t-button {
width: calc(50% - .25rem);
margin: 0;
flex: 1 1 0;
}
}
.t-button { flex: 1 1 auto; }
}
.photos {
margin: 0 1rem;
padding: 1rem;
background: var(--td-bg-color-container, #fff);
box-shadow: var(--td-shadow-1);
border-radius: 1.25rem;
}
.items {
.item {
padding: 10px;
.avatar {
width: 3.5rem;
height: 3.5rem;
}
}
}
} }
</style> </style>
+23 -9
View File
@@ -48,6 +48,7 @@ import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import OrderFulfillmentListCell from "@/pages/fulfillment/components/OrderFulfillmentListCell.vue"; import OrderFulfillmentListCell from "@/pages/fulfillment/components/OrderFulfillmentListCell.vue";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { FulfillmentStatus, FulfillmentType, OrderFulfillment } from "@/types/Fulfillment"; import type { FulfillmentStatus, FulfillmentType, OrderFulfillment } from "@/types/Fulfillment";
import type { NavBarAction } from "@/types/NavBarAction";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
@@ -59,13 +60,22 @@ const router = useRouter();
const navBarStore = useNavBarStore(); const navBarStore = useNavBarStore();
const userStore = useUserStore(); const userStore = useUserStore();
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)); const canCreate = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create));
const actions = computed(() => canCreate.value ? [{ const actions = computed<NavBarAction[]>(() => [
key: "create", {
text: "创建履约单", key: "by-order",
icon: "add", text: "通过订单创建",
theme: "primary" as const, icon: "task",
action: toCreate theme: "primary",
}] : []); action: toSalesOrderList
},
{
key: "by-package",
text: "通过套餐创建",
icon: "gift",
theme: "primary",
action: toCustomerPackageList
}
]);
const type = ref<"ALL" | FulfillmentType>("ALL"); const type = ref<"ALL" | FulfillmentType>("ALL");
const status = ref<"ALL" | FulfillmentStatus>(getInitialStatus(route.query.status)); const status = ref<"ALL" | FulfillmentStatus>(getInitialStatus(route.query.status));
const list = ref<OrderFulfillment[]>([]); const list = ref<OrderFulfillment[]>([]);
@@ -172,8 +182,12 @@ async function toDetail(item: OrderFulfillment): Promise<void> {
} }
} }
async function toCreate(): Promise<void> { async function toSalesOrderList(): Promise<void> {
await router.push("/management/order-fulfillments/create"); await router.push({ name: "SalesOrderList" });
}
async function toCustomerPackageList(): Promise<void> {
await router.push({ name: "CustomerPackageList" });
} }
function getCurrentRouteDepth(): number { function getCurrentRouteDepth(): number {
+1
View File
@@ -8,6 +8,7 @@ export type OrderFulfillmentItem = {
fulfillmentId?: string; fulfillmentId?: string;
productId?: string; productId?: string;
productUnitId?: string; productUnitId?: string;
attachmentList?: Attachment[];
sourceOrderItemId?: string; sourceOrderItemId?: string;
sourceCustomerPackageItemId?: string; sourceCustomerPackageItemId?: string;
quantity?: number | string; quantity?: number | string;