This commit is contained in:
2026-04-29 16:05:03 +08:00
parent af5dc954e3
commit f5b99f5bb3
11 changed files with 494 additions and 12 deletions
+13
View File
@@ -41,3 +41,16 @@
- `AddEditWorkOrder.vue`: 编辑模式加载时回填 `selectedItems``selectedCustomers`
- `AddEditWorkOrder.vue`: 编辑提交时发送 `item_ids``customer_ids`
- `apiWorkOrder.go`: `/update` 接口新增 `ItemIDs``CustomerIDs` 字段,重建物品/客户关联绑定
- 仓库物品添加页支持关联客户
- `binds.go`: 新增 `TabWarehouseItemCustomerBind` 关联表(物品-客户多对多)
- `apiWarehouse.go`: `/add_item` 接口新增 `CustomerIDs` 字段,仅新建物品时创建客户关联绑定
- `WarehouseAddItem.vue`: 添加客户搜索选择组件(多选),提交时发送 `customer_ids`
- 使用已有的 `customerApi.list()` 搜索客户
- 仓库物品详情页显示关联客户
- `apiWarehouse.go`: `/get_item` 接口返回 `customers` 列表(包含客户 ID、姓名、称呼)
- `WarehouseItemDetail.vue`: 新增"关联客户" Tab,显示关联客户列表(头像、姓名、称呼),点击可跳转到客户详情页
- i18n: 添加 `warehouse.customers``warehouse.no_customers` 翻译
- 仓库物品编辑页支持关联客户
- `apiWarehouse.go`: `/update_item` 接口新增 `CustomerIDs` 字段,重建客户关联绑定
- `WarehouseItemEdit.vue`: 添加客户搜索选择组件(多选),加载时回填已关联客户,提交时发送 `customer_ids`
- i18n: 添加 `warehouse.linked_customers``warehouse.linked_customer_placeholder``warehouse.linked_customer_not_found` 等翻译到 `warehouse` 节点
+60 -6
View File
@@ -544,6 +544,7 @@ func ApiWarehouse(r *gin.RouterGroup) {
Quantity int `json:"quantity"`
ContainerID *uint `json:"container_id"`
Photos []string `json:"photos"`
CustomerIDs []uint `json:"customer_ids"`
}
var from FromAdd
if err := decodeJSON(data, &from); err != nil || from.Name == "" {
@@ -673,6 +674,21 @@ func ApiWarehouse(r *gin.RouterGroup) {
IP: ctx.ClientIP(),
})
// 绑定客户关联(仅新建时)
if !exists {
for _, customerID := range from.CustomerIDs {
// 检查客户是否存在
var bindCustomer TabCustomer
if models.DB.First(&bindCustomer, customerID).Error == nil {
models.DB.Create(&TabWarehouseItemCustomerBind{
ItemID: itemID,
CustomerID: customerID,
CreatorID: user.ID,
})
}
}
}
ReturnJson(ctx, "apiOK", gin.H{"id": itemID, "updated": exists})
})
@@ -685,12 +701,13 @@ func ApiWarehouse(r *gin.RouterGroup) {
}
type FromUpdate struct {
ID uint `json:"id"`
Name string `json:"name"`
SerialNumber string `json:"serial_number"`
Remark string `json:"remark"`
Quantity int `json:"quantity"`
Photos []string `json:"photos"`
ID uint `json:"id"`
Name string `json:"name"`
SerialNumber string `json:"serial_number"`
Remark string `json:"remark"`
Quantity int `json:"quantity"`
Photos []string `json:"photos"`
CustomerIDs []uint `json:"customer_ids"`
}
var from FromUpdate
if err := decodeJSON(data, &from); err != nil || from.ID == 0 || from.Name == "" {
@@ -738,6 +755,19 @@ func ApiWarehouse(r *gin.RouterGroup) {
}
}
// 重建客户关联绑定
models.DB.Where("item_id = ?", from.ID).Delete(&TabWarehouseItemCustomerBind{})
for _, customerID := range from.CustomerIDs {
var bindCustomer TabCustomer
if models.DB.First(&bindCustomer, customerID).Error == nil {
models.DB.Create(&TabWarehouseItemCustomerBind{
ItemID: from.ID,
CustomerID: customerID,
CreatorID: user.ID,
})
}
}
newContent, _ := json.Marshal(from)
models.DB.Create(&TabWarehouseLog{
EntityType: "item",
@@ -966,11 +996,35 @@ func ApiWarehouse(r *gin.RouterGroup) {
}
}
// 关联客户
var customerBinds []TabWarehouseItemCustomerBind
models.DB.Where("item_id = ?", from.ID).Find(&customerBinds)
type CustomerInfo struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Title string `json:"title"`
}
var customers []CustomerInfo
for _, b := range customerBinds {
var c TabCustomer
if models.DB.Where("id = ?", b.CustomerID).First(&c).Error == nil {
customers = append(customers, CustomerInfo{
ID: c.ID,
FirstName: c.FirstName,
LastName: c.LastName,
Title: c.Title,
})
}
}
ReturnJson(ctx, "apiOK", gin.H{
"item": item,
"photos": files,
"commits": commitsWithBreadcrumb,
"work_orders": workOrders,
"customers": customers,
"canModifyItem": canModifyWarehouse(user.ID, item.CreatorID),
"container_breadcrumb": func() string {
if item.ContainerID == nil {
+10
View File
@@ -73,6 +73,15 @@ type TabWarehouseContainerFileBind struct {
CreatedAt time.Time `gorm:"type:datetime;autoCreateTime"`
}
// TabWarehouseItemCustomerBind 物品与客户关联表
type TabWarehouseItemCustomerBind struct {
ID uint `gorm:"primarykey"`
ItemID uint `gorm:"not null;index;comment:关联物品ID"`
CustomerID uint `gorm:"not null;index;comment:关联客户ID"`
CreatorID uint `gorm:"not null;comment:绑定人id"`
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
}
func BindsInit() {
models.DB.AutoMigrate(
&TabPurchaseFileBind{},
@@ -83,6 +92,7 @@ func BindsInit() {
&TabWorkOrderCommitFileBind{},
&TabWorkOrderPurchaseOrderBind{},
&TabWorkOrderCustomerBind{},
&TabWarehouseItemCustomerBind{},
)
}
+9 -1
View File
@@ -222,8 +222,10 @@
"move_item": "Move Item",
"move_history": "Move History",
"work_orders": "Work Orders",
"customers": "Customers",
"no_move_history": "No move history",
"no_work_orders": "No related work orders",
"no_customers": "No related customers",
"current_location": "Current Location",
"target_container": "Target Container",
"search_container": "Search container...",
@@ -234,7 +236,12 @@
"delete_item_msg": "Are you sure you want to delete item \"{name}\"? This action cannot be undone.",
"items_in_containers": "Stored",
"total_items": "{count} items",
"search_item_placeholder": "Search by name or serial number"
"search_item_placeholder": "Search by name or serial number",
"linked_customers": "Linked Customers",
"linked_customer_placeholder": "Search customer by name or phone...",
"linked_customer_not_found": "No matching customers found",
"linked_customer_selected": "Selected",
"clear_linked_customer": "Clear"
},
"purchase_addorder": {
"add_order": "Add Order",
@@ -554,6 +561,7 @@
"created_at": "Created At",
"primary": "Primary",
"set_primary": "Set Primary",
"primary_contact": "Primary Contact",
"label_mobile": "Mobile",
"label_work": "Work",
"label_home": "Home",
+9 -1
View File
@@ -222,8 +222,10 @@
"move_item": "移动物品",
"move_history": "移动历史",
"work_orders": "关联工单",
"customers": "关联客户",
"no_move_history": "暂无移动记录",
"no_work_orders": "暂无关联工单",
"no_customers": "暂无关联客户",
"current_location": "当前位置",
"target_container": "目标容器",
"search_container": "搜索容器...",
@@ -234,7 +236,12 @@
"delete_item_msg": "确定要删除物品「{name}」吗?此操作不可撤销。",
"items_in_containers": "已入库",
"total_items": "共 {count} 条",
"search_item_placeholder": "搜索物品名称或序列号"
"search_item_placeholder": "搜索物品名称或序列号",
"linked_customers": "关联客户",
"linked_customer_placeholder": "搜索客户姓名或电话...",
"linked_customer_not_found": "未找到匹配的客户",
"linked_customer_selected": "已选择",
"clear_linked_customer": "清除"
},
"purchase_addorder": {
"add_order": "添加订单",
@@ -554,6 +561,7 @@
"created_at": "创建时间",
"primary": "主",
"set_primary": "设为主",
"primary_contact": "主要联系方式",
"label_mobile": "手机",
"label_work": "工作",
"label_home": "家庭",
@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { customerApi } from '@/api/customer'
@@ -17,6 +17,12 @@ const emails = ref([])
const companies = ref([])
const loading = ref(false)
// 主要联系方式
const primaryPhone = computed(() => phones.value.find(p => p.is_primary) || null)
const primaryEmail = computed(() => emails.value.find(e => e.is_primary) || null)
const primaryCompany = computed(() => companies.value.find(c => c.is_primary) || null)
const hasPrimaryInfo = computed(() => primaryPhone.value || primaryEmail.value || primaryCompany.value)
const toast = ref({ show: false, message: '', type: 'success' })
// 获取客户详情
@@ -51,9 +57,9 @@ async function fetchCustomerDetail() {
}
}
// 返回列表
// 返回上一页
function goBack() {
router.push('/customer')
router.back()
}
// 格式化日期
@@ -141,6 +147,40 @@ onMounted(() => {
</div>
</div>
<!-- Primary Contact Info Card -->
<div v-if="hasPrimaryInfo" class="rounded-lg border border-amber-200 bg-amber-50 p-6 dark:border-amber-800 dark:bg-amber-900/20">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('customer.primary_contact') }}</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div v-if="primaryPhone">
<label class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.phone') }}</label>
<p class="font-medium text-gray-900 dark:text-dk-text">
+{{ primaryPhone.prefix }} {{ primaryPhone.phone }}
<span class="ml-1 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{{ getLabelText(primaryPhone.label) }}
</span>
</p>
</div>
<div v-if="primaryEmail">
<label class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.email') }}</label>
<p class="font-medium text-gray-900 dark:text-dk-text">
{{ primaryEmail.email }}
<span class="ml-1 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{{ getLabelText(primaryEmail.label) }}
</span>
</p>
</div>
<div v-if="primaryCompany" class="sm:col-span-2">
<label class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.company') }}</label>
<p class="font-medium text-gray-900 dark:text-dk-text">
{{ primaryCompany.company_name }}
</p>
<div v-if="primaryCompany.department || primaryCompany.position" class="mt-1 text-sm text-gray-500 dark:text-dk-subtle">
{{ primaryCompany.department }}{{ primaryCompany.department && primaryCompany.position ? ' · ' : '' }}{{ primaryCompany.position }}
</div>
</div>
</div>
</div>
<!-- Phones Card -->
<div v-if="phones.length > 0" class="rounded-lg border border-gray-200 bg-white p-6 dark:border-dk-muted dark:bg-dk-card">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('customer.phones') }}</h2>
@@ -5,7 +5,9 @@ import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { warehouseApi } from '@/api/warehouse'
import { customerApi } from '@/api/customer'
import useDropzone from '@/components/useDropzone.vue'
import { IconUser, IconX } from '@tabler/icons-vue'
usePageTitle('warehouse.add_item')
const { t } = useI18n()
@@ -32,7 +34,66 @@ function getPhotoHashes() {
return dropzoneRef.value?.return_files().map((f) => f.hash) ?? []
}
// ==================== 关联客户搜索(多选) ====================
const customerSearchQuery = ref('')
const customerSearchResults = ref([])
const customerSearchLoading = ref(false)
const showCustomerDropdown = ref(false)
const selectedCustomers = ref([])
let customerSearchTimer = null
function onCustomerSearchInput() {
clearTimeout(customerSearchTimer)
customerSearchTimer = setTimeout(async () => {
customerSearchLoading.value = true
showCustomerDropdown.value = true
try {
let res
if (customerSearchQuery.value.trim().length > 0) {
res = await customerApi.list({ search: customerSearchQuery.value.trim(), page: 1, page_size: 10 })
if (res.errCode === 0 && res.data) {
customerSearchResults.value = (res.data.customers || []).slice(0, 10)
} else {
customerSearchResults.value = []
}
} else {
res = await customerApi.list({ page: 1, page_size: 5 })
if (res.errCode === 0 && res.data) {
customerSearchResults.value = (res.data.customers || []).sort((a, b) => b.ID - a.ID)
} else {
customerSearchResults.value = []
}
}
} catch {
customerSearchResults.value = []
} finally {
customerSearchLoading.value = false
}
}, 300)
}
function selectCustomer(customer) {
if (!selectedCustomers.value.find(c => c.id === customer.id)) {
selectedCustomers.value.push(customer)
}
customerSearchQuery.value = ''
customerSearchResults.value = []
showCustomerDropdown.value = false
}
function removeSelectedCustomer(customerId) {
selectedCustomers.value = selectedCustomers.value.filter(c => c.id !== customerId)
}
function handleClickOutside(e) {
if (!e.target.closest('.customer-search-wrapper')) {
showCustomerDropdown.value = false
}
}
onMounted(async () => {
document.addEventListener('click', handleClickOutside)
try {
const { errCode, data } = await warehouseApi.getContainer(containerId.value)
if (errCode === 0 && data?.container) {
@@ -64,6 +125,7 @@ async function submit() {
remark: form.remark.trim(),
quantity: form.quantity > 0 ? form.quantity : 1,
photos: hashes,
customer_ids: selectedCustomers.value.map(c => c.id),
})
if (errCode === 0) {
toast.success(t('message.save_success'))
@@ -167,6 +229,75 @@ async function submit() {
></textarea>
</div>
<!-- 关联客户搜索多选 -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('warehouse.linked_customers') || '关联客户' }}
</label>
<!-- 已选择客户列表 -->
<div v-if="selectedCustomers.length > 0" class="mb-2 flex flex-wrap gap-2">
<div
v-for="customer in selectedCustomers"
:key="customer.id"
class="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
>
<IconUser :size="12" />
{{ (customer.last_name || '') + (customer.first_name ? ' ' + customer.first_name : '') }}
<button
type="button"
class="ml-0.5 rounded-full p-0.5 transition-colors hover:bg-blue-200 dark:hover:bg-blue-800"
@click="removeSelectedCustomer(customer.id)"
>
<IconX :size="12" />
</button>
</div>
</div>
<!-- 搜索框 -->
<div class="customer-search-wrapper relative">
<input
v-model="customerSearchQuery"
type="text"
:placeholder="t('warehouse.linked_customer_placeholder') || '搜索客户...'"
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"
@input="onCustomerSearchInput"
@focus="customerSearchQuery || onCustomerSearchInput()"
/>
<!-- 下拉结果 -->
<div
v-if="showCustomerDropdown && customerSearchResults.length > 0"
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card"
>
<div
v-for="customer in customerSearchResults"
:key="customer.id"
class="cursor-pointer px-3 py-2 text-sm hover:bg-blue-50 dark:hover:bg-blue-900/30"
@click="selectCustomer(customer)"
>
<div class="font-medium text-gray-900 dark:text-white">{{ (customer.last_name || '') + (customer.first_name ? ' ' + customer.first_name : '') }}</div>
<div v-if="customer.primary_phone || customer.primary_company" class="text-xs text-gray-500 dark:text-gray-400">
{{ customer.primary_phone }}{{ customer.primary_phone && customer.primary_company ? ' · ' : '' }}{{ customer.primary_company }}
</div>
</div>
</div>
<!-- 加载中 -->
<div
v-if="showCustomerDropdown && customerSearchLoading"
class="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 shadow-lg dark:border-dk-muted dark:bg-dk-card"
>
{{ t('message.loading') }}
</div>
<!-- 无结果 -->
<div
v-if="showCustomerDropdown && !customerSearchLoading && customerSearchResults.length === 0 && customerSearchQuery.trim().length > 0"
class="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 shadow-lg dark:border-dk-muted dark:bg-dk-card"
>
{{ t('warehouse.linked_customer_not_found') || '未找到客户' }}
</div>
</div>
</div>
<!-- 图片上传 -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -43,6 +43,9 @@ const containerBreadcrumb = ref('')
// ── Tab ──
const activeTab = ref('work_orders')
// ── 关联客户 ──
const customers = ref([])
// ── 移动弹窗 ──
const showMove = ref(false)
const moveTarget = ref(null)
@@ -77,6 +80,21 @@ function getStatusLabel(status) {
return t(`work_order.status_${status}`) || status
}
// ── 客户名称格式化 ──
function getCustomerDisplayName(customer) {
const lastName = customer.last_name || ''
const firstName = customer.first_name || ''
if (lastName && firstName) {
return `${lastName} ${firstName}`
}
return lastName || firstName || `Customer#${customer.id}`
}
function getCustomerTitleLabel(title) {
if (!title) return ''
return t(`customer.salutation_${title.toLowerCase()}`)
}
// ── 时间格式化 ──
function fmtTs(ts) {
if (!ts) return '—'
@@ -112,6 +130,7 @@ async function fetchItem() {
photos.value = data.photos ?? []
commits.value = data.commits ?? []
workOrders.value = data.work_orders ?? []
customers.value = data.customers ?? []
canModifyItem.value = data.canModifyItem === true
containerBreadcrumb.value = data.container_breadcrumb ?? ''
loadContainerNames()
@@ -175,6 +194,12 @@ function openLinkWorkOrder() {
? `${item.value.Name}-${item.value.SerialNumber}`
: item.value.Name,
description: item.value.Remark || '',
customers: customers.value.map(c => ({
id: c.id,
first_name: c.first_name || '',
last_name: c.last_name || '',
primary_phone: c.primary_phone || '',
})),
}
localStorage.setItem('prefill_work_order', JSON.stringify(prefillData))
router.push('/work_order/add')
@@ -440,6 +465,15 @@ onMounted(() => {
>
{{ t('warehouse.work_orders') }} ({{ workOrders.length }})
</button>
<button
class="px-4 py-1.5 text-sm rounded-md font-medium transition-colors"
:class="activeTab === 'customers'
? 'bg-white text-gray-900 shadow-sm dark:bg-dk-card dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="activeTab = 'customers'"
>
{{ t('warehouse.customers') }} ({{ customers.length }})
</button>
<button
class="px-4 py-1.5 text-sm rounded-md font-medium transition-colors"
:class="activeTab === 'history'
@@ -477,6 +511,38 @@ onMounted(() => {
</div>
</div>
<!-- 关联客户 -->
<div v-if="activeTab === 'customers'">
<div v-if="customers.length === 0" class="rounded-xl border border-gray-200 bg-white px-5 py-8 text-center text-sm text-gray-400 dark:border-dk-muted dark:bg-dk-card">
{{ t('warehouse.no_customers') }}
</div>
<div v-else class="space-y-2">
<RouterLink
v-for="c in customers"
:key="c.id"
:to="`/customer/detail/${c.id}`"
class="rounded-xl border border-gray-200 bg-white px-4 py-3 flex items-center justify-between gap-3 hover:shadow transition-shadow dark:border-dk-muted dark:bg-dk-card dark:hover:shadow-none"
>
<div class="flex items-center gap-3 min-w-0">
<div class="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center flex-shrink-0">
<span class="text-sm font-medium text-blue-600 dark:text-blue-400">
{{ (c.last_name || c.first_name || 'C').charAt(0).toUpperCase() }}
</span>
</div>
<div class="min-w-0">
<span class="font-medium text-sm text-gray-900 truncate dark:text-white block">
{{ getCustomerDisplayName(c) }}
</span>
<span v-if="c.title" class="text-xs text-gray-400">
{{ getCustomerTitleLabel(c.title) }}
</span>
</div>
</div>
<IconArrowRight :size="14" class="text-gray-400 flex-shrink-0" />
</RouterLink>
</div>
</div>
<!-- 移动历史 -->
<div v-if="activeTab === 'history'">
<div v-if="commits.length === 0" class="rounded-xl border border-gray-200 bg-white px-5 py-8 text-center text-sm text-gray-400 dark:border-dk-muted dark:bg-dk-card">
@@ -5,7 +5,9 @@ import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { warehouseApi } from '@/api/warehouse'
import { customerApi } from '@/api/customer'
import useDropzone from '@/components/useDropzone.vue'
import { IconUser, IconX } from '@tabler/icons-vue'
usePageTitle('warehouse.edit_item')
const { t } = useI18n()
@@ -34,7 +36,66 @@ function getPhotoHashes() {
return dropzoneRef.value?.return_files().map((f) => f.hash) ?? []
}
// ==================== 关联客户搜索(多选) ====================
const customerSearchQuery = ref('')
const customerSearchResults = ref([])
const customerSearchLoading = ref(false)
const showCustomerDropdown = ref(false)
const selectedCustomers = ref([])
let customerSearchTimer = null
function onCustomerSearchInput() {
clearTimeout(customerSearchTimer)
customerSearchTimer = setTimeout(async () => {
customerSearchLoading.value = true
showCustomerDropdown.value = true
try {
let res
if (customerSearchQuery.value.trim().length > 0) {
res = await customerApi.list({ search: customerSearchQuery.value.trim(), page: 1, page_size: 10 })
if (res.errCode === 0 && res.data) {
customerSearchResults.value = (res.data.customers || []).slice(0, 10)
} else {
customerSearchResults.value = []
}
} else {
res = await customerApi.list({ page: 1, page_size: 5 })
if (res.errCode === 0 && res.data) {
customerSearchResults.value = (res.data.customers || []).sort((a, b) => b.ID - a.ID)
} else {
customerSearchResults.value = []
}
}
} catch {
customerSearchResults.value = []
} finally {
customerSearchLoading.value = false
}
}, 300)
}
function selectCustomer(customer) {
if (!selectedCustomers.value.find(c => c.id === customer.id)) {
selectedCustomers.value.push(customer)
}
customerSearchQuery.value = ''
customerSearchResults.value = []
showCustomerDropdown.value = false
}
function removeSelectedCustomer(customerId) {
selectedCustomers.value = selectedCustomers.value.filter(c => c.id !== customerId)
}
function handleClickOutside(e) {
if (!e.target.closest('.customer-search-wrapper')) {
showCustomerDropdown.value = false
}
}
onMounted(async () => {
document.addEventListener('click', handleClickOutside)
try {
const { errCode, data } = await warehouseApi.getItem(itemId.value)
if (errCode === 0 && data?.item) {
@@ -44,6 +105,16 @@ onMounted(async () => {
form.quantity = data.item.Quantity ?? 1
existingPhotos.value = data.photos ?? []
// 回填已关联的客户
if (data.customers && data.customers.length > 0) {
selectedCustomers.value = data.customers.map(c => ({
id: c.id,
first_name: c.first_name,
last_name: c.last_name,
title: c.title
}))
}
// 获取容器名称
if (data.item.ContainerID) {
const { errCode: cErr, data: cData } = await warehouseApi.getContainer(data.item.ContainerID)
@@ -80,6 +151,7 @@ async function submit() {
remark: form.remark.trim(),
quantity: form.quantity > 0 ? form.quantity : 1,
photos: hashes,
customer_ids: selectedCustomers.value.map(c => c.id),
})
if (errCode === 0) {
toast.success(t('message.save_success'))
@@ -202,6 +274,75 @@ async function submit() {
></textarea>
</div>
<!-- 关联客户搜索多选 -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('warehouse.linked_customers') }}
</label>
<!-- 已选择客户列表 -->
<div v-if="selectedCustomers.length > 0" class="mb-2 flex flex-wrap gap-2">
<div
v-for="customer in selectedCustomers"
:key="customer.id"
class="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
>
<IconUser :size="12" />
{{ (customer.last_name || '') + (customer.first_name ? ' ' + customer.first_name : '') }}
<button
type="button"
class="ml-0.5 rounded-full p-0.5 transition-colors hover:bg-blue-200 dark:hover:bg-blue-800"
@click="removeSelectedCustomer(customer.id)"
>
<IconX :size="12" />
</button>
</div>
</div>
<!-- 搜索框 -->
<div class="customer-search-wrapper relative">
<input
v-model="customerSearchQuery"
type="text"
:placeholder="t('warehouse.linked_customer_placeholder')"
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"
@input="onCustomerSearchInput"
@focus="customerSearchQuery || onCustomerSearchInput()"
/>
<!-- 下拉结果 -->
<div
v-if="showCustomerDropdown && customerSearchResults.length > 0"
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card"
>
<div
v-for="customer in customerSearchResults"
:key="customer.id"
class="cursor-pointer px-3 py-2 text-sm hover:bg-blue-50 dark:hover:bg-blue-900/30"
@click="selectCustomer(customer)"
>
<div class="font-medium text-gray-900 dark:text-white">{{ (customer.last_name || '') + (customer.first_name ? ' ' + customer.first_name : '') }}</div>
<div v-if="customer.primary_phone || customer.primary_company" class="text-xs text-gray-500 dark:text-gray-400">
{{ customer.primary_phone }}{{ customer.primary_phone && customer.primary_company ? ' · ' : '' }}{{ customer.primary_company }}
</div>
</div>
</div>
<!-- 加载中 -->
<div
v-if="showCustomerDropdown && customerSearchLoading"
class="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 shadow-lg dark:border-dk-muted dark:bg-dk-card"
>
{{ t('message.loading') }}
</div>
<!-- 无结果 -->
<div
v-if="showCustomerDropdown && !customerSearchLoading && customerSearchResults.length === 0 && customerSearchQuery.trim().length > 0"
class="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 shadow-lg dark:border-dk-muted dark:bg-dk-card"
>
{{ t('warehouse.linked_customer_not_found') }}
</div>
</div>
</div>
<!-- 图片上传 -->
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -205,6 +205,17 @@ onMounted(async () => {
}
}
// 如果有客户信息,自动选中
if (prefill.customers && prefill.customers.length > 0) {
selectedCustomers.value = prefill.customers.map(c => ({
id: c.id,
first_name: c.first_name,
last_name: c.last_name,
primary_phone: c.primary_phone,
}))
linkedCustomerIds.value = prefill.customers.map(c => c.id)
}
localStorage.removeItem('prefill_work_order')
} catch {
// ignore