add product

This commit is contained in:
Timi
2026-09-03 14:27:40 +08:00
parent 7f669621fb
commit 333999fe21
63 changed files with 5662 additions and 2210 deletions
+596
View File
@@ -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>