This commit is contained in:
2026-04-14 12:14:45 +08:00
parent 4c16617e6c
commit 126e15dfa9
11 changed files with 684 additions and 14 deletions
@@ -0,0 +1,323 @@
<script setup>
/**
* PurchaseOrderForm —— 采购订单表单公共组件
*
* 供 addorder.vue 和 editorder.vue 共用,包含:
* - 标题、备注、链接、款式标签
* - 费用明细(添加/删除)
* - 图片上传(useDropzone
*
* Props:
* modelValue {Object} 表单数据(v-model
* initialCosts {Array} 回填的费用列表(编辑时传入,单位:分)
* initialPhotos {Array} 回填的图片列表 [{ id, hash, name, ... }]
*
* Emits:
* update:modelValue
*/
import { reactive, ref, computed, watch, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import tagadder from "@/components/tagadder.vue";
import useDropzone from "@/components/useDropzone.vue";
const props = defineProps({
modelValue: {
type: Object,
required: true,
},
/** 回填费用列表(分为单位) */
initialCosts: {
type: Array,
default: () => [],
},
/** 回填图片列表 */
initialPhotos: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:modelValue"]);
const { t } = useI18n();
// ==================== 常量 ====================
const textMaxLen = 256;
const currencyOptions = { 1: "CNY", 2: "MOP", 3: "HKD", 4: "USD" };
const costType = computed(() => ({
1: t("cost_type.unit_price"),
2: t("cost_type.freight"),
}));
// ==================== 费用明细 ====================
/** 展示用(元为单位) */
const costEntries = reactive([]);
const newCost = reactive({
type: 1,
int: 1,
cost: 0,
currencyType: 1,
});
const costError = ref(false);
const newCostTotal = computed(() =>
parseFloat((newCost.int * newCost.cost).toFixed(2)),
);
function addCostEntry() {
if (newCost.cost <= 0) {
costError.value = true;
return;
}
costError.value = false;
costEntries.push({
type: newCost.type,
int: newCost.int,
cost: newCost.cost,
costt: newCostTotal.value,
currencytype: newCost.currencyType,
});
newCost.type = 1;
newCost.int = 1;
newCost.cost = 0;
newCost.currencyType = 1;
syncCosts();
}
function removeCostEntry(index) {
costEntries.splice(index, 1);
syncCosts();
}
/** 将当前 costEntries(元)转换为分并同步到父组件 */
function syncCosts() {
const converted = costEntries.map((h) => ({
...h,
cost: Math.round(h.cost * 100),
costt: Math.round(h.costt * 100),
}));
emit("update:modelValue", { ...props.modelValue, _costs: converted });
}
watch(
() => newCost.cost,
(val) => {
const fixed = parseFloat(val).toFixed(2);
if (parseFloat(fixed) !== val) newCost.cost = parseFloat(fixed);
if (val > 0) costError.value = false;
},
);
// 回填费用(分→元)
watch(
() => props.initialCosts,
(list) => {
if (!list || list.length === 0) return;
costEntries.splice(0, costEntries.length);
list.forEach((c) => {
costEntries.push({
type: c.costType,
int: c.quantity,
cost: parseFloat((c.price / 100).toFixed(2)),
costt: parseFloat(((c.price * c.quantity) / 100).toFixed(2)),
currencytype: c.currencyType,
});
});
syncCosts();
},
{ immediate: true },
);
// ==================== 图片上传 ====================
const photosRef = ref(null);
/**
* 供父组件调用,获取当前上传图片的哈希列表
*/
function getPhotoHashes() {
return photosRef.value?.return_files().map((f) => f.hash) ?? [];
}
defineExpose({ getPhotoHashes, costEntries });
// ==================== 表单字段双向绑定 ====================
function update(field, value) {
emit("update:modelValue", { ...props.modelValue, [field]: value });
}
</script>
<template>
<div class="space-y-4 px-6 py-5">
<!-- 标题必填 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("purchase_addorder.part_name") }}
<span class="text-red-500">*</span>
</label>
<input
:value="modelValue.title"
@input="update('title', $event.target.value)"
type="text"
maxlength="50"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:placeholder="t('purchase_addorder.part_name')"
/>
</div>
<!-- 备注 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("purchase_addorder.remarks") }}
<span class="text-gray-400">{{ modelValue.remark.length }}/{{ textMaxLen }}</span>
</label>
<textarea
:value="modelValue.remark"
@input="update('remark', $event.target.value)"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
rows="4"
:placeholder="t('purchase_addorder.remarks_text')"
/>
</div>
<!-- 采购链接 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("purchase_addorder.link") }}
</label>
<textarea
:value="modelValue.link"
@input="update('link', $event.target.value)"
class="w-full rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-dk-base dark:text-white"
rows="2"
placeholder="url"
/>
</div>
<!-- 款式标签 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("purchase_addorder.style_remarks") }}
</label>
<tagadder
:placeholder="t('purchase_addorder.add_style')"
:modelValue="modelValue.styles"
@update:modelValue="update('styles', $event)"
/>
</div>
<!-- 费用明细 -->
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("purchase_addorder.cost") }}
</label>
<!-- 已有费用列表 -->
<div v-if="costEntries.length" class="mb-4 overflow-x-auto">
<table class="w-full text-left text-sm text-gray-900">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 text-gray-500 dark:border-dk-muted dark:bg-dk-base">
<th class="px-3 py-2 font-medium">{{ t("purchase_addorder.type") }}</th>
<th class="px-3 py-2 font-medium">{{ t("purchase_addorder.quantity") }}</th>
<th class="px-3 py-2 font-medium">{{ t("purchase_addorder.fee") }}</th>
<th class="px-3 py-2 font-medium">{{ t("purchase_addorder.total_price") }}</th>
<th class="px-3 py-2 font-medium">{{ t("purchase_addorder.currency") }}</th>
<th class="px-3 py-2 font-medium">{{ t("purchase_addorder.operation") }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, idx) in costEntries"
:key="idx"
class="border-b border-gray-100 dark:border-dk-muted"
>
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white">{{ costType[item.type] }}</td>
<td class="px-3 py-2 text-gray-500">{{ item.int }}</td>
<td class="px-3 py-2 text-gray-500">{{ item.cost }}</td>
<td class="px-3 py-2 text-gray-500">{{ item.costt }}</td>
<td class="px-3 py-2 text-gray-500">{{ currencyOptions[item.currencytype] }}</td>
<td class="px-3 py-2">
<button
class="rounded px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
@click="removeCostEntry(idx)"
>
{{ t("purchase_addorder.remove") }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 添加费用表单 -->
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
<div>
<label class="mb-1 block text-xs font-medium text-gray-500">{{ t("purchase_addorder.fee_type") }}</label>
<select
v-model="newCost.type"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
>
<template v-for="(label, key) in costType" :key="key">
<option :value="key">{{ label }}</option>
</template>
</select>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-500">{{ t("purchase_addorder.input_quantity") }}</label>
<input
v-model.number="newCost.int"
type="number"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
min="1"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-500">{{ t("purchase_addorder.input_fee") }}</label>
<input
v-model="newCost.cost"
type="number"
class="w-full rounded-lg border bg-white px-3 py-2 text-sm dark:bg-dk-base dark:text-white"
:class="costError ? 'border-red-500' : 'border-gray-300 dark:border-dk-muted'"
step="0.01"
min="0"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-500">{{ t("purchase_addorder.select_currency") }}</label>
<select
v-model="newCost.currencyType"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-dk-muted dark:bg-dk-base dark:text-white"
>
<template v-for="(label, key) in currencyOptions" :key="key">
<option :value="key">{{ label }}</option>
</template>
</select>
</div>
<div class="flex items-end">
<button
class="w-full rounded-lg border border-gray-300 bg-blue-600 px-3 py-2 text-sm font-semibold text-blue-100 transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500/20 dark:border-dk-muted dark:bg-blue-600 dark:text-white"
@click="addCostEntry"
>
{{ t("purchase_addorder.add") }}
</button>
</div>
</div>
</div>
<!-- 图片上传 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("purchase_addorder.photo_remarks") }}
</label>
<useDropzone
acceptFiles="image/*"
uploadURL="/api/files/upload/image"
:maxFiles="10"
:initialFiles="initialPhotos"
ref="photosRef"
/>
</div>
</div>
</template>