云
2 天以前 6a6f43983826ac5f86087e7a056d6e81512bcaf0
feat(mes): 新增MES质量巡检模块及前后端功能

- 新增质量巡检后端接口与数据访问层实现增删改查
- 新增前端巡检记录列表页及查询和表单弹窗功能
- 新增质检附件上传组件并集成至巡检记录表单中
- 新增Excel必填注解及导出表头必填项标红处理器
- 更新gitignore以忽略.playwright-mcp和.tmp目录
已添加2个文件
176 ■■■■■ 文件已修改
src/views/mes/qc/components/qc-attachments.vue 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/patrol/index.vue 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/components/qc-attachments.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { SystemStorageApi } from '#/api/system/storage';
import { onMounted, ref } from 'vue';
import { Button, message, Popconfirm, Upload } from 'ant-design-vue';
import { bindAttachments, deleteAttachments, listAttachments, uploadFile } from '#/api/system/storage';
const props = defineProps<{ qcType: 'patrol'; qcId: number }>();
const files = ref<SystemStorageApi.StorageBlob[]>([]);
const loading = ref(false);
const params = () => ({ recordType: `mes_qc_${props.qcType}`, recordId: props.qcId, application: 'file' });
async function load() {
  files.value = await listAttachments(params());
}
async function upload(file: File) {
  if (files.value.length >= 50) {
    message.warning('最多上传 50 ä»½é™„ä»¶');
    return Upload.LIST_IGNORE;
  }
  loading.value = true;
  try {
    const result = await uploadFile([file]);
    const blob = result?.[0];
    if (!blob?.id) throw new Error('上传结果缺少文件编号');
    await bindAttachments({ ...params(), blobItems: [...files.value.map(item => ({ blobId: item.id })), { blobId: blob.id }] });
    await load();
    message.success('附件已上传');
  } finally {
    loading.value = false;
  }
  return Upload.LIST_IGNORE;
}
async function remove(file: SystemStorageApi.StorageBlob) {
  if (!file.storageAttachmentId) return;
  await deleteAttachments([file.storageAttachmentId]);
  await load();
}
onMounted(load);
</script>
<template>
  <Upload :show-upload-list="false" :before-upload="upload" :disabled="loading">
    <Button :loading="loading">上传巡检附件</Button>
  </Upload>
  <div v-for="file in files" :key="file.id" class="flex items-center gap-2">
    <a v-if="file.downloadURL || file.url" :href="file.downloadURL || file.url" target="_blank" rel="noopener noreferrer">{{ file.originalFilename || file.name || file.id }}</a>
    <span v-else>{{ file.originalFilename || file.name || file.id }}</span>
    <Popconfirm title="确认删除此附件?" @confirm="remove(file)">
      <Button type="link" danger>删除</Button>
    </Popconfirm>
  </div>
</template>
src/views/mes/qc/patrol/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { Button, DatePicker, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Upload } from 'ant-design-vue';
import dayjs from 'dayjs';
import { requestClient } from '#/api/request';
import { getSimpleUserList } from '#/api/system/user';
import { getItem } from '#/api/mdm/item';
import { bindAttachments, listAttachments, uploadFile } from '#/api/system/storage';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
import QcAttachments from '../components/qc-attachments.vue';
defineOptions({ name: 'MesQcPatrol' });
interface Patrol { id?: number; code?: string; name?: string; itemId?: number; reelNo?: string; batchCode?: string; qualifiedQuantity?: number; unqualifiedQuantity?: number; inspectDate?: string; inspectorUserId?: number; remark?: string }
const { hasAccessByCodes, hasAccessByRoles } = useAccess();
const can = (action: string) => hasAccessByRoles(['super_admin']) || hasAccessByCodes([`mes:qc-patrol:${action}`]);
const itemNames = ref<Record<number, string>>({});
const rows = ref<Patrol[]>([]); const users = ref<any[]>([]); const loading = ref(false); const saving = ref(false); const open = ref(false);
const query = reactive({ pageNo: 1, pageSize: 10, total: 0, code: '', reelNo: '' });
const form = reactive<Patrol>({});
const pendingFiles = ref<{ id: number; name: string }[]>([]);
const uploading = ref(false);
const attachmentVersion = ref(0);
async function addPendingFile(file: File) {
  if (uploading.value || pendingFiles.value.length >= 50) { if (pendingFiles.value.length >= 50) message.warning('最多上传 50 ä»½é™„ä»¶'); return Upload.LIST_IGNORE; }
  uploading.value = true;
  try { const result = await uploadFile([file]); const blob = result?.[0]; if (!blob?.id) throw new Error('上传结果缺少文件编号'); pendingFiles.value.push({ id: blob.id, name: file.name }); message.success('附件已上传'); } finally { uploading.value = false; }
  return Upload.LIST_IGNORE;
}
async function bindPendingFiles() {
  if (pendingFiles.value.length && form.id) {
    const params = { recordType: 'mes_qc_patrol', recordId: form.id, application: 'file' };
    const existing = await listAttachments(params);
    await bindAttachments({ ...params, blobItems: [...existing.map(file => ({ blobId: file.id })), ...pendingFiles.value.map(file => ({ blobId: file.id }))] });
    pendingFiles.value = [];
  }
  attachmentVersion.value++;
}
const columns = [ { title: '巡检单号', dataIndex: 'code' }, { title: '巡检名称', dataIndex: 'name' }, { title: '物料名称', dataIndex: 'itemId', key: 'itemId' }, { title: '盘号', dataIndex: 'reelNo' }, { title: '批次号', dataIndex: 'batchCode' }, { title: '合格数', dataIndex: 'qualifiedQuantity' }, { title: '不合格数', dataIndex: 'unqualifiedQuantity' }, { title: '巡检日期', dataIndex: 'inspectDate' }, { title: '操作', key: 'action', width: 145 } ];
async function load() { loading.value=true; try { const res = await requestClient.get('/mes/qc/patrol/page', { params: { pageNo: query.pageNo, pageSize: query.pageSize, code: query.code || undefined, reelNo: query.reelNo || undefined } }); rows.value=res.list; query.total=res.total; await Promise.all([...new Set<number>(res.list.map((row: Patrol) => row.itemId).filter(Boolean))].map(async id => { if (!itemNames.value[id]) { const item = await getItem(id); itemNames.value[id] = item?.name || String(id); } })); } finally { loading.value=false; } }
async function edit(row?: Patrol) { pendingFiles.value = []; Object.keys(form).forEach(k => delete (form as any)[k]); if (row?.id) { const data = await requestClient.get('/mes/qc/patrol/get', {params:{id:row.id}}); Object.assign(form, data, { inspectDate: dayjs(data.inspectDate).format('YYYY-MM-DDTHH:mm:ss') }); } else Object.assign(form, {qualifiedQuantity:0, unqualifiedQuantity:0, inspectDate:dayjs().format('YYYY-MM-DDTHH:mm:ss')}); open.value=true; }
async function save() { if (!form.code?.trim() || !form.name?.trim() || !form.itemId || !form.inspectorUserId || !form.inspectDate || form.qualifiedQuantity == null || form.unqualifiedQuantity == null || form.qualifiedQuantity + form.unqualifiedQuantity <= 0) { message.warning('请填写单号、名称、物料、巡检人员、日期和有效数量'); return; } saving.value=true; try { if(form.id) await requestClient.put('/mes/qc/patrol/update',form); else { form.id = await requestClient.post('/mes/qc/patrol/create',form); } await bindPendingFiles(); message.success('保存成功'); await load(); } finally { saving.value=false; } }
async function remove(id: number) { await requestClient.delete('/mes/qc/patrol/delete',{params:{id}}); message.success('删除成功'); await load(); }
onMounted(async () => { await Promise.all([load(), getSimpleUserList().then(data => { users.value = data; })]); });
</script>
<template>
  <Page auto-content-height>
    <div class="patrol-page">
      <section class="patrol-search" aria-label="巡检查询条件">
        <div class="search-fields">
          <label class="search-field"><span>巡检单号</span><Input v-model:value="query.code" placeholder="请输入巡检单号" allow-clear @press-enter="query.pageNo = 1; load()" /></label>
          <label class="search-field"><span>盘号</span><Input v-model:value="query.reelNo" placeholder="请输入盘号" allow-clear @press-enter="query.pageNo = 1; load()" /></label>
        </div>
        <Space class="search-actions">
          <Button @click="query.code = ''; query.reelNo = ''; query.pageNo = 1; load()">重置</Button>
          <Button type="primary" :loading="loading" @click="query.pageNo = 1; load()">查询</Button>
        </Space>
      </section>
      <section class="patrol-list" aria-label="巡检记录列表">
        <div class="list-heading">
          <div><h2>巡检记录</h2><p>共 {{ query.total }} æ¡è®°å½•</p></div>
          <Space><Button :loading="loading" @click="load()">刷新</Button><Button v-if="can('create')" type="primary" @click="edit()">新增巡检</Button></Space>
        </div>
        <Table row-key="id" :columns="columns" :data-source="rows" :loading="loading" :scroll="{ x: 1100 }" :pagination="{current:query.pageNo,pageSize:query.pageSize,total:query.total,showSizeChanger:true,showTotal:(total:number)=>`共 ${total} æ¡è®°å½•`}" @change="(p:any) => {query.pageNo=p.current; query.pageSize=p.pageSize; load();}">
          <template #bodyCell="{column,record}">
            <template v-if="column.key==='itemId'">{{ itemNames[record.itemId] || record.itemId }}</template>
            <template v-else-if="column.dataIndex==='inspectDate'">{{ record.inspectDate ? dayjs(record.inspectDate).format('YYYY-MM-DD HH:mm') : '—' }}</template>
            <template v-else-if="column.dataIndex==='reelNo' || column.dataIndex==='batchCode'">{{ record[column.dataIndex] || '—' }}</template>
            <template v-else-if="column.key==='action'"><Space :size="0"><Button v-if="can('update')" type="link" @click="edit(record)">修改</Button><Popconfirm v-if="can('delete')" title="确认删除此巡检记录?" @confirm="remove(record.id)"><Button type="link" danger>删除</Button></Popconfirm></Space></template>
          </template>
          <template #emptyText><div class="empty-state"><p>暂无巡检记录</p><span>新增巡检后,记录会显示在这里</span><Button v-if="can('create')" type="link" @click="edit()">新增巡检</Button></div></template>
        </Table>
      </section>
    </div>
    <Modal v-model:open="open" :title="form.id ? '修改巡检记录' : '新增巡检记录'" :width="850" :confirm-loading="saving" :closable="!uploading && !saving" :mask-closable="!uploading && !saving" :cancel-button-props="{ disabled: uploading || saving }" :ok-button-props="{ disabled: uploading }" ok-text="保存" @ok="save">
      <div class="patrol-modal">
        <div class="form-heading"><h3>基本信息</h3><p>填写巡检对象及记录信息</p></div>
        <Form layout="vertical" class="patrol-form">
          <Form.Item label="巡检单号" required><Input v-model:value="form.code" :maxlength="100" placeholder="请输入巡检单号" /></Form.Item>
          <Form.Item label="巡检名称" required><Input v-model:value="form.name" :maxlength="255" placeholder="请输入巡检名称" /></Form.Item>
          <Form.Item label="物料" required><MdmItemSelect v-model="form.itemId" /></Form.Item>
          <Form.Item label="盘号"><Input v-model:value="form.reelNo" :maxlength="128" placeholder="请输入盘号" /></Form.Item>
          <Form.Item label="批次号"><Input v-model:value="form.batchCode" :maxlength="128" placeholder="请输入批次号" /></Form.Item>
          <Form.Item label="巡检人员" required><Select v-model:value="form.inspectorUserId" show-search option-filter-prop="label" placeholder="请选择巡检人员" :options="users.map(u=>({value:u.id,label:u.nickname}))" /></Form.Item>
          <Form.Item label="合格数量" required><InputNumber v-model:value="form.qualifiedQuantity" :min="0" class="w-full" /></Form.Item>
          <Form.Item label="不合格数量" required><InputNumber v-model:value="form.unqualifiedQuantity" :min="0" class="w-full" /></Form.Item>
          <Form.Item label="巡检日期" required><DatePicker v-model:value="form.inspectDate" show-time value-format="YYYY-MM-DDTHH:mm:ss" class="w-full" /></Form.Item>
          <Form.Item label="备注"><Input v-model:value="form.remark" :maxlength="1000" placeholder="请输入备注" /></Form.Item>
        </Form>
        <div class="attachment-section"><div class="form-heading"><h3>巡检附件</h3><p>上传巡检相关文件</p></div><QcAttachments v-if="form.id" :key="attachmentVersion" qc-type="patrol" :qc-id="form.id" /><Upload v-else :show-upload-list="false" :before-upload="addPendingFile" :disabled="uploading"><Button :loading="uploading">上传巡检附件</Button></Upload><div v-for="file in pendingFiles" :key="file.id" class="pending-file"><span>{{ file.name }}(等待保存关联)</span><Button type="link" danger @click="pendingFiles = pendingFiles.filter(item => item.id !== file.id)">移除</Button></div></div>
      </div>
    </Modal>
  </Page>
</template>
<style scoped>
.patrol-page { display: flex; flex-direction: column; gap: 16px; min-height: 100%; }
.patrol-search, .patrol-list { background: hsl(var(--card)); border-radius: 8px; padding: 20px 24px; }
.patrol-search { display: flex; align-items: end; justify-content: space-between; gap: 24px; }
.search-fields { display: grid; grid-template-columns: repeat(2, minmax(180px, 280px)); gap: 24px; flex: 1; }
.search-field { display: grid; gap: 8px; color: hsl(var(--foreground)); font-weight: 500; }
.search-actions { flex-shrink: 0; }
.patrol-list { flex: 1; min-width: 0; }
.list-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.list-heading h2 { margin: 0; font-size: 16px; font-weight: 600; }
.list-heading p, .form-heading p { margin: 4px 0 0; color: hsl(var(--muted-foreground)); font-size: 13px; }
.empty-state { display: flex; min-height: 180px; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: hsl(var(--muted-foreground)); }
.empty-state p { margin: 0; color: hsl(var(--foreground)); font-weight: 500; }
.empty-state span { font-size: 13px; }
.patrol-modal { max-height: 65vh; overflow-y: auto; padding: 8px 4px 0; }
.form-heading { margin-bottom: 16px; }
.form-heading h3 { margin: 0; font-size: 15px; font-weight: 600; }
.patrol-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; }
.attachment-section { border-top: 1px solid hsl(var(--border)); padding-top: 20px; margin-top: 4px; }
.pending-file { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
.pending-file span { overflow-wrap: anywhere; }
@media (max-width: 768px) { .patrol-search { align-items: stretch; flex-direction: column; gap: 16px; } .search-fields { grid-template-columns: 1fr; gap: 12px; } .search-actions { align-self: end; } .patrol-search, .patrol-list { padding: 16px; } .patrol-form { grid-template-columns: 1fr; } }
</style>