feat(aftersales): 售后工单支持附件上传和显示
- 在售后工单表单中新增附件上传组件,支持多文件上传
- 在工单详情页面添加附件列表显示功能
- 实现工单编辑时附件数据的回显功能
- 添加附件相关的API字段定义和数据处理逻辑
- 优化附件文件的URL处理逻辑
feat(mes): 工序产出物料支持多选功能
- 将产出物料相关字段从单个值改为数组形式
- 修改工艺路线和工单流程中的产出物料选择组件为多选模式
- 更新相关API接口定义以支持多个产出物料
- 调整表格列显示逻辑以适配新的多选数据结构
- 修改物料选择组件以支持单选和多选模式切换
chore(config): 配置文件中启用MCP服务器支持
- 在本地设置中添加MCP JSON服务器配置
- 启用所有项目MCP服务器功能
- 添加IDEA服务器支持配置
| | |
| | | "Bash(node lib/script/postinstall.js)", |
| | | "Bash(mvn compile *)" |
| | | ] |
| | | } |
| | | }, |
| | | "enabledMcpjsonServers": [ |
| | | "idea" |
| | | ], |
| | | "enableAllProjectMcpServers": true |
| | | } |
| | |
| | | createTime?: string; |
| | | updater?: string; |
| | | updateTime?: string; |
| | | /** 附件 blobId 列表(保存时传入) */ |
| | | blobIds?: number[]; |
| | | /** 附件列表(详情时返回) */ |
| | | attachmentList?: { id: number; name?: string; url?: string }[]; |
| | | items?: TicketItem[]; |
| | | } |
| | | } |
| | |
| | | outputItemId?: number; |
| | | outputItemCode?: string; |
| | | outputItemName?: string; |
| | | outputItemIds?: number[]; |
| | | outputItemNames?: string; |
| | | remark?: string; |
| | | bomItems?: ProcessBomItem[]; |
| | | createTime?: Date; |
| | |
| | | keyFlag?: boolean; // 是否关键工序 |
| | | checkFlag?: boolean; // 是否质检工序 |
| | | backflushFlag?: boolean; // 是否倒冲 |
| | | outputItemId?: number; // 产出产品物料编号 |
| | | outputItemCode?: string; // 产出产品物料编码 |
| | | outputItemName?: string; // 产出产品物料名称 |
| | | outputItemId?: number; // 产出产品物料编号(第一个,向后兼容) |
| | | outputItemCode?: string; // 产出产品物料编码(第一个) |
| | | outputItemName?: string; // 产出产品物料名称(第一个) |
| | | outputItemIds?: number[]; // 产出产品物料编号列表 |
| | | outputItemNames?: string; // 产出产品物料名称(逗号拼接) |
| | | bomItems?: ProcessBomItem[]; // 工序投料BOM明细 |
| | | remark?: string; // 备注 |
| | | } |
| | |
| | | rows: 3, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'blobIds', |
| | | label: '附件', |
| | | component: 'FileUpload', |
| | | componentProps: { |
| | | valueKey: 'id', |
| | | maxNumber: 10, |
| | | multiple: true, |
| | | disabled: readonly, |
| | | }, |
| | | formItemClass: 'col-span-3', |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | |
| | | </div> |
| | | </Card> |
| | | |
| | | <!-- 附件 --> |
| | | <Card v-if="ticket?.attachmentList?.length" title="附件" class="mb-4"> |
| | | <div class="flex flex-wrap gap-3"> |
| | | <a |
| | | v-for="file in ticket.attachmentList" |
| | | :key="file.id" |
| | | :href="file.url" |
| | | target="_blank" |
| | | class="text-blue-500 hover:underline" |
| | | > |
| | | {{ file.name || '附件-' + file.id }} |
| | | </a> |
| | | </div> |
| | | </Card> |
| | | |
| | | <!-- 问题判定表单 --> |
| | | <Card v-if="showClassifyInput" title="问题判定" class="mb-4"> |
| | | <div class="flex flex-wrap items-center gap-3"> |
| | |
| | | items.value = formData.value?.items || []; |
| | | await refreshItemsGrid(); |
| | | await formApi.setValues(formData.value as Record<string, unknown>); |
| | | // 回显附件 |
| | | if (formData.value?.attachmentList?.length) { |
| | | const blobIds = formData.value.attachmentList.map((item) => ({ |
| | | uid: String(item.id), name: item.name || '', url: item.url || '', |
| | | status: 'done', id: item.id, |
| | | })); |
| | | await formApi.setFieldValue('blobIds', blobIds); |
| | | } |
| | | if (formData.value?.saleOrderId) { |
| | | const order = await getSaleOrder(formData.value.saleOrderId); |
| | | orderItems.value = (order as Record<string, unknown>).items as ErpSaleOrderApi.SaleOrderItem[] || []; |
| | |
| | | splitLoading.value = true; |
| | | try { |
| | | // 调用后端分段接口,获取文档的分段内容、字符数和 Token 数 |
| | | const url = typeof file.url === 'string' ? file.url : file.url?.url; |
| | | file.segments = await splitContent( |
| | | file.url, |
| | | url, |
| | | modelData.value.segmentMaxTokens, |
| | | ); |
| | | } catch (error) { |
| | |
| | | defineProps<{ |
| | | allowClear?: boolean; |
| | | disabled?: boolean; |
| | | modelValue?: number; |
| | | modelValue?: number | number[]; |
| | | multiple?: boolean; |
| | | placeholder?: string; |
| | | productIds?: number[]; |
| | | }>(), |
| | |
| | | allowClear: true, |
| | | disabled: false, |
| | | modelValue: undefined, |
| | | multiple: false, |
| | | placeholder: '请选择产品物料', |
| | | productIds: () => [], |
| | | }, |
| | | ); |
| | | const emit = defineEmits<{ |
| | | change: [item: MesMdItemApi.Item | undefined]; |
| | | 'update:modelValue': [value: number | undefined]; |
| | | change: [items: MesMdItemApi.Item | MesMdItemApi.Item[] | undefined]; |
| | | 'update:modelValue': [value: number | number[] | undefined]; |
| | | }>(); |
| | | const attrs = useAttrs(); // 透传属性 |
| | | const dialogRef = ref<InstanceType<typeof MdItemSelectDialog>>(); // 物料选择弹窗 |
| | | const hovering = ref(false); // 是否悬停 |
| | | const selectedItem = ref<MesMdItemApi.Item>(); // 当前选中物料 |
| | | const attrs = useAttrs(); |
| | | const dialogRef = ref<InstanceType<typeof MdItemSelectDialog>>(); |
| | | const hovering = ref(false); |
| | | const selectedItems = ref<MesMdItemApi.Item[]>([]); |
| | | |
| | | const displayLabel = computed(() => selectedItem.value?.name ?? ''); // 选择器展示名称 |
| | | const displayLabel = computed(() => { |
| | | if (selectedItems.value.length === 0) return ''; |
| | | return selectedItems.value.map(item => item.name ?? '').join('、'); |
| | | }); |
| | | const showClear = computed( |
| | | () => |
| | | props.allowClear && |
| | | !props.disabled && |
| | | hovering.value && |
| | | props.modelValue !== null, |
| | | (props.multiple |
| | | ? Array.isArray(props.modelValue) && props.modelValue.length > 0 |
| | | : props.modelValue != null), |
| | | ); |
| | | |
| | | /** 根据物料编号回显选择器 */ |
| | | async function resolveItemById(id: number | undefined) { |
| | | if (!id) { |
| | | selectedItem.value = undefined; |
| | | async function resolveItemsById(values: number | number[] | undefined) { |
| | | if (values == null || (Array.isArray(values) && values.length === 0)) { |
| | | selectedItems.value = []; |
| | | return; |
| | | } |
| | | if (selectedItem.value?.id === id) { |
| | | const ids = Array.isArray(values) ? values : [values]; |
| | | // 已全部回显则跳过 |
| | | if ( |
| | | ids.length === selectedItems.value.length && |
| | | ids.every((id, i) => selectedItems.value[i]?.id === id) |
| | | ) { |
| | | return; |
| | | } |
| | | selectedItem.value = await getItem(id); |
| | | const items = await Promise.all(ids.map(id => getItem(id))); |
| | | selectedItems.value = items.filter(Boolean) as MesMdItemApi.Item[]; |
| | | } |
| | | |
| | | watch( |
| | | () => props.modelValue, |
| | | (value) => { |
| | | resolveItemById(value); |
| | | resolveItemsById(value); |
| | | }, |
| | | { immediate: true }, |
| | | ); |
| | | |
| | | /** 清空已选物料 */ |
| | | function clearSelected() { |
| | | selectedItem.value = undefined; |
| | | selectedItems.value = []; |
| | | if (props.multiple) { |
| | | emit('update:modelValue', []); |
| | | emit('change', []); |
| | | } else { |
| | | emit('update:modelValue', undefined); |
| | | emit('change', undefined); |
| | | } |
| | | } |
| | | |
| | | /** 打开物料选择弹窗 */ |
| | |
| | | clearSelected(); |
| | | return; |
| | | } |
| | | const selectedIds = ( |
| | | props.modelValue === null ? [] : [props.modelValue] |
| | | ) as number[]; |
| | | const selectedIds: number[] = props.multiple |
| | | ? (props.modelValue as number[] | undefined) ?? [] |
| | | : props.modelValue != null |
| | | ? [props.modelValue as number] |
| | | : []; |
| | | dialogRef.value?.open(selectedIds, { |
| | | multiple: false, |
| | | multiple: props.multiple, |
| | | productIds: props.productIds, |
| | | }); |
| | | } |
| | | |
| | | /** 回填选中的物料 */ |
| | | function handleSelected(rows: MesMdItemApi.Item[]) { |
| | | if (props.multiple) { |
| | | selectedItems.value = rows; |
| | | const ids = rows.map(r => r.id).filter(Boolean) as number[]; |
| | | emit('update:modelValue', ids); |
| | | emit('change', rows); |
| | | } else { |
| | | const item = rows[0]; |
| | | if (!item) { |
| | | return; |
| | | } |
| | | selectedItem.value = item; |
| | | selectedItems.value = [item]; |
| | | emit('update:modelValue', item.id); |
| | | emit('change', item); |
| | | } |
| | | } |
| | | </script> |
| | | |
| | |
| | | @mouseenter="hovering = true" |
| | | @mouseleave="hovering = false" |
| | | > |
| | | <Tooltip :mouse-enter-delay="0.5" :open="selectedItem ? undefined : false"> |
| | | <Tooltip :mouse-enter-delay="0.5" :open="selectedItems.length > 0 ? undefined : false"> |
| | | <template #title> |
| | | <div v-if="selectedItem" class="leading-6"> |
| | | <div>编码:{{ selectedItem.code || '-' }}</div> |
| | | <div>名称:{{ selectedItem.name || '-' }}</div> |
| | | <div>规格:{{ selectedItem.specification || '-' }}</div> |
| | | <div>单位:{{ selectedItem.unitMeasureName || '-' }}</div> |
| | | <div v-if="selectedItems.length === 1" class="leading-6"> |
| | | <div>编码:{{ selectedItems[0].code || '-' }}</div> |
| | | <div>名称:{{ selectedItems[0].name || '-' }}</div> |
| | | <div>规格:{{ selectedItems[0].specification || '-' }}</div> |
| | | <div>单位:{{ selectedItems[0].unitMeasureName || '-' }}</div> |
| | | </div> |
| | | <div v-else-if="selectedItems.length > 1" class="leading-6"> |
| | | <div v-for="item in selectedItems" :key="item.id" class="mb-2 border-b border-gray-100 pb-1 last:border-b-0 last:pb-0"> |
| | | <div>编码:{{ item.code || '-' }}</div> |
| | | <div>名称:{{ item.name || '-' }}</div> |
| | | <div>规格:{{ item.specification || '-' }}</div> |
| | | <div>单位:{{ item.unitMeasureName || '-' }}</div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | <Input |
| | |
| | | slots: { default: 'backflushFlag' }, |
| | | }, |
| | | { |
| | | field: 'outputItemCode', |
| | | title: '产出物料编码', |
| | | width: 120, |
| | | }, |
| | | { |
| | | field: 'outputItemName', |
| | | title: '产出物料名称', |
| | | width: 120, |
| | | field: 'outputItemNames', |
| | | title: '产出物料', |
| | | width: 160, |
| | | }, |
| | | { |
| | | field: 'remark', |
| | |
| | | defaultValue: true, |
| | | }, |
| | | { |
| | | fieldName: 'outputItemId', |
| | | fieldName: 'outputItemIds', |
| | | label: '产出物料', |
| | | component: MdItemSelect, |
| | | componentProps: { |
| | | multiple: true, |
| | | placeholder: '请选择产出物料', |
| | | }, |
| | | }, |
| | |
| | | keyFlag: item.keyFlag, |
| | | checkFlag: item.checkFlag, |
| | | backflushFlag: item.backflushFlag, |
| | | outputItemId: item.outputItemId, |
| | | outputItemCode: item.outputItemCode, |
| | | outputItemName: item.outputItemName, |
| | | outputItemIds: item.outputItemIds, |
| | | outputItemNames: item.outputItemNames, |
| | | remark: item.remark, |
| | | bomItems: item.bomItems, |
| | | })); |
| | |
| | | rules: z.boolean().default(true), |
| | | }, |
| | | { |
| | | fieldName: "outputItemId", |
| | | fieldName: "outputItemIds", |
| | | label: "产出产品", |
| | | component: markRaw(MdItemSelect), |
| | | componentProps: { |
| | | multiple: true, |
| | | placeholder: "留空则默认为工单成品", |
| | | }, |
| | | formItemClass: "col-span-2", |
| | |
| | | }, |
| | | }, |
| | | { |
| | | field: "outputItemName", |
| | | field: "outputItemNames", |
| | | title: "产出产品", |
| | | width: 140, |
| | | slots: { default: "outputItemName" }, |
| | | width: 160, |
| | | }, |
| | | { field: "prepareTime", title: "准备时间(分)", width: 110 }, |
| | | { field: "waitTime", title: "等待时间(分)", width: 110 }, |
| | |
| | | <span>{{ row.colorCode }}</span> |
| | | </div> |
| | | </template> |
| | | <template #outputItemName="{ row }"> |
| | | <span v-if="row.outputItemName">{{ row.outputItemName }}</span> |
| | | <span v-else class="text-gray-400">-</span> |
| | | </template> |
| | | <template #actions="{ row }"> |
| | | <TableAction |
| | | :actions="[ |