add product
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.salesOrder.cancel" class="sales-order-operation">
|
||||
<t-cell-group v-if="order" title="销售订单" theme="card"><t-cell title="订单号" :note="order.orderNo || order.id" /><t-cell title="当前状态" :note="order.status || '未知'" /></t-cell-group>
|
||||
<t-form v-if="order" class="form" label-align="left" label-width="6rem">
|
||||
<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="!order" :description="loading ? '正在加载订单' : '订单不存在'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import SalesOrderAPI from "@/api/SalesOrderAPI";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { SalesOrder } from "@/types/SalesOrder";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "SalesOrderCancel" });
|
||||
const route = useRoute(); const router = useRouter(); const navBarStore = useNavBarStore(); const userStore = useUserStore();
|
||||
const order = ref<SalesOrder>(); const loading = ref(false); const saving = ref(false); const reason = ref("");
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) return;
|
||||
loading.value = true;
|
||||
try { order.value = await SalesOrderAPI.detail(id); }
|
||||
catch (error) { Message.error(getErrorMessage(error, "订单加载失败")); }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
async function save(): Promise<void> {
|
||||
if (!order.value?.id || !reason.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await SalesOrderAPI.cancel({ salesOrderId: order.value.id, reason: reason.value.trim() });
|
||||
Message.success("订单已取消");
|
||||
await router.replace(`/management/sales-orders/${order.value.id}`);
|
||||
} catch (error) { Message.error(getErrorMessage(error, "订单取消失败")); }
|
||||
finally { saving.value = false; }
|
||||
}
|
||||
onMounted(async () => {
|
||||
if (!userStore.hasPermissions(permissionCodes.salesOrder.cancel)) return;
|
||||
navBarStore.setRightText("确认取消", "var(--td-danger-color)", save);
|
||||
await loadData();
|
||||
});
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.sales-order-operation { gap: 1rem; padding: calc(var(--app-nav-height) + 1rem) 1rem 2rem; display: flex; flex-direction: column;
|
||||
.form { padding: 0 .5rem; background: var(--td-bg-color-container); box-shadow: var(--td-shadow-1); border-radius: 1rem; overflow: hidden; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,596 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.salesOrder.create" class="sales-order-create">
|
||||
<header class="intro">
|
||||
<span class="eyebrow">销售订单</span>
|
||||
<h1>创建直接产品订单</h1>
|
||||
<p>选择客户并添加本次购买的产品,提交后即可继续处理收款。</p>
|
||||
</header>
|
||||
|
||||
<section class="section customer-section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="step" v-text="'01 / 客户'" />
|
||||
<h2>选择客户</h2>
|
||||
<p>订单将归档到所选客户名下</p>
|
||||
</div>
|
||||
<span class="required" v-text="'必填'" />
|
||||
</div>
|
||||
<button class="customer-picker" type="button" @click="customerSelectorVisible = true">
|
||||
<span class="customer-avatar" :class="{selected: !!customer}" v-text="customer?.name?.slice(0, 1) || '客'" />
|
||||
<span class="customer-copy">
|
||||
<strong v-if="customer" v-text="customer.name || customer.code" />
|
||||
<strong v-else class="placeholder">请选择客户</strong>
|
||||
<span v-if="customer?.telephone" v-text="customer.telephone" />
|
||||
<span v-else class="hint" v-text="customer ? customer.code : '支持按姓名、编码或电话搜索'" />
|
||||
</span>
|
||||
<t-icon name="chevron-right" size="1.25rem" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="section product-section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="step" v-text="'02 / 产品'" />
|
||||
<h2>添加产品</h2>
|
||||
<p>同一产品单位不能重复,数量最多三位小数</p>
|
||||
</div>
|
||||
<span class="item-count" v-text="`${items.length} 项`" />
|
||||
</div>
|
||||
<div class="items">
|
||||
<article v-for="(item, index) in items" :key="item.clientKey" class="order-item">
|
||||
<div class="item-head">
|
||||
<span class="item-number" v-text="String(index + 1).padStart(2, '0')" />
|
||||
<div class="item-title">
|
||||
<span class="item-label" v-text="`产品 ${index + 1}`" />
|
||||
<strong v-text="item.productName || '未选择产品'" />
|
||||
</div>
|
||||
<t-button theme="danger" variant="text" size="small" @click="removeItem(index)">删除</t-button>
|
||||
</div>
|
||||
<t-form class="item-form" label-align="left" label-width="5.25rem">
|
||||
<product-selector
|
||||
:model-value="item.productId && item.productUnitId ? {productId: item.productId, productUnitId: item.productUnitId} : undefined"
|
||||
label="产品单位"
|
||||
placeholder="请选择产品及单位"
|
||||
required-mark
|
||||
@update:model-value="applyProduct(index, $event)"
|
||||
/>
|
||||
<div class="number-fields">
|
||||
<t-form-item label="数量" required-mark>
|
||||
<t-input v-model.trim="item.quantity" type="number" align="right" borderless placeholder="请输入数量" />
|
||||
</t-form-item>
|
||||
<t-form-item v-if="canPriceOverride" label="成交单价">
|
||||
<t-input v-model.trim="item.unitPriceText" type="number" align="right" borderless placeholder="产品价" />
|
||||
</t-form-item>
|
||||
</div>
|
||||
<t-form-item label="产品备注">
|
||||
<t-input v-model.trim="item.remark" borderless placeholder="选填" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</article>
|
||||
</div>
|
||||
<button class="add-item" type="button" @click="addItem">
|
||||
<t-icon name="add" size="1.125rem" />
|
||||
<span>继续添加产品</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="section setting-section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="step" v-text="'03 / 设置'" />
|
||||
<h2>订单设置</h2>
|
||||
<p>时间不填则默认使用提交时刻</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="setting-row" type="button" @click="pickerVisible = true">
|
||||
<span class="setting-icon"><t-icon name="time" size="1.25rem" /></span>
|
||||
<span class="setting-copy">
|
||||
<strong>下单时间</strong>
|
||||
<span v-text="purchasedAtText || '此刻'" />
|
||||
</span>
|
||||
<span class="setting-action" v-text="purchasedAtText ? '已设置' : '修改'" />
|
||||
<t-icon name="chevron-right" size="1.125rem" />
|
||||
</button>
|
||||
<div class="remark-field">
|
||||
<div class="remark-head">
|
||||
<strong>订单备注</strong>
|
||||
<span v-text="'选填'" />
|
||||
</div>
|
||||
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="记录本单需要注意的内容" />
|
||||
</div>
|
||||
<package-photo-field v-model="photoItems" title="购买照片(消费事件要求时必填)" />
|
||||
</section>
|
||||
|
||||
<div class="submit-bar">
|
||||
<span>确认信息无误后提交订单</span>
|
||||
<t-button theme="primary" block :loading="isSaving" :disabled="isSaving" @click="save">提交订单</t-button>
|
||||
</div>
|
||||
<customer-selector v-model="customerIdList" v-model:visible="customerSelectorVisible" selection-mode="single" @change="loadCustomer" />
|
||||
<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, type Attachment } from "timi-web-api";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import ProductAPI from "@/api/ProductAPI";
|
||||
import SalesOrderAPI from "@/api/SalesOrderAPI";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import PackagePhotoField from "@/components/PackagePhotoField.vue";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { Product } from "@/types/Product";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import type { DirectProductOrderItemRequest } from "@/types/SalesOrder";
|
||||
import { createPackageRequestNo, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
|
||||
type FormItem = Partial<DirectProductOrderItemRequest> & { clientKey: string; productName: string; unitPriceText: string };
|
||||
defineOptions({ name: "SalesOrderCreate" });
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
const userStore = useUserStore();
|
||||
const canPriceOverride = computed(() => userStore.hasPermissions(permissionCodes.salesOrder.priceOverride));
|
||||
const customerIdList = ref<string[]>([]);
|
||||
const customer = ref<Customer>();
|
||||
const customerSelectorVisible = ref(false);
|
||||
const items = ref<FormItem[]>([]);
|
||||
const remark = ref("");
|
||||
const purchasedAtText = ref("");
|
||||
const draftTime = ref("");
|
||||
const pickerVisible = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const photoItems = ref<PhotoItem[]>([]);
|
||||
const productCache = new Map<string, Product>();
|
||||
const requestNo = ref("");
|
||||
function createItem(): FormItem { return { clientKey: window.crypto.randomUUID(), quantity: "", productName: "", unitPriceText: "", remark: "" }; }
|
||||
function addItem(): void { items.value.push(createItem()); }
|
||||
function removeItem(index: number): void { if (1 < items.value.length) items.value.splice(index, 1); else Message.warning("订单至少需要一项产品"); }
|
||||
async function applyProduct(index: number, value?: { productId: string; productUnitId: string }): Promise<void> {
|
||||
if (!value) return;
|
||||
const product = productCache.get(value.productId) || await ProductAPI.detail(value.productId);
|
||||
productCache.set(value.productId, product);
|
||||
items.value[index].productId = value.productId;
|
||||
items.value[index].productUnitId = value.productUnitId;
|
||||
items.value[index].productName = `${product.name} · ${product.unitList?.find(unit => unit.id === value.productUnitId)?.name || "单位"}`;
|
||||
}
|
||||
async function loadCustomer(value: string | string[]): Promise<void> { const id = Array.isArray(value) ? value[0] : value; customer.value = id ? await CustomerAPI.detail(id) : undefined; }
|
||||
function confirmTime(value: string | number): void { purchasedAtText.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(): Partial<Attachment>[] {
|
||||
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
|
||||
}
|
||||
function validate(): boolean {
|
||||
if (!customer.value?.id) { Message.warning("请选择客户"); return false; }
|
||||
if (!items.value.length) { Message.warning("请至少添加一项产品"); return false; }
|
||||
const keys = new Set<string>();
|
||||
for (const item of items.value) {
|
||||
const quantity = normalizePackageQuantity(item.quantity ?? "");
|
||||
const key = `${item.productId}\u0000${item.productUnitId}`;
|
||||
if (!item.productId || !item.productUnitId || !quantity || quantity === "0") { Message.warning("产品单位和正数量不能为空"); return false; }
|
||||
if (keys.has(key)) { Message.warning("产品单位不能重复"); return false; }
|
||||
keys.add(key);
|
||||
if (canPriceOverride.value && item.unitPriceText && parsePackageAmount(item.unitPriceText) === undefined) { Message.warning("成交单价必须是合法金额"); return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
async function save(): Promise<void> {
|
||||
if (isSaving.value || !validate()) return;
|
||||
if (!requestNo.value) requestNo.value = createPackageRequestNo();
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const itemList = items.value.map((item, index) => ({
|
||||
productId: item.productId!,
|
||||
productUnitId: item.productUnitId!,
|
||||
quantity: normalizePackageQuantity(item.quantity ?? "")!,
|
||||
unitPrice: canPriceOverride.value && item.unitPriceText ? parsePackageAmount(item.unitPriceText) : undefined,
|
||||
sort: index,
|
||||
remark: item.remark?.trim() || null
|
||||
}));
|
||||
const result = await SalesOrderAPI.createDirectProduct({
|
||||
requestNo: requestNo.value,
|
||||
customerId: customer.value!.id!,
|
||||
purchasedAt: resolveTimestamp(purchasedAtText.value),
|
||||
itemList,
|
||||
attachmentList: buildAttachments(),
|
||||
remark: remark.value.trim() || null
|
||||
});
|
||||
Message.success("销售订单已创建");
|
||||
await router.replace(`/management/sales-orders/${result.id}`);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "销售订单创建失败,重试会复用同一请求号");
|
||||
if (message.includes("直接产品消费事件")) {
|
||||
Message.warning("请先在门店配置中选择直接产品消费事件");
|
||||
const storeId = currentStoreStore.currentStoreId;
|
||||
if (storeId) {
|
||||
await router.replace(`/management/stores/${storeId}`);
|
||||
}
|
||||
} else {
|
||||
Message.error(message);
|
||||
}
|
||||
}
|
||||
finally { isSaving.value = false; }
|
||||
}
|
||||
onMounted(() => { addItem(); navBarStore.setRightText("提交订单", "var(--td-brand-color)", save); });
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.sales-order-create {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 1rem calc(7.5rem + env(safe-area-inset-bottom, 0));
|
||||
flex-direction: column;
|
||||
|
||||
.intro {
|
||||
padding: .25rem .25rem .5rem;
|
||||
|
||||
.eyebrow {
|
||||
color: var(--td-brand-color);
|
||||
font-size: .6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: .5rem 0 .35rem;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 1rem;
|
||||
background: var(--td-bg-color-container);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
|
||||
.step {
|
||||
color: var(--td-brand-color);
|
||||
display: block;
|
||||
font-size: .6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: .3rem 0 .25rem;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.required,
|
||||
.item-count {
|
||||
color: var(--td-brand-color);
|
||||
padding: .25rem .5rem;
|
||||
background: var(--td-brand-color-1);
|
||||
border-radius: .75rem;
|
||||
font-size: .6875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.customer-picker,
|
||||
.setting-row {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.customer-picker {
|
||||
gap: .75rem;
|
||||
margin-top: 1rem;
|
||||
padding: .75rem;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .875rem;
|
||||
|
||||
&:active {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
|
||||
.customer-avatar {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
color: var(--td-brand-color);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--td-brand-color-1);
|
||||
border-radius: .875rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
|
||||
&.selected {
|
||||
color: var(--td-text-color-anti);
|
||||
background: var(--td-brand-color);
|
||||
}
|
||||
}
|
||||
|
||||
.customer-copy {
|
||||
gap: .2rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
strong,
|
||||
span {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: .9375rem;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.placeholder,
|
||||
.hint {
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.order-item {
|
||||
padding: .875rem;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
border-radius: .875rem;
|
||||
}
|
||||
|
||||
.item-head {
|
||||
gap: .625rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.item-number {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
color: var(--td-brand-color);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--td-brand-color-1);
|
||||
border-radius: .625rem;
|
||||
font-size: .6875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
gap: .1rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.item-label {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .6875rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
overflow: hidden;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: .875rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.item-form {
|
||||
:deep(.t-form__item) {
|
||||
min-height: 2.75rem;
|
||||
padding: .125rem 0;
|
||||
border-bottom: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
|
||||
:deep(.t-form__item:last-child) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.t-form__label) {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
:deep(.t-input) {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
:deep(.t-form__controls) {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.number-fields {
|
||||
gap: .75rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
:deep(.t-form__item) {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.add-item {
|
||||
gap: .375rem;
|
||||
width: 100%;
|
||||
margin-top: .75rem;
|
||||
padding: .75rem;
|
||||
color: var(--td-brand-color);
|
||||
border: 1px dashed var(--td-brand-color-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--td-brand-color-1);
|
||||
border-radius: .75rem;
|
||||
font-size: .8125rem;
|
||||
font-weight: 600;
|
||||
|
||||
&:active {
|
||||
background: var(--td-brand-color-2);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
gap: .75rem;
|
||||
margin-top: 1rem;
|
||||
padding: .75rem 0;
|
||||
border-top: 1px solid var(--td-border-level-1-color);
|
||||
border-bottom: 1px solid var(--td-border-level-1-color);
|
||||
|
||||
&:active {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
|
||||
.setting-icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
color: var(--td-brand-color);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--td-brand-color-1);
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.setting-copy {
|
||||
gap: .2rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
strong {
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-action {
|
||||
color: var(--td-brand-color);
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.remark-field {
|
||||
padding-top: 1rem;
|
||||
|
||||
.remark-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
strong {
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.t-textarea) {
|
||||
margin-top: .5rem;
|
||||
padding: .75rem;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-bar {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 12;
|
||||
gap: .75rem;
|
||||
padding: .625rem 1rem calc(.625rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
box-shadow: 0 -.25rem 1rem rgba(15, 23, 42, .08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--td-bg-color-container);
|
||||
backdrop-filter: blur(1rem);
|
||||
|
||||
span {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .6875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.t-button) {
|
||||
width: 9rem;
|
||||
flex: none;
|
||||
border-radius: .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.sales-order-create {
|
||||
.submit-bar {
|
||||
box-shadow: 0 -.25rem 1rem rgba(0, 0, 0, .2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.salesOrder.read" class="sales-order-detail">
|
||||
<template v-if="order">
|
||||
<customer-profile-card v-if="order.customer" class="customer" :customer="order.customer" v-entrance-transition />
|
||||
<section class="hero" v-entrance-transition="35">
|
||||
<span class="eyebrow" v-text="'销售订单'" />
|
||||
<div class="hero-title">
|
||||
<h1 v-text="order.orderNo || order.id || '未命名订单'" />
|
||||
<t-tag :content="getSalesOrderStatusText(order.status)" variant="light-outline" :theme="getStatusTheme(order.status)" />
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span v-text="getSalesOrderTypeText(order.orderType)" />
|
||||
<strong v-text="formatPackageAmount(order.saleAmount)" />
|
||||
</div>
|
||||
</section>
|
||||
<t-cell-group title="订单信息" theme="card" v-entrance-transition="70">
|
||||
<t-cell title="订单号" :note="order.orderNo || order.id" />
|
||||
<t-cell title="订单类型" :note="getSalesOrderTypeText(order.orderType)" />
|
||||
<t-cell title="订单状态">
|
||||
<template #note><t-tag :content="getSalesOrderStatusText(order.status)" variant="light-outline" :theme="getStatusTheme(order.status)" /></template>
|
||||
</t-cell>
|
||||
<t-cell title="下单时间" :note="order.purchasedAt ? Time.toDateTime(order.purchasedAt) : '未知'" />
|
||||
<t-cell v-if="order.sourceEventRecord?.id" title="来源登记记录" arrow @click="toEventRecord" />
|
||||
<t-cell v-if="order.remark" title="备注" :note="order.remark" />
|
||||
</t-cell-group>
|
||||
<t-cell-group v-if="order.itemList?.length" title="订单明细" theme="card" v-entrance-transition="140">
|
||||
<t-cell
|
||||
v-for="item in order.itemList"
|
||||
:key="item.id"
|
||||
:title="item.productNameSnapshot || '未命名产品'"
|
||||
:description="`${item.quantity || 0} ${item.unitSnapshot || ''} · 已履约 ${item.fulfilledQuantity || 0} · 待履约 ${item.reservedQuantity || 0}`"
|
||||
:note="formatPackageAmount(item.lineAmount)"
|
||||
/>
|
||||
</t-cell-group>
|
||||
<sales-order-finance-panel :order="order" :can-read-fund="canReadFund" @fund="toFundRecords" @record="toFundDetail" />
|
||||
<t-cell-group title="统一履约" theme="card" v-entrance-transition="210">
|
||||
<t-cell title="履约记录" note="查看本单所有履约单" arrow @click="toFulfillmentList" />
|
||||
<t-cell v-if="canCreateFulfillment && order.orderType === 'DIRECT_PRODUCT' && order.status === 'ACTIVE'" title="创建履约单" note="直接产品订单统一履约" arrow @click="toCreateFulfillment" />
|
||||
</t-cell-group>
|
||||
<section class="actions" v-entrance-transition="280">
|
||||
<t-button v-if="canReceive" theme="primary" @click="toPayment">登记收款</t-button>
|
||||
<t-button v-if="canRefund" theme="danger" variant="outline" @click="toRefund">登记退款</t-button>
|
||||
<t-button v-if="canConfirmNoRefund" theme="default" variant="outline" @click="toNoRefund">确认无需退款</t-button>
|
||||
<t-button v-if="canCancel" theme="danger" variant="outline" @click="toCancel">取消订单</t-button>
|
||||
</section>
|
||||
</template>
|
||||
<t-empty v-else :description="loading ? '正在加载订单' : '订单不存在'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Time } from "timi-web";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import SalesOrderAPI from "@/api/SalesOrderAPI";
|
||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||
import SalesOrderFinancePanel from "@/pages/salesOrder/components/SalesOrderFinancePanel.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { SalesOrder } from "@/types/SalesOrder";
|
||||
import { formatPackageAmount } from "@/utils/package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { getSalesOrderStatusText, getSalesOrderTypeText, getStatusTheme } from "@/utils/salesOrder";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "SalesOrderDetail" });
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
const order = ref<SalesOrder>();
|
||||
const loading = ref(false);
|
||||
const canReadFund = computed(() => userStore.hasPermissions(permissionCodes.fundRecord.read));
|
||||
const canCreateFulfillment = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create));
|
||||
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));
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) return;
|
||||
loading.value = true;
|
||||
try { order.value = await SalesOrderAPI.detail(id); }
|
||||
catch (error) { Message.error(getErrorMessage(error, "订单加载失败")); }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
async function toEventRecord(): Promise<void> { if (order.value?.sourceEventRecord?.id) await router.push(`/management/registration-event-records/${order.value.sourceEventRecord.id}`); }
|
||||
async function toFundRecords(): Promise<void> { if (order.value?.id) await router.push({ path: "/management/fund-records", query: { salesOrderId: order.value.id } }); }
|
||||
async function toFundDetail(id: string): Promise<void> { await router.push(`/management/fund-records/${id}`); }
|
||||
async function toFulfillmentList(): Promise<void> { if (order.value?.id) await router.push({ path: "/management/order-fulfillments", query: { salesOrderId: order.value.id } }); }
|
||||
async function toCreateFulfillment(): Promise<void> { if (order.value?.id) await router.push({ path: "/management/order-fulfillments/create", query: { salesOrderId: order.value.id } }); }
|
||||
async function toPayment(): Promise<void> { if (order.value?.id) await router.push(`/management/sales-orders/${order.value.id}/payment`); }
|
||||
async function toRefund(): Promise<void> { if (order.value?.id) await router.push(`/management/sales-orders/${order.value.id}/refund`); }
|
||||
async function toNoRefund(): Promise<void> { if (order.value?.id) await router.push(`/management/sales-orders/${order.value.id}/no-refund`); }
|
||||
async function toCancel(): Promise<void> { if (order.value?.id) await router.push(`/management/sales-orders/${order.value.id}/cancel`); }
|
||||
onMounted(loadData);
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.sales-order-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: linear-gradient(135deg, #fff7ed, #eff6ff); box-shadow: var(--td-shadow-1); border-radius: 1.25rem;
|
||||
.eyebrow { color: #c2410c; display: block; font-size: .6875rem; font-weight: 700; letter-spacing: .08em; }
|
||||
.hero-title { gap: .5rem; display: flex; margin-top: .35rem; align-items: center; justify-content: space-between; }
|
||||
h1 { margin: 0; min-width: 0; font-size: 1.2rem; line-height: 1.35; }
|
||||
.hero-meta { gap: .75rem; display: flex; margin-top: .6rem; color: var(--td-text-color-secondary); align-items: center; justify-content: space-between; }
|
||||
strong { color: #c2410c; font-size: 1.2rem; white-space: nowrap; }
|
||||
}
|
||||
.actions { gap: .5rem; padding: 0 1rem; display: flex; flex-wrap: wrap; }
|
||||
.actions .t-button { flex: 1 1 8rem; }
|
||||
}
|
||||
|
||||
:global(html.theme-dark) .sales-order-detail .hero { background: linear-gradient(135deg, rgba(194, 65, 12, .2), rgba(37, 99, 235, .18));
|
||||
.eyebrow, strong { color: #fdba74; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.salesOrder.read" class="sales-order-list">
|
||||
<div class="filters">
|
||||
<t-input v-model.trim="keyword" class="keyword" type="search" borderless clearable placeholder="搜索订单号、客户" />
|
||||
<t-dropdown-menu class="menus">
|
||||
<t-dropdown-item v-model="orderType" label="订单类型" :options="orderTypeOptions" @change="selectOrderType" />
|
||||
<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>
|
||||
</div>
|
||||
<t-pull-down-refresh v-model="refreshing" :refresh-timeout="30000" @refresh="refresh" @scrolltolower="loadMore">
|
||||
<div v-if="viewMode" class="view-tip" v-text="viewMode === 'pending-receive' ? '当前显示未收清订单' : '当前显示待处理退款订单'" />
|
||||
<t-cell-group v-if="displayList.length" theme="card">
|
||||
<t-cell v-for="(item, index) in displayList" :key="item.id" arrow hover v-entrance-transition="index * 30" @click="toDetail(item)">
|
||||
<template #title>
|
||||
<div class="title">
|
||||
<strong v-text="item.orderNo || item.id || '未命名订单'" />
|
||||
<t-tag :content="getSalesOrderStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
|
||||
</div>
|
||||
</template>
|
||||
<template #note><span class="amount" v-text="formatPackageAmount(item.saleAmount)" /></template>
|
||||
<template #description>
|
||||
<div class="description">
|
||||
<span v-text="getSalesOrderTypeText(item.orderType)" />
|
||||
<span v-text="item.customer?.name || item.customerId || '未知客户'" />
|
||||
<t-tag :content="getSalesPaymentStatusText(item.paymentStatus)" theme="primary" variant="outline" />
|
||||
</div>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty" v-text="loading ? '正在加载销售订单' : '暂无销售订单'" />
|
||||
<div class="center-tips" v-text="loading ? '正在加载' : noMore ? '已经到底了' : '上拉加载更多'" />
|
||||
</t-pull-down-refresh>
|
||||
</div>
|
||||
<nav-bar-action-menu v-if="canCreate" :actions="actions" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type DropdownValue } from "tdesign-mobile-vue";
|
||||
import { Toolkit } from "timi-web";
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import SalesOrderAPI from "@/api/SalesOrderAPI";
|
||||
import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { SalesOrder, SalesOrderStatus, SalesOrderType, SalesPaymentStatus } from "@/types/SalesOrder";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { formatPackageAmount } from "@/utils/package";
|
||||
import { getSalesOrderStatusText, getSalesOrderTypeText, getSalesPaymentStatusText, getStatusTheme } from "@/utils/salesOrder";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "SalesOrderList" });
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.salesOrder.create));
|
||||
const actions = computed(() => canCreate.value ? [{ key: "create", text: "直接产品下单", icon: "add", theme: "primary" as const, action: toCreate }] : []);
|
||||
const keyword = ref("");
|
||||
const orderType = ref("ALL");
|
||||
const viewMode = computed(() => route.query.view === "pending-receive" || route.query.view === "pending-refund" ? String(route.query.view) : "");
|
||||
const status = ref(viewMode.value === "pending-refund" ? "CANCELLED" : "ALL");
|
||||
const paymentStatus = ref("ALL");
|
||||
const list = ref<SalesOrder[]>([]);
|
||||
const pageIndex = ref(0);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const noMore = ref(false);
|
||||
const pageSize = 15;
|
||||
const orderTypeOptions = [{ label: "全部类型", value: "ALL" }, { label: "套餐订单", value: "PACKAGE" }, { label: "直接产品", value: "DIRECT_PRODUCT" }];
|
||||
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 displayList = computed(() => list.value.filter(item => {
|
||||
if (viewMode.value === "pending-receive") return item.paymentStatus !== "PAID";
|
||||
if (viewMode.value === "pending-refund") return item.status === "CANCELLED" && item.refundStatus === "PENDING";
|
||||
return true;
|
||||
}));
|
||||
|
||||
async function fetchList(reset: boolean): Promise<void> {
|
||||
if (loading.value || (!reset && noMore.value)) return;
|
||||
if (reset) { pageIndex.value = 0; total.value = 0; noMore.value = false; list.value = []; }
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await SalesOrderAPI.list({
|
||||
index: pageIndex.value,
|
||||
size: pageSize,
|
||||
keyword: keyword.value || undefined,
|
||||
orderType: orderType.value === "ALL" ? undefined : orderType.value as SalesOrderType,
|
||||
status: status.value === "ALL" ? undefined : status.value as SalesOrderStatus,
|
||||
paymentStatus: paymentStatus.value === "ALL" ? undefined : paymentStatus.value as SalesPaymentStatus
|
||||
});
|
||||
const rows = result.list || [];
|
||||
list.value = reset ? rows : list.value.concat(rows);
|
||||
total.value = result.total || 0;
|
||||
pageIndex.value += 1;
|
||||
noMore.value = rows.length < pageSize || total.value <= list.value.length;
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
async function refresh(): Promise<void> { try { await fetchList(true); } catch (error) { Message.error(getErrorMessage(error, "销售订单加载失败")); } finally { refreshing.value = false; } }
|
||||
async function loadMore(): Promise<void> { try { await fetchList(false); } catch (error) { Message.error(getErrorMessage(error, "销售订单加载失败")); } }
|
||||
async function selectOrderType(value: DropdownValue): Promise<void> { if (!Array.isArray(value)) { orderType.value = String(value); await fetchList(true); } }
|
||||
async function selectStatus(value: DropdownValue): Promise<void> { if (!Array.isArray(value)) { status.value = String(value); await fetchList(true); } }
|
||||
async function selectPaymentStatus(value: DropdownValue): Promise<void> { if (!Array.isArray(value)) { paymentStatus.value = String(value); await fetchList(true); } }
|
||||
async function toDetail(item: SalesOrder): Promise<void> { if (item.id) await router.push(`/management/sales-orders/${item.id}`); }
|
||||
async function toCreate(): Promise<void> { await router.push("/management/sales-orders/create"); }
|
||||
const search = Toolkit.debounce(async () => { try { await fetchList(true); } catch (error) { Message.error(getErrorMessage(error, "销售订单加载失败")); } }, false);
|
||||
watch(keyword, () => search());
|
||||
watch(viewMode, async value => {
|
||||
status.value = value === "pending-refund" ? "CANCELLED" : "ALL";
|
||||
paymentStatus.value = "ALL";
|
||||
await fetchList(true);
|
||||
});
|
||||
onMounted(async () => { navBarStore.clearRight(); navBarStore.setCustomRightVisible(canCreate.value); try { await fetchList(true); } catch (error) { Message.error(getErrorMessage(error, "销售订单加载失败")); } });
|
||||
onUnmounted(() => { navBarStore.clearRight(); search.cancel(); });
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.sales-order-list { padding: var(--app-nav-height) 0 1rem;
|
||||
.filters { top: var(--app-nav-height); z-index: 10; padding: .75rem 1rem .5rem; position: sticky; background: var(--td-bg-color-page); box-shadow: var(--td-shadow-1);
|
||||
.keyword { border: 1px solid var(--td-component-border); background: var(--td-bg-color-container); border-radius: 1rem; }
|
||||
.menus { margin-top: .5rem; border-radius: .875rem;
|
||||
:deep(.t-dropdown-menu__item) { min-width: 0; }
|
||||
:deep(.t-dropdown-menu__title) { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
}
|
||||
}
|
||||
.title, .description { gap: .5rem; display: flex; align-items: center; }
|
||||
.description { color: var(--td-text-color-secondary); flex-wrap: wrap; }
|
||||
.amount { color: var(--td-brand-color); font-weight: 700; }
|
||||
.view-tip { color: var(--td-text-color-secondary); padding: .75rem 1rem 0; font-size: .8125rem; }
|
||||
.empty { color: var(--td-text-color-secondary); padding: 3rem 1rem; text-align: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.fund.refund" class="sales-order-operation">
|
||||
<t-cell-group v-if="order" title="销售订单" theme="card"><t-cell title="订单号" :note="order.orderNo || order.id" /><t-cell title="当前退款状态" :note="order.refundStatus || '未知'" /></t-cell-group>
|
||||
<t-form v-if="order" class="form" label-align="left" label-width="6rem">
|
||||
<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="!order" :description="loading ? '正在加载订单' : '订单不存在'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import SalesOrderAPI from "@/api/SalesOrderAPI";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { SalesOrder } from "@/types/SalesOrder";
|
||||
import { createPackageRequestNo } from "@/utils/package";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "SalesOrderNoRefund" });
|
||||
const route = useRoute(); const router = useRouter(); const navBarStore = useNavBarStore(); const userStore = useUserStore();
|
||||
const order = ref<SalesOrder>(); const loading = ref(false); const saving = ref(false); const reason = ref(""); const requestNo = ref("");
|
||||
async function loadData(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) return;
|
||||
loading.value = true;
|
||||
try { order.value = await SalesOrderAPI.detail(id); }
|
||||
catch (error) { Message.error(getErrorMessage(error, "订单加载失败")); }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
async function save(): Promise<void> {
|
||||
if (!order.value?.id || !reason.value || saving.value) return;
|
||||
if (!requestNo.value) requestNo.value = createPackageRequestNo();
|
||||
saving.value = true;
|
||||
try {
|
||||
await SalesOrderAPI.confirmNoRefund({ salesOrderId: order.value.id, requestNo: requestNo.value, reason: reason.value.trim() });
|
||||
Message.success("已确认无需退款");
|
||||
await router.replace(`/management/sales-orders/${order.value.id}`);
|
||||
} catch (error) { Message.error(getErrorMessage(error, "确认失败,重试会复用同一请求号")); }
|
||||
finally { saving.value = false; }
|
||||
}
|
||||
onMounted(async () => {
|
||||
if (!userStore.hasPermissions(permissionCodes.fund.refund)) return;
|
||||
navBarStore.setRightText("确认提交", "var(--td-brand-color)", save);
|
||||
await loadData();
|
||||
});
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.sales-order-operation { gap: 1rem; padding: calc(var(--app-nav-height) + 1rem) 1rem 2rem; display: flex; flex-direction: column;
|
||||
.form { padding: 0 .5rem; background: var(--td-bg-color-container); box-shadow: var(--td-shadow-1); border-radius: 1rem; overflow: hidden; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.fundReceive.create" class="sales-order-operation">
|
||||
<t-cell-group v-if="order" title="销售订单" theme="card"><t-cell title="订单号" :note="order.orderNo || order.id" /><t-cell title="剩余待收款" :note="formatPackageAmount(order.unpaidAmount)" /></t-cell-group>
|
||||
<t-form v-if="order" class="form" label-align="left" label-width="6rem">
|
||||
<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="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" /></t-form-item>
|
||||
</t-form>
|
||||
<package-photo-field v-if="order" v-model="photoItems" required title="收款凭证照片" />
|
||||
<t-empty v-if="!order" :description="loading ? '正在加载订单' : '订单不存在'" />
|
||||
<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, type Attachment } from "timi-web-api";
|
||||
import PackagePhotoField from "@/components/PackagePhotoField.vue";
|
||||
import SalesOrderAPI from "@/api/SalesOrderAPI";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { SalesOrder } from "@/types/SalesOrder";
|
||||
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: "SalesOrderPayment" });
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
const order = ref<SalesOrder>();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const amountText = ref("");
|
||||
const occurredAtText = ref("");
|
||||
const draftTime = ref("");
|
||||
const pickerVisible = ref(false);
|
||||
const remark = ref("");
|
||||
const photoItems = ref<PhotoItem[]>([]);
|
||||
const requestNo = ref("");
|
||||
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(): Partial<Attachment>[] { 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;
|
||||
loading.value = true;
|
||||
try { order.value = await SalesOrderAPI.detail(id); }
|
||||
catch (error) { Message.error(getErrorMessage(error, "订单加载失败")); }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
async function save(): Promise<void> {
|
||||
if (saving.value || !order.value?.id) return;
|
||||
const amount = parsePackageAmount(amountText.value);
|
||||
const attachmentList = buildAttachments();
|
||||
if (amount === undefined || amount === 0) { Message.warning("请输入合法的正金额"); return; }
|
||||
if (!attachmentList.length) { Message.warning("收款必须上传至少一张照片"); return; }
|
||||
if (!requestNo.value) requestNo.value = createPackageRequestNo(); saving.value = true;
|
||||
try {
|
||||
await SalesOrderAPI.receivePayment({
|
||||
salesOrderId: order.value.id,
|
||||
requestNo: requestNo.value,
|
||||
amount,
|
||||
occurredAt: resolveTimestamp(occurredAtText.value),
|
||||
attachmentList,
|
||||
remark: remark.value.trim() || null
|
||||
});
|
||||
Message.success("收款登记已提交");
|
||||
await router.replace(`/management/sales-orders/${order.value.id}`);
|
||||
} catch (error) { Message.error(getErrorMessage(error, "收款登记失败,重试会复用同一请求号")); }
|
||||
finally { saving.value = false; }
|
||||
}
|
||||
onMounted(async () => { if (!userStore.hasPermissions(permissionCodes.fundReceive.create)) return; navBarStore.setRightText("提交收款", "var(--td-brand-color)", save); await loadData(); });
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.sales-order-operation { gap: 1rem; padding: calc(var(--app-nav-height) + 1rem) 0 2rem; display: flex; flex-direction: column;
|
||||
.form { margin: 0 1rem; padding: 0 .5rem; background: var(--td-bg-color-container); box-shadow: var(--td-shadow-1); border-radius: 1rem; overflow: hidden; }
|
||||
.picker-text { width: 100%; text-align: right; }
|
||||
.package-photo-field { padding: 0 1rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.fund.refund" class="sales-order-operation">
|
||||
<t-cell-group v-if="order" title="销售订单" theme="card">
|
||||
<t-cell title="订单号" :note="order.orderNo || order.id" />
|
||||
<t-cell title="可退款金额" :note="formatPackageAmount((order.paidAmount || 0) - (order.refundedAmount || 0))" />
|
||||
</t-cell-group>
|
||||
<t-form v-if="order" class="form" label-align="left" label-width="6rem">
|
||||
<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="退款原因" required-mark><t-textarea v-model.trim="reason" borderless autosize maxlength="500" /></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" /></t-form-item>
|
||||
</t-form>
|
||||
<package-photo-field v-if="order" v-model="photoItems" required title="退款凭证照片" />
|
||||
<t-empty v-if="!order" :description="loading ? '正在加载订单' : '订单不存在'" />
|
||||
<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, type Attachment } from "timi-web-api";
|
||||
import PackagePhotoField from "@/components/PackagePhotoField.vue";
|
||||
import SalesOrderAPI from "@/api/SalesOrderAPI";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { SalesOrder } from "@/types/SalesOrder";
|
||||
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: "SalesOrderRefund" });
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
const order = ref<SalesOrder>();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const amountText = ref("");
|
||||
const reason = ref("");
|
||||
const occurredAtText = ref("");
|
||||
const draftTime = ref("");
|
||||
const pickerVisible = ref(false);
|
||||
const remark = ref("");
|
||||
const photoItems = ref<PhotoItem[]>([]);
|
||||
const requestNo = ref("");
|
||||
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(): Partial<Attachment>[] { 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;
|
||||
loading.value = true;
|
||||
try { order.value = await SalesOrderAPI.detail(id); }
|
||||
catch (error) { Message.error(getErrorMessage(error, "订单加载失败")); }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
async function save(): Promise<void> {
|
||||
if (saving.value || !order.value?.id) return;
|
||||
const amount = parsePackageAmount(amountText.value); const attachmentList = buildAttachments();
|
||||
if (amount === undefined || amount === 0 || !reason.value) { Message.warning("退款金额和原因不能为空"); return; }
|
||||
if (!attachmentList.length) { Message.warning("退款必须上传至少一张照片"); return; }
|
||||
if (!requestNo.value) requestNo.value = createPackageRequestNo(); saving.value = true;
|
||||
try {
|
||||
await SalesOrderAPI.refund({
|
||||
salesOrderId: order.value.id,
|
||||
requestNo: requestNo.value,
|
||||
amount,
|
||||
occurredAt: resolveTimestamp(occurredAtText.value),
|
||||
reason: reason.value.trim(),
|
||||
attachmentList,
|
||||
remark: remark.value.trim() || null
|
||||
});
|
||||
Message.success("退款登记已提交");
|
||||
await router.replace(`/management/sales-orders/${order.value.id}`);
|
||||
} catch (error) { Message.error(getErrorMessage(error, "退款登记失败,重试会复用同一请求号")); }
|
||||
finally { saving.value = false; }
|
||||
}
|
||||
onMounted(async () => { if (!userStore.hasPermissions(permissionCodes.fund.refund)) return; navBarStore.setRightText("提交退款", "var(--td-danger-color)", save); await loadData(); });
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.sales-order-operation { gap: 1rem; padding: calc(var(--app-nav-height) + 1rem) 0 2rem; display: flex; flex-direction: column;
|
||||
.form { margin: 0 1rem; padding: 0 .5rem; background: var(--td-bg-color-container); box-shadow: var(--td-shadow-1); border-radius: 1rem; overflow: hidden; }
|
||||
.picker-text { width: 100%; text-align: right; }
|
||||
.package-photo-field { padding: 0 1rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="finance-panel">
|
||||
<t-cell-group title="资金摘要" theme="card">
|
||||
<t-cell title="成交金额" :note="formatPackageAmount(props.order.saleAmount)" />
|
||||
<t-cell title="已生效收款" :note="formatPackageAmount(props.order.paidAmount)" />
|
||||
<t-cell title="待核销收款" :note="formatPackageAmount(props.order.pendingPaidAmount)" />
|
||||
<t-cell title="剩余待收款" :note="formatPackageAmount(props.order.unpaidAmount)" />
|
||||
<t-cell title="有效退款" :note="formatPackageAmount(props.order.refundedAmount)" />
|
||||
<t-cell title="收款状态" :note="getSalesPaymentStatusText(props.order.paymentStatus)" />
|
||||
<t-cell title="退款状态" :note="getSalesRefundStatusText(props.order.refundStatus)" />
|
||||
<t-cell v-if="props.canReadFund" title="查看全部资金流水" arrow @click="emit('fund')" />
|
||||
</t-cell-group>
|
||||
<t-cell-group title="本单资金操作" theme="card">
|
||||
<t-cell
|
||||
v-for="item in props.order.fundRecordList || []"
|
||||
:key="item.id"
|
||||
:title="getFundOperationText(item.operationType)"
|
||||
:note="`${getFundStatusText(item.status)} · ${item.id || ''}`"
|
||||
arrow
|
||||
@click="item.id && emit('record', item.id)"
|
||||
>
|
||||
<template #note>
|
||||
<span class="record-note">
|
||||
<t-tag :content="getFundStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
|
||||
<span class="amount" v-text="formatPackageAmount(item.amount)" />
|
||||
</span>
|
||||
</template>
|
||||
</t-cell>
|
||||
<t-cell v-if="!props.order.fundRecordList?.length" title="暂无资金流水" />
|
||||
</t-cell-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SalesOrder } from "@/types/SalesOrder";
|
||||
import { formatPackageAmount } from "@/utils/package";
|
||||
import { getFundOperationText, getFundStatusText, getSalesPaymentStatusText, getSalesRefundStatusText, getStatusTheme } from "@/utils/salesOrder";
|
||||
|
||||
const props = defineProps<{ order: SalesOrder; canReadFund: boolean }>();
|
||||
const emit = defineEmits<{ fund: []; record: [id: string] }>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.finance-panel { display: grid; gap: 1rem;
|
||||
.record-note { gap: .5rem; display: flex; align-items: center; }
|
||||
.amount { color: var(--td-brand-color); font-weight: 700; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user