瀏覽代碼

优化修改审核功能

Huanyi 2 月之前
父節點
當前提交
878ebeb701
共有 3 個文件被更改,包括 2060 次插入148 次删除
  1. 1283 0
      src/components/DocumentWpsAuditDialog/index.vue
  2. 725 0
      src/components/QcAuditDialog/index.vue
  3. 52 148
      src/views/Qc/task/detail.vue

+ 1283 - 0
src/components/DocumentWpsAuditDialog/index.vue

@@ -0,0 +1,1283 @@
+<template>
+  <el-dialog v-model="dialogVisible" :title="title" width="1200px" top="5vh" append-to-body destroy-on-close class="audit-dialog">
+    <div class="audit-container">
+      <!-- 左侧:文档编辑区域 -->
+      <div class="preview-section">
+        <div class="preview-header">
+          <div class="file-info">
+            <el-icon class="file-icon"><Document /></el-icon>
+            <span class="file-name">{{ document?.fileName || t('document.document.documentAudit.untitledDocument') }}</span>
+          </div>
+          <div class="header-actions">
+            <el-tooltip :content="t('document.document.documentAudit.viewVersionsTooltip')" placement="bottom">
+              <el-button size="small" @click="handleViewVersions" :loading="loadingVersions">
+                <el-icon><Clock /></el-icon>
+                {{ t('document.document.documentAudit.viewVersions') }}
+              </el-button>
+            </el-tooltip>
+            <el-tooltip :content="t('document.document.documentAudit.cleanCommentsTooltip')" placement="bottom">
+              <el-button size="small" @click="handleCleanComments" :loading="cleaningComments">
+                <el-icon><Delete /></el-icon>
+                {{ t('document.document.documentAudit.cleanComments') }}
+              </el-button>
+            </el-tooltip>
+            <el-tooltip :content="t('document.document.documentAudit.copySignatureTooltip')" placement="bottom">
+              <el-button type="primary" size="small" @click="handleCopyAvatar" class="copy-avatar-btn" :loading="copyingAvatar">
+                <el-icon><Picture /></el-icon>
+                {{ t('document.document.documentAudit.copySignature') }}
+              </el-button>
+            </el-tooltip>
+          </div>
+        </div>
+        <div class="preview-container">
+          <!-- WPS 编辑器容器 -->
+          <div
+            v-if="document?.ossId"
+            ref="wpsContainerRef"
+            class="wps-container"
+            @dragenter="handleDragEnter"
+            @dragleave="handleDragLeave"
+            @dragover.prevent="handleDragOver"
+            @drop.prevent="handleDrop"
+          >
+            <!-- 拖拽提示层 -->
+            <div v-if="isDragging" class="drag-overlay">
+              <div class="drag-hint">
+                <el-icon class="drag-icon"><Upload /></el-icon>
+                <p>{{ t('document.document.documentAudit.dropImageHint') }}</p>
+              </div>
+            </div>
+
+            <!-- 降级方案:iframe 预览 -->
+            <iframe v-if="wpsError && document?.url" :src="document.url" class="document-iframe" frameborder="0"></iframe>
+          </div>
+
+          <!-- 加载状态 -->
+          <div v-if="wpsLoading" class="loading-state">
+            <el-icon class="is-loading"><Loading /></el-icon>
+            <p>{{ t('document.document.documentAudit.loadingEditor') }}</p>
+          </div>
+
+          <!-- 错误状态 -->
+          <div v-if="wpsError && !document?.url" class="error-state">
+            <el-result icon="error" :title="t('document.document.documentAudit.loadFailed')" :sub-title="wpsError">
+              <template #extra>
+                <el-button type="primary" @click="() => initWpsEditor(false)">{{ t('document.document.documentAudit.reload') }}</el-button>
+              </template>
+            </el-result>
+          </div>
+
+          <!-- 空状态 -->
+          <el-empty v-if="!document?.ossId" :description="t('document.document.documentAudit.noDocument')" :image-size="100" />
+        </div>
+      </div>
+
+      <!-- 右侧:审核表单区域 -->
+      <div class="audit-section">
+        <div class="section-header">
+          <el-icon><Edit /></el-icon>
+          <span>{{ t('document.document.documentAudit.auditInfo') }}</span>
+        </div>
+
+        <el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-position="top" class="audit-form">
+          <!-- 文档信息卡片 -->
+          <el-card shadow="never" class="info-card">
+            <template #header>
+              <div class="card-header">
+                <el-icon><InfoFilled /></el-icon>
+                <span>{{ t('document.document.documentAudit.documentInfo') }}</span>
+              </div>
+            </template>
+            <div class="info-item">
+              <span class="info-label">{{ t('document.document.documentAudit.documentName') }}:</span>
+              <span class="info-value">{{ document?.fileName || '-' }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">{{ t('document.document.documentAudit.documentId') }}:</span>
+              <span class="info-value">{{ document?.id || '-' }}</span>
+            </div>
+          </el-card>
+
+          <!-- 审核结果 -->
+          <el-form-item :label="t('document.document.auditForm.result')" prop="result">
+            <el-radio-group v-model="auditForm.result" class="result-radio-group">
+              <el-radio label="3" border>
+                <div class="radio-content">
+                  <el-icon class="radio-icon success"><CircleCheck /></el-icon>
+                  <div class="radio-text">
+                    <div class="radio-title">{{ t('document.document.auditForm.pass') }}</div>
+                    <div class="radio-desc">{{ t('document.document.documentAudit.passDesc') }}</div>
+                  </div>
+                </div>
+              </el-radio>
+              <el-radio label="2" border>
+                <div class="radio-content">
+                  <el-icon class="radio-icon danger"><CircleClose /></el-icon>
+                  <div class="radio-text">
+                    <div class="radio-title">{{ t('document.document.auditForm.reject') }}</div>
+                    <div class="radio-desc">{{ t('document.document.documentAudit.rejectDesc') }}</div>
+                  </div>
+                </div>
+              </el-radio>
+            </el-radio-group>
+          </el-form-item>
+
+          <!-- 驳回原因 -->
+          <el-form-item v-if="auditForm.result === '2'" :label="t('document.document.auditForm.reason')" prop="reason">
+            <el-input
+              v-model="auditForm.reason"
+              type="textarea"
+              :rows="6"
+              :placeholder="t('document.document.auditForm.reasonPlaceholder')"
+              maxlength="500"
+              show-word-limit
+            />
+          </el-form-item>
+
+          <!-- 审核提示 -->
+          <el-alert v-if="auditForm.result === '3'" :title="t('document.document.documentAudit.passAlert')" type="success" :closable="false" show-icon />
+          <el-alert v-if="auditForm.result === '2'" :title="t('document.document.documentAudit.rejectAlert')" type="warning" :closable="false" show-icon />
+        </el-form>
+      </div>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="handleCancel" size="large">
+          <el-icon><Close /></el-icon>
+          {{ t('document.document.button.cancel') }}
+        </el-button>
+        <el-button type="primary" @click="submitForm" :loading="loading" size="large">
+          <el-icon><Check /></el-icon>
+          {{ t('document.document.button.submit') }}
+        </el-button>
+      </div>
+    </template>
+  </el-dialog>
+
+  <!-- 历史版本对话框 -->
+  <el-dialog v-model="showVersionDialog" :title="t('document.document.documentAudit.historyVersions')" width="800px" append-to-body destroy-on-close">
+    <el-table :data="versionList" v-loading="loadingVersions" stripe>
+      <el-table-column prop="version" :label="t('document.document.documentAudit.versionNumber')" width="150" align="center" />
+      <el-table-column prop="createTime" :label="t('document.document.documentAudit.createTime')" min-width="180" align="center" />
+      <el-table-column prop="updateTime" :label="t('document.document.documentAudit.updateTime')" min-width="180" align="center" />
+      <el-table-column :label="t('document.document.documentAudit.action')" width="120" align="center" fixed="right">
+        <template #default="{ row }">
+          <el-button type="primary" size="small" @click="handleSelectVersion(row.version)"> {{ t('document.document.documentAudit.select') }} </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <template #footer>
+      <el-button @click="showVersionDialog = false">{{ t('document.document.button.cancel') }}</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, watch, nextTick, onBeforeUnmount } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { FormInstance } from 'element-plus';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import { Document, Edit, InfoFilled, CircleCheck, CircleClose, Close, Check, Loading, Upload, Picture, Delete, Clock } from '@element-plus/icons-vue';
+import { useUserStore } from '@/store/modules/user';
+import { cleanDocumentComments, getFileVersionList, getFinalFile, initWpsDocument, cancelWpsDocument, type FileVersion } from '@/api/wps/save';
+
+interface Document {
+  id: number | string;
+  name?: string;
+  ossId?: number | string;
+  fileName?: string;
+  url?: string;
+}
+
+interface AuditData {
+  documentId: number | string;
+  result: number;
+  rejectReason?: string;
+  ossId?: number | string; // 最终的 ossId
+}
+
+interface Props {
+  modelValue: boolean;
+  document?: Document | null;
+  title?: string;
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: boolean): void;
+  (e: 'submit', data: AuditData): void; // 提交审核数据
+}
+
+const props = defineProps<Props>();
+const emit = defineEmits<Emits>();
+
+const { t } = useI18n();
+const userStore = useUserStore();
+
+const dialogVisible = ref(false);
+const loading = ref(false);
+const auditFormRef = ref<FormInstance>();
+
+// WPS 编辑器相关
+const wpsContainerRef = ref<HTMLDivElement>();
+const wpsLoading = ref(false);
+const wpsError = ref('');
+const isDragging = ref(false);
+const copyingAvatar = ref(false);
+const cleaningComments = ref(false);
+const currentVersion = ref(1); // 当前文档版本号
+const showVersionDialog = ref(false); // 显示历史版本对话框
+const versionList = ref<FileVersion[]>([]); // 历史版本列表
+const loadingVersions = ref(false); // 加载历史版本中
+let wpsInstance: any = null;
+let dragCounter = 0; // 用于跟踪拖拽进入/离开次数
+
+// WPS 配置
+const WPS_APP_ID = 'SX20260105YMMIXV';
+
+// 审核表单数据
+const auditForm = ref({
+  id: 0 as number | string,
+  result: '3',
+  reason: ''
+});
+
+// 审核表单验证规则
+const auditRules = reactive({
+  result: [
+    {
+      required: true,
+      message: t('document.document.auditRule.resultRequired'),
+      trigger: 'change'
+    }
+  ],
+  reason: [
+    {
+      required: true,
+      message: t('document.document.auditRule.reasonRequired'),
+      trigger: 'blur'
+    }
+  ]
+});
+
+// 获取文件类型
+const getFileType = (fileName: string) => {
+  const name = fileName.toLowerCase();
+  if (name.endsWith('.docx') || name.endsWith('.doc')) return 'w';
+  if (name.endsWith('.xlsx') || name.endsWith('.xls')) return 's';
+  if (name.endsWith('.pptx') || name.endsWith('.ppt')) return 'p';
+  if (name.endsWith('.pdf')) return 'f';
+  return 'w';
+};
+
+// 初始化 WPS 编辑器
+// shouldCallInitApi: 是否需要调用后端初始化接口(只在 dialog 首次打开时为 true)
+const initWpsEditor = async (shouldCallInitApi = false) => {
+  if (!wpsContainerRef.value || !props.document?.ossId) {
+    return;
+  }
+
+  try {
+    wpsLoading.value = true;
+    wpsError.value = '';
+
+    // 检查 WPS SDK
+    if (!(window as any).WebOfficeSDK) {
+      wpsError.value = 'WPS SDK 未加载';
+      wpsLoading.value = false;
+      return;
+    }
+
+    const WebOfficeSDK = (window as any).WebOfficeSDK;
+
+    // 获取文件类型
+    const officeType = getFileType(props.document.fileName || '', props.document.url);
+
+    // 只在 dialog 首次打开时调用后端接口初始化文档
+    if (shouldCallInitApi) {
+      console.log('[WPS] 调用后端初始化接口,ossId:', props.document.ossId);
+      try {
+        const initRes = await initWpsDocument(props.document.ossId);
+        const backendVersion = initRes.data; // data 直接是版本号
+        currentVersion.value = backendVersion;
+        console.log('[WPS] 后端返回版本号:', backendVersion);
+      } catch (err: any) {
+        console.error('[WPS] 调用后端初始化接口失败:', err);
+        wpsLoading.value = false;
+        wpsError.value = '初始化文档失败';
+
+        // 显示错误提示
+        ElMessage.error('初始化文档失败: ' + (err.message || '未知错误'));
+
+        // 关闭对话框
+        setTimeout(() => {
+          dialogVisible.value = false;
+        }, 1500);
+
+        return; // 终止初始化流程
+      }
+    } else {
+      console.log('[WPS] 使用当前版本号重新初始化编辑器,版本:', currentVersion.value);
+    }
+
+    // 使用 ossId + 当前版本号组成 fileId
+    const fileId = `${props.document.ossId}_${currentVersion.value}`;
+
+    console.log('[WPS] 初始化配置:', {
+      appId: WPS_APP_ID,
+      officeType: officeType,
+      fileId: fileId,
+      ossId: props.document.ossId,
+      version: currentVersion.value,
+      fileName: props.document.fileName,
+      fileUrl: props.document.url
+    });
+
+    // 标准初始化配置(按官方文档)
+    const config = {
+      // 必需参数
+      appId: WPS_APP_ID,
+      officeType: officeType,
+      fileId: fileId,
+
+      // 可选参数
+      mount: wpsContainerRef.value,
+      // 指定当前用户ID为编辑者ID
+      userId: String(userStore.userId)
+    };
+
+    // 初始化 WPS 编辑器
+    wpsInstance = WebOfficeSDK.init(config);
+
+    wpsLoading.value = false;
+    console.log('[WPS] 编辑器初始化成功');
+  } catch (err: any) {
+    console.error('[WPS] 初始化失败:', err);
+    wpsError.value = err.message || '初始化失败';
+    wpsLoading.value = false;
+  }
+};
+
+// 销毁 WPS 编辑器
+const destroyWpsEditor = () => {
+  if (wpsInstance) {
+    try {
+      if (wpsInstance.destroy) {
+        wpsInstance.destroy();
+      }
+      wpsInstance = null;
+      console.log('[WPS] 编辑器已销毁');
+    } catch (err) {
+      console.error('[WPS] 销毁编辑器失败:', err);
+    }
+  }
+};
+
+// 保存文档
+const saveWpsDocument = async () => {
+  if (!wpsInstance) {
+    return null;
+  }
+
+  try {
+    console.log('[WPS] 开始保存文档');
+    const result = await wpsInstance.save();
+    console.log('[WPS] 保存成功:', result);
+    return result;
+  } catch (err: any) {
+    console.error('[WPS] 保存失败:', err);
+    throw err;
+  }
+};
+
+// 拖拽进入
+const handleDragEnter = (e: DragEvent) => {
+  dragCounter++;
+  if (dragCounter === 1) {
+    isDragging.value = true;
+  }
+};
+
+// 拖拽离开
+const handleDragLeave = (e: DragEvent) => {
+  dragCounter--;
+  if (dragCounter === 0) {
+    isDragging.value = false;
+  }
+};
+
+// 拖拽悬停
+const handleDragOver = (e: DragEvent) => {
+  e.preventDefault();
+  e.stopPropagation();
+};
+
+// 处理拖放
+const handleDrop = async (e: DragEvent) => {
+  e.preventDefault();
+  e.stopPropagation();
+
+  isDragging.value = false;
+  dragCounter = 0;
+
+  if (!wpsInstance) {
+    ElMessage.warning(t('document.document.documentAudit.wpsNotInitialized'));
+    return;
+  }
+
+  // 处理图片拖放
+  const files = e.dataTransfer?.files;
+  if (!files || files.length === 0) {
+    return;
+  }
+
+  // 只处理第一个文件
+  const file = files[0];
+
+  // 检查是否是图片
+  if (!file.type.startsWith('image/')) {
+    ElMessage.warning(t('document.document.documentAudit.onlyImageSupported'));
+    return;
+  }
+
+  try {
+    console.log('[WPS] 开始插入图片:', file.name);
+
+    // 读取图片为 base64
+    const reader = new FileReader();
+    reader.onload = async (event) => {
+      const base64 = event.target?.result as string;
+
+      try {
+        // 获取 WPS Application 对象
+        const app = await wpsInstance.Application;
+
+        if (!app) {
+          ElMessage.error('无法获取 WPS Application 对象');
+          return;
+        }
+
+        // 根据文件类型插入图片
+        const officeType = getFileType(props.document?.fileName || '');
+
+        if (officeType === 'w') {
+          // Word 文档:插入图片到光标位置
+          const selection = await app.ActiveDocument.Application.Selection;
+          await selection.InlineShapes.AddPicture(base64);
+          ElMessage.success(t('document.document.documentAudit.imageInserted'));
+        } else if (officeType === 's') {
+          // Excel 表格:插入图片到当前单元格
+          const activeSheet = await app.ActiveSheet;
+          const activeCell = await app.ActiveCell;
+          const row = await activeCell.Row;
+          const col = await activeCell.Column;
+          await activeSheet.Shapes.AddPicture(base64, false, true, col * 64, row * 20, 200, 150);
+          ElMessage.success(t('document.document.documentAudit.imageInserted'));
+        } else if (officeType === 'p') {
+          // PowerPoint:插入图片到当前幻灯片
+          const activeSlide = await app.ActivePresentation.Slides.Item(await app.ActiveWindow.Selection.SlideRange.SlideIndex);
+          await activeSlide.Shapes.AddPicture(base64, false, true, 100, 100, 200, 150);
+          ElMessage.success(t('document.document.documentAudit.imageInserted'));
+        } else {
+          ElMessage.warning(t('document.document.documentAudit.currentTypeNotSupported'));
+        }
+
+        console.log('[WPS] 图片插入成功');
+      } catch (err: any) {
+        console.error('[WPS] 插入图片失败:', err);
+        ElMessage.error(t('document.document.documentAudit.insertImageFailed') + ': ' + err.message);
+      }
+    };
+
+    reader.onerror = () => {
+      ElMessage.error(t('document.document.documentAudit.readImageFailed'));
+    };
+
+    reader.readAsDataURL(file);
+  } catch (err: any) {
+    console.error('[WPS] 处理拖放失败:', err);
+    ElMessage.error(t('document.document.documentAudit.dropHandleFailed'));
+  }
+};
+
+// 复制头像到剪贴板
+const handleCopyAvatar = async () => {
+  try {
+    // 先让用户选择审核结果
+    let reviewResult: string;
+
+    try {
+      await ElMessageBox.confirm(t('document.document.copySignature.selectResultMessage'), t('document.document.copySignature.selectResult'), {
+        confirmButtonText: t('document.document.copySignature.pass'),
+        cancelButtonText: t('document.document.copySignature.reject'),
+        distinguishCancelAndClose: true,
+        closeOnClickModal: false,
+        closeOnPressEscape: false,
+        type: 'info'
+      });
+      // 点击确认按钮 = 通过
+      reviewResult = 'pass';
+    } catch (action) {
+      if (action === 'cancel') {
+        // 点击取消按钮 = 驳回
+        reviewResult = 'reject';
+      } else {
+        // 点击关闭或按 ESC = 取消操作
+        console.log('[签名] 用户取消选择');
+        return;
+      }
+    }
+
+    copyingAvatar.value = true;
+    console.log('[签名] 开始生成审核信息图片,审核结果:', reviewResult);
+
+    // 获取当前用户昵称
+    const reviewerName = userStore.nickname || userStore.name || t('document.document.copySignature.unknown');
+
+    // 获取当前时间
+    const now = new Date();
+    const year = now.getFullYear();
+    const month = now.getMonth() + 1;
+    const day = now.getDate();
+    const hour = now.getHours();
+    const minute = now.getMinutes();
+    const reviewTime = t('document.document.copySignature.timeFormat', { year, month, day, hour, minute });
+
+    // 审核结果文本
+    const resultText = reviewResult === 'pass' ? t('document.document.copySignature.passText') : t('document.document.copySignature.rejectText');
+
+    // 根据审核结果确定颜色
+    const color = reviewResult === 'pass' ? '#00aa00' : '#ff0000';
+
+    console.log('[签名] 审核结果:', reviewResult, '颜色:', color, '结果文本:', resultText);
+
+    // 创建 canvas
+    const canvas = document.createElement('canvas');
+    const ctx = canvas.getContext('2d');
+
+    if (!ctx) {
+      ElMessage.error(t('document.document.copySignature.canvasNotSupported'));
+      copyingAvatar.value = false;
+      return;
+    }
+
+    // 设置 canvas 尺寸
+    const width = 300;
+    const height = 100;
+    canvas.width = width;
+    canvas.height = height;
+
+    // 不填充背景,保持透明
+
+    // 绘制边框(5px 粗,根据审核结果变色)
+    ctx.strokeStyle = color;
+    ctx.lineWidth = 5;
+    ctx.strokeRect(2.5, 2.5, width - 5, height - 5);
+
+    // 设置字体样式(根据审核结果变色)
+    ctx.fillStyle = color;
+    ctx.textBaseline = 'middle';
+
+    // 第一行:审核人(左边)和审核结果(右边)
+    ctx.font = 'bold 18px Arial, "Microsoft YaHei", sans-serif';
+    const reviewerText = t('document.document.copySignature.reviewer', { name: reviewerName });
+    ctx.fillText(reviewerText, 20, height / 3);
+
+    // 审核结果靠右显示
+    const resultWidth = ctx.measureText(resultText).width;
+    ctx.fillText(resultText, width - resultWidth - 20, height / 3);
+
+    // 第二行:审核时间(居左对齐,加粗)
+    ctx.font = 'bold 16px Arial, "Microsoft YaHei", sans-serif';
+    const line2 = t('document.document.copySignature.reviewTime', { time: reviewTime });
+    ctx.fillText(line2, 20, (height * 2) / 3);
+
+    console.log('[签名] Canvas 绘制完成');
+
+    // 将 canvas 转换为 Blob
+    canvas.toBlob(async (blob) => {
+      if (!blob) {
+        ElMessage.error(t('document.document.copySignature.generateFailed'));
+        copyingAvatar.value = false;
+        return;
+      }
+
+      console.log('[签名] 图片生成成功,大小:', blob.size);
+
+      try {
+        // 复制到剪贴板
+        await navigator.clipboard.write([
+          new ClipboardItem({
+            'image/png': blob
+          })
+        ]);
+
+        ElMessage({
+          type: 'success',
+          message: t('document.document.copySignature.copySuccess'),
+          duration: 3000
+        });
+
+        // 显示使用提示
+        setTimeout(() => {
+          ElMessage({
+            type: 'info',
+            dangerouslyUseHTMLString: true,
+            message: t('document.document.copySignature.usageHint'),
+            duration: 6000,
+            showClose: true
+          });
+        }, 500);
+
+        console.log('[签名] 复制成功');
+      } catch (err: any) {
+        console.error('[签名] 复制失败:', err);
+
+        // 根据错误类型显示不同提示
+        if (err.message?.includes('clipboard') || err.message?.includes('Clipboard')) {
+          ElMessage({
+            type: 'warning',
+            dangerouslyUseHTMLString: true,
+            message: t('document.document.copySignature.browserNotSupported'),
+            duration: 6000,
+            showClose: true
+          });
+        } else {
+          ElMessage({
+            type: 'error',
+            message: t('document.document.copySignature.copyFailed', { error: err.message || t('document.document.copySignature.unknownError') }),
+            duration: 5000
+          });
+        }
+      } finally {
+        copyingAvatar.value = false;
+      }
+    }, 'image/png');
+  } catch (err: any) {
+    console.error('[签名] 生成图片失败:', err);
+    ElMessage.error(t('document.document.copySignature.generateFailed') + ': ' + (err.message || t('document.document.copySignature.unknownError')));
+    copyingAvatar.value = false;
+  }
+};
+
+// 清空批注
+const handleCleanComments = async () => {
+  if (!props.document?.id || !props.document?.ossId) {
+    ElMessage.warning(t('document.document.documentAudit.documentInfoIncomplete'));
+    return;
+  }
+
+  try {
+    await ElMessageBox.confirm(t('document.document.documentAudit.cleanCommentsConfirm'), t('document.document.documentAudit.cleanCommentsTitle'), {
+      confirmButtonText: t('document.document.message.confirmButton'),
+      cancelButtonText: t('document.document.message.cancelButton'),
+      type: 'warning'
+    });
+
+    cleaningComments.value = true;
+    console.log('[清空批注] 开始清空文档批注,文档ID:', props.document.id, 'ossId:', props.document.ossId, '当前版本:', currentVersion.value);
+
+    // 调用后端接口清空批注,获取新版本号
+    const res = await cleanDocumentComments(props.document.ossId);
+    const newVersion = res.data; // 后端返回的新版本号
+    currentVersion.value = newVersion;
+
+    ElMessage.success(t('document.document.documentAudit.cleanCommentsSuccess'));
+    console.log('[清空批注] 批注清空成功,新版本号:', newVersion);
+
+    // 销毁当前 WPS 编辑器
+    destroyWpsEditor();
+
+    // 等待 DOM 更新
+    await nextTick();
+
+    // 使用新版本号重新初始化 WPS 编辑器(不调用后端初始化接口)
+    await initWpsEditor(false);
+
+    console.log('[清空批注] WPS 编辑器已使用新版本重新初始化,fileId:', `${props.document.ossId}_${currentVersion.value}`);
+  } catch (err: any) {
+    if (err === 'cancel') {
+      console.log('[清空批注] 用户取消操作');
+      return;
+    }
+
+    console.error('[清空批注] 清空失败:', err);
+    ElMessage.error(t('document.document.documentAudit.cleanCommentsFailed') + ': ' + (err.message || t('document.document.copySignature.unknownError')));
+  } finally {
+    cleaningComments.value = false;
+  }
+};
+
+// 查看历史版本
+const handleViewVersions = async () => {
+  if (!props.document?.ossId) {
+    ElMessage.warning(t('document.document.documentAudit.documentInfoIncomplete'));
+    return;
+  }
+
+  try {
+    loadingVersions.value = true;
+    showVersionDialog.value = true;
+
+    console.log('[历史版本] 获取历史版本列表,ossId:', props.document.ossId);
+
+    const res = await getFileVersionList(props.document.ossId);
+    versionList.value = res.data || [];
+
+    console.log('[历史版本] 获取成功,版本数量:', versionList.value.length);
+  } catch (err: any) {
+    console.error('[历史版本] 获取失败:', err);
+    ElMessage.error(t('document.document.documentAudit.getVersionsFailed') + ': ' + (err.message || t('document.document.copySignature.unknownError')));
+    showVersionDialog.value = false;
+  } finally {
+    loadingVersions.value = false;
+  }
+};
+
+// 选择历史版本
+const handleSelectVersion = async (version: number) => {
+  if (!props.document?.ossId) {
+    ElMessage.warning(t('document.document.documentAudit.documentInfoIncomplete'));
+    return;
+  }
+
+  try {
+    console.log('[历史版本] 选择版本:', version, 'ossId:', props.document.ossId);
+
+    // 更新当前版本号
+    currentVersion.value = version;
+
+    // 关闭历史版本对话框
+    showVersionDialog.value = false;
+
+    // 销毁当前 WPS 编辑器
+    destroyWpsEditor();
+
+    // 等待 DOM 更新
+    await nextTick();
+
+    // 使用选择的版本号重新初始化 WPS 编辑器(不调用后端初始化接口)
+    await initWpsEditor(false);
+
+    ElMessage.success(t('document.document.documentAudit.switchVersionSuccess', { version }));
+    console.log('[历史版本] WPS 编辑器已切换到版本:', version, 'fileId:', `${props.document.ossId}_${version}`);
+  } catch (err: any) {
+    console.error('[历史版本] 切换版本失败:', err);
+    ElMessage.error(t('document.document.documentAudit.switchVersionFailed') + ': ' + (err.message || t('document.document.copySignature.unknownError')));
+  }
+};
+
+// 监听 modelValue 变化
+watch(
+  () => props.modelValue,
+  (val) => {
+    dialogVisible.value = val;
+    if (val && props.document) {
+      // 重置版本号为 1
+      currentVersion.value = 1;
+
+      auditForm.value = {
+        id: props.document.id,
+        result: '3',
+        reason: ''
+      };
+      nextTick(() => {
+        auditFormRef.value?.clearValidate();
+        // 自动初始化 WPS 编辑器,使用版本号 1(调用后端初始化接口)
+        if (props.document?.ossId) {
+          console.log('[WPS] 对话框打开,初始化编辑器,版本号:', currentVersion.value);
+          initWpsEditor(true);
+        }
+      });
+    }
+  }
+);
+
+// 监听dialogVisible变化
+watch(dialogVisible, async (val) => {
+  emit('update:modelValue', val);
+  if (!val) {
+    // 对话框关闭时,调用取消接口
+    if (props.document?.ossId) {
+      try {
+        console.log('[WPS] 对话框关闭,调用取消接口,ossId:', props.document.ossId);
+        await cancelWpsDocument(props.document.ossId);
+        console.log('[WPS] 取消接口调用成功');
+      } catch (err) {
+        console.error('[WPS] 取消接口调用失败:', err);
+        // 取消接口失败不影响关闭流程
+      }
+    }
+
+    auditForm.value = {
+      id: 0,
+      result: '3',
+      reason: ''
+    };
+    // 关闭对话框时重置版本号
+    currentVersion.value = 1;
+    destroyWpsEditor();
+  }
+});
+
+// 取消操作
+const handleCancel = () => {
+  dialogVisible.value = false;
+};
+
+// 提交表单
+const submitForm = () => {
+  auditFormRef.value?.validate(async (valid: boolean) => {
+    if (valid) {
+      loading.value = true;
+      try {
+        // 如果使用了 WPS 编辑器,先保存文档
+        let savedFileInfo = null;
+        if (wpsInstance) {
+          try {
+            savedFileInfo = await saveWpsDocument();
+            if (savedFileInfo) {
+              console.log('[审核提交] 文档保存成功:', savedFileInfo);
+              ElMessage.success(t('document.document.documentAudit.documentSaved'));
+            }
+          } catch (err) {
+            console.error('[审核提交] 保存文档失败:', err);
+            ElMessage.warning(t('document.document.documentAudit.documentSaveFailed'));
+          }
+        }
+
+        // 获取当前 fileId
+        const currentFileId = `${props.document?.ossId}_${currentVersion.value}`;
+        console.log('[审核提交] 当前 fileId:', currentFileId);
+
+        // 调用接口获取最终文档信息
+        let finalOssId = props.document?.ossId;
+        try {
+          console.log('[审核提交] 获取最终文档信息...');
+          const finalFileRes = await getFinalFile(currentFileId);
+          finalOssId = finalFileRes.data.ossId;
+          console.log('[审核提交] 获取到最终 ossId:', finalOssId);
+        } catch (err) {
+          console.error('[审核提交] 获取最终文档信息失败:', err);
+          ElMessage.warning(t('document.document.documentAudit.getFinalFileFailed'));
+        }
+
+        // 构建审核数据
+        const auditData: AuditData = {
+          documentId: auditForm.value.id,
+          result: parseInt(auditForm.value.result),
+          rejectReason: auditForm.value.reason,
+          ossId: finalOssId // 使用最终的 ossId
+        };
+
+        console.log('[审核提交] 提交审核数据到父组件:', auditData);
+
+        // 关闭对话框
+        dialogVisible.value = false;
+
+        // 通过 emit 将审核数据传递给父组件
+        emit('submit', auditData);
+      } catch (error) {
+        console.error('[审核提交] 处理失败:', error);
+        ElMessage.error(t('document.document.documentAudit.processFailed') + ': ' + (error as any).message || t('document.document.copySignature.unknownError'));
+      } finally {
+        loading.value = false;
+      }
+    }
+  });
+};
+
+// 组件卸载前清理
+onBeforeUnmount(() => {
+  destroyWpsEditor();
+});
+</script>
+
+<style scoped lang="scss">
+.audit-dialog {
+  :deep(.el-dialog__header) {
+    padding: 20px 24px;
+    border-bottom: 1px solid #e4e7ed;
+    margin: 0;
+
+    .el-dialog__title {
+      font-size: 18px;
+      font-weight: 600;
+      color: #303133;
+    }
+  }
+
+  :deep(.el-dialog__body) {
+    padding: 0;
+    height: calc(80vh - 140px);
+    overflow: hidden;
+  }
+
+  :deep(.el-dialog__footer) {
+    padding: 16px 24px;
+    border-top: 1px solid #e4e7ed;
+  }
+}
+
+.audit-container {
+  display: flex;
+  height: 100%;
+  gap: 1px;
+  background: #e4e7ed;
+}
+
+.preview-section {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  min-width: 0;
+  position: relative;
+}
+
+.preview-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16px 20px;
+  background: #fff;
+  border-bottom: 1px solid #e4e7ed;
+  flex-shrink: 0;
+
+  .file-info {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+
+    .file-icon {
+      font-size: 24px;
+      color: #409eff;
+    }
+
+    .file-name {
+      font-size: 16px;
+      font-weight: 500;
+      color: #303133;
+      max-width: 600px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  }
+
+  .header-actions {
+    display: flex;
+    gap: 8px;
+
+    .copy-avatar-btn {
+      .el-icon {
+        margin-right: 4px;
+      }
+    }
+  }
+}
+
+.preview-container {
+  position: absolute;
+  top: 68px;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  overflow: hidden;
+
+  .wps-container {
+    width: 100%;
+    height: 100%;
+    position: relative;
+  }
+
+  .document-iframe {
+    width: 100%;
+    height: 100%;
+    border: none;
+  }
+
+  .drag-overlay {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background: rgba(64, 158, 255, 0.1);
+    border: 2px dashed #409eff;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 9999;
+    pointer-events: none;
+
+    .drag-hint {
+      text-align: center;
+
+      .drag-icon {
+        font-size: 64px;
+        color: #409eff;
+        margin-bottom: 16px;
+      }
+
+      p {
+        font-size: 18px;
+        font-weight: 500;
+        color: #409eff;
+      }
+    }
+  }
+
+  .loading-state {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    text-align: center;
+
+    .el-icon {
+      font-size: 48px;
+      color: #409eff;
+      margin-bottom: 16px;
+    }
+
+    p {
+      font-size: 14px;
+      color: #909399;
+    }
+  }
+
+  :deep(.el-result),
+  :deep(.el-empty) {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    width: 100%;
+  }
+}
+
+.audit-section {
+  width: 400px;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  flex-shrink: 0;
+}
+
+.section-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 16px 20px;
+  background: #f5f7fa;
+  border-bottom: 1px solid #e4e7ed;
+  font-size: 15px;
+  font-weight: 500;
+  color: #303133;
+
+  .el-icon {
+    font-size: 18px;
+    color: #409eff;
+  }
+}
+
+.audit-form {
+  flex: 1;
+  padding: 20px;
+  overflow-y: auto;
+
+  &::-webkit-scrollbar {
+    width: 6px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: #dcdfe6;
+    border-radius: 3px;
+
+    &:hover {
+      background: #c0c4cc;
+    }
+  }
+}
+
+.info-card {
+  margin-bottom: 20px;
+
+  :deep(.el-card__header) {
+    padding: 12px 16px;
+    background: #f5f7fa;
+  }
+
+  .card-header {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    font-size: 14px;
+    font-weight: 500;
+    color: #303133;
+
+    .el-icon {
+      font-size: 16px;
+      color: #409eff;
+    }
+  }
+
+  :deep(.el-card__body) {
+    padding: 16px;
+  }
+}
+
+.info-item {
+  display: flex;
+  align-items: center;
+  padding: 8px 0;
+  font-size: 14px;
+
+  &:not(:last-child) {
+    border-bottom: 1px solid #f0f2f5;
+  }
+
+  .info-label {
+    color: #909399;
+    min-width: 80px;
+  }
+
+  .info-value {
+    color: #303133;
+    flex: 1;
+    word-break: break-all;
+  }
+}
+
+.result-radio-group {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+
+  :deep(.el-radio) {
+    margin: 0;
+    padding: 0;
+    height: auto;
+
+    &.is-bordered {
+      padding: 16px;
+      border-radius: 8px;
+      border: 2px solid #dcdfe6;
+      transition: all 0.3s;
+
+      &:hover {
+        border-color: #409eff;
+      }
+
+      &.is-checked {
+        border-color: #409eff;
+        background: #ecf5ff;
+      }
+    }
+
+    .el-radio__input {
+      display: none;
+    }
+
+    .el-radio__label {
+      padding: 0;
+    }
+  }
+}
+
+.radio-content {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  width: 100%;
+
+  .radio-icon {
+    font-size: 32px;
+    flex-shrink: 0;
+
+    &.success {
+      color: #67c23a;
+    }
+
+    &.danger {
+      color: #f56c6c;
+    }
+  }
+
+  .radio-text {
+    flex: 1;
+
+    .radio-title {
+      font-size: 15px;
+      font-weight: 500;
+      color: #303133;
+      margin-bottom: 4px;
+    }
+
+    .radio-desc {
+      font-size: 13px;
+      color: #909399;
+    }
+  }
+}
+
+:deep(.el-form-item) {
+  margin-bottom: 20px;
+
+  .el-form-item__label {
+    font-weight: 500;
+    color: #303133;
+    margin-bottom: 8px;
+  }
+}
+
+:deep(.el-alert) {
+  margin-top: 12px;
+  border-radius: 6px;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+
+  .el-button {
+    min-width: 100px;
+
+    .el-icon {
+      margin-right: 4px;
+    }
+  }
+}
+
+// 响应式设计
+@media (max-width: 1400px) {
+  .audit-dialog {
+    :deep(.el-dialog) {
+      width: 95% !important;
+    }
+  }
+
+  .audit-section {
+    width: 350px;
+  }
+}
+
+@media (max-width: 1200px) {
+  .audit-container {
+    flex-direction: column;
+  }
+
+  .preview-section {
+    height: 50%;
+  }
+
+  .audit-section {
+    width: 100%;
+    height: 50%;
+  }
+}
+</style>

+ 725 - 0
src/components/QcAuditDialog/index.vue

@@ -0,0 +1,725 @@
+<template>
+  <el-dialog v-model="visible" :title="title" width="1200px" append-to-body @close="handleClose">
+    <div class="qc-audit-container">
+      <!-- 左侧:PDF 预览 -->
+      <div class="qc-preview-section">
+        <div v-if="previewLoading" v-loading="true" class="preview-loading">
+          {{ t('qc.task.auditDialog.loading') }}
+        </div>
+        <div v-else-if="previewError" class="preview-error">
+          <el-alert :title="previewError" type="error" :closable="false" />
+        </div>
+        <div v-else-if="pdfBlob" class="preview-content">
+          <VueOfficePdf :src="pdfBlob" @rendered="handleRendered" @error="handlePreviewError" />
+        </div>
+        <div v-else class="preview-empty">
+          <el-empty :description="t('qc.task.auditDialog.noDocument')" />
+        </div>
+      </div>
+
+      <!-- 右侧:审核表单 -->
+      <div class="qc-form-section">
+        <div class="qc-form-header">
+          <div class="qc-form-icon">
+            <el-icon :size="24"><Check /></el-icon>
+          </div>
+          <div class="qc-form-content">
+            <h3 class="qc-form-title">{{ t('qc.task.auditDialog.auditInfo') }}</h3>
+            <p class="qc-form-subtitle">{{ t('qc.task.auditDialog.auditInfoDesc') }}</p>
+          </div>
+        </div>
+
+        <div class="qc-form-body">
+          <el-form ref="formRef" :model="form" :rules="rules" label-position="top">
+            <el-form-item :label="t('qc.task.auditDialog.result')" prop="result" class="result-item">
+              <el-radio-group v-model="form.result" class="result-radio-group">
+                <el-radio :value="1" class="result-radio result-radio-pass" border>
+                  <div class="radio-content">
+                    <el-icon :size="20"><CircleCheck /></el-icon>
+                    <span>{{ t('qc.task.auditDialog.pass') }}</span>
+                  </div>
+                </el-radio>
+                <el-radio :value="2" class="result-radio result-radio-reject" border>
+                  <div class="radio-content">
+                    <el-icon :size="20"><CircleClose /></el-icon>
+                    <span>{{ t('qc.task.auditDialog.reject') }}</span>
+                  </div>
+                </el-radio>
+              </el-radio-group>
+            </el-form-item>
+
+            <transition name="el-fade-in">
+              <div v-if="form.result === 2" class="reject-form-items">
+                <el-form-item :label="t('qc.task.auditDialog.rejectionType')" prop="rejectionType">
+                  <el-select v-model="form.rejectionType" :placeholder="t('qc.task.auditDialog.rejectionTypePlaceholder')" style="width: 100%">
+                    <el-option v-for="dict in qcQuestionType" :key="dict.value" :label="parseI18nName(dict.label)"
+                      :value="dict.value" />
+                  </el-select>
+                </el-form-item>
+
+                <el-form-item :label="t('qc.task.auditDialog.opinion')" prop="opinion">
+                  <el-input v-model="form.opinion" type="textarea" :rows="3" :placeholder="t('qc.task.auditDialog.opinionPlaceholder')" />
+                </el-form-item>
+
+                <el-form-item :label="t('qc.task.auditDialog.designatedDealer')" prop="designatedDealer">
+                  <el-select v-model="form.designatedDealer" filterable remote reserve-keyword :placeholder="t('qc.task.auditDialog.designatedDealerPlaceholder')"
+                    :remote-method="searchDealers" :loading="dealerSearchLoading" style="width: 100%">
+                    <el-option v-for="dealer in dealerOptions" :key="dealer.id"
+                      :label="`${dealer.name} / ${dealer.dept} --- ${dealer.phoneNumber}`" :value="dealer.id" />
+                  </el-select>
+                </el-form-item>
+
+                <el-form-item :label="t('qc.task.auditDialog.deadline')" prop="deadline">
+                  <el-date-picker v-model="form.deadline" type="date" value-format="YYYY-MM-DD" :placeholder="t('qc.task.auditDialog.deadlinePlaceholder')"
+                    style="width: 100%" />
+                </el-form-item>
+              </div>
+            </transition>
+          </el-form>
+        </div>
+
+        <div class="qc-form-footer">
+          <el-button size="large" @click="handleClose" class="footer-btn">
+            <el-icon><Close /></el-icon>
+            <span>{{ t('qc.task.auditDialog.cancel') }}</span>
+          </el-button>
+          <el-button type="primary" size="large" :loading="loading" @click="handleSubmit" class="footer-btn footer-btn-primary">
+            <el-icon v-if="!loading"><Check /></el-icon>
+            <span>{{ t('qc.task.auditDialog.confirm') }}</span>
+          </el-button>
+        </div>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed, nextTick, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { DocumentChecked, CircleCheck, CircleClose, Check, Close } from '@element-plus/icons-vue';
+import type { FormInstance } from 'element-plus';
+import { parseI18nName } from '@/utils/i18n';
+import VueOfficePdf from '@vue-office/pdf';
+import axios from 'axios';
+import { globalHeaders } from '@/utils/request';
+
+const baseURL = import.meta.env.VITE_APP_BASE_API;
+
+interface TaskDetail {
+  id?: number;
+  documentName?: string;
+  executorName?: string;
+  executionTime?: string;
+  finishTime?: string;
+  note?: string;
+  actualDocument?: string | number;
+  [key: string]: any;
+}
+
+interface AuditFormData {
+  result: number;
+  rejectionType?: string;
+  opinion?: string;
+  designatedDealer?: number;
+  deadline?: string;
+}
+
+interface Dealer {
+  id: number;
+  name: string;
+  dept: string;
+  phoneNumber: string;
+}
+
+interface Props {
+  modelValue: boolean;
+  taskDetail: TaskDetail | null;
+  title?: string;
+  qcQuestionType?: any[];
+  dealerSearchMethod?: (query: string) => Promise<Dealer[]>;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  title: '',
+  qcQuestionType: () => [],
+  dealerSearchMethod: undefined
+});
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean];
+  submit: [data: AuditFormData];
+}>();
+
+const { t } = useI18n();
+const formRef = ref<FormInstance>();
+const loading = ref(false);
+const dealerSearchLoading = ref(false);
+const dealerOptions = ref<Dealer[]>([]);
+const previewLoading = ref(false);
+const previewError = ref('');
+const pdfBlob = ref<ArrayBuffer | null>(null);
+let dealerSearchTimer: NodeJS.Timeout | null = null;
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+});
+
+const title = computed(() => props.title || t('qc.task.auditDialog.title'));
+
+const form = ref<AuditFormData>({
+  result: 1,
+  rejectionType: undefined,
+  opinion: undefined,
+  designatedDealer: undefined,
+  deadline: undefined
+});
+
+const rules = reactive({
+  result: [
+    {
+      required: true,
+      message: t('qc.task.auditRule.resultRequired'),
+      trigger: 'change'
+    }
+  ],
+  rejectionType: [
+    {
+      required: true,
+      message: t('qc.task.auditRule.rejectionTypeRequired'),
+      trigger: 'change'
+    }
+  ],
+  opinion: [
+    {
+      required: true,
+      message: t('qc.task.auditRule.opinionRequired'),
+      trigger: 'blur'
+    }
+  ],
+  designatedDealer: [
+    {
+      required: true,
+      message: t('qc.task.auditRule.designatedDealerRequired'),
+      trigger: 'change'
+    }
+  ],
+  deadline: [
+    {
+      required: true,
+      message: t('qc.task.auditRule.deadlineRequired'),
+      trigger: 'change'
+    }
+  ]
+});
+
+// 监听对话框打开和 taskDetail 变化
+watch(
+  [() => props.modelValue, () => props.taskDetail],
+  ([isVisible, newDetail]) => {
+    if (isVisible && newDetail) {
+      form.value = {
+        result: 1,
+        rejectionType: undefined,
+        opinion: undefined,
+        designatedDealer: undefined,
+        deadline: undefined
+      };
+      dealerOptions.value = [];
+      nextTick(() => {
+        formRef.value?.clearValidate();
+      });
+      
+      // 加载 PDF 预览
+      loadPdfPreview(newDetail);
+    } else if (!isVisible) {
+      // 对话框关闭时清理状态
+      dealerOptions.value = [];
+      dealerSearchLoading.value = false;
+      pdfBlob.value = null;
+      previewError.value = '';
+      previewLoading.value = false;
+      if (dealerSearchTimer) {
+        clearTimeout(dealerSearchTimer);
+      }
+    }
+  },
+  { immediate: true }
+);
+
+// 搜索处理人
+const searchDealers = async (query: string) => {
+  if (!query || query.trim() === '') {
+    dealerOptions.value = [];
+    return;
+  }
+
+  if (dealerSearchTimer) {
+    clearTimeout(dealerSearchTimer);
+  }
+
+  dealerSearchTimer = setTimeout(async () => {
+    if (!props.dealerSearchMethod) {
+      return;
+    }
+
+    dealerSearchLoading.value = true;
+    try {
+      dealerOptions.value = await props.dealerSearchMethod(query);
+    } catch (error) {
+      console.error('搜索处理人失败:', error);
+      dealerOptions.value = [];
+    } finally {
+      dealerSearchLoading.value = false;
+    }
+  }, 300);
+};
+
+// 加载 PDF 预览
+const loadPdfPreview = async (detail: TaskDetail) => {
+  previewLoading.value = true;
+  previewError.value = '';
+  pdfBlob.value = null;
+
+  if (!detail.actualDocument) {
+    previewError.value = t('qc.task.auditDialog.documentNotExist');
+    previewLoading.value = false;
+    return;
+  }
+
+  try {
+    // 使用统一的文件下载接口获取 PDF 文件
+    const response = await axios({
+      method: 'post',
+      url: `${baseURL}/document/document/download/${detail.actualDocument}`,
+      responseType: 'arraybuffer',
+      headers: globalHeaders()
+    });
+
+    pdfBlob.value = response.data;
+    previewLoading.value = false;
+  } catch (error) {
+    console.error('下载PDF文件失败:', error);
+    previewError.value = t('qc.task.auditDialog.loadFailed');
+    previewLoading.value = false;
+  }
+};
+
+// PDF 渲染完成
+const handleRendered = () => {
+  previewLoading.value = false;
+};
+
+// PDF 预览错误
+const handlePreviewError = (error: any) => {
+  previewLoading.value = false;
+  previewError.value = t('qc.task.auditDialog.previewError');
+  console.error('PDF预览错误:', error);
+};
+
+const handleClose = () => {
+  visible.value = false;
+  formRef.value?.resetFields();
+  form.value = {
+    result: 1,
+    rejectionType: undefined,
+    opinion: undefined,
+    designatedDealer: undefined,
+    deadline: undefined
+  };
+  dealerOptions.value = [];
+};
+
+const handleSubmit = async () => {
+  if (!formRef.value) return;
+
+  // 如果选择驳回,需要验证驳回相关字段
+  if (form.value.result === 2) {
+    try {
+      await formRef.value.validate();
+    } catch (error) {
+      return;
+    }
+  }
+
+  loading.value = true;
+  try {
+    const submitData: AuditFormData = {
+      result: form.value.result
+    };
+
+    // 驳回时添加额外字段
+    if (form.value.result === 2) {
+      submitData.rejectionType = form.value.rejectionType;
+      submitData.opinion = form.value.opinion;
+      submitData.designatedDealer = form.value.designatedDealer;
+      submitData.deadline = form.value.deadline;
+    }
+
+    // 触发提交事件,由父组件处理实际的审核请求
+    emit('submit', submitData);
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 暴露方法供父组件调用
+const closeDialog = () => {
+  handleClose();
+};
+
+defineExpose({
+  closeDialog
+});
+</script>
+
+<style scoped lang="scss">
+.qc-audit-container {
+  display: flex;
+  gap: 20px;
+  height: 70vh;
+  min-height: 500px;
+
+  .qc-preview-section {
+    flex: 1;
+    border: 1px solid #dcdfe6;
+    border-radius: 8px;
+    overflow: hidden;
+    background-color: #f5f7fa;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+
+    .preview-loading,
+    .preview-error,
+    .preview-empty {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      height: 100%;
+    }
+
+    .preview-content {
+      height: 100%;
+      overflow: auto;
+      background-color: #fff;
+
+      :deep(.vue-office-pdf) {
+        height: 100%;
+      }
+    }
+  }
+
+  .qc-info-section {
+    flex: 1;
+    border: 1px solid #dcdfe6;
+    border-radius: 8px;
+    overflow: hidden;
+    background-color: #f5f7fa;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+    display: flex;
+    flex-direction: column;
+
+    .qc-info-header {
+      display: flex;
+      align-items: center;
+      gap: 16px;
+      padding: 24px;
+      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+      color: #fff;
+
+      .qc-info-icon {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 48px;
+        height: 48px;
+        background: rgba(255, 255, 255, 0.2);
+        border-radius: 12px;
+        backdrop-filter: blur(10px);
+      }
+
+      .qc-info-content {
+        flex: 1;
+
+        .qc-info-title {
+          margin: 0;
+          font-size: 18px;
+          font-weight: 600;
+          line-height: 1.4;
+        }
+
+        .qc-info-subtitle {
+          margin: 4px 0 0;
+          font-size: 13px;
+          opacity: 0.9;
+          line-height: 1.4;
+        }
+      }
+    }
+
+    .qc-info-body {
+      flex: 1;
+      padding: 24px;
+      overflow-y: auto;
+
+      .info-item {
+        display: flex;
+        margin-bottom: 16px;
+        padding-bottom: 12px;
+        border-bottom: 1px solid #e4e7ed;
+
+        &:last-child {
+          border-bottom: none;
+          margin-bottom: 0;
+          padding-bottom: 0;
+        }
+
+        .info-label {
+          font-weight: 600;
+          color: #606266;
+          min-width: 120px;
+          flex-shrink: 0;
+        }
+
+        .info-value {
+          color: #303133;
+          flex: 1;
+          word-break: break-all;
+        }
+      }
+    }
+  }
+
+  .qc-form-section {
+    width: 420px;
+    display: flex;
+    flex-direction: column;
+    background: linear-gradient(to bottom, #ffffff 0%, #fafbfc 100%);
+    border-radius: 8px;
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+    overflow: hidden;
+
+    .qc-form-header {
+      display: flex;
+      align-items: center;
+      gap: 16px;
+      padding: 24px;
+      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+      color: #fff;
+
+      .qc-form-icon {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 48px;
+        height: 48px;
+        background: rgba(255, 255, 255, 0.2);
+        border-radius: 12px;
+        backdrop-filter: blur(10px);
+      }
+
+      .qc-form-content {
+        flex: 1;
+
+        .qc-form-title {
+          margin: 0;
+          font-size: 18px;
+          font-weight: 600;
+          line-height: 1.4;
+        }
+
+        .qc-form-subtitle {
+          margin: 4px 0 0;
+          font-size: 13px;
+          opacity: 0.9;
+          line-height: 1.4;
+        }
+      }
+    }
+
+    .qc-form-body {
+      flex: 1;
+      padding: 24px;
+      overflow-y: auto;
+
+      .el-form {
+        height: 100%;
+
+        .result-item {
+          margin-bottom: 24px;
+
+          :deep(.el-form-item__label) {
+            font-weight: 600;
+            font-size: 14px;
+            color: #303133;
+            margin-bottom: 12px;
+          }
+
+          .result-radio-group {
+            display: flex;
+            flex-direction: column;
+            gap: 12px;
+            width: 100%;
+
+            .result-radio {
+              width: 100%;
+              margin: 0;
+              padding: 0;
+              border-radius: 8px;
+              transition: all 0.3s ease;
+
+              :deep(.el-radio__input) {
+                display: none;
+              }
+
+              :deep(.el-radio__label) {
+                width: 100%;
+                padding: 16px 20px;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+              }
+
+              .radio-content {
+                display: flex;
+                align-items: center;
+                gap: 10px;
+                font-size: 15px;
+                font-weight: 500;
+              }
+
+              &.result-radio-pass {
+                border-color: #e8f4ea;
+                background-color: #f0f9f1;
+
+                &:hover {
+                  border-color: #67c23a;
+                  background-color: #e8f4ea;
+                  transform: translateY(-2px);
+                  box-shadow: 0 4px 12px rgba(103, 194, 58, 0.2);
+                }
+
+                &.is-checked {
+                  border-color: #67c23a;
+                  background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
+                  box-shadow: 0 4px 12px rgba(103, 194, 58, 0.3);
+
+                  :deep(.el-radio__label) {
+                    color: #fff;
+                  }
+
+                  .radio-content {
+                    color: #fff;
+                  }
+                }
+              }
+
+              &.result-radio-reject {
+                border-color: #fde8e8;
+                background-color: #fef0f0;
+
+                &:hover {
+                  border-color: #f56c6c;
+                  background-color: #fde8e8;
+                  transform: translateY(-2px);
+                  box-shadow: 0 4px 12px rgba(245, 108, 108, 0.2);
+                }
+
+                &.is-checked {
+                  border-color: #f56c6c;
+                  background: linear-gradient(135deg, #f56c6c 0%, #f78989 100%);
+                  box-shadow: 0 4px 12px rgba(245, 108, 108, 0.3);
+
+                  :deep(.el-radio__label) {
+                    color: #fff;
+                  }
+
+                  .radio-content {
+                    color: #fff;
+                  }
+                }
+              }
+            }
+          }
+        }
+
+        .reject-form-items {
+          :deep(.el-form-item__label) {
+            font-weight: 600;
+            font-size: 14px;
+            color: #303133;
+            margin-bottom: 12px;
+          }
+
+          :deep(.el-input__inner),
+          :deep(.el-textarea__inner) {
+            border-radius: 8px;
+            border: 2px solid #e4e7ed;
+            transition: all 0.3s ease;
+
+            &:focus {
+              border-color: #667eea;
+              box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+            }
+          }
+
+          :deep(.el-select__wrapper) {
+            border-radius: 8px;
+          }
+        }
+      }
+    }
+
+    .qc-form-footer {
+      display: flex;
+      gap: 12px;
+      padding: 20px 24px;
+      background-color: #fff;
+      border-top: 1px solid #e4e7ed;
+      box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
+
+      .footer-btn {
+        flex: 1;
+        height: 44px;
+        font-size: 15px;
+        font-weight: 500;
+        border-radius: 8px;
+        transition: all 0.3s ease;
+
+        .el-icon {
+          margin-right: 6px;
+        }
+
+        &:hover {
+          transform: translateY(-2px);
+          box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+        }
+
+        &.footer-btn-primary {
+          background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+          border: none;
+
+          &:hover {
+            background: linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%);
+          }
+        }
+      }
+    }
+  }
+}
+
+/* 过渡动画 */
+.el-fade-in-enter-active,
+.el-fade-in-leave-active {
+  transition: all 0.3s ease;
+}
+
+.el-fade-in-enter-from {
+  opacity: 0;
+  transform: translateY(-10px);
+}
+
+.el-fade-in-leave-to {
+  opacity: 0;
+  transform: translateY(-10px);
+}
+</style>

+ 52 - 148
src/views/Qc/task/detail.vue

@@ -114,48 +114,13 @@
     </el-card>
 
     <!-- 审核弹窗 -->
-    <el-dialog v-model="auditDialog.visible" :title="t('qc.task.auditDialog.title')" width="500px" append-to-body>
-      <el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="100px">
-        <el-form-item :label="t('qc.task.auditDialog.result')" prop="result">
-          <el-radio-group v-model="auditForm.result">
-            <el-radio :label="1">{{ t('qc.task.auditDialog.pass') }}</el-radio>
-            <el-radio :label="2">{{ t('qc.task.auditDialog.reject') }}</el-radio>
-          </el-radio-group>
-        </el-form-item>
-
-        <template v-if="auditForm.result === 2">
-          <el-form-item :label="t('qc.task.auditDialog.rejectionType')" prop="rejectionType">
-            <el-select v-model="auditForm.rejectionType" :placeholder="t('qc.task.auditDialog.rejectionTypePlaceholder')" style="width: 100%">
-              <el-option v-for="dict in qc_question_type" :key="dict.value" :label="parseI18nName(dict.label)"
-                :value="dict.value" />
-            </el-select>
-          </el-form-item>
-
-          <el-form-item :label="t('qc.task.auditDialog.opinion')" prop="opinion">
-            <el-input v-model="auditForm.opinion" type="textarea" :rows="3" :placeholder="t('qc.task.auditDialog.opinionPlaceholder')" />
-          </el-form-item>
-
-          <el-form-item :label="t('qc.task.auditDialog.designatedDealer')" prop="designatedDealer">
-            <el-select v-model="auditForm.designatedDealer" filterable remote reserve-keyword :placeholder="t('qc.task.auditDialog.designatedDealerPlaceholder')"
-              :remote-method="searchDealers" :loading="dealerSearchLoading" style="width: 100%">
-              <el-option v-for="dealer in dealerOptions" :key="dealer.id"
-                :label="`${dealer.name} / ${dealer.dept} --- ${dealer.phoneNumber}`" :value="dealer.id" />
-            </el-select>
-          </el-form-item>
-
-          <el-form-item :label="t('qc.task.auditDialog.deadline')" prop="deadline">
-            <el-date-picker v-model="auditForm.deadline" type="date" value-format="YYYY-MM-DD" :placeholder="t('qc.task.auditDialog.deadlinePlaceholder')"
-              style="width: 100%" />
-          </el-form-item>
-        </template>
-      </el-form>
-      <template #footer>
-        <div class="dialog-footer">
-          <el-button @click="cancelAudit">{{ t('qc.task.auditDialog.cancel') }}</el-button>
-          <el-button type="primary" :loading="auditLoading" @click="submitAudit">{{ t('qc.task.auditDialog.confirm') }}</el-button>
-        </div>
-      </template>
-    </el-dialog>
+    <QcAuditDialog 
+      v-model="auditDialog.visible" 
+      :task-detail="currentAuditRow" 
+      :qc-question-type="qc_question_type"
+      :dealer-search-method="searchDealersForComponent"
+      @submit="handleAuditSubmit" 
+    />
 
     <!-- 审核记录弹窗 -->
     <QcLogDialog v-model="logDialogVisible" :detail-id="currentLogDetailId" />
@@ -163,7 +128,7 @@
 </template>
 
 <script setup name="TaskDetail" lang="ts">
-import { ref, reactive, onMounted, watch, computed, toRefs } from 'vue';
+import { ref, reactive, onMounted, watch, computed, toRefs, getCurrentInstance } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import { getTask, listTaskDetail, auditTaskDetail } from '@/api/qc/task';
@@ -172,11 +137,12 @@ import { downloadDocumentFile } from '@/api/document/document';
 import { queryMemberNotInCenter } from '@/api/project/management';
 import { MemberNotInCenterVO, MemberNotInCenterQuery } from '@/api/project/management/types';
 import { ElMessage } from 'element-plus';
-import type { FormInstance } from 'element-plus';
+import type { FormInstance, ComponentInternalInstance } from 'element-plus';
 import { useUserStore } from '@/store/modules/user';
 import { parseI18nName } from '@/utils/i18n';
 import { formatDocumentName } from '@/utils/ruoyi';
 import QcLogDialog from '@/components/QcLogDialog/index.vue';
+import QcAuditDialog from '@/components/QcAuditDialog/index.vue';
 
 // 定义任务详情项类型
 interface TaskDetailItem {
@@ -226,40 +192,11 @@ const loading = ref(true);
 const taskDetail = ref<TaskVO | null>(null);
 
 // 审核相关
-const auditFormRef = ref<FormInstance>();
 const auditDialog = reactive({
   visible: false
 });
-const auditLoading = ref(false);
 const currentAuditRow = ref<TaskDetailItem | null>(null);
 
-// 审核表单初始数据
-const initAuditForm: TaskDetailAuditForm = {
-  taskId: 0,
-  detailId: 0,
-  result: 1,
-  rejectionType: undefined,
-  opinion: undefined,
-  designatedDealer: undefined,
-  deadline: undefined
-};
-
-const auditForm = ref<TaskDetailAuditForm>({ ...initAuditForm });
-
-// 审核表单验证规则
-const auditRules = computed(() => ({
-  result: [{ required: true, message: t('qc.task.auditRule.resultRequired'), trigger: 'change' }],
-  rejectionType: auditForm.value.result === 2 ? [{ required: true, message: t('qc.task.auditRule.rejectionTypeRequired'), trigger: 'change' }] : [],
-  opinion: auditForm.value.result === 2 ? [{ required: true, message: t('qc.task.auditRule.opinionRequired'), trigger: 'blur' }] : [],
-  designatedDealer: auditForm.value.result === 2 ? [{ required: true, message: t('qc.task.auditRule.designatedDealerRequired'), trigger: 'change' }] : [],
-  deadline: auditForm.value.result === 2 ? [{ required: true, message: t('qc.task.auditRule.deadlineRequired'), trigger: 'change' }] : []
-}));
-
-// 处理人搜索相关
-const dealerSearchLoading = ref(false);
-const dealerOptions = ref<MemberNotInCenterVO[]>([]);
-let dealerSearchTimer: NodeJS.Timeout | null = null;
-
 // 审核记录相关
 const logDialogVisible = ref(false);
 const currentLogDetailId = ref<number | undefined>(undefined);
@@ -316,88 +253,55 @@ const handleTaskItemsPagination = (pageNum: number, pageSize: number) => {
 /** 审核任务 */
 const handleAudit = (row: TaskDetailItem) => {
   currentAuditRow.value = row;
-  auditForm.value = {
-    ...initAuditForm,
-    taskId: taskDetail.value?.id as number,
-    detailId: row.id
-  };
-  dealerOptions.value = [];
   auditDialog.visible = true;
 };
 
-/** 搜索处理人 */
-const searchDealers = async (query: string) => {
-  if (!query || query.trim() === '') {
-    dealerOptions.value = [];
-    return;
-  }
-
-  if (dealerSearchTimer) {
-    clearTimeout(dealerSearchTimer);
+/** 搜索处理人(供组件使用) */
+const searchDealersForComponent = async (query: string): Promise<MemberNotInCenterVO[]> => {
+  try {
+    const queryParams: MemberNotInCenterQuery = {
+      pageNum: 1,
+      pageSize: 10,
+      projectId: taskDetail.value?.projectId || 0,
+      folderId: 0,
+      name: query
+    };
+    const res = await queryMemberNotInCenter(queryParams);
+    return res.rows || [];
+  } catch (error) {
+    console.error('搜索处理人失败:', error);
+    ElMessage.error(t('qc.task.message.searchDealerFailed'));
+    return [];
   }
-
-  dealerSearchTimer = setTimeout(async () => {
-    dealerSearchLoading.value = true;
-    try {
-      const queryParams: MemberNotInCenterQuery = {
-        pageNum: 1,
-        pageSize: 10,
-        projectId: taskDetail.value?.projectId || 0,
-        folderId: 0,
-        name: query
-      };
-      const res = await queryMemberNotInCenter(queryParams);
-      dealerOptions.value = res.rows || [];
-    } catch (error) {
-      console.error('搜索处理人失败:', error);
-      ElMessage.error(t('qc.task.message.searchDealerFailed'));
-    } finally {
-      dealerSearchLoading.value = false;
-    }
-  }, 300);
-};
-
-/** 取消审核 */
-const cancelAudit = () => {
-  auditDialog.visible = false;
-  auditFormRef.value?.resetFields();
-  currentAuditRow.value = null;
 };
 
-/** 提交审核 */
-const submitAudit = () => {
-  auditFormRef.value?.validate(async (valid: boolean) => {
-    if (valid) {
-      auditLoading.value = true;
-      try {
-        const submitData: TaskDetailAuditForm = {
-          taskId: auditForm.value.taskId,
-          detailId: auditForm.value.detailId,
-          result: auditForm.value.result
-        };
-
-        // 驳回时添加额外字段
-        if (auditForm.value.result === 2) {
-          submitData.rejectionType = auditForm.value.rejectionType;
-          submitData.opinion = auditForm.value.opinion;
-          submitData.designatedDealer = auditForm.value.designatedDealer;
-          submitData.deadline = auditForm.value.deadline;
-        }
-
-        await auditTaskDetail(submitData);
-        proxy?.$modal.msgSuccess(t('qc.task.message.auditSuccess'));
-        auditDialog.visible = false;
-        // 刷新任务列表
-        await fetchTaskItems();
-        // 刷新任务详情
-        await fetchTaskDetail();
-      } catch (error) {
-        console.error(t('qc.task.message.auditFailed'), error);
-      } finally {
-        auditLoading.value = false;
-      }
+/** 处理审核提交 */
+const handleAuditSubmit = async (data: any) => {
+  try {
+    const submitData: TaskDetailAuditForm = {
+      taskId: taskDetail.value?.id as number,
+      detailId: currentAuditRow.value?.id as number,
+      result: data.result
+    };
+
+    // 驳回时添加额外字段
+    if (data.result === 2) {
+      submitData.rejectionType = data.rejectionType;
+      submitData.opinion = data.opinion;
+      submitData.designatedDealer = data.designatedDealer;
+      submitData.deadline = data.deadline;
     }
-  });
+
+    await auditTaskDetail(submitData);
+    proxy?.$modal.msgSuccess(t('qc.task.message.auditSuccess'));
+    auditDialog.visible = false;
+    // 刷新任务列表
+    await fetchTaskItems();
+    // 刷新任务详情
+    await fetchTaskDetail();
+  } catch (error) {
+    console.error(t('qc.task.message.auditFailed'), error);
+  }
 };
 
 /** 下载文件 */