dingdanquanliucheng/frontend/web-sales/src/views/OrderFormPage.vue

1514 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<section class="page">
<div class="page-hero">
<div>
<span class="eyebrow">录单中心</span>
<h2>{{ isEditMode ? '编辑订单' : '新建订单' }}</h2>
<p class="tips">填写客户要求和关键信息快速创建订单</p>
</div>
</div>
<div v-if="message" class="message-box" :class="{ error: messageType === 'error' }">
{{ message }}
</div>
<!-- 智能填单 -->
<section v-if="!parsedResult" class="form-section parse-card">
<div class="section-header">
<h3>智能填单</h3>
<span v-if="isMockMode" class="mode-tag fallback">演示数据</span>
</div>
<div class="parse-text-area">
<textarea v-model="parseInput" rows="5" placeholder="请粘贴订单相关文本(微信聊天记录、电话记录等)"></textarea>
<div class="parse-actions">
<button type="button" class="primary-btn" :disabled="parseLoading || !parseInput.trim()" @click="handleParseSubmit">
{{ parseLoading ? '解析中...' : '智能解析' }}
</button>
</div>
</div>
</section>
<!-- 解析结果预览 -->
<section v-if="parsedResult" class="form-section parse-result-card">
<div class="section-header">
<h3>智能填单 - 解析结果</h3>
<div class="parse-result-meta">
<span v-if="isMockMode" class="mode-tag fallback">演示数据</span>
<span class="confidence-tag">置信度 {{ Math.round(parsedResult.confidence * 100) }}%</span>
</div>
</div>
<div class="parse-result-fields">
<label>
<span>客户姓名</span>
<input v-model="parseEditable.customer_name" type="text" />
</label>
<label>
<span>客户手机号</span>
<input v-model="parseEditable.customer_mobile" type="text" />
</label>
<label>
<span>客户地址</span>
<input v-model="parseEditable.customer_address" type="text" />
</label>
<label>
<span>备注</span>
<input v-model="parseEditable.remark" type="text" placeholder="可选" />
</label>
</div>
<div v-if="parseEditable.items.length" class="parse-result-items">
<div class="items-header" @click="itemsExpanded = !itemsExpanded">
<h4>产品明细 ({{ parseEditable.items.length }}项)</h4>
<span class="expand-icon">{{ itemsExpanded ? '▼' : '▶' }}</span>
</div>
<div v-show="itemsExpanded">
<div v-for="(item, idx) in parseEditable.items" :key="idx" class="parse-item-card">
<div class="parse-item-main">
<strong>{{ item.product_name || '未识别产品' }}</strong>
<span>{{ item.specification || '-' }} / {{ item.unit || '-' }} / {{ item.quantity || 0 }} / 单价 {{ item.sale_price || 0 }}</span>
<span v-if="item.demand_specification" class="demand-spec-tag">需求规格: {{ item.demand_specification }}</span>
<p v-if="item._calcHint" class="calc-hint">{{ item._calcHint }}</p>
</div>
<div class="parse-item-select">
<span class="select-label">手动选择产品:</span>
<div class="product-search-box parse-product-search">
<input
v-model="item._parseSearchText"
type="text"
placeholder="搜索产品名称/规格..."
@input="handleParseProductSearch(item)"
@focus="item._parseShowDropdown = true"
@blur="hideParseProductDropdown(item)"
/>
<div v-if="item._parseShowDropdown && item._parseFilteredProducts && item._parseFilteredProducts.length" class="product-dropdown">
<div
v-for="opt in item._parseFilteredProducts"
:key="opt.value"
class="product-dropdown-item"
@mousedown.prevent="handleParseProductSelect(idx, String(opt.value))"
>
{{ opt.label }}
</div>
</div>
</div>
</div>
<div v-if="parsedResult.product_matches?.[idx]?.candidates?.length" class="parse-item-candidates">
<span class="candidate-label">匹配建议:</span>
<button
v-for="(c, ci) in parsedResult.product_matches[idx].candidates"
:key="ci"
type="button"
class="candidate-btn"
:class="{ selected: item._selectedCandidate?.product_id === c.product_id }"
@click="handleProductCandidateSelect(idx, c)"
>
{{ c.product_name }} / {{ c.specification }} ({{ Math.round(c.match_score * 100) }}%)
</button>
</div>
</div>
</div>
</div>
<div v-if="parsedResult.warnings?.length" class="parse-warnings">
<p v-for="(w, wi) in parsedResult.warnings" :key="wi">{{ w }}</p>
</div>
<div class="parse-actions">
<button type="button" class="ghost-btn" @click="handleResetParse">重新解析</button>
<button type="button" class="primary-btn" @click="handleConfirmParse">确认填入表单</button>
</div>
</section>
<form class="form-grid" @submit.prevent="handleSubmit">
<!-- 客户信息 -->
<section class="form-section">
<div class="section-header">
<h3>客户信息</h3>
<span>选择或输入客户信息</span>
</div>
<div class="section-grid">
<label>
<span>选择客户</span>
<select v-model="selectedCustomerId" @change="handleCustomerChange">
<option value="">请选择客户</option>
<option v-for="option in customerOptions" :key="option.value" :value="String(option.value)">
{{ option.label }}
</option>
</select>
</label>
<label>
<span>客户姓名</span>
<input v-model.trim="form.customer_name" type="text" placeholder="请输入客户姓名" @blur="handleCustomerInputBlur" />
</label>
<label>
<span>客户手机号</span>
<input v-model.trim="form.customer_mobile" type="text" placeholder="请输入客户手机号" @blur="handleCustomerInputBlur" />
</label>
<label>
<span>收货地址</span>
<input v-model.trim="form.customer_address" type="text" placeholder="请输入收货地址" />
</label>
</div>
<p v-if="customerHint" class="hint-line">{{ customerHint }}</p>
</section>
<!-- 客户要求与产品 -->
<section class="form-section full-width">
<div class="section-header">
<h3>客户要求</h3>
<div class="section-actions">
<span>输入客户需求,支持手动选择产品</span>
<button type="button" class="ghost-btn small-btn" @click="addItemRow">添加产品</button>
</div>
</div>
<div>
<label class="full-width-label">
<span>客户要求描述</span>
<textarea v-model.trim="form.customer_demand" rows="3" placeholder="请输入客户要求,如产品名称、规格、数量等"></textarea>
</label>
</div>
<div class="items-toggle" @click="formItemsExpanded = !formItemsExpanded">
<span>产品明细 ({{ items.length }}项)</span>
<span class="expand-icon">{{ formItemsExpanded ? '▼' : '▶' }}</span>
</div>
<p class="hint-text-cost">此处属于成本核算区域,需要根据实际的货物选择对应的规格</p>
<div v-show="formItemsExpanded">
<div v-for="(row, index) in items" :key="row.rowKey" class="item-card">
<div class="item-card-header">
<h4>产品 {{ index + 1 }}</h4>
<button v-if="items.length > 1" type="button" class="link-btn danger-link" @click="removeItemRow(index)">删除</button>
</div>
<div class="section-grid">
<label class="product-search-label">
<span>选择产品</span>
<div class="product-search-box">
<input
v-model="row._searchText"
type="text"
placeholder="搜索产品名称/规格..."
@input="handleProductSearch(row)"
@focus="row._showDropdown = true"
@blur="hideProductDropdown(row)"
/>
<div v-if="row._showDropdown && row._filteredProducts && row._filteredProducts.length" class="product-dropdown">
<div
v-for="opt in row._filteredProducts"
:key="opt.value"
class="product-dropdown-item"
@mousedown.prevent="selectProduct(row, opt)"
>
{{ opt.label }}
</div>
</div>
</div>
</label>
<label>
<span>产品名称</span>
<input v-model.trim="row.product_name" type="text" placeholder="请输入产品名称" />
</label>
<label>
<span>规格</span>
<input v-model.trim="row.specification" type="text" placeholder="请输入规格" />
<p v-if="row._calcHint" class="calc-hint">{{ row._calcHint }}</p>
</label>
<label>
<span>需求规格</span>
<input v-model.trim="row.demand_specification" type="text" placeholder="如: 25cm×25cm" @input="calcDemandResult(row)" />
</label>
<label v-if="row._demandResult">
<span>计算结果</span>
<div class="calc-result">{{ row._demandResult }}</div>
<p v-if="row._demandCalcHint" class="calc-hint">{{ row._demandCalcHint }}</p>
</label>
<label v-for="field in row._extraInputs" :key="field.key">
<span>{{ field.label || field.key }}</span>
<select v-model="row[field.key]">
<option value="">{{ field.label || field.key }}</option>
<option v-for="opt in field.options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</label>
<label>
<span>单位</span>
<input v-model.trim="row.unit" type="text" placeholder="例如:吨" />
</label>
<label>
<span>数量</span>
<input v-model.number="row.quantity" type="number" min="0.01" step="0.01" @input="calcDemandResult(row)" />
</label>
<!-- 特殊计价字段 -->
<label>
<span>长度(米)</span>
<input v-model.number="row.length_m" type="number" min="0" step="0.01" placeholder="选填" />
</label>
<label>
<span>宽度(米)</span>
<input v-model.number="row.width_m" type="number" min="0" step="0.01" placeholder="选填" />
</label>
<label>
<span>克重(g)</span>
<input v-model.number="row.weight_g" type="number" min="0" step="1" placeholder="选填" />
</label>
</div>
</div>
</div>
</section>
<!-- 合同金额 -->
<section class="form-section">
<div class="section-header">
<h3>合同金额</h3>
<span>填写订单金额与收款信息</span>
</div>
<div class="section-grid">
<label>
<span>合同金额</span>
<input v-model.number="form.contract_amount" type="number" min="0" step="0.01" placeholder="请输入合同金额" />
</label>
<label>
<span>收款渠道</span>
<div class="payment-method-input">
<select v-model="form.payment_method" @change="handlePaymentMethodChange">
<option value="">请选择收款渠道</option>
<option v-for="ch in paymentChannels" :key="ch.name" :value="ch.name">{{ ch.name }}</option>
<option value="其他">其他</option>
</select>
<input
v-if="form.payment_method === '其他'"
v-model.trim="form.payment_method_custom"
type="text"
placeholder="请输入自定义收款渠道"
/>
</div>
</label>
<label>
<span>订单类型</span>
<select v-model="form.order_type">
<option value="">请选择订单类型</option>
<option value="industry">工业订单</option>
<option value="daily">日用品订单</option>
</select>
</label>
<label>
<span>提成金额(可选)</span>
<input v-model.number="form.commission_amount" type="number" min="0" step="0.01" placeholder="留空表示无提成" />
</label>
<label class="checkline">
<input v-model="form.need_invoice" type="checkbox" />
<span>是否开发票</span>
</label>
</div>
</section>
<!-- 订单附件 -->
<section class="form-section full-width">
<div class="section-header">
<h3>订单附件</h3>
<span>上传合同、产品照片等</span>
</div>
<div class="attachment-area">
<label class="ghost-btn attachment-upload-label">
选择图片
<input type="file" accept="image/*" multiple style="display:none" @change="handleAttachmentChange" />
</label>
<p class="hint-line">支持 jpg/png/webp 格式,不限数量</p>
<div v-if="attachmentFiles.length" class="attachment-list">
<div v-for="(file, idx) in attachmentFiles" :key="file.rowKey" class="attachment-card">
<img :src="file.preview" class="attachment-thumb" />
<span class="attachment-name">{{ file.file.name }}</span>
<button type="button" class="link-btn danger-link" @click="removeAttachment(idx)">删除</button>
</div>
</div>
</div>
</section>
<!-- 物流信息 -->
<section class="form-section">
<div class="section-header">
<h3>物流信息</h3>
<span>如已填写快递单号,订单将跳过工厂下发环节</span>
</div>
<div class="section-grid">
<label class="checkline">
<input v-model="form.is_self_pickup" type="checkbox" @change="onSelfPickupChange" />
<span>客户自提(跳过物流)</span>
</label>
<label v-if="!form.is_self_pickup">
<span>快递单号(可选)</span>
<input v-model.trim="form.tracking_number" type="text" placeholder="" />
</label>
<label v-if="!form.is_self_pickup">
<span>快递公司(必选)</span>
<ExpressCompanyField v-model="form.express_company" />
</label>
</div>
</section>
</form>
<section class="actions">
<button type="button" class="secondary-btn" @click="resetForm">重置表单</button>
<button type="button" class="primary-btn" :disabled="submitting || optionLoading" @click="handleSubmit">
{{ submitting ? (isEditMode ? "更新中..." : "创建中...") : (isEditMode ? "更新订单" : "创建订单") }}
</button>
</section>
</section>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import ExpressCompanyField from "../components/ExpressCompanyField.vue";
import {
createOrder,
fetchConfigItem,
fetchCustomerOptions,
fetchOrderForEdit,
fetchProductOptions,
localUploadFile,
parseOrderText,
saveAttachment,
updateOrder,
} from "../mockApi";
const router = useRouter();
const route = useRoute();
const submitting = ref(false);
const optionLoading = ref(true);
const message = ref("");
const messageType = ref("success");
const editingOrderId = ref(null);
const isEditMode = computed(() => Boolean(editingOrderId.value));
const customerOptions = ref([]);
const productOptions = ref([]);
const selectedCustomerId = ref("");
// 智能填单状态
const parseInput = ref("");
const parsedResult = ref(null);
const parseLoading = ref(false);
const itemsExpanded = ref(false);
const parseEditable = ref({
customer_name: "",
customer_mobile: "",
customer_address: "",
remark: "",
items: [],
});
function buildDefaultItem() {
return {
rowKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
selectedProductId: "",
product_id: null,
product_name: "",
specification: "",
demand_specification: "",
_demandResult: "",
_demandCalcHint: "",
unit: "",
quantity: 1,
sale_price: 0,
cost_price: 0,
remark: "",
// 特殊计价字段
length_m: null,
width_m: null,
weight_g: null,
pricing_type: "",
price_tier: "",
_extraInputs: [],
_searchText: "",
_showDropdown: false,
_filteredProducts: [],
};
}
function buildDefaultForm() {
return {
customer_name: "",
customer_mobile: "",
customer_address: "",
customer_demand: "",
contract_amount: 0,
freight_total: 0,
rebate_total: 0,
tax_total: 0,
other_fee_total: 0,
commission_amount: 0,
tax_amount: 0,
need_invoice: false,
order_type: "",
is_self_pickup: false,
tracking_number: "",
express_company: "",
payment_method: "",
payment_method_custom: "",
auto_sync_customer: true,
remark: "",
};
}
const form = reactive(buildDefaultForm());
const items = ref([buildDefaultItem()]);
const formItemsExpanded = ref(false);
const customerHint = ref("");
const attachmentFiles = ref([]);
const saleTotal = computed(() =>
items.value.reduce((total, row) => total + Number(row.quantity || 0) * Number(row.sale_price || 0), 0),
);
function onSelfPickupChange() {
if (form.is_self_pickup) {
form.tracking_number = "";
form.express_company = "";
}
}
function resetForm() {
Object.assign(form, buildDefaultForm());
items.value = [buildDefaultItem()];
selectedCustomerId.value = "";
customerHint.value = "";
message.value = "";
formItemsExpanded.value = false;
}
function addItemRow() {
items.value.push(buildDefaultItem());
formItemsExpanded.value = true;
}
function removeItemRow(index) {
if (items.value.length <= 1) {
return;
}
items.value.splice(index, 1);
}
function handleCustomerChange() {
const selected = customerOptions.value.find((option) => String(option.value) === selectedCustomerId.value);
if (!selected) {
return;
}
form.customer_name = selected.customer_name;
form.customer_mobile = selected.customer_mobile;
form.customer_address = selected.customer_address;
customerHint.value = `已选中客户 ${selected.customer_name},系统将优先使用客户库信息。`;
}
function handleCustomerInputBlur() {
if (selectedCustomerId.value || !form.customer_name.trim() || !form.customer_mobile.trim()) {
return;
}
const exists = customerOptions.value.find((option) => option.customer_name === form.customer_name.trim() && option.customer_mobile === form.customer_mobile.trim());
if (exists) {
selectedCustomerId.value = String(exists.value);
handleCustomerChange();
customerHint.value = '已匹配到客户库中的同名同号客户,系统已自动关联。';
return;
}
customerHint.value = form.auto_sync_customer ? '系统将把当前客户视为新客户,并在提交后自动同步到客户库。' : '当前客户未在客户库中匹配到记录,请确认是否需要手动新增。';
}
function handlePaymentMethodChange() {
if (form.payment_method !== '其他') {
form.payment_method_custom = '';
}
}
// 智能填单
const isMockMode = computed(() => parsedResult.value?.is_mock === true);
async function handleParseSubmit() {
if (parseLoading.value || !parseInput.value.trim()) return;
parseLoading.value = true;
message.value = "";
try {
const result = await parseOrderText(parseInput.value);
parsedResult.value = result;
itemsExpanded.value = false;
parseEditable.value = {
customer_name: result.parsed_order.customer_name || "",
customer_mobile: result.parsed_order.customer_mobile || "",
customer_address: result.parsed_order.customer_address || "",
remark: result.parsed_order.remark || "",
items: (result.parsed_order.items || []).map((item, idx) => ({
...item,
_selectedCandidate: result.product_matches?.[idx]?.candidates?.[0] || null,
_parseSearchText: "",
_parseShowDropdown: false,
_parseFilteredProducts: [],
})),
};
} catch (error) {
message.value = error.message || "解析失败";
messageType.value = "error";
} finally {
parseLoading.value = false;
}
}
function handleConfirmParse() {
const parsed = parseEditable.value;
form.customer_name = parsed.customer_name || "";
form.customer_mobile = parsed.customer_mobile || "";
form.customer_address = parsed.customer_address || "";
form.remark = parsed.remark || "";
// 将产品需求填入客户需求字段
if (parsed.items.length > 0) {
const demandLines = parsed.items.map((item, idx) => {
const name = item.product_name || '未识别产品';
const spec = item.specification ? ` ${item.specification}` : '';
const unit = item.unit ? ` ${item.unit}` : '';
const qty = item.quantity ? ` ${item.quantity}${unit}` : '';
return `${idx + 1}. ${name}${spec}${qty}`;
});
form.customer_demand = demandLines.join('\n');
}
if (parsedResult.value?.parsed_order?.customer_id) {
const cid = parsedResult.value.parsed_order.customer_id;
selectedCustomerId.value = String(cid);
handleCustomerChange();
} else {
selectedCustomerId.value = "";
}
const newItems = [];
for (const parsedItem of parsed.items) {
const row = buildDefaultItem();
if (parsedItem._selectedCandidate) {
const c = parsedItem._selectedCandidate;
row.selectedProductId = String(c.product_id);
row.product_id = c.product_id;
row.product_name = c.product_name;
row.specification = c.specification;
row.demand_specification = parsedItem.demand_specification || "";
row.unit = c.unit;
row.sale_price = c.sale_price;
row.cost_price = c.cost_price;
row.quantity = parsedItem.quantity || 1;
loadExtraInputs(row);
// Auto-fill extra inputs from AI recognition (e.g., joint_type)
if (parsedItem.joint_type && row._extraInputs && row._extraInputs.length) {
row.joint_type = parsedItem.joint_type;
}
// 复制计算逻辑提示
if (parsedItem._calcHint) {
row._calcHint = parsedItem._calcHint;
} else if (c.surcharge_detail) {
try {
const detail = typeof c.surcharge_detail === 'string' ? JSON.parse(c.surcharge_detail) : c.surcharge_detail;
const parts = [];
if (detail.method) parts.push(`计价方式: ${detail.method}`);
if (detail.formula) parts.push(`公式: ${detail.formula}`);
if (detail.length_m && detail.width_m) parts.push(`${detail.length_m}m × ${detail.width_m}m`);
if (detail.area_sqm) parts.push(`面积: ${detail.area_sqm}`);
row._calcHint = parts.join(' | ') || '';
} catch (e) {
row._calcHint = '';
}
}
} else {
row.product_name = parsedItem.product_name || "";
row.specification = parsedItem.specification || "";
row.demand_specification = parsedItem.demand_specification || "";
row.unit = parsedItem.unit || "";
row.quantity = parsedItem.quantity || 1;
row.sale_price = parsedItem.sale_price || 0;
row.cost_price = parsedItem.cost_price || 0;
}
if (!row.quantity || row.quantity <= 0) row.quantity = parsedItem.quantity || 1;
if (row.sale_price <= 0 && parsedItem.sale_price > 0) row.sale_price = parsedItem.sale_price;
if (parsedItem.remark) row.remark = parsedItem.remark;
if (row.demand_specification) calcDemandResult(row);
newItems.push(row);
}
items.value = newItems.length > 0 ? newItems : [buildDefaultItem()];
formItemsExpanded.value = true;
parsedResult.value = null;
parseEditable.value = { customer_name: "", customer_mobile: "", customer_address: "", remark: "", items: [] };
}
function handleProductCandidateSelect(itemIndex, candidate) {
const item = parseEditable.value.items[itemIndex];
if (!item) return;
item._selectedCandidate = candidate;
// 生成计算逻辑提示
if (candidate.surcharge_detail) {
try {
const detail = typeof candidate.surcharge_detail === 'string' ? JSON.parse(candidate.surcharge_detail) : candidate.surcharge_detail;
const parts = [];
if (detail.method) parts.push(`计价方式: ${detail.method}`);
if (detail.formula) parts.push(`公式: ${detail.formula}`);
if (detail.length_m && detail.width_m) parts.push(`${detail.length_m}m × ${detail.width_m}m`);
if (detail.area_sqm) parts.push(`面积: ${detail.area_sqm}`);
item._calcHint = parts.join(' | ') || '';
} catch (e) {
item._calcHint = '';
}
} else {
item._calcHint = '';
}
}
function handleParseProductSelect(itemIndex, productId) {
const item = parseEditable.value.items[itemIndex];
if (!item) return;
if (!productId) {
item._selectedCandidate = null;
return;
}
const selected = productOptions.value.find((opt) => String(opt.value) === productId);
if (selected) {
item._selectedCandidate = {
product_id: selected.value,
product_name: selected.product_name,
specification: selected.specification,
unit: selected.unit,
sale_price: selected.sale_price,
cost_price: selected.cost_price,
surcharge_detail: selected.surcharge_detail,
match_score: 1,
};
item._parseSearchText = selected.product_name;
item._parseShowDropdown = false;
// 生成计算逻辑提示
if (selected.surcharge_detail) {
try {
const detail = typeof selected.surcharge_detail === 'string' ? JSON.parse(selected.surcharge_detail) : selected.surcharge_detail;
const parts = [];
if (detail.method) parts.push(`计价方式: ${detail.method}`);
if (detail.formula) parts.push(`公式: ${detail.formula}`);
if (detail.length_m && detail.width_m) parts.push(`${detail.length_m}m × ${detail.width_m}m`);
if (detail.area_sqm) parts.push(`面积: ${detail.area_sqm}`);
item._calcHint = parts.join(' | ') || '';
} catch (e) {
item._calcHint = '';
}
} else {
item._calcHint = '';
}
}
}
function handleParseProductSearch(item) {
const text = (item._parseSearchText || "").toLowerCase();
if (!text) {
item._parseFilteredProducts = productOptions.value.slice(0, 50);
} else {
item._parseFilteredProducts = productOptions.value.filter((opt) => {
const name = (opt.product_name || "").toLowerCase();
const spec = (opt.specification || "").toLowerCase();
return name.includes(text) || spec.includes(text);
}).slice(0, 50);
}
}
function hideParseProductDropdown(item) {
setTimeout(() => { item._parseShowDropdown = false; }, 200);
}
function handleResetParse() {
parsedResult.value = null;
parseEditable.value = { customer_name: "", customer_mobile: "", customer_address: "", remark: "", items: [] };
parseInput.value = "";
itemsExpanded.value = false;
}
// 订单附件
function handleAttachmentChange(event) {
const files = Array.from(event.target.files || []);
for (const file of files) {
attachmentFiles.value.push({
rowKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file,
preview: URL.createObjectURL(file),
});
}
event.target.value = "";
}
function removeAttachment(index) {
const removed = attachmentFiles.value[index];
if (removed?.preview) URL.revokeObjectURL(removed.preview);
attachmentFiles.value.splice(index, 1);
}
async function uploadAttachments(orderId) {
for (const item of attachmentFiles.value) {
if (item.existing) continue; // 跳过已有附件,不重复上传
const uploadResult = await localUploadFile(item.file);
await saveAttachment({
biz_type: "sales_order",
biz_id: orderId,
file_name: item.file.name,
file_url: uploadResult.url,
file_type: item.file.type || "image/jpeg",
file_size: item.file.size,
});
}
}
function handleProductChange(row) {
const selected = productOptions.value.find((option) => String(option.value) === row.selectedProductId);
if (!selected) return;
row.product_id = selected.value;
row.product_name = selected.product_name;
row.specification = selected.specification;
row.unit = selected.unit;
row.sale_price = Number(selected.sale_price || 0);
row.cost_price = Number(selected.cost_price || 0);
// Load extra pricing inputs (e.g., joint_type for conveyor belts)
loadExtraInputs(row);
// 计算逻辑提示
if (selected.surcharge_detail) {
try {
const detail = typeof selected.surcharge_detail === 'string' ? JSON.parse(selected.surcharge_detail) : selected.surcharge_detail;
const parts = [];
if (detail.method) parts.push(`计价方式: ${detail.method}`);
if (detail.formula) parts.push(`公式: ${detail.formula}`);
if (detail.length_m && detail.width_m) parts.push(`${detail.length_m}m × ${detail.width_m}m`);
if (detail.area_sqm) parts.push(`面积: ${detail.area_sqm}`);
row._calcHint = parts.join(' | ') || '';
} catch (e) {
row._calcHint = '';
}
} else {
row._calcHint = '';
}
}
function getJointTypeLabel(value) {
const map = {
niubi: "牛鼻子接头",
polyester_spiral: "聚酯螺旋接头",
steel_buckle: "钢扣接头",
wall_kevlar: "墙式凯夫拉布接头",
niubi_kevlar: "牛鼻子凯夫拉接头",
diagonal: "斜接",
overlap: "搭接",
flat: "平接",
};
return map[value] || value || "";
}
function loadExtraInputs(row) {
const selected = productOptions.value.find((opt) => String(opt.value) === row.selectedProductId);
if (!selected || !selected.pricing_inputs) {
row._extraInputs = [];
return;
}
try {
const inputs = JSON.parse(selected.pricing_inputs || '[]');
row._extraInputs = inputs.filter(f => f.type === 'select');
if (row.joint_type) {
row.joint_type_label = getJointTypeLabel(row.joint_type);
}
} catch { row._extraInputs = []; }
}
function handleProductSearch(row) {
const text = (row._searchText || "").toLowerCase();
if (!text) {
row._filteredProducts = productOptions.value.slice(0, 50);
} else {
row._filteredProducts = productOptions.value.filter((opt) => {
const name = (opt.product_name || "").toLowerCase();
const spec = (opt.specification || "").toLowerCase();
return name.includes(text) || spec.includes(text);
}).slice(0, 50);
}
}
function selectProduct(row, opt) {
row.selectedProductId = String(opt.value);
row._searchText = opt.product_name;
row._showDropdown = false;
handleProductChange(row);
}
function hideProductDropdown(row) {
setTimeout(() => { row._showDropdown = false; }, 200);
}
/**
* 解析需求规格并计算结果
* 支持格式:
* - 长度尺寸: "25cm×25cm", "25×30", "25cm x 30cm", "0.25m×0.3m", "250mm×300mm", "25厘米×30厘米"
* - 重量: "500克", "2斤", "1.5kg", "2公斤"
* 计算面积(宽×高)或显示重量
*/
function calcDemandResult(row) {
const spec = (row.demand_specification || "").trim();
if (!spec) {
row._demandResult = "";
row._demandCalcHint = "";
return;
}
// 标准化长度单位
const normalizeLengthUnit = (unit) => {
if (!unit) return "cm";
const u = unit.toLowerCase();
if (u === "m" || u === "米") return "m";
if (u === "mm" || u === "毫米") return "mm";
return "cm"; // 厘米、cm 或无单位默认
};
// 匹配长度尺寸: 数字 + 可选单位 × 数字 + 可选单位
const lengthMatch = spec.match(/([\d.]+)\s*(cm|厘米|m|米|mm|毫米)?\s*[×xX*]\s*([\d.]+)\s*(cm|厘米|m|米|mm|毫米)?/i);
if (lengthMatch) {
const v1 = parseFloat(lengthMatch[1]);
const u1 = normalizeLengthUnit(lengthMatch[2]);
const v2 = parseFloat(lengthMatch[3]);
const u2 = normalizeLengthUnit(lengthMatch[4]);
// 统一转换为米
const toM = (v, u) => u === "m" ? v : u === "mm" ? v / 1000 : v / 100;
const m1 = toM(v1, u1);
const m2 = toM(v2, u2);
row.length_m = m2;
row.width_m = m1;
const qty = Number(row.quantity) || 0;
const total = m1 * m2 * qty;
row._demandResult = qty > 0 ? `${total.toFixed(4)}` : `${(m1 * m2).toFixed(4)}`;
row._demandCalcHint = qty > 0
? `${v1}${u1} × ${v2}${u2} × ${qty} = ${m1}m × ${m2}m × ${qty} = ${total.toFixed(4)}`
: `${v1}${u1} × ${v2}${u2} = ${m1}m × ${m2}m = ${(m1 * m2).toFixed(4)}`;
return;
}
// 匹配重量: 数字 + 单位
const weightMatch = spec.match(/([\d.]+)\s*(克|斤|kg|公斤)/i);
if (weightMatch) {
const value = parseFloat(weightMatch[1]);
const unit = weightMatch[2];
row._demandResult = `${value}${unit}`;
row._demandCalcHint = `重量: ${value}${unit}`;
return;
}
// 无法识别
row._demandResult = "";
row._demandCalcHint = "";
}
const paymentChannels = ref([]);
async function loadOptions() {
optionLoading.value = true;
try {
const [customers, products, channelsConfig] = await Promise.all([
fetchCustomerOptions(),
fetchProductOptions(),
fetchConfigItem("payment_channels").catch(() => null),
]);
customerOptions.value = customers;
productOptions.value = products;
// 解析收款渠道配置
if (channelsConfig && channelsConfig.config_value) {
try {
paymentChannels.value = JSON.parse(channelsConfig.config_value);
} catch (e) {
paymentChannels.value = [];
}
}
} catch (error) {
message.value = error.message || "加载客户、产品选项失败";
messageType.value = "error";
} finally {
optionLoading.value = false;
}
}
async function handleSubmit() {
if (submitting.value) return; // 防止重复提交
if (!form.customer_name.trim()) {
message.value = "请输入客户姓名";
messageType.value = "error";
return;
}
if (!form.customer_mobile.trim()) {
message.value = "请输入客户手机号";
messageType.value = "error";
return;
}
const matchedCustomer = customerOptions.value.find((option) => option.customer_name === form.customer_name.trim() && option.customer_mobile === form.customer_mobile.trim());
if (!matchedCustomer && form.auto_sync_customer) {
customerHint.value = '当前客户未匹配到客户库,提交后将自动同步为新客户。';
}
const validItems = items.value.filter((row) => row.product_name.trim());
if (!validItems.length) {
message.value = "请至少填写一个产品";
messageType.value = "error";
return;
}
submitting.value = true;
message.value = "";
try {
const paymentMethod = form.payment_method === '其他' ? form.payment_method_custom : form.payment_method;
// 根据收款渠道自动判断订单来源(电商/普通)
const selectedChannel = paymentChannels.value.find((ch) => ch.name === form.payment_method);
const orderSource = selectedChannel && selectedChannel.is_ecommerce ? "电商" : "";
const selfDelivery = form.tracking_number ? 1 : 0;
const payload = {
customer_name: form.customer_name,
customer_mobile: form.customer_mobile,
customer_address: form.customer_address,
customer_demand: form.customer_demand,
contract_amount: form.contract_amount,
freight_total: form.freight_total,
rebate_total: form.commission_amount,
tax_total: form.tax_total,
other_fee_total: form.other_fee_total,
commission_amount: form.commission_amount,
tax_amount: form.tax_amount,
need_invoice: form.need_invoice ? 1 : 0,
order_type: form.order_type,
self_delivery: selfDelivery,
is_self_pickup: form.is_self_pickup ? 1 : 0,
tracking_number: form.tracking_number || null,
express_company: form.express_company || null,
payment_method: paymentMethod,
order_source: orderSource,
remark: form.remark,
auto_sync_customer: form.auto_sync_customer,
customer_sync_mode: matchedCustomer ? 'linked' : form.auto_sync_customer ? 'auto_create' : 'manual_review',
items: validItems.map(({ rowKey, selectedProductId, _searchText, _showDropdown, _filteredProducts, _demandResult, _demandCalcHint, _calcHint, _extraInputs, ...rest }) => {
const extra = {};
if (_extraInputs) {
for (const f of _extraInputs) {
if (rest[f.key]) extra[f.key] = rest[f.key];
}
}
return { ...rest, pricing_extra_inputs: Object.keys(extra).length ? extra : undefined };
}),
};
let result;
if (isEditMode.value) {
result = await updateOrder(editingOrderId.value, payload);
if (attachmentFiles.value.length) await uploadAttachments(result.order_id);
message.value = `订单 ${result.order_no} 更新成功。`;
} else {
result = await createOrder(payload);
if (attachmentFiles.value.length) await uploadAttachments(result.order_id);
message.value = `订单 ${result.order_no} 创建成功,已提交审批。`;
}
messageType.value = "success";
await router.push(`/orders/${result.order_id}`);
} catch (error) {
message.value = error.message || (isEditMode.value ? "更新订单失败" : "创建订单失败");
messageType.value = "error";
} finally {
submitting.value = false;
}
}
async function loadOrderForEdit(orderId) {
try {
const data = await fetchOrderForEdit(orderId);
form.customer_name = data.customer_name || "";
form.customer_mobile = data.customer_mobile || "";
form.customer_address = data.customer_address || "";
form.customer_demand = data.customer_demand || "";
form.contract_amount = data.contract_amount || 0;
form.freight_total = data.freight_total || 0;
form.rebate_total = data.rebate_total || 0;
form.tax_total = data.tax_total || 0;
form.other_fee_total = data.other_fee_total || 0;
form.commission_amount = data.commission_amount || 0;
form.tax_amount = data.tax_amount || 0;
form.need_invoice = data.need_invoice ? true : false;
form.order_type = data.order_type || "";
form.is_self_pickup = Boolean(data.is_self_pickup);
form.tracking_number = data.tracking_number || "";
form.express_company = data.express_company || "";
form.payment_method = data.payment_method || "";
form.remark = data.remark || "";
if (data.customer_id) {
selectedCustomerId.value = String(data.customer_id);
handleCustomerChange();
}
if (data.items && data.items.length) {
items.value = data.items.map((item) => {
const row = buildDefaultItem();
row.product_id = item.product_id;
row.product_name = item.product_name;
row.specification = item.specification;
row.unit = item.unit;
row.quantity = item.quantity;
row.sale_price = item.sale_price;
row.cost_price = item.cost_price || 0;
row.remark = item.remark || "";
if (item.product_id) {
row.selectedProductId = String(item.product_id);
}
return row;
});
}
customerHint.value = `正在编辑订单,修改后将覆盖原有订单内容。`;
if (data.attachments && data.attachments.length) {
attachmentFiles.value = data.attachments
.filter(function (a) { return a.biz_type === "sales_order"; })
.map(function (a) {
return {
rowKey: "existing-" + a.attachment_id,
file: { name: a.file_name, type: a.file_type, size: a.file_size },
preview: a.file_url,
existing: true,
attachmentId: a.attachment_id,
};
});
}
} catch (error) {
message.value = error.message || "加载订单数据失败";
messageType.value = "error";
}
}
onMounted(async () => {
await loadOptions();
const editId = route.query.edit;
if (editId) {
editingOrderId.value = Number(editId);
await loadOrderForEdit(editId);
}
});
</script>
<style scoped>
.page {
display: grid;
gap: 18px;
}
.page-hero,
.form-section,
.summary {
background: rgba(255, 255, 255, 0.92);
border: 1px solid #e5e7eb;
border-radius: 20px;
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.06);
}
.page-hero {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-start;
padding: 24px;
}
.eyebrow {
display: inline-flex;
padding: 6px 12px;
border-radius: 999px;
background: #dbeafe;
color: #1d4ed8;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
}
h2 {
margin: 12px 0 8px;
font-size: 30px;
}
.tips,
.section-header span {
color: #64748b;
}
.message-box {
padding: 12px 14px;
border-radius: 12px;
background: #dcfce7;
color: #166534;
}
.message-box.error {
background: #fee2e2;
color: #b91c1c;
}
.form-grid {
display: grid;
gap: 16px;
}
.form-section {
padding: 20px;
}
.section-header {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
align-items: center;
}
.section-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.section-header h3,
.summary strong {
margin: 0;
}
.section-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.full-width-label { grid-column: 1 / -1; }
.full-width { grid-column: 1 / -1; }
label {
display: grid;
gap: 8px;
}
label span {
color: #374151;
font-size: 13px;
font-weight: 600;
}
.checkline {
display: flex;
align-items: center;
gap: 8px;
}
.checkline input[type="checkbox"] {
width: 18px;
height: 18px;
}
input,
textarea,
select {
border: 1px solid #d1d5db;
border-radius: 12px;
padding: 10px 12px;
background: #fff;
}
.payment-method-input {
display: grid;
gap: 8px;
}
.summary {
display: grid;
grid-template-columns: 1fr;
max-width: 320px;
gap: 12px;
padding: 18px 20px;
}
.summary article {
padding: 16px;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff, #f8fbff);
border: 1px solid #dbe3ef;
display: grid;
gap: 8px;
}
.summary span {
color: #64748b;
}
.summary strong {
font-size: 24px;
color: #0f172a;
}
.items-toggle {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 10px 12px;
margin: 10px 0;
background: #f1f5f9;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
color: #374151;
}
.items-toggle:hover {
background: #e2e8f0;
}
.item-card {
margin-bottom: 14px;
padding: 14px;
border: 1px solid #dbe3ef;
border-radius: 16px;
background: #f8fbff;
overflow: visible;
}
.item-card-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.item-card-header h4 { margin: 0; }
.product-search-label {
position: relative;
}
.product-search-box {
position: relative;
}
.product-search-box input {
width: 100%;
box-sizing: border-box;
}
.product-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
background: #fff;
border: 1px solid #dbe3ef;
border-radius: 8px;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.parse-product-search {
position: relative;
min-width: 0;
}
.parse-product-search .product-dropdown {
width: 100%;
right: auto;
}
.product-dropdown-item {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
}
.product-dropdown-item:hover {
background: #f0f7ff;
}
.hint-text-cost {
font-size: 12px;
color: #94a3b8;
margin: 4px 0 8px;
padding: 0 4px;
}
.calc-hint {
font-size: 11px;
color: #6366f1;
margin: 2px 0 0;
padding: 0;
}
.calc-result {
font-size: 14px;
font-weight: 600;
color: #059669;
padding: 8px 10px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
}
.actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
button {
border: 1px solid #d1d5db;
background: #fff;
border-radius: 12px;
padding: 10px 14px;
}
.small-btn { padding: 8px 12px; }
.danger-link { color: #dc2626; }
.link-btn { background: none; border: none; cursor: pointer; }
.primary-btn {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #fff;
border-color: transparent;
}
button:disabled {
color: #9ca3af;
background: #f3f4f6;
}
.secondary-btn {
color: #374151;
}
.hint-line {
font-size: 12px;
color: #64748b;
margin-top: 8px;
}
@media (max-width: 960px) {
.page-hero {
flex-direction: column;
}
.summary,
.section-grid {
grid-template-columns: 1fr;
}
.actions {
flex-direction: column;
}
.section-header,
.item-card-header {
flex-direction: column;
align-items: stretch;
}
}
@media (max-width: 640px) {
.page {
gap: 12px;
}
.page-hero,
.form-section,
.summary {
padding: 14px;
border-radius: 16px;
}
h2 {
font-size: 24px;
margin: 10px 0 6px;
}
.tips,
.section-header span,
label span,
.summary span,
.message-box,
button {
font-size: 12px;
}
.form-grid {
gap: 12px;
}
.form-section {
padding: 12px;
}
.section-header {
margin-bottom: 10px;
}
.section-grid,
.summary {
gap: 10px;
}
input,
textarea,
select,
button {
padding: 9px 10px;
border-radius: 10px;
}
.summary strong {
font-size: 18px;
}
.actions {
gap: 8px;
}
}
/* 智能填单样式 */
.ghost-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #d1d5db;
border-radius: 12px;
padding: 10px 14px;
background: #f8fafc;
color: #334155;
cursor: pointer;
font-size: 13px;
}
.ghost-btn:disabled {
color: #9ca3af;
background: #f3f4f6;
cursor: not-allowed;
}
.parse-card { border: 2px dashed #93c5fd; background: linear-gradient(180deg, #f0f9ff, #fff); }
.parse-text-area textarea { width: 100%; border: 1px solid #d1d5db; border-radius: 12px; padding: 10px 12px; resize: vertical; font-size: 14px; }
.parse-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.parse-result-card { border: 2px solid #2563eb; background: linear-gradient(180deg, #eff6ff, #fff); }
.parse-result-meta { display: flex; gap: 8px; align-items: center; }
.confidence-tag { display: inline-flex; padding: 4px 10px; border-radius: 999px; background: #dcfce7; color: #166534; font-size: 12px; font-weight: 600; }
.mode-tag.fallback { display: inline-flex; padding: 4px 10px; border-radius: 999px; background: #fef3c7; color: #92400e; font-size: 12px; font-weight: 600; }
.parse-result-fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 14px; }
.parse-result-items { margin-bottom: 14px; }
.items-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 8px 0; }
.items-header h4 { margin: 0; font-size: 14px; }
.expand-icon { font-size: 12px; color: #6b7280; }
.parse-item-card { padding: 10px; border: 1px solid #e5e7eb; border-radius: 10px; margin-bottom: 8px; background: #f9fafb; overflow: visible; }
.parse-item-main { display: flex; flex-direction: column; gap: 4px; }
.parse-item-main strong { font-size: 14px; }
.parse-item-main span { font-size: 12px; color: #6b7280; }
.demand-spec-tag {
display: inline-flex; padding: 2px 8px; margin-top: 4px;
background: #eff6ff; color: #1d4ed8; border-radius: 6px;
font-size: 11px; font-weight: 600;
}
.parse-item-select { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.parse-item-select .select-label { font-size: 12px; color: #6b7280; white-space: nowrap; }
.parse-product-search { flex: 1; }
.parse-item-candidates { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; align-items: center; }
.candidate-label { font-size: 12px; color: #6b7280; }
.candidate-btn { border: 1px solid #d1d5db; background: #fff; border-radius: 8px; padding: 4px 10px; font-size: 11px; cursor: pointer; }
.candidate-btn.selected { background: #2563eb; color: #fff; border-color: #2563eb; }
.parse-warnings { margin-bottom: 10px; }
.parse-warnings p { font-size: 12px; color: #b45309; margin: 2px 0; }
/* 订单附件 */
.attachment-area { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.attachment-upload-label { cursor: pointer; }
.attachment-list { display: flex; flex-wrap: wrap; gap: 10px; }
.attachment-card {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f9fafb;
}
.attachment-thumb { width: 48px; height: 48px; object-fit: cover; border-radius: 8px; }
.attachment-name { font-size: 12px; color: #374151; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>