xiaoyi
2 天以前 18c174490b5778a652c10a3b57509ff61e743dd4
完善设备监测、安全管理及采购业务页面
已添加3个文件
已修改13个文件
554 ■■■■■ 文件已修改
src/api/erp/purchase/statistics/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/dv/collect/index.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorder/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/safety/document.ts 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/wm-doc-import/index.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/statistics/price.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/repair/modules/form.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/statistics/oee.vue 197 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/modules/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/index.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/document/index.vue 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/hazard/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/detail.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/index.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/modules/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/statistics/index.ts
@@ -30,6 +30,7 @@
    productId?: number; // äº§å“ç¼–号
    productName?: string; // äº§å“åç§°
    productBarCode?: string; // äº§å“æ¡ç 
    totalQuantity?: number; // å·²å®¡æ ¸è®¢å•采购数量
    avgPrice?: number; // å¹³å‡ä»·æ ¼
    minPrice?: number; // æœ€ä½Žä»·æ ¼
    maxPrice?: number; // æœ€é«˜ä»·æ ¼
@@ -81,4 +82,4 @@
    '/erp/purchase-statistics/uncompleted',
    { params },
  );
}
}
src/api/mes/dv/collect/index.ts
@@ -26,6 +26,7 @@
    workOrderId?: number; // ç”Ÿäº§å·¥å•编号
    workOrderCode?: string; // ç”Ÿäº§å·¥å•编码
    activeAlarmCount?: number; // æœªæ¶ˆé™¤æŠ¥è­¦æ•°é‡
    dataJson?: string; // ç½‘关上报的原始瞬时数据
  }
  /** æ•°é‡‡å®žæ—¶æ•°æ® */
@@ -86,6 +87,16 @@
    dataJson?: string;
  }
  export interface OfflineInterval {
    startTime: string;
    endTime: string;
    durationSeconds: number;
  }
  export interface OfflineResult {
    firstSampleTime?: string;
    intervals: OfflineInterval[];
  }
  /** æ•°é‡‡å·¥è‰ºå‚æ•°å¿«ç…§ */
  export interface CollectTech {
    id?: number;
@@ -96,6 +107,11 @@
  }
}
/** æŒ‰æ•°é‡‡ç¦»çº¿æ—¶é—´æŸ¥è¯¢åœæœºåŒºé—´ï¼Œæ•°æ®æ¥è‡ªæ•°é‡‡åŽ†å²ã€‚ */
export function getCollectOfflineIntervals(params: { deviceCode: string; startTime: string; endTime: string }) {
  return requestClient.get<MesCollectApi.OfflineResult>('/mes/collect/offline/intervals', { params });
}
/** æŸ¥è¯¢æ•°é‡‡è®¾å¤‡çŠ¶æ€åˆ—è¡¨ */
export function getCollectDeviceList() {
  return requestClient.get<MesCollectApi.CollectDevice[]>(
src/api/mes/pro/workorder/index.ts
@@ -19,6 +19,7 @@
    unitMeasureName?: string; // å•位名称
    quantity?: number; // ç”Ÿäº§æ•°é‡
    quantityProduced?: number; // å·²ç”Ÿäº§æ•°é‡
    completionProgressQuantity?: number; // æŽ’产页面状态进度展示口径,不参与报工统计
    quantityChanged?: number; // è°ƒæ•´æ•°é‡
    quantityScheduled?: number; // å·²æŽ’产数量
    clientId?: number; // å®¢æˆ·ç¼–号
src/api/safety/document.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export interface SafetyDocument {
  id?: number;
  documentKey?: string;
  revision?: number;
  title: string;
  category: string;
  attachmentUrl: string;
  description?: string;
  effectiveDate?: string;
  status?: string;
  ownerUserId?: number;
  submitterUserId?: number;
  approverUserId?: number;
  approvalTime?: string;
  approvalComment?: string;
}
export interface SafetyDocumentApproval {
  id: number;
  approverUserId: number;
  decision: string;
  comment?: string;
  decisionTime: string;
  attachmentUrlSnapshot?: string;
}
export function getSafetyDocuments(params: PageParam & { title?: string; status?: string }) {
  return requestClient.get<PageResult<SafetyDocument>>('/safety/document/page', { params });
}
export function getSafetyDocumentApprovals(id: number) {
  return requestClient.get<SafetyDocumentApproval[]>('/safety/document/approvals', { params: { id } });
}
export function createSafetyDocument(data: SafetyDocument) {
  return requestClient.post<number>('/safety/document/create', data);
}
export function updateSafetyDocument(data: SafetyDocument) {
  return requestClient.put<boolean>('/safety/document/update', data);
}
export function submitSafetyDocument(id: number) {
  return requestClient.put<boolean>('/safety/document/submit', undefined, { params: { id } });
}
export function approveSafetyDocument(data: { id: number; approved: boolean; comment?: string }) {
  return requestClient.put<boolean>('/safety/document/approve', data);
}
export function reviseSafetyDocument(id: number) {
  return requestClient.post<number>('/safety/document/revision', undefined, { params: { id } });
}
src/components/wm-doc-import/index.vue
@@ -3,6 +3,7 @@
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { shallowRef } from 'vue';
import { Button, message, Upload } from 'ant-design-vue';
@@ -10,21 +11,16 @@
defineOptions({ name: 'WmDocImportForm' });
const props = withDefaults(
  defineProps<{
interface ImportOptions {
    /** ä¸‹è½½æ¨¡æ¿æŽ¥å£ */
    templateApi: () => Promise<Blob | BlobPart | any>;
    /** å¯¼å…¥æŽ¥å£ */
    importApi: (file: File) => Promise<any>;
    /** å•据名称,用于提示与文件名 */
    subject: string;
    /** ä¸Šä¼ æ¡†æ ‡ç­¾ */
    fileLabel?: string;
  }>(),
  {
    fileLabel: 'Excel æ•°æ®',
  },
);
}
const options = shallowRef<ImportOptions>();
const emit = defineEmits(['success']);
@@ -48,6 +44,11 @@
});
const [Modal, modalApi] = useVbenModal({
  onOpenChange(isOpen) {
    if (isOpen) {
      options.value = modalApi.getData<ImportOptions>();
    }
  },
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
@@ -56,7 +57,7 @@
    modalApi.lock();
    const data = await formApi.getValues();
    try {
      const result = (await props.importApi(data.file as File)) as any;
      const result = (await options.value!.importApi(data.file as File)) as any;
      const createCodes: string[] = result?.createCodes ?? [];
      const failureCodes: Record<string, string> = result?.failureCodes ?? {};
      const failCount = Object.keys(failureCodes).length;
@@ -69,7 +70,7 @@
      // ç»“果提示:成功/失败分别汇总,失败明细最多展示 5 æ¡
      const lines: string[] = [];
      if (createCodes.length > 0) {
        lines.push(`成功导入 ${createCodes.length} å¼ ${props.subject}`);
        lines.push(`成功导入 ${createCodes.length} å¼ ${options.value!.subject}`);
      }
      if (failCount > 0) {
        const detail = Object.entries(failureCodes)
@@ -91,6 +92,8 @@
      } else {
        message.warning(lines.join(';'));
      }
    } catch {
      // è¯·æ±‚层已提示错误,保留弹窗供用户调整文件后重试。
    } finally {
      modalApi.unlock();
    }
@@ -105,16 +108,16 @@
/** ä¸‹è½½æ¨¡ç‰ˆ */
async function handleDownload() {
  const data = await props.templateApi();
  const data = await options.value!.templateApi();
  downloadFileFromBlobPart({
    fileName: `${props.subject}导入模板.xls`,
    fileName: `${options.value!.subject}导入模板.xls`,
    source: data,
  });
}
</script>
<template>
  <Modal :title="`导入${subject}`" class="w-2/5">
  <Modal :title="`导入${options?.subject ?? ''}`" class="w-2/5">
    <Form class="mx-4">
      <template #file>
        <div class="w-full">
src/views/erp/purchase/statistics/price.vue
@@ -6,7 +6,7 @@
import { Page } from '@vben/common-ui';
import { Tag } from 'ant-design-vue';
import { Alert, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getPriceAnalysis } from '#/api/erp/purchase/statistics';
@@ -47,12 +47,17 @@
  },
  {
    field: 'productBarCode',
    title: '产品条码',
    title: '物料编码',
    minWidth: 120,
  },
  {
    field: 'totalQuantity',
    title: '采购数量',
    minWidth: 120,
  },
  {
    field: 'avgPrice',
    title: '平均价格',
    title: '加权平均价格',
    minWidth: 120,
    formatter: 'formatAmount2',
  },
@@ -115,9 +120,11 @@
<template>
  <Page auto-content-height>
    <Alert class="mb-4" type="info" show-icon message="统计已审核采购订单的未税单价;平均价格按数量加权,价格变化率比较筛选范围内最近两笔采购价格。" />
    <Grid table-title="采购价格分析">
      <template #priceChangeRate="{ row }">
        <Tag v-if="row.priceChangeRate > 0" color="red">
        <Tag v-if="row.priceChangeRate == null">暂无对比</Tag>
        <Tag v-else-if="row.priceChangeRate > 0" color="red">
          +{{ row.priceChangeRate }}%
        </Tag>
        <Tag v-else-if="row.priceChangeRate < 0" color="green">
@@ -127,4 +134,4 @@
      </template>
    </Grid>
  </Page>
</template>
</template>
src/views/mes/dv/repair/modules/form.vue
@@ -89,6 +89,8 @@
    await modalApi.close();
    emit('success');
    message.success('提交成功');
  } catch {
    // è¯·æ±‚层展示失败原因,保留维修单供修改。
  } finally {
    modalApi.unlock();
  }
@@ -115,6 +117,8 @@
    await modalApi.close();
    emit('success');
    message.success($t('ui.actionMessage.operationSuccess'));
  } catch {
    // è¯·æ±‚层展示失败原因,保留维修单供修改。
  } finally {
    modalApi.unlock();
  }
@@ -136,6 +140,8 @@
    await modalApi.close();
    emit('success');
    message.success($t('ui.actionMessage.operationSuccess'));
  } catch {
    // è¯·æ±‚层展示失败原因,保留验收窗口。
  } finally {
    modalApi.unlock();
  }
@@ -174,6 +180,8 @@
      }
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } catch {
      // è¯·æ±‚层展示失败原因,保留当前表单。
    } finally {
      modalApi.unlock();
    }
src/views/mes/dv/statistics/oee.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,197 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Alert, Button, Card, Col, DatePicker, Modal, Row, Statistic, Table } from 'ant-design-vue';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { requestClient } from '#/api/request';
import { getCollectDeviceList, getCollectOfflineIntervals } from '#/api/mes/dv/collect';
import type { MesCollectApi } from '#/api/mes/dv/collect';
interface DeviceOee {
  machineryId: number;
  machineryCode: string;
  machineryName: string;
  oee: number;
  availability: number;
  performance: number;
  quality: number;
  taskCount: number;
  plannedQuantity: number;
  producedQuantity: number;
}
interface OeeResult {
  overallOee: number;
  overallAvailability: number;
  overallPerformance: number;
  overallQuality: number;
  taskCount: number;
  items: DeviceOee[];
}
const loading = ref(false);
const dates = ref<[Dayjs, Dayjs]>();
const result = ref<OeeResult>();
const collectDevices = ref<MesCollectApi.CollectDevice[]>([]);
const collectError = ref(false);
const collectLoading = ref(false);
const offlineOpen = ref(false);
const offlineLoading = ref(false);
const offlineDevice = ref<MesCollectApi.CollectDevice>();
const offlineRange = ref<[Dayjs, Dayjs] | undefined>([dayjs().subtract(1, 'day'), dayjs()]);
const offlineIntervals = ref<MesCollectApi.OfflineInterval[]>([]);
const offlineObserved = ref<string>();
const offlineQueried = ref(false);
const columns = [
  { title: '设备编码', dataIndex: 'machineryCode' },
  { title: '设备名称', dataIndex: 'machineryName' },
  { title: '综合效率(%)', dataIndex: 'oee' },
  { title: '稼动率(%)', dataIndex: 'availability' },
  { title: '性能开动率(%)', dataIndex: 'performance' },
  { title: '良品率(%)', dataIndex: 'quality' },
  { title: '任务数', dataIndex: 'taskCount' },
  { title: '计划产量', dataIndex: 'plannedQuantity' },
  { title: '实际产量', dataIndex: 'producedQuantity' },
];
const collectColumns = [
  { title: '设备编码', dataIndex: 'machineryCode' },
  { title: '设备名称', dataIndex: 'machineryName' },
  { title: '在线状态', key: 'online' },
  { title: '运行状态', dataIndex: 'stsName' },
  { title: '采集数据', key: 'readings', width: 300 },
  { title: '油温', dataIndex: 'ot' },
  { title: '未消除报警', dataIndex: 'activeAlarmCount' },
  { title: '最近采集时间', dataIndex: 'lastCollectTime' },
  { title: '操作', key: 'action', fixed: 'right' as const, width: 110 },
];
const offlineColumns = [
  { title: '离线开始', dataIndex: 'startTime' },
  { title: '恢复或查询结束', dataIndex: 'endTime' },
  { title: '离线时长', key: 'duration' },
];
function formatReadings(data?: string) {
  if (!data) return '暂无数据';
  try {
    const values: unknown = JSON.parse(data);
    if (!values || typeof values !== 'object' || Array.isArray(values)) return '暂无数据';
    return Object.entries(values).slice(0, 6).map(([key, value]) => `${key}:${String(value)}`).join(',');
  } catch {
    return '采集数据格式异常';
  }
}
async function load() {
  loading.value = true;
  try {
    const range = dates.value;
    result.value = await requestClient.get<OeeResult>('/mes/dv/statistics/oee', {
      params: range ? {
        startTime: range[0].startOf('day').format('YYYY-MM-DD HH:mm:ss'),
        endTime: range[1].endOf('day').format('YYYY-MM-DD HH:mm:ss'),
      } : {},
    });
  } catch {
    result.value = undefined;
  } finally {
    loading.value = false;
  }
}
async function loadCollect() {
  collectLoading.value = true;
  try {
    collectDevices.value = await getCollectDeviceList();
    collectError.value = false;
  } catch {
    collectDevices.value = [];
    collectError.value = true;
  } finally {
    collectLoading.value = false;
  }
}
async function loadOfflineIntervals() {
  if (!offlineDevice.value?.collectDeviceCode || !offlineRange.value) return;
  offlineLoading.value = true;
  try {
    const data = await getCollectOfflineIntervals({
      deviceCode: offlineDevice.value.collectDeviceCode,
      startTime: offlineRange.value[0].format('YYYY-MM-DD HH:mm:ss'),
      endTime: offlineRange.value[1].format('YYYY-MM-DD HH:mm:ss'),
    });
    offlineIntervals.value = data.intervals;
    offlineObserved.value = data.firstSampleTime;
    offlineQueried.value = true;
  } catch {
    offlineIntervals.value = [];
    offlineQueried.value = false;
  } finally {
    offlineLoading.value = false;
  }
}
function openOffline(device: MesCollectApi.CollectDevice) {
  offlineDevice.value = device;
  offlineRange.value = [dayjs().subtract(1, 'day'), dayjs()];
  offlineIntervals.value = [];
  offlineObserved.value = undefined;
  offlineQueried.value = false;
  offlineOpen.value = true;
  void loadOfflineIntervals();
}
function durationText(seconds: number) {
  const minutes = Math.floor(seconds / 60);
  return `${Math.floor(minutes / 60)}小时${minutes % 60}分${seconds % 60}秒`;
}
onMounted(() => { void load(); void loadCollect(); });
</script>
<template>
  <Page auto-content-height>
    <div class="space-y-4">
      <Alert type="info" show-icon message="当前指标按生产任务计划和报工记录估算,未接入设备实时运行及停机数据。" />
      <Card>
        <div class="mb-4 flex items-center gap-3">
          <span>任务完成日期</span>
          <DatePicker.RangePicker v-model:value="dates" />
          <Button type="primary" :loading="loading" @click="load">查询</Button>
        </div>
        <Row :gutter="16">
          <Col :span="6"><Statistic title="综合效率" :value="result?.overallOee ?? 0" suffix="%" /></Col>
          <Col :span="6"><Statistic title="稼动率" :value="result?.overallAvailability ?? 0" suffix="%" /></Col>
          <Col :span="6"><Statistic title="性能开动率" :value="result?.overallPerformance ?? 0" suffix="%" /></Col>
          <Col :span="6"><Statistic title="良品率" :value="result?.overallQuality ?? 0" suffix="%" /></Col>
        </Row>
      </Card>
      <Card title="设备明细">
        <Table :columns="columns" :data-source="result?.items ?? []" :loading="loading" row-key="machineryId" :scroll="{ x: 1000 }" />
      </Card>
      <Card title="设备数采实时状态">
        <template #extra><Button :loading="collectLoading" @click="loadCollect">刷新</Button></template>
        <Alert v-if="collectError" type="warning" show-icon message="数采状态加载失败,请检查数采服务后重试。" class="mb-4" />
        <Table :columns="collectColumns" :data-source="collectDevices" :loading="collectLoading" row-key="machineryId" :scroll="{ x: 900 }">
          <template #bodyCell="{ column, record }">
            <template v-if="column.key === 'online'">{{ record.online ? '在线' : '离线' }}</template>
            <template v-else-if="column.key === 'readings'">{{ formatReadings(record.dataJson) }}</template>
            <template v-else-if="column.key === 'action'"><Button type="link" @click="openOffline(record)">停机记录</Button></template>
          </template>
        </Table>
      </Card>
      <Modal v-model:open="offlineOpen" :title="`${offlineDevice?.machineryName ?? ''} - ç¦»çº¿åœæœºè®°å½•`" :footer="null" width="800px">
        <Alert class="mb-4" type="info" show-icon message="按数采中断超过 5 åˆ†é’Ÿè®¡ç®—;通信中断也会计入。首次采集前不推断,记录受数采历史保留期限制,单次最多查询 7 å¤©ã€‚" />
        <Alert v-if="offlineQueried && !offlineObserved" class="mb-4" type="warning" show-icon message="该时段没有可用采样记录,无法判断停机情况。" />
        <Alert v-else-if="offlineQueried && offlineObserved && offlineRange && dayjs(offlineObserved).isAfter(offlineRange[0])" class="mb-4" type="warning" show-icon message="查询开始时没有历史采样,首次采样前的状态无法判断。" />
        <div class="mb-4 flex items-center gap-3">
          <DatePicker.RangePicker v-model:value="offlineRange" show-time />
          <Button type="primary" :loading="offlineLoading" @click="loadOfflineIntervals">查询</Button>
          <span v-if="offlineQueried && offlineObserved">区间合计:{{ durationText(offlineIntervals.reduce((sum, item) => sum + item.durationSeconds, 0)) }}</span>
        </div>
        <Table :columns="offlineColumns" :data-source="offlineIntervals" :loading="offlineLoading" row-key="startTime">
          <template #bodyCell="{ column, record }">
            <template v-if="column.key === 'duration'">{{ durationText(record.durationSeconds) }}</template>
          </template>
        </Table>
      </Modal>
    </div>
  </Page>
</template>
src/views/mes/pro/feedback/modules/form.vue
@@ -171,6 +171,8 @@
    await modalApi.close();
    emit('success');
    message.success('报工单已提交');
  } catch {
    // è¯·æ±‚层已提示失败原因,保留弹窗供用户修改后重试。
  } finally {
    modalApi.unlock();
  }
src/views/mes/pro/task/data.ts
@@ -155,12 +155,12 @@
    // },
    {
      field: 'quantityProduced',
      title: '已生产数量',
      title: '实际报工数量',
      width: 100,
    },
    {
      field: 'progressPercent',
      title: '完成进度',
      title: '状态进度',
      width: 150,
      slots: { default: 'progressPercent' },
    },
src/views/mes/pro/task/index.vue
@@ -100,19 +100,12 @@
}
/**
 * å·²å®Œæˆç±»åž‹è¡Œçš„“完成态”兜底展示:
 * å·²ç”Ÿäº§æ•°é‡æŒ‰è®¢å•数量展示(若低于订单数量),使完成进度显示为 100%。
 * ä»…作用于本页列表展示,不修改后端数据,不影响库存/报工/导出等真实口径。
 * å·²å®Œæˆå·¥å•的状态进度按完成状态展示;实际报工数量保留接口原值。
 */
function normalizeCompletionDisplay(row: MesProWorkOrderApi.WorkOrder): void {
  if (!isCompletedWorkOrder(row)) {
    return;
  }
  const quantity = row.quantity ?? 0;
  const produced = row.quantityProduced ?? 0;
  if (produced < quantity) {
    row.quantityProduced = quantity;
  }
  Object.assign(row, { completionProgressQuantity: isCompletedWorkOrder(row) ? Math.max(produced, quantity) : produced });
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -146,7 +139,7 @@
            type: MesProWorkOrderTypeEnum.SELF,
            ...formValues,
          });
          // å·²å®Œæˆç±»åž‹è¡Œï¼šå·²ç”Ÿäº§æ•°é‡/完成进度做“完成态”兜底展示(仅本页展示,不改库)
          // çŠ¶æ€è¿›åº¦ä¸Žå®žé™…æŠ¥å·¥æ•°é‡åˆ†åˆ«å±•ç¤ºã€‚
          if (res?.list) {
            res.list.forEach(normalizeCompletionDisplay);
          }
@@ -223,7 +216,7 @@
      </template>
      <template #progressPercent="{ row }">
        <Progress
          :percent="Math.min(100, Math.round((row.quantity ? ((row.quantityProduced || 0) / row.quantity) * 100 : 0)))"
          :percent="Math.min(100, Math.round((row.quantity ? ((row.completionProgressQuantity || 0) / row.quantity) * 100 : 0)))"
          size="small"
        />
      </template>
src/views/safety/document/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,129 @@
<script lang="ts" setup>
import type { SafetyDocument, SafetyDocumentApproval } from '#/api/safety/document';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Alert, Button, Card, DatePicker, Form, FormItem, Input, message, Modal, Select, Space, Table, Tag } from 'ant-design-vue';
import { uploadSafetyFiles } from '#/api/safety';
import { approveSafetyDocument, createSafetyDocument, getSafetyDocumentApprovals, getSafetyDocuments, reviseSafetyDocument, submitSafetyDocument, updateSafetyDocument } from '#/api/safety/document';
defineOptions({ name: 'SafetyDocument' });
const rows = ref<SafetyDocument[]>([]);
const loading = ref(false);
const saving = ref(false);
const uploading = ref(false);
const open = ref(false);
const approvalOpen = ref(false);
const historyOpen = ref(false);
const approvalHistory = ref<SafetyDocumentApproval[]>([]);
const approvalRow = ref<SafetyDocument>();
const approvalComment = ref('');
const filters = reactive<{ title?: string; status?: string }>({});
const paging = reactive({ current: 1, pageSize: 10, total: 0 });
const model = reactive<SafetyDocument>({ title: '', category: '安全管理制度', attachmentUrl: '' });
const states: Record<string, string> = { DRAFT: '草稿', PENDING: '待审批', APPROVED: '已批准', REJECTED: '已驳回', SUPERSEDED: '历史版本' };
const columns = [
  { title: '制度名称', dataIndex: 'title' }, { title: '类别', dataIndex: 'category' },
  { title: '版本', dataIndex: 'revision' }, { title: '生效日期', dataIndex: 'effectiveDate' },
  { title: '状态', key: 'status' }, { title: '附件', key: 'file' },
  { title: '最近审批意见', dataIndex: 'approvalComment' }, { title: '操作', key: 'action', width: 280 },
];
async function load(reset = false) {
  if (reset) paging.current = 1;
  loading.value = true;
  try {
    const result = await getSafetyDocuments({ pageNo: paging.current, pageSize: paging.pageSize, ...filters });
    rows.value = result.list; paging.total = result.total;
  } catch { rows.value = []; } finally { loading.value = false; }
}
function edit(row?: SafetyDocument) {
  Object.assign(model, { id: undefined, title: '', category: '安全管理制度', attachmentUrl: '', description: '', effectiveDate: undefined }, row || {});
  open.value = true;
}
async function upload(event: Event) {
  const input = event.target as HTMLInputElement;
  const file = input.files?.[0];
  if (!file) return;
  uploading.value = true;
  try { const urls = await uploadSafetyFiles([file], 'safety/document'); model.attachmentUrl = urls[0] || ''; }
  catch { /* è¯·æ±‚层展示错误 */ }
  finally { uploading.value = false; input.value = ''; }
}
async function save() {
  if (!model.title.trim() || !model.category.trim() || !model.attachmentUrl.trim()) { message.warning('请填写制度名称、类别并上传附件'); return; }
  saving.value = true;
  try {
    const data = { id: model.id, title: model.title, category: model.category, attachmentUrl: model.attachmentUrl, description: model.description, effectiveDate: model.effectiveDate };
    if (model.id) await updateSafetyDocument(data); else await createSafetyDocument(data);
    message.success('制度草稿已保存'); open.value = false; await load();
  } catch { /* è¯·æ±‚层展示错误 */ } finally { saving.value = false; }
}
async function submit(row: SafetyDocument) {
  if (!row.id) return;
  try { await submitSafetyDocument(row.id); message.success('已提交审批'); await load(); } catch { /* è¯·æ±‚层展示错误 */ }
}
async function revise(row: SafetyDocument) {
  if (!row.id) return;
  try { await reviseSafetyDocument(row.id); message.success('已创建修订草稿'); await load(true); } catch { /* è¯·æ±‚层展示错误 */ }
}
function openApproval(row: SafetyDocument) { approvalRow.value = row; approvalComment.value = ''; approvalOpen.value = true; }
async function showHistory(row: SafetyDocument) {
  if (!row.id) return;
  try { approvalHistory.value = await getSafetyDocumentApprovals(row.id); historyOpen.value = true; } catch { /* è¯·æ±‚层展示错误 */ }
}
async function approve(approved: boolean) {
  if (!approvalRow.value?.id) return;
  if (!approved && !approvalComment.value.trim()) { message.warning('驳回时请填写审批意见'); return; }
  saving.value = true;
  try { await approveSafetyDocument({ id: approvalRow.value.id, approved, comment: approvalComment.value }); message.success(approved ? '审批通过' : '已驳回'); approvalOpen.value = false; await load(); }
  catch { /* è¯·æ±‚层展示错误 */ } finally { saving.value = false; }
}
onMounted(() => { void load(); });
</script>
<template>
  <Page title="安全制度档案" description="维护安全制度附件,提交审批并保留历史版本。" auto-content-height>
    <Card>
      <div class="mb-4 flex flex-wrap items-center gap-3">
        <Input v-model:value="filters.title" placeholder="制度名称" style="width: 200px" allow-clear />
        <Select v-model:value="filters.status" placeholder="状态" style="width: 130px" allow-clear :options="Object.entries(states).map(([value, label]) => ({ value, label }))" />
        <Button type="primary" @click="load(true)">查询</Button><Button v-access:code="['safety:document:create']" @click="edit()">新增制度</Button>
      </div>
      <Table :columns="columns" :data-source="rows" :loading="loading" :pagination="paging" row-key="id" :scroll="{ x: 1200 }" @change="p => { paging.current = p.current || 1; paging.pageSize = p.pageSize || 10; load(); }">
        <template #bodyCell="{ column, record }">
          <Tag v-if="column.key === 'status'">{{ states[record.status] || record.status }}</Tag>
          <a v-else-if="column.key === 'file'" :href="record.attachmentUrl" target="_blank" rel="noopener noreferrer">查看附件</a>
          <Space v-else-if="column.key === 'action'">
            <Button type="link" @click="showHistory(record as SafetyDocument)">审批记录</Button>
            <Button v-if="['DRAFT', 'REJECTED'].includes(record.status)" v-access:code="['safety:document:update']" type="link" @click="edit(record as SafetyDocument)">编辑</Button>
            <Button v-if="record.status === 'DRAFT'" v-access:code="['safety:document:update']" type="link" @click="submit(record as SafetyDocument)">提交</Button>
            <Button v-if="record.status === 'PENDING'" v-access:code="['safety:document:approve']" type="link" @click="openApproval(record as SafetyDocument)">审批</Button>
            <Button v-if="record.status === 'APPROVED'" v-access:code="['safety:document:create']" type="link" @click="revise(record as SafetyDocument)">创建修订</Button>
          </Space>
        </template>
      </Table>
    </Card>
    <Modal v-model:open="open" title="制度草稿" :confirm-loading="saving" ok-text="保存" @ok="save">
      <Form layout="vertical">
        <FormItem label="制度名称" required><Input v-model:value="model.title" :maxlength="200" /></FormItem>
        <FormItem label="类别" required><Input v-model:value="model.category" :maxlength="50" /></FormItem>
        <FormItem label="生效日期" extra="暂不支持未来日期自动生效;设置未来日期时,请在生效日期当天审批。"><DatePicker v-model:value="model.effectiveDate" value-format="YYYY-MM-DD" class="w-full" /></FormItem>
        <FormItem label="制度附件" required><input type="file" accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png" :disabled="uploading" @change="upload" /><div v-if="model.attachmentUrl">附件已上传</div></FormItem>
        <FormItem label="说明"><Input.TextArea v-model:value="model.description" :maxlength="2000" :rows="3" /></FormItem>
      </Form>
    </Modal>
    <Modal v-model:open="approvalOpen" title="审批制度" :footer="null">
      <Alert type="info" show-icon message="提交人不能审批自己的制度。" class="mb-4" />
      <Input.TextArea v-model:value="approvalComment" placeholder="审批意见,驳回时必填" :maxlength="500" :rows="3" />
      <div class="mt-4 flex justify-end gap-2"><Button :loading="saving" danger @click="approve(false)">驳回</Button><Button :loading="saving" type="primary" @click="approve(true)">通过</Button></div>
    </Modal>
    <Modal v-model:open="historyOpen" title="审批记录" :footer="null">
      <Table :data-source="approvalHistory" row-key="id" :pagination="false" :columns="[{ title: '审批人账号编号', dataIndex: 'approverUserId' }, { title: '结果', key: 'decision' }, { title: '意见', dataIndex: 'comment' }, { title: '时间', dataIndex: 'decisionTime' }, { title: '审批时附件', key: 'attachment' }]">
        <template #bodyCell="{ column, record }">
          <template v-if="column.key === 'decision'">{{ states[record.decision] || record.decision }}</template>
          <a v-else-if="column.key === 'attachment' && record.attachmentUrlSnapshot" :href="record.attachmentUrlSnapshot" target="_blank" rel="noopener noreferrer">查看</a>
        </template>
      </Table>
    </Modal>
  </Page>
</template>
src/views/safety/hazard/index.vue
@@ -236,6 +236,8 @@
    message.success('已分派');
    assignOpen.value = false;
    await load();
  } catch {
    // è¯·æ±‚层提示原因,保留分派窗口供用户修正。
  } finally {
    saving.value = false;
  }
src/views/srm/tender/detail.vue
@@ -88,13 +88,12 @@
    message.warning('该物料已添加,请勿重复添加');
    return;
  }
  if (materialForm.value.id) {
    await updateMaterial(materialForm.value);
  } else {
    await createMaterial(materialForm.value);
  }
  materialFormVisible.value = false;
  loadMaterials();
  try {
    if (materialForm.value.id) await updateMaterial(materialForm.value);
    else await createMaterial(materialForm.value);
    materialFormVisible.value = false;
    await loadMaterials();
  } catch { /* è¯·æ±‚层已提示错误,保留表单 */ }
}
function onMaterialSelect(item?: MdmItemApi.Item) {
  if (item) {
@@ -129,9 +128,11 @@
  try { bidList.value = await getBidListByProject(projectId); } finally { bidLoading.value = false; }
}
async function handleCreateBid() {
  await createBid(bidForm.value);
  bidFormVisible.value = false;
  loadBids();
  try {
    await createBid(bidForm.value);
    bidFormVisible.value = false;
    await loadBids();
  } catch { /* è¯·æ±‚层已提示错误,保留表单 */ }
}
async function handleWithdrawBid(id: number) {
  await withdrawBid(id);
@@ -170,13 +171,12 @@
  bidTotalDraft.value = bid?.bidTotalAmount ?? undefined;
}
async function handleSaveQuote() {
  if (quoteForm.value.id) {
    await updateQuote(quoteForm.value);
  } else {
    await createQuote(quoteForm.value);
  }
  quoteFormVisible.value = false;
  loadQuotes(selectedBidId.value);
  try {
    if (quoteForm.value.id) await updateQuote(quoteForm.value);
    else await createQuote(quoteForm.value);
    quoteFormVisible.value = false;
    await loadQuotes(selectedBidId.value);
  } catch { /* è¯·æ±‚层已提示错误,保留表单 */ }
}
function handleAddQuote() {
  quoteForm.value = { bidId: selectedBidId.value!, tenderMaterialId: undefined! };
@@ -1081,4 +1081,4 @@
      </a-modal>
    </template>
  </Page>
</template>
</template>
src/views/srm/tender/index.vue
@@ -56,46 +56,48 @@
    await deleteTenderProject(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.tenderName]));
    handleRefresh();
  } catch {
    // è¯·æ±‚层已提示失败原因。
  } finally {
    hideLoading();
  }
}
async function handlePublish(row: SrmTenderApi.TenderProjectVO) {
  await publishTender(row.id!);
  message.success('发布成功');
  handleRefresh();
  try {
    await publishTender(row.id!); message.success('发布成功'); handleRefresh();
  } catch { /* è¯·æ±‚层已提示错误 */ }
}
async function handleStartBidding(row: SrmTenderApi.TenderProjectVO) {
  await startBidding(row.id!);
  message.success('已开始投标');
  handleRefresh();
  try {
    await startBidding(row.id!); message.success('已开始投标'); handleRefresh();
  } catch { /* è¯·æ±‚层已提示错误 */ }
}
async function handleStartEvaluation(row: SrmTenderApi.TenderProjectVO) {
  await startEvaluation(row.id!);
  message.success('已开始评标');
  handleRefresh();
  try {
    await startEvaluation(row.id!); message.success('已开始评标'); handleRefresh();
  } catch { /* è¯·æ±‚层已提示错误 */ }
}
async function handleClose(row: SrmTenderApi.TenderProjectVO) {
  await closeTender(row.id!);
  message.success('已关闭');
  handleRefresh();
  try {
    await closeTender(row.id!); message.success('已关闭'); handleRefresh();
  } catch { /* è¯·æ±‚层已提示错误 */ }
}
async function handleGeneratePurchaseOrder(row: SrmTenderApi.TenderProjectVO) {
  if (!row.awardId) return;
  await generatePurchaseOrder(row.awardId);
  message.success('采购订单已生成');
  handleRefresh();
  try {
    await generatePurchaseOrder(row.awardId); message.success('采购订单已生成'); handleRefresh();
  } catch { /* è¯·æ±‚层已提示错误 */ }
}
async function handleStartPriceComparing(row: SrmTenderApi.TenderProjectVO) {
  await startPriceComparing(row.id!);
  message.success('已开始比价');
  handleRefresh();
  try {
    await startPriceComparing(row.id!); message.success('已开始比价'); handleRefresh();
  } catch { /* è¯·æ±‚层已提示错误 */ }
}
// ç¡®è®¤ä¸­æ ‡ç›¸å…³
@@ -114,6 +116,8 @@
      label: `${b.supplierName} - ${b.bidNo} (Â¥${b.bidTotalAmount ?? 0})`,
      value: b.id!,
    }));
  } catch {
    return;
  } finally {
    confirmLoading.value = false;
  }
@@ -134,6 +138,8 @@
    message.success('确认中标成功,采购订单已自动生成');
    confirmModalApi.close();
    handleRefresh();
  } catch {
    // è¯·æ±‚层已提示失败原因,保留弹窗。
  } finally {
    confirmLoading.value = false;
  }
src/views/srm/tender/modules/form.vue
@@ -68,6 +68,8 @@
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } catch {
      // è¯·æ±‚层已提示失败原因,保留当前表单。
    } finally {
      modalApi.unlock();
    }