Compare commits
2 Commits
4f03d18843
...
8b7d1379d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b7d1379d1 | |||
| afe6993929 |
@ -19,13 +19,13 @@ class OrderItemPayload(BaseModel):
|
|||||||
product_name: str
|
product_name: str
|
||||||
specification: str
|
specification: str
|
||||||
unit: str
|
unit: str
|
||||||
quantity: float
|
quantity: float = Field(gt=0)
|
||||||
sale_price: float
|
sale_price: float = Field(ge=0)
|
||||||
cost_price: float
|
cost_price: float = Field(ge=0)
|
||||||
rebate_amount: float = 0
|
rebate_amount: float = Field(default=0, ge=0)
|
||||||
freight_amount: float = 0
|
freight_amount: float = Field(default=0, ge=0)
|
||||||
tax_amount: float = 0
|
tax_amount: float = Field(default=0, ge=0)
|
||||||
other_fee_amount: float = 0
|
other_fee_amount: float = Field(default=0, ge=0)
|
||||||
remark: str | None = None
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@ -36,11 +36,11 @@ class CreateOrderRequest(BaseModel):
|
|||||||
order_source: str | None = None
|
order_source: str | None = None
|
||||||
delivery_type: str | None = None
|
delivery_type: str | None = None
|
||||||
factory_id: int | None = None
|
factory_id: int | None = None
|
||||||
commission_amount: float = 0
|
commission_amount: float = Field(default=0, ge=0)
|
||||||
rebate_total: float = 0
|
rebate_total: float = Field(default=0, ge=0)
|
||||||
freight_total: float = 0
|
freight_total: float = Field(default=0, ge=0)
|
||||||
tax_total: float = 0
|
tax_total: float = Field(default=0, ge=0)
|
||||||
other_fee_total: float = 0
|
other_fee_total: float = Field(default=0, ge=0)
|
||||||
remark: str | None = None
|
remark: str | None = None
|
||||||
items: list[OrderItemPayload] = Field(default_factory=list)
|
items: list[OrderItemPayload] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
@ -346,6 +346,11 @@ class OrderService:
|
|||||||
def create_order(self, payload: dict, session: Session | None = None) -> dict:
|
def create_order(self, payload: dict, session: Session | None = None) -> dict:
|
||||||
if session is not None:
|
if session is not None:
|
||||||
try:
|
try:
|
||||||
|
if not payload["customer_name"].strip():
|
||||||
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="客户姓名不能为空", status_code=400)
|
||||||
|
if not payload["customer_mobile"].strip():
|
||||||
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="客户手机号不能为空", status_code=400)
|
||||||
|
|
||||||
customer = self.customer_repository.find_by_name_and_mobile(
|
customer = self.customer_repository.find_by_name_and_mobile(
|
||||||
session,
|
session,
|
||||||
payload["customer_name"],
|
payload["customer_name"],
|
||||||
@ -368,6 +373,9 @@ class OrderService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
items = payload["items"]
|
items = payload["items"]
|
||||||
|
if not items:
|
||||||
|
raise AppException(code=ErrorCode.PARAM_ERROR, message="订单明细不能为空", status_code=400)
|
||||||
|
|
||||||
sale_total = sum(item["quantity"] * item["sale_price"] for item in items)
|
sale_total = sum(item["quantity"] * item["sale_price"] for item in items)
|
||||||
cost_total = sum(item["quantity"] * item["cost_price"] for item in items)
|
cost_total = sum(item["quantity"] * item["cost_price"] for item in items)
|
||||||
profit_total = (
|
profit_total = (
|
||||||
|
|||||||
@ -15,12 +15,18 @@ function wait(data) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(path) {
|
async function request(path, options = {}) {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`);
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
if (!response.ok) {
|
headers: {
|
||||||
throw new Error(`请求失败: ${response.status}`);
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || result.code !== 0) {
|
||||||
|
throw new Error(result.message || `请求失败: ${response.status}`);
|
||||||
}
|
}
|
||||||
const result = await response.json();
|
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,13 +72,19 @@ export async function fetchOrdersPage() {
|
|||||||
customer: item.customer_name,
|
customer: item.customer_name,
|
||||||
mobile: maskMobile(item.customer_mobile),
|
mobile: maskMobile(item.customer_mobile),
|
||||||
status: mapOrderStatus(item.order_status),
|
status: mapOrderStatus(item.order_status),
|
||||||
|
rawStatus: item.order_status,
|
||||||
source: item.order_source || "-",
|
source: item.order_source || "-",
|
||||||
amount: Number(item.sale_price_total || 0).toFixed(2),
|
amount: Number(item.sale_price_total || 0).toFixed(2),
|
||||||
})),
|
})),
|
||||||
isMock: false,
|
isMock: false,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return wait({ orderFilters, orderActions, orderRows, isMock: true });
|
return wait({
|
||||||
|
orderFilters,
|
||||||
|
orderActions,
|
||||||
|
orderRows: orderRows.map((item) => ({ ...item, rawStatus: "draft" })),
|
||||||
|
isMock: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,6 +92,13 @@ export function fetchOrderForm() {
|
|||||||
return wait({ sections: orderFormSections });
|
return wait({ sections: orderFormSections });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createOrder(payload) {
|
||||||
|
return request("/api/orders", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchOrderDetail(orderId) {
|
export async function fetchOrderDetail(orderId) {
|
||||||
try {
|
try {
|
||||||
const data = await request(`/api/orders/${orderId}`);
|
const data = await request(`/api/orders/${orderId}`);
|
||||||
@ -110,7 +129,16 @@ export async function fetchOrderDetail(orderId) {
|
|||||||
: "暂无审批记录",
|
: "暂无审批记录",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return { blocks, isMock: false };
|
return {
|
||||||
|
blocks,
|
||||||
|
orderInfo: {
|
||||||
|
orderId: data.order_id,
|
||||||
|
orderNo: data.order_no,
|
||||||
|
rawStatus: data.order_status,
|
||||||
|
statusText: mapOrderStatus(data.order_status),
|
||||||
|
},
|
||||||
|
isMock: false,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return wait({
|
return wait({
|
||||||
blocks: [
|
blocks: [
|
||||||
@ -120,11 +148,33 @@ export async function fetchOrderDetail(orderId) {
|
|||||||
{ title: "发厂信息", content: "工厂、配送方式与备注信息当前为演示占位。" },
|
{ title: "发厂信息", content: "工厂、配送方式与备注信息当前为演示占位。" },
|
||||||
{ title: "审批记录", content: "暂无真实审批记录,当前为演示模式。" },
|
{ title: "审批记录", content: "暂无真实审批记录,当前为演示模式。" },
|
||||||
],
|
],
|
||||||
|
orderInfo: {
|
||||||
|
orderId,
|
||||||
|
orderNo: `SO${String(orderId).padStart(12, "0")}`,
|
||||||
|
rawStatus: "draft",
|
||||||
|
statusText: "草稿",
|
||||||
|
},
|
||||||
isMock: true,
|
isMock: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function submitOrder(orderId) {
|
||||||
|
return request(`/api/orders/${orderId}/submit`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelOrder(orderId, cancelReason = "业务员取消订单") {
|
||||||
|
return request(`/api/orders/${orderId}/cancel`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
cancel_reason: cancelReason,
|
||||||
|
cancel_opinion: "前端业务员端发起取消",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchRemindersPage() {
|
export function fetchRemindersPage() {
|
||||||
return wait({ reminderTypes, reminderRows });
|
return wait({ reminderTypes, reminderRows });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,8 +6,36 @@
|
|||||||
{{ isMock ? "演示数据" : "真实接口" }}
|
{{ isMock ? "演示数据" : "真实接口" }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="loading" class="loading">正在加载订单详情...</p>
|
<p v-if="loading" class="loading">正在加载订单详情...</p>
|
||||||
<div v-else class="grid">
|
|
||||||
|
<div v-if="message" class="message-box" :class="{ error: messageType === 'error' }">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && orderInfo" class="toolbar">
|
||||||
|
<div class="order-brief">
|
||||||
|
<span>订单编号:{{ orderInfo.orderNo }}</span>
|
||||||
|
<span>当前状态:{{ orderInfo.statusText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-group">
|
||||||
|
<button
|
||||||
|
:disabled="actionLoading || !canSubmit(orderInfo.rawStatus) || isMock"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
{{ actionLoading === "submit" ? "提交中..." : "提交审核" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="danger-btn"
|
||||||
|
:disabled="actionLoading || !canCancel(orderInfo.rawStatus) || isMock"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
{{ actionLoading === "cancel" ? "取消中..." : "取消订单" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading" class="grid">
|
||||||
<article v-for="block in blocks" :key="block.title" class="card">
|
<article v-for="block in blocks" :key="block.title" class="card">
|
||||||
<h3>{{ block.title }}</h3>
|
<h3>{{ block.title }}</h3>
|
||||||
<p>{{ block.content }}</p>
|
<p>{{ block.content }}</p>
|
||||||
@ -20,17 +48,76 @@
|
|||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
import { fetchOrderDetail } from "../mockApi";
|
import { cancelOrder, fetchOrderDetail, submitOrder } from "../mockApi";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const isMock = ref(false);
|
const isMock = ref(false);
|
||||||
const blocks = ref([]);
|
const blocks = ref([]);
|
||||||
|
const orderInfo = ref(null);
|
||||||
|
const actionLoading = ref("");
|
||||||
|
const message = ref("");
|
||||||
|
const messageType = ref("success");
|
||||||
|
|
||||||
onMounted(async () => {
|
function canSubmit(status) {
|
||||||
|
return ["draft", "rejected"].includes(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCancel(status) {
|
||||||
|
return !["canceled", "settled"].includes(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
const data = await fetchOrderDetail(route.params.id);
|
const data = await fetchOrderDetail(route.params.id);
|
||||||
blocks.value = data.blocks;
|
blocks.value = data.blocks;
|
||||||
|
orderInfo.value = data.orderInfo;
|
||||||
isMock.value = Boolean(data.isMock);
|
isMock.value = Boolean(data.isMock);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!orderInfo.value?.orderId || isMock.value) {
|
||||||
|
message.value = "当前是演示数据模式,暂不执行真实提交。";
|
||||||
|
messageType.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionLoading.value = "submit";
|
||||||
|
try {
|
||||||
|
await submitOrder(orderInfo.value.orderId);
|
||||||
|
await loadDetail();
|
||||||
|
message.value = `订单 ${orderInfo.value.orderNo} 已提交审核。`;
|
||||||
|
messageType.value = "success";
|
||||||
|
} catch (error) {
|
||||||
|
message.value = error.message || "提交审核失败";
|
||||||
|
messageType.value = "error";
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
if (!orderInfo.value?.orderId || isMock.value) {
|
||||||
|
message.value = "当前是演示数据模式,暂不执行真实取消。";
|
||||||
|
messageType.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionLoading.value = "cancel";
|
||||||
|
try {
|
||||||
|
await cancelOrder(orderInfo.value.orderId);
|
||||||
|
await loadDetail();
|
||||||
|
message.value = `订单 ${orderInfo.value.orderNo} 已发起取消。`;
|
||||||
|
messageType.value = "success";
|
||||||
|
} catch (error) {
|
||||||
|
message.value = error.message || "取消订单失败";
|
||||||
|
messageType.value = "error";
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadDetail();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -42,6 +129,14 @@ h2 { margin: 0; }
|
|||||||
.mode-tag { padding: 6px 10px; border-radius: 999px; background: #dcfce7; color: #166534; font-size: 12px; }
|
.mode-tag { padding: 6px 10px; border-radius: 999px; background: #dcfce7; color: #166534; font-size: 12px; }
|
||||||
.mode-tag.fallback { background: #fef3c7; color: #92400e; }
|
.mode-tag.fallback { background: #fef3c7; color: #92400e; }
|
||||||
.loading { margin: 0; color: #6b7280; }
|
.loading { margin: 0; color: #6b7280; }
|
||||||
|
.message-box { margin-bottom: 16px; padding: 12px 14px; border-radius: 8px; background: #dcfce7; color: #166534; }
|
||||||
|
.message-box.error { background: #fee2e2; color: #b91c1c; }
|
||||||
|
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 16px; padding: 14px; border: 1px solid #e5e7eb; border-radius: 8px; background: #f9fafb; }
|
||||||
|
.order-brief { display: flex; flex-wrap: wrap; gap: 16px; color: #374151; }
|
||||||
|
.action-group { display: flex; gap: 10px; }
|
||||||
|
.action-group button { border: 1px solid #d1d5db; background: #fff; border-radius: 8px; padding: 8px 12px; }
|
||||||
|
.action-group button:disabled { color: #9ca3af; background: #f3f4f6; }
|
||||||
|
.danger-btn { color: #b91c1c; border-color: #fecaca; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
|
||||||
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
|
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
|
||||||
h3 { margin: 0 0 10px; font-size: 16px; }
|
h3 { margin: 0 0 10px; font-size: 16px; }
|
||||||
|
|||||||
@ -1,63 +1,245 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="page">
|
<section class="page">
|
||||||
<h2>订单录入 / 编辑页</h2>
|
<h2>新建订单</h2>
|
||||||
<div class="tips">
|
<p class="tips">当前先接入业务员端最小可用创建流程,后续再扩展多明细和更多字段。</p>
|
||||||
<span>规则:客户姓名、客户手机号必填</span>
|
|
||||||
<span>规则:至少保留一条明细</span>
|
<div v-if="message" class="message-box" :class="{ error: messageType === 'error' }">
|
||||||
<span>规则:数量必须大于 0</span>
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
<p v-if="loading" class="loading">加载中...</p>
|
|
||||||
<div v-else class="grid">
|
<form class="form-grid" @submit.prevent="handleSubmit">
|
||||||
<article v-for="section in orderFormSections" :key="section.title" class="card">
|
<label>
|
||||||
<h3>{{ section.title }}</h3>
|
<span>客户姓名</span>
|
||||||
<ul>
|
<input v-model.trim="form.customer_name" type="text" placeholder="请输入客户姓名" />
|
||||||
<li v-for="field in section.fields" :key="field">{{ field }}</li>
|
</label>
|
||||||
</ul>
|
<label>
|
||||||
</article>
|
<span>客户手机号</span>
|
||||||
</div>
|
<input v-model.trim="form.customer_mobile" type="text" placeholder="请输入客户手机号" />
|
||||||
<section v-if="!loading" class="actions">
|
</label>
|
||||||
<button>保存草稿</button>
|
<label>
|
||||||
<button disabled>保存并提交</button>
|
<span>客户地址</span>
|
||||||
<button>新增明细</button>
|
<input v-model.trim="form.customer_address" type="text" placeholder="请输入客户地址" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>订单来源</span>
|
||||||
|
<input v-model.trim="form.order_source" type="text" placeholder="例如:线下拜访" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>配送方式</span>
|
||||||
|
<input v-model.trim="form.delivery_type" type="text" placeholder="例如:工厂直送" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>工厂ID</span>
|
||||||
|
<input v-model.number="form.factory_id" type="number" min="1" placeholder="请输入工厂ID" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>提成金额</span>
|
||||||
|
<input v-model.number="form.commission_amount" type="number" min="0" step="0.01" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>返利合计</span>
|
||||||
|
<input v-model.number="form.rebate_total" type="number" min="0" step="0.01" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>运费合计</span>
|
||||||
|
<input v-model.number="form.freight_total" type="number" min="0" step="0.01" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>税费合计</span>
|
||||||
|
<input v-model.number="form.tax_total" type="number" min="0" step="0.01" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>其他费用</span>
|
||||||
|
<input v-model.number="form.other_fee_total" type="number" min="0" step="0.01" />
|
||||||
|
</label>
|
||||||
|
<label class="full-width">
|
||||||
|
<span>备注</span>
|
||||||
|
<textarea v-model.trim="form.remark" rows="3" placeholder="请输入订单备注"></textarea>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="item-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>订单明细</h3>
|
||||||
|
<span>当前先维护 1 条明细</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>产品名称</span>
|
||||||
|
<input v-model.trim="item.product_name" type="text" placeholder="请输入产品名称" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>规格</span>
|
||||||
|
<input v-model.trim="item.specification" type="text" placeholder="请输入规格" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>单位</span>
|
||||||
|
<input v-model.trim="item.unit" type="text" placeholder="例如:吨" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>数量</span>
|
||||||
|
<input v-model.number="item.quantity" type="number" min="0.01" step="0.01" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>销售单价</span>
|
||||||
|
<input v-model.number="item.sale_price" type="number" min="0" step="0.01" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>成本单价</span>
|
||||||
|
<input v-model.number="item.cost_price" type="number" min="0" step="0.01" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="!loading" class="summary">
|
|
||||||
<div>销售金额汇总:100.00</div>
|
<section class="summary">
|
||||||
<div>成本金额汇总:60.00</div>
|
<div>销售额:{{ saleTotal.toFixed(2) }}</div>
|
||||||
<div>利润预览:20.00</div>
|
<div>成本额:{{ costTotal.toFixed(2) }}</div>
|
||||||
|
<div>预估利润:{{ profitTotal.toFixed(2) }}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="actions">
|
||||||
|
<button type="button" class="secondary-btn" @click="resetForm">重置表单</button>
|
||||||
|
<button type="button" class="secondary-btn" @click="fillDemoData">填充演示数据</button>
|
||||||
|
<button type="submit" :disabled="submitting" @click="handleSubmit">
|
||||||
|
{{ submitting ? "创建中..." : "创建订单" }}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<p v-if="!loading" class="validation">当前为演示状态:提交按钮默认禁用,代表必填项和金额校验未全部通过。</p>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from "vue";
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
import { fetchOrderForm } from "../mockApi";
|
import { createOrder } from "../mockApi";
|
||||||
|
|
||||||
const loading = ref(true);
|
const router = useRouter();
|
||||||
const orderFormSections = ref([]);
|
const submitting = ref(false);
|
||||||
|
const message = ref("");
|
||||||
|
const messageType = ref("success");
|
||||||
|
|
||||||
onMounted(async () => {
|
function buildDefaultForm() {
|
||||||
const data = await fetchOrderForm();
|
return {
|
||||||
orderFormSections.value = data.sections;
|
customer_name: "",
|
||||||
loading.value = false;
|
customer_mobile: "",
|
||||||
});
|
customer_address: "",
|
||||||
|
order_source: "",
|
||||||
|
delivery_type: "",
|
||||||
|
factory_id: 1001,
|
||||||
|
commission_amount: 0,
|
||||||
|
rebate_total: 0,
|
||||||
|
freight_total: 0,
|
||||||
|
tax_total: 0,
|
||||||
|
other_fee_total: 0,
|
||||||
|
remark: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultItem() {
|
||||||
|
return {
|
||||||
|
product_name: "",
|
||||||
|
specification: "",
|
||||||
|
unit: "",
|
||||||
|
quantity: 1,
|
||||||
|
sale_price: 0,
|
||||||
|
cost_price: 0,
|
||||||
|
rebate_amount: 0,
|
||||||
|
freight_amount: 0,
|
||||||
|
tax_amount: 0,
|
||||||
|
other_fee_amount: 0,
|
||||||
|
remark: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive(buildDefaultForm());
|
||||||
|
const item = reactive(buildDefaultItem());
|
||||||
|
|
||||||
|
const saleTotal = computed(() => Number(item.quantity || 0) * Number(item.sale_price || 0));
|
||||||
|
const costTotal = computed(() => Number(item.quantity || 0) * Number(item.cost_price || 0));
|
||||||
|
const profitTotal = computed(
|
||||||
|
() =>
|
||||||
|
saleTotal.value -
|
||||||
|
costTotal.value -
|
||||||
|
Number(form.rebate_total || 0) -
|
||||||
|
Number(form.freight_total || 0) -
|
||||||
|
Number(form.tax_total || 0) -
|
||||||
|
Number(form.other_fee_total || 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
Object.assign(form, buildDefaultForm());
|
||||||
|
Object.assign(item, buildDefaultItem());
|
||||||
|
message.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillDemoData() {
|
||||||
|
Object.assign(form, {
|
||||||
|
customer_name: "演示客户",
|
||||||
|
customer_mobile: "13900000000",
|
||||||
|
customer_address: "杭州市西湖区演示地址 1 号",
|
||||||
|
order_source: "线下拜访",
|
||||||
|
delivery_type: "工厂直送",
|
||||||
|
factory_id: 1001,
|
||||||
|
commission_amount: 50,
|
||||||
|
rebate_total: 5,
|
||||||
|
freight_total: 10,
|
||||||
|
tax_total: 3,
|
||||||
|
other_fee_total: 2,
|
||||||
|
remark: "前端联调演示订单",
|
||||||
|
});
|
||||||
|
Object.assign(item, {
|
||||||
|
product_name: "演示产品A",
|
||||||
|
specification: "10kg",
|
||||||
|
unit: "吨",
|
||||||
|
quantity: 1,
|
||||||
|
sale_price: 100,
|
||||||
|
cost_price: 60,
|
||||||
|
rebate_amount: 5,
|
||||||
|
freight_amount: 10,
|
||||||
|
tax_amount: 3,
|
||||||
|
other_fee_amount: 2,
|
||||||
|
remark: "演示明细",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
submitting.value = true;
|
||||||
|
message.value = "";
|
||||||
|
try {
|
||||||
|
const result = await createOrder({
|
||||||
|
...form,
|
||||||
|
items: [{ ...item }],
|
||||||
|
});
|
||||||
|
message.value = `订单 ${result.order_no} 创建成功,当前状态:${result.order_status}`;
|
||||||
|
messageType.value = "success";
|
||||||
|
await router.push(`/orders/${result.order_id}`);
|
||||||
|
} catch (error) {
|
||||||
|
message.value = error.message || "创建订单失败";
|
||||||
|
messageType.value = "error";
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page { background: #fff; border-radius: 8px; padding: 20px; }
|
.page { background: #fff; border-radius: 8px; padding: 20px; }
|
||||||
h2 { margin: 0 0 12px; }
|
h2 { margin: 0 0 12px; }
|
||||||
.tips { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
|
.tips { margin: 0 0 16px; color: #6b7280; }
|
||||||
.tips span { padding: 6px 10px; border-radius: 999px; background: #fff7ed; color: #c2410c; font-size: 13px; }
|
.message-box { margin-bottom: 16px; padding: 12px 14px; border-radius: 8px; background: #dcfce7; color: #166534; }
|
||||||
.loading { margin: 0; color: #6b7280; }
|
.message-box.error { background: #fee2e2; color: #b91c1c; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||||
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
|
label { display: grid; gap: 6px; }
|
||||||
h3 { margin: 0 0 10px; font-size: 16px; }
|
label span { color: #4b5563; font-size: 13px; }
|
||||||
ul { margin: 0; padding-left: 18px; }
|
input, textarea { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 12px; background: #fff; }
|
||||||
li + li { margin-top: 8px; }
|
.full-width { grid-column: 1 / -1; }
|
||||||
|
.item-panel { margin-top: 20px; padding: 16px; border: 1px solid #e5e7eb; border-radius: 8px; }
|
||||||
|
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||||
|
.panel-header h3 { margin: 0; font-size: 16px; }
|
||||||
|
.panel-header span { color: #6b7280; font-size: 13px; }
|
||||||
|
.summary { display: flex; gap: 20px; margin-top: 20px; padding: 14px; border: 1px solid #e5e7eb; border-radius: 8px; background: #f9fafb; }
|
||||||
.actions { display: flex; gap: 12px; margin-top: 20px; }
|
.actions { display: flex; gap: 12px; margin-top: 20px; }
|
||||||
button { border: 1px solid #d1d5db; background: #fff; border-radius: 8px; padding: 10px 14px; }
|
button { border: 1px solid #d1d5db; background: #fff; border-radius: 8px; padding: 10px 14px; }
|
||||||
button:disabled { color: #9ca3af; background: #f3f4f6; }
|
button:disabled { color: #9ca3af; background: #f3f4f6; }
|
||||||
.summary { display: flex; gap: 20px; margin-top: 20px; padding: 14px; border: 1px solid #e5e7eb; border-radius: 8px; background: #f9fafb; }
|
.secondary-btn { color: #374151; }
|
||||||
.validation { margin: 16px 0 0; color: #6b7280; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -19,18 +19,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!loading" class="block">
|
<div v-if="message" class="message-box" :class="{ error: messageType === 'error' }">
|
||||||
<h3>快捷操作</h3>
|
{{ message }}
|
||||||
<div class="chips">
|
|
||||||
<button
|
|
||||||
v-for="item in orderActions"
|
|
||||||
:key="item"
|
|
||||||
class="action-btn"
|
|
||||||
:disabled="item === '提交审核'"
|
|
||||||
>
|
|
||||||
{{ item }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!loading && !orderRows.length" class="empty-state">当前没有订单数据</div>
|
<div v-if="!loading && !orderRows.length" class="empty-state">当前没有订单数据</div>
|
||||||
@ -57,42 +47,106 @@
|
|||||||
<td><span class="status">{{ row.status }}</span></td>
|
<td><span class="status">{{ row.status }}</span></td>
|
||||||
<td>{{ row.source }}</td>
|
<td>{{ row.source }}</td>
|
||||||
<td>{{ row.amount }}</td>
|
<td>{{ row.amount }}</td>
|
||||||
<td>
|
<td class="actions-cell">
|
||||||
<RouterLink v-if="row.orderId" class="detail-link" :to="`/orders/${row.orderId}`">查看详情</RouterLink>
|
<RouterLink v-if="row.orderId" class="detail-link" :to="`/orders/${row.orderId}`">查看详情</RouterLink>
|
||||||
<span v-else class="detail-link disabled-link">演示详情</span>
|
<span v-else class="detail-link disabled-link">演示详情</span>
|
||||||
|
<button
|
||||||
|
class="inline-btn"
|
||||||
|
:disabled="submittingId === row.orderId || !canSubmit(row.rawStatus)"
|
||||||
|
@click="handleSubmit(row)"
|
||||||
|
>
|
||||||
|
{{ submittingId === row.orderId ? "提交中..." : "提交审核" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-btn danger-btn"
|
||||||
|
:disabled="cancelingId === row.orderId || !canCancel(row.rawStatus)"
|
||||||
|
@click="handleCancel(row)"
|
||||||
|
>
|
||||||
|
{{ cancelingId === row.orderId ? "取消中..." : "取消订单" }}
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!loading" class="pagination">
|
|
||||||
<button disabled>上一页</button>
|
|
||||||
<span>当前展示第一页,后续接入真实翻页交互</span>
|
|
||||||
<button disabled>下一页</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
|
|
||||||
import { fetchOrdersPage } from "../mockApi";
|
import { cancelOrder, fetchOrdersPage, submitOrder } from "../mockApi";
|
||||||
import { salesStore } from "../store";
|
import { salesStore } from "../store";
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const isMock = ref(false);
|
const isMock = ref(false);
|
||||||
const orderFilters = ref([]);
|
const orderFilters = ref([]);
|
||||||
const orderActions = ref([]);
|
|
||||||
const orderRows = ref([]);
|
const orderRows = ref([]);
|
||||||
const user = salesStore.user;
|
const user = salesStore.user;
|
||||||
|
const submittingId = ref(null);
|
||||||
|
const cancelingId = ref(null);
|
||||||
|
const message = ref("");
|
||||||
|
const messageType = ref("success");
|
||||||
|
|
||||||
onMounted(async () => {
|
function canSubmit(status) {
|
||||||
|
return ["draft", "rejected"].includes(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCancel(status) {
|
||||||
|
return !["canceled", "settled"].includes(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrders() {
|
||||||
const data = await fetchOrdersPage();
|
const data = await fetchOrdersPage();
|
||||||
orderFilters.value = data.orderFilters;
|
orderFilters.value = data.orderFilters;
|
||||||
orderActions.value = data.orderActions;
|
|
||||||
orderRows.value = data.orderRows;
|
orderRows.value = data.orderRows;
|
||||||
isMock.value = Boolean(data.isMock);
|
isMock.value = Boolean(data.isMock);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(row) {
|
||||||
|
if (!row.orderId || isMock.value) {
|
||||||
|
message.value = "当前是演示数据模式,暂不执行真实提交。";
|
||||||
|
messageType.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submittingId.value = row.orderId;
|
||||||
|
try {
|
||||||
|
await submitOrder(row.orderId);
|
||||||
|
await loadOrders();
|
||||||
|
message.value = `订单 ${row.orderNo} 已提交审核。`;
|
||||||
|
messageType.value = "success";
|
||||||
|
} catch (error) {
|
||||||
|
message.value = error.message || "提交审核失败";
|
||||||
|
messageType.value = "error";
|
||||||
|
} finally {
|
||||||
|
submittingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel(row) {
|
||||||
|
if (!row.orderId || isMock.value) {
|
||||||
|
message.value = "当前是演示数据模式,暂不执行真实取消。";
|
||||||
|
messageType.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelingId.value = row.orderId;
|
||||||
|
try {
|
||||||
|
await cancelOrder(row.orderId);
|
||||||
|
await loadOrders();
|
||||||
|
message.value = `订单 ${row.orderNo} 已发起取消。`;
|
||||||
|
messageType.value = "success";
|
||||||
|
} catch (error) {
|
||||||
|
message.value = error.message || "取消订单失败";
|
||||||
|
messageType.value = "error";
|
||||||
|
} finally {
|
||||||
|
cancelingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadOrders();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -111,16 +165,17 @@ h3 { margin: 0; font-size: 16px; }
|
|||||||
.filter { display: grid; gap: 6px; min-width: 180px; }
|
.filter { display: grid; gap: 6px; min-width: 180px; }
|
||||||
.filter span { color: #4b5563; font-size: 13px; }
|
.filter span { color: #4b5563; font-size: 13px; }
|
||||||
.filter input { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 12px; background: #f9fafb; }
|
.filter input { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 12px; background: #f9fafb; }
|
||||||
.action-btn { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 14px; background: #fff; }
|
.message-box { margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #dcfce7; color: #166534; }
|
||||||
.action-btn:disabled { color: #9ca3af; background: #f3f4f6; }
|
.message-box.error { background: #fee2e2; color: #b91c1c; }
|
||||||
.empty-state { margin-top: 20px; padding: 24px; border: 1px dashed #d1d5db; border-radius: 8px; color: #6b7280; text-align: center; }
|
.empty-state { margin-top: 20px; padding: 24px; border: 1px dashed #d1d5db; border-radius: 8px; color: #6b7280; text-align: center; }
|
||||||
.table-panel { border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
|
.table-panel { margin-top: 20px; border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
|
||||||
table { width: 100%; border-collapse: collapse; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #e5e7eb; }
|
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #e5e7eb; vertical-align: top; }
|
||||||
.status { display: inline-block; padding: 4px 10px; border-radius: 999px; background: #eef2ff; color: #4338ca; }
|
.status { display: inline-block; padding: 4px 10px; border-radius: 999px; background: #eef2ff; color: #4338ca; }
|
||||||
|
.actions-cell { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||||
.detail-link { color: #2563eb; text-decoration: none; }
|
.detail-link { color: #2563eb; text-decoration: none; }
|
||||||
.disabled-link { color: #9ca3af; }
|
.disabled-link { color: #9ca3af; }
|
||||||
.pagination { display: flex; justify-content: flex-end; align-items: center; gap: 12px; margin-top: 16px; }
|
.inline-btn { border: 1px solid #d1d5db; border-radius: 8px; padding: 6px 10px; background: #fff; }
|
||||||
.pagination button { border: 1px solid #d1d5db; border-radius: 8px; padding: 8px 12px; background: #fff; }
|
.danger-btn { color: #b91c1c; border-color: #fecaca; }
|
||||||
.pagination button:disabled { color: #9ca3af; background: #f3f4f6; }
|
.inline-btn:disabled { color: #9ca3af; background: #f3f4f6; border-color: #e5e7eb; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -94,3 +94,5 @@
|
|||||||
| 2026-05-14 | 已继续推进审批主线真实化:新增订单审批日志模型与仓储;`approve`、`cancel-approve` 会写入真实审批日志;订单详情 `approve_logs` 已开始返回真实数据库记录。 |
|
| 2026-05-14 | 已继续推进审批主线真实化:新增订单审批日志模型与仓储;`approve`、`cancel-approve` 会写入真实审批日志;订单详情 `approve_logs` 已开始返回真实数据库记录。 |
|
||||||
| 2026-05-14 | 已继续推进审计查询主线:`/api/audit-logs` 已支持按 `biz_type/biz_id/operate_type/start_time/end_time/page_no/page_size` 查询,并接入真实订单审批日志数据源。 |
|
| 2026-05-14 | 已继续推进审计查询主线:`/api/audit-logs` 已支持按 `biz_type/biz_id/operate_type/start_time/end_time/page_no/page_size` 查询,并接入真实订单审批日志数据源。 |
|
||||||
| 2026-05-14 | 已开始补前端真实联调:`frontend/web-sales` 的订单列表与订单详情已改为优先请求真实后端接口,失败时再回退 mock;同时清理了该模块关键页面中的乱码展示文本。 |
|
| 2026-05-14 | 已开始补前端真实联调:`frontend/web-sales` 的订单列表与订单详情已改为优先请求真实后端接口,失败时再回退 mock;同时清理了该模块关键页面中的乱码展示文本。 |
|
||||||
|
| 2026-05-14 | 已继续按前后端同步方式推进:后端订单接口已清理真实中文提示语;前端 `web-sales` 订单列表页与详情页已接入真实 `提交审核/取消订单` 动作,并补充加载态、禁用态与操作结果提示。 |
|
||||||
|
| 2026-05-14 | 已继续按前后端同步方式推进创建订单链路:后端补充创建订单参数校验;前端 `web-sales` 新建订单页已接入真实 `/api/orders` 创建接口,并支持最小可用单明细录入。 |
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user