index.vue 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283
  1. <template>
  2. <el-dialog v-model="dialogVisible" :title="title" width="1200px" top="5vh" append-to-body destroy-on-close class="audit-dialog">
  3. <div class="audit-container">
  4. <!-- 左侧:文档编辑区域 -->
  5. <div class="preview-section">
  6. <div class="preview-header">
  7. <div class="file-info">
  8. <el-icon class="file-icon"><Document /></el-icon>
  9. <span class="file-name">{{ document?.fileName || t('document.document.documentAudit.untitledDocument') }}</span>
  10. </div>
  11. <div class="header-actions">
  12. <el-tooltip :content="t('document.document.documentAudit.viewVersionsTooltip')" placement="bottom">
  13. <el-button size="small" @click="handleViewVersions" :loading="loadingVersions">
  14. <el-icon><Clock /></el-icon>
  15. {{ t('document.document.documentAudit.viewVersions') }}
  16. </el-button>
  17. </el-tooltip>
  18. <el-tooltip :content="t('document.document.documentAudit.cleanCommentsTooltip')" placement="bottom">
  19. <el-button size="small" @click="handleCleanComments" :loading="cleaningComments">
  20. <el-icon><Delete /></el-icon>
  21. {{ t('document.document.documentAudit.cleanComments') }}
  22. </el-button>
  23. </el-tooltip>
  24. <el-tooltip :content="t('document.document.documentAudit.copySignatureTooltip')" placement="bottom">
  25. <el-button type="primary" size="small" @click="handleCopyAvatar" class="copy-avatar-btn" :loading="copyingAvatar">
  26. <el-icon><Picture /></el-icon>
  27. {{ t('document.document.documentAudit.copySignature') }}
  28. </el-button>
  29. </el-tooltip>
  30. </div>
  31. </div>
  32. <div class="preview-container">
  33. <!-- WPS 编辑器容器 -->
  34. <div
  35. v-if="document?.ossId"
  36. ref="wpsContainerRef"
  37. class="wps-container"
  38. @dragenter="handleDragEnter"
  39. @dragleave="handleDragLeave"
  40. @dragover.prevent="handleDragOver"
  41. @drop.prevent="handleDrop"
  42. >
  43. <!-- 拖拽提示层 -->
  44. <div v-if="isDragging" class="drag-overlay">
  45. <div class="drag-hint">
  46. <el-icon class="drag-icon"><Upload /></el-icon>
  47. <p>{{ t('document.document.documentAudit.dropImageHint') }}</p>
  48. </div>
  49. </div>
  50. <!-- 降级方案:iframe 预览 -->
  51. <iframe v-if="wpsError && document?.url" :src="document.url" class="document-iframe" frameborder="0"></iframe>
  52. </div>
  53. <!-- 加载状态 -->
  54. <div v-if="wpsLoading" class="loading-state">
  55. <el-icon class="is-loading"><Loading /></el-icon>
  56. <p>{{ t('document.document.documentAudit.loadingEditor') }}</p>
  57. </div>
  58. <!-- 错误状态 -->
  59. <div v-if="wpsError && !document?.url" class="error-state">
  60. <el-result icon="error" :title="t('document.document.documentAudit.loadFailed')" :sub-title="wpsError">
  61. <template #extra>
  62. <el-button type="primary" @click="() => initWpsEditor(false)">{{ t('document.document.documentAudit.reload') }}</el-button>
  63. </template>
  64. </el-result>
  65. </div>
  66. <!-- 空状态 -->
  67. <el-empty v-if="!document?.ossId" :description="t('document.document.documentAudit.noDocument')" :image-size="100" />
  68. </div>
  69. </div>
  70. <!-- 右侧:审核表单区域 -->
  71. <div class="audit-section">
  72. <div class="section-header">
  73. <el-icon><Edit /></el-icon>
  74. <span>{{ t('document.document.documentAudit.auditInfo') }}</span>
  75. </div>
  76. <el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-position="top" class="audit-form">
  77. <!-- 文档信息卡片 -->
  78. <el-card shadow="never" class="info-card">
  79. <template #header>
  80. <div class="card-header">
  81. <el-icon><InfoFilled /></el-icon>
  82. <span>{{ t('document.document.documentAudit.documentInfo') }}</span>
  83. </div>
  84. </template>
  85. <div class="info-item">
  86. <span class="info-label">{{ t('document.document.documentAudit.documentName') }}:</span>
  87. <span class="info-value">{{ document?.fileName || '-' }}</span>
  88. </div>
  89. <div class="info-item">
  90. <span class="info-label">{{ t('document.document.documentAudit.documentId') }}:</span>
  91. <span class="info-value">{{ document?.id || '-' }}</span>
  92. </div>
  93. </el-card>
  94. <!-- 审核结果 -->
  95. <el-form-item :label="t('document.document.auditForm.result')" prop="result">
  96. <el-radio-group v-model="auditForm.result" class="result-radio-group">
  97. <el-radio label="3" border>
  98. <div class="radio-content">
  99. <el-icon class="radio-icon success"><CircleCheck /></el-icon>
  100. <div class="radio-text">
  101. <div class="radio-title">{{ t('document.document.auditForm.pass') }}</div>
  102. <div class="radio-desc">{{ t('document.document.documentAudit.passDesc') }}</div>
  103. </div>
  104. </div>
  105. </el-radio>
  106. <el-radio label="2" border>
  107. <div class="radio-content">
  108. <el-icon class="radio-icon danger"><CircleClose /></el-icon>
  109. <div class="radio-text">
  110. <div class="radio-title">{{ t('document.document.auditForm.reject') }}</div>
  111. <div class="radio-desc">{{ t('document.document.documentAudit.rejectDesc') }}</div>
  112. </div>
  113. </div>
  114. </el-radio>
  115. </el-radio-group>
  116. </el-form-item>
  117. <!-- 驳回原因 -->
  118. <el-form-item v-if="auditForm.result === '2'" :label="t('document.document.auditForm.reason')" prop="reason">
  119. <el-input
  120. v-model="auditForm.reason"
  121. type="textarea"
  122. :rows="6"
  123. :placeholder="t('document.document.auditForm.reasonPlaceholder')"
  124. maxlength="500"
  125. show-word-limit
  126. />
  127. </el-form-item>
  128. <!-- 审核提示 -->
  129. <el-alert v-if="auditForm.result === '3'" :title="t('document.document.documentAudit.passAlert')" type="success" :closable="false" show-icon />
  130. <el-alert v-if="auditForm.result === '2'" :title="t('document.document.documentAudit.rejectAlert')" type="warning" :closable="false" show-icon />
  131. </el-form>
  132. </div>
  133. </div>
  134. <template #footer>
  135. <div class="dialog-footer">
  136. <el-button @click="handleCancel" size="large">
  137. <el-icon><Close /></el-icon>
  138. {{ t('document.document.button.cancel') }}
  139. </el-button>
  140. <el-button type="primary" @click="submitForm" :loading="loading" size="large">
  141. <el-icon><Check /></el-icon>
  142. {{ t('document.document.button.submit') }}
  143. </el-button>
  144. </div>
  145. </template>
  146. </el-dialog>
  147. <!-- 历史版本对话框 -->
  148. <el-dialog v-model="showVersionDialog" :title="t('document.document.documentAudit.historyVersions')" width="800px" append-to-body destroy-on-close">
  149. <el-table :data="versionList" v-loading="loadingVersions" stripe>
  150. <el-table-column prop="version" :label="t('document.document.documentAudit.versionNumber')" width="150" align="center" />
  151. <el-table-column prop="createTime" :label="t('document.document.documentAudit.createTime')" min-width="180" align="center" />
  152. <el-table-column prop="updateTime" :label="t('document.document.documentAudit.updateTime')" min-width="180" align="center" />
  153. <el-table-column :label="t('document.document.documentAudit.action')" width="120" align="center" fixed="right">
  154. <template #default="{ row }">
  155. <el-button type="primary" size="small" @click="handleSelectVersion(row.version)"> {{ t('document.document.documentAudit.select') }} </el-button>
  156. </template>
  157. </el-table-column>
  158. </el-table>
  159. <template #footer>
  160. <el-button @click="showVersionDialog = false">{{ t('document.document.button.cancel') }}</el-button>
  161. </template>
  162. </el-dialog>
  163. </template>
  164. <script setup lang="ts">
  165. import { ref, reactive, watch, nextTick, onBeforeUnmount } from 'vue';
  166. import { useI18n } from 'vue-i18n';
  167. import type { FormInstance } from 'element-plus';
  168. import { ElMessage, ElMessageBox } from 'element-plus';
  169. import { Document, Edit, InfoFilled, CircleCheck, CircleClose, Close, Check, Loading, Upload, Picture, Delete, Clock } from '@element-plus/icons-vue';
  170. import { useUserStore } from '@/store/modules/user';
  171. import { cleanDocumentComments, getFileVersionList, getFinalFile, initWpsDocument, cancelWpsDocument, type FileVersion } from '@/api/wps/save';
  172. interface Document {
  173. id: number | string;
  174. name?: string;
  175. ossId?: number | string;
  176. fileName?: string;
  177. url?: string;
  178. }
  179. interface AuditData {
  180. documentId: number | string;
  181. result: number;
  182. rejectReason?: string;
  183. ossId?: number | string; // 最终的 ossId
  184. }
  185. interface Props {
  186. modelValue: boolean;
  187. document?: Document | null;
  188. title?: string;
  189. }
  190. interface Emits {
  191. (e: 'update:modelValue', value: boolean): void;
  192. (e: 'submit', data: AuditData): void; // 提交审核数据
  193. }
  194. const props = defineProps<Props>();
  195. const emit = defineEmits<Emits>();
  196. const { t } = useI18n();
  197. const userStore = useUserStore();
  198. const dialogVisible = ref(false);
  199. const loading = ref(false);
  200. const auditFormRef = ref<FormInstance>();
  201. // WPS 编辑器相关
  202. const wpsContainerRef = ref<HTMLDivElement>();
  203. const wpsLoading = ref(false);
  204. const wpsError = ref('');
  205. const isDragging = ref(false);
  206. const copyingAvatar = ref(false);
  207. const cleaningComments = ref(false);
  208. const currentVersion = ref(1); // 当前文档版本号
  209. const showVersionDialog = ref(false); // 显示历史版本对话框
  210. const versionList = ref<FileVersion[]>([]); // 历史版本列表
  211. const loadingVersions = ref(false); // 加载历史版本中
  212. let wpsInstance: any = null;
  213. let dragCounter = 0; // 用于跟踪拖拽进入/离开次数
  214. // WPS 配置
  215. const WPS_APP_ID = 'SX20260105YMMIXV';
  216. // 审核表单数据
  217. const auditForm = ref({
  218. id: 0 as number | string,
  219. result: '3',
  220. reason: ''
  221. });
  222. // 审核表单验证规则
  223. const auditRules = reactive({
  224. result: [
  225. {
  226. required: true,
  227. message: t('document.document.auditRule.resultRequired'),
  228. trigger: 'change'
  229. }
  230. ],
  231. reason: [
  232. {
  233. required: true,
  234. message: t('document.document.auditRule.reasonRequired'),
  235. trigger: 'blur'
  236. }
  237. ]
  238. });
  239. // 获取文件类型
  240. const getFileType = (fileName: string) => {
  241. const name = fileName.toLowerCase();
  242. if (name.endsWith('.docx') || name.endsWith('.doc')) return 'w';
  243. if (name.endsWith('.xlsx') || name.endsWith('.xls')) return 's';
  244. if (name.endsWith('.pptx') || name.endsWith('.ppt')) return 'p';
  245. if (name.endsWith('.pdf')) return 'f';
  246. return 'w';
  247. };
  248. // 初始化 WPS 编辑器
  249. // shouldCallInitApi: 是否需要调用后端初始化接口(只在 dialog 首次打开时为 true)
  250. const initWpsEditor = async (shouldCallInitApi = false) => {
  251. if (!wpsContainerRef.value || !props.document?.ossId) {
  252. return;
  253. }
  254. try {
  255. wpsLoading.value = true;
  256. wpsError.value = '';
  257. // 检查 WPS SDK
  258. if (!(window as any).WebOfficeSDK) {
  259. wpsError.value = 'WPS SDK 未加载';
  260. wpsLoading.value = false;
  261. return;
  262. }
  263. const WebOfficeSDK = (window as any).WebOfficeSDK;
  264. // 获取文件类型
  265. const officeType = getFileType(props.document.fileName || '', props.document.url);
  266. // 只在 dialog 首次打开时调用后端接口初始化文档
  267. if (shouldCallInitApi) {
  268. console.log('[WPS] 调用后端初始化接口,ossId:', props.document.ossId);
  269. try {
  270. const initRes = await initWpsDocument(props.document.ossId);
  271. const backendVersion = initRes.data; // data 直接是版本号
  272. currentVersion.value = backendVersion;
  273. console.log('[WPS] 后端返回版本号:', backendVersion);
  274. } catch (err: any) {
  275. console.error('[WPS] 调用后端初始化接口失败:', err);
  276. wpsLoading.value = false;
  277. wpsError.value = '初始化文档失败';
  278. // 显示错误提示
  279. ElMessage.error('初始化文档失败: ' + (err.message || '未知错误'));
  280. // 关闭对话框
  281. setTimeout(() => {
  282. dialogVisible.value = false;
  283. }, 1500);
  284. return; // 终止初始化流程
  285. }
  286. } else {
  287. console.log('[WPS] 使用当前版本号重新初始化编辑器,版本:', currentVersion.value);
  288. }
  289. // 使用 ossId + 当前版本号组成 fileId
  290. const fileId = `${props.document.ossId}_${currentVersion.value}`;
  291. console.log('[WPS] 初始化配置:', {
  292. appId: WPS_APP_ID,
  293. officeType: officeType,
  294. fileId: fileId,
  295. ossId: props.document.ossId,
  296. version: currentVersion.value,
  297. fileName: props.document.fileName,
  298. fileUrl: props.document.url
  299. });
  300. // 标准初始化配置(按官方文档)
  301. const config = {
  302. // 必需参数
  303. appId: WPS_APP_ID,
  304. officeType: officeType,
  305. fileId: fileId,
  306. // 可选参数
  307. mount: wpsContainerRef.value,
  308. // 指定当前用户ID为编辑者ID
  309. userId: String(userStore.userId)
  310. };
  311. // 初始化 WPS 编辑器
  312. wpsInstance = WebOfficeSDK.init(config);
  313. wpsLoading.value = false;
  314. console.log('[WPS] 编辑器初始化成功');
  315. } catch (err: any) {
  316. console.error('[WPS] 初始化失败:', err);
  317. wpsError.value = err.message || '初始化失败';
  318. wpsLoading.value = false;
  319. }
  320. };
  321. // 销毁 WPS 编辑器
  322. const destroyWpsEditor = () => {
  323. if (wpsInstance) {
  324. try {
  325. if (wpsInstance.destroy) {
  326. wpsInstance.destroy();
  327. }
  328. wpsInstance = null;
  329. console.log('[WPS] 编辑器已销毁');
  330. } catch (err) {
  331. console.error('[WPS] 销毁编辑器失败:', err);
  332. }
  333. }
  334. };
  335. // 保存文档
  336. const saveWpsDocument = async () => {
  337. if (!wpsInstance) {
  338. return null;
  339. }
  340. try {
  341. console.log('[WPS] 开始保存文档');
  342. const result = await wpsInstance.save();
  343. console.log('[WPS] 保存成功:', result);
  344. return result;
  345. } catch (err: any) {
  346. console.error('[WPS] 保存失败:', err);
  347. throw err;
  348. }
  349. };
  350. // 拖拽进入
  351. const handleDragEnter = (e: DragEvent) => {
  352. dragCounter++;
  353. if (dragCounter === 1) {
  354. isDragging.value = true;
  355. }
  356. };
  357. // 拖拽离开
  358. const handleDragLeave = (e: DragEvent) => {
  359. dragCounter--;
  360. if (dragCounter === 0) {
  361. isDragging.value = false;
  362. }
  363. };
  364. // 拖拽悬停
  365. const handleDragOver = (e: DragEvent) => {
  366. e.preventDefault();
  367. e.stopPropagation();
  368. };
  369. // 处理拖放
  370. const handleDrop = async (e: DragEvent) => {
  371. e.preventDefault();
  372. e.stopPropagation();
  373. isDragging.value = false;
  374. dragCounter = 0;
  375. if (!wpsInstance) {
  376. ElMessage.warning(t('document.document.documentAudit.wpsNotInitialized'));
  377. return;
  378. }
  379. // 处理图片拖放
  380. const files = e.dataTransfer?.files;
  381. if (!files || files.length === 0) {
  382. return;
  383. }
  384. // 只处理第一个文件
  385. const file = files[0];
  386. // 检查是否是图片
  387. if (!file.type.startsWith('image/')) {
  388. ElMessage.warning(t('document.document.documentAudit.onlyImageSupported'));
  389. return;
  390. }
  391. try {
  392. console.log('[WPS] 开始插入图片:', file.name);
  393. // 读取图片为 base64
  394. const reader = new FileReader();
  395. reader.onload = async (event) => {
  396. const base64 = event.target?.result as string;
  397. try {
  398. // 获取 WPS Application 对象
  399. const app = await wpsInstance.Application;
  400. if (!app) {
  401. ElMessage.error('无法获取 WPS Application 对象');
  402. return;
  403. }
  404. // 根据文件类型插入图片
  405. const officeType = getFileType(props.document?.fileName || '');
  406. if (officeType === 'w') {
  407. // Word 文档:插入图片到光标位置
  408. const selection = await app.ActiveDocument.Application.Selection;
  409. await selection.InlineShapes.AddPicture(base64);
  410. ElMessage.success(t('document.document.documentAudit.imageInserted'));
  411. } else if (officeType === 's') {
  412. // Excel 表格:插入图片到当前单元格
  413. const activeSheet = await app.ActiveSheet;
  414. const activeCell = await app.ActiveCell;
  415. const row = await activeCell.Row;
  416. const col = await activeCell.Column;
  417. await activeSheet.Shapes.AddPicture(base64, false, true, col * 64, row * 20, 200, 150);
  418. ElMessage.success(t('document.document.documentAudit.imageInserted'));
  419. } else if (officeType === 'p') {
  420. // PowerPoint:插入图片到当前幻灯片
  421. const activeSlide = await app.ActivePresentation.Slides.Item(await app.ActiveWindow.Selection.SlideRange.SlideIndex);
  422. await activeSlide.Shapes.AddPicture(base64, false, true, 100, 100, 200, 150);
  423. ElMessage.success(t('document.document.documentAudit.imageInserted'));
  424. } else {
  425. ElMessage.warning(t('document.document.documentAudit.currentTypeNotSupported'));
  426. }
  427. console.log('[WPS] 图片插入成功');
  428. } catch (err: any) {
  429. console.error('[WPS] 插入图片失败:', err);
  430. ElMessage.error(t('document.document.documentAudit.insertImageFailed') + ': ' + err.message);
  431. }
  432. };
  433. reader.onerror = () => {
  434. ElMessage.error(t('document.document.documentAudit.readImageFailed'));
  435. };
  436. reader.readAsDataURL(file);
  437. } catch (err: any) {
  438. console.error('[WPS] 处理拖放失败:', err);
  439. ElMessage.error(t('document.document.documentAudit.dropHandleFailed'));
  440. }
  441. };
  442. // 复制头像到剪贴板
  443. const handleCopyAvatar = async () => {
  444. try {
  445. // 先让用户选择审核结果
  446. let reviewResult: string;
  447. try {
  448. await ElMessageBox.confirm(t('document.document.copySignature.selectResultMessage'), t('document.document.copySignature.selectResult'), {
  449. confirmButtonText: t('document.document.copySignature.pass'),
  450. cancelButtonText: t('document.document.copySignature.reject'),
  451. distinguishCancelAndClose: true,
  452. closeOnClickModal: false,
  453. closeOnPressEscape: false,
  454. type: 'info'
  455. });
  456. // 点击确认按钮 = 通过
  457. reviewResult = 'pass';
  458. } catch (action) {
  459. if (action === 'cancel') {
  460. // 点击取消按钮 = 驳回
  461. reviewResult = 'reject';
  462. } else {
  463. // 点击关闭或按 ESC = 取消操作
  464. console.log('[签名] 用户取消选择');
  465. return;
  466. }
  467. }
  468. copyingAvatar.value = true;
  469. console.log('[签名] 开始生成审核信息图片,审核结果:', reviewResult);
  470. // 获取当前用户昵称
  471. const reviewerName = userStore.nickname || userStore.name || t('document.document.copySignature.unknown');
  472. // 获取当前时间
  473. const now = new Date();
  474. const year = now.getFullYear();
  475. const month = now.getMonth() + 1;
  476. const day = now.getDate();
  477. const hour = now.getHours();
  478. const minute = now.getMinutes();
  479. const reviewTime = t('document.document.copySignature.timeFormat', { year, month, day, hour, minute });
  480. // 审核结果文本
  481. const resultText = reviewResult === 'pass' ? t('document.document.copySignature.passText') : t('document.document.copySignature.rejectText');
  482. // 根据审核结果确定颜色
  483. const color = reviewResult === 'pass' ? '#00aa00' : '#ff0000';
  484. console.log('[签名] 审核结果:', reviewResult, '颜色:', color, '结果文本:', resultText);
  485. // 创建 canvas
  486. const canvas = document.createElement('canvas');
  487. const ctx = canvas.getContext('2d');
  488. if (!ctx) {
  489. ElMessage.error(t('document.document.copySignature.canvasNotSupported'));
  490. copyingAvatar.value = false;
  491. return;
  492. }
  493. // 设置 canvas 尺寸
  494. const width = 300;
  495. const height = 100;
  496. canvas.width = width;
  497. canvas.height = height;
  498. // 不填充背景,保持透明
  499. // 绘制边框(5px 粗,根据审核结果变色)
  500. ctx.strokeStyle = color;
  501. ctx.lineWidth = 5;
  502. ctx.strokeRect(2.5, 2.5, width - 5, height - 5);
  503. // 设置字体样式(根据审核结果变色)
  504. ctx.fillStyle = color;
  505. ctx.textBaseline = 'middle';
  506. // 第一行:审核人(左边)和审核结果(右边)
  507. ctx.font = 'bold 18px Arial, "Microsoft YaHei", sans-serif';
  508. const reviewerText = t('document.document.copySignature.reviewer', { name: reviewerName });
  509. ctx.fillText(reviewerText, 20, height / 3);
  510. // 审核结果靠右显示
  511. const resultWidth = ctx.measureText(resultText).width;
  512. ctx.fillText(resultText, width - resultWidth - 20, height / 3);
  513. // 第二行:审核时间(居左对齐,加粗)
  514. ctx.font = 'bold 16px Arial, "Microsoft YaHei", sans-serif';
  515. const line2 = t('document.document.copySignature.reviewTime', { time: reviewTime });
  516. ctx.fillText(line2, 20, (height * 2) / 3);
  517. console.log('[签名] Canvas 绘制完成');
  518. // 将 canvas 转换为 Blob
  519. canvas.toBlob(async (blob) => {
  520. if (!blob) {
  521. ElMessage.error(t('document.document.copySignature.generateFailed'));
  522. copyingAvatar.value = false;
  523. return;
  524. }
  525. console.log('[签名] 图片生成成功,大小:', blob.size);
  526. try {
  527. // 复制到剪贴板
  528. await navigator.clipboard.write([
  529. new ClipboardItem({
  530. 'image/png': blob
  531. })
  532. ]);
  533. ElMessage({
  534. type: 'success',
  535. message: t('document.document.copySignature.copySuccess'),
  536. duration: 3000
  537. });
  538. // 显示使用提示
  539. setTimeout(() => {
  540. ElMessage({
  541. type: 'info',
  542. dangerouslyUseHTMLString: true,
  543. message: t('document.document.copySignature.usageHint'),
  544. duration: 6000,
  545. showClose: true
  546. });
  547. }, 500);
  548. console.log('[签名] 复制成功');
  549. } catch (err: any) {
  550. console.error('[签名] 复制失败:', err);
  551. // 根据错误类型显示不同提示
  552. if (err.message?.includes('clipboard') || err.message?.includes('Clipboard')) {
  553. ElMessage({
  554. type: 'warning',
  555. dangerouslyUseHTMLString: true,
  556. message: t('document.document.copySignature.browserNotSupported'),
  557. duration: 6000,
  558. showClose: true
  559. });
  560. } else {
  561. ElMessage({
  562. type: 'error',
  563. message: t('document.document.copySignature.copyFailed', { error: err.message || t('document.document.copySignature.unknownError') }),
  564. duration: 5000
  565. });
  566. }
  567. } finally {
  568. copyingAvatar.value = false;
  569. }
  570. }, 'image/png');
  571. } catch (err: any) {
  572. console.error('[签名] 生成图片失败:', err);
  573. ElMessage.error(t('document.document.copySignature.generateFailed') + ': ' + (err.message || t('document.document.copySignature.unknownError')));
  574. copyingAvatar.value = false;
  575. }
  576. };
  577. // 清空批注
  578. const handleCleanComments = async () => {
  579. if (!props.document?.id || !props.document?.ossId) {
  580. ElMessage.warning(t('document.document.documentAudit.documentInfoIncomplete'));
  581. return;
  582. }
  583. try {
  584. await ElMessageBox.confirm(t('document.document.documentAudit.cleanCommentsConfirm'), t('document.document.documentAudit.cleanCommentsTitle'), {
  585. confirmButtonText: t('document.document.message.confirmButton'),
  586. cancelButtonText: t('document.document.message.cancelButton'),
  587. type: 'warning'
  588. });
  589. cleaningComments.value = true;
  590. console.log('[清空批注] 开始清空文档批注,文档ID:', props.document.id, 'ossId:', props.document.ossId, '当前版本:', currentVersion.value);
  591. // 调用后端接口清空批注,获取新版本号
  592. const res = await cleanDocumentComments(props.document.ossId);
  593. const newVersion = res.data; // 后端返回的新版本号
  594. currentVersion.value = newVersion;
  595. ElMessage.success(t('document.document.documentAudit.cleanCommentsSuccess'));
  596. console.log('[清空批注] 批注清空成功,新版本号:', newVersion);
  597. // 销毁当前 WPS 编辑器
  598. destroyWpsEditor();
  599. // 等待 DOM 更新
  600. await nextTick();
  601. // 使用新版本号重新初始化 WPS 编辑器(不调用后端初始化接口)
  602. await initWpsEditor(false);
  603. console.log('[清空批注] WPS 编辑器已使用新版本重新初始化,fileId:', `${props.document.ossId}_${currentVersion.value}`);
  604. } catch (err: any) {
  605. if (err === 'cancel') {
  606. console.log('[清空批注] 用户取消操作');
  607. return;
  608. }
  609. console.error('[清空批注] 清空失败:', err);
  610. ElMessage.error(t('document.document.documentAudit.cleanCommentsFailed') + ': ' + (err.message || t('document.document.copySignature.unknownError')));
  611. } finally {
  612. cleaningComments.value = false;
  613. }
  614. };
  615. // 查看历史版本
  616. const handleViewVersions = async () => {
  617. if (!props.document?.ossId) {
  618. ElMessage.warning(t('document.document.documentAudit.documentInfoIncomplete'));
  619. return;
  620. }
  621. try {
  622. loadingVersions.value = true;
  623. showVersionDialog.value = true;
  624. console.log('[历史版本] 获取历史版本列表,ossId:', props.document.ossId);
  625. const res = await getFileVersionList(props.document.ossId);
  626. versionList.value = res.data || [];
  627. console.log('[历史版本] 获取成功,版本数量:', versionList.value.length);
  628. } catch (err: any) {
  629. console.error('[历史版本] 获取失败:', err);
  630. ElMessage.error(t('document.document.documentAudit.getVersionsFailed') + ': ' + (err.message || t('document.document.copySignature.unknownError')));
  631. showVersionDialog.value = false;
  632. } finally {
  633. loadingVersions.value = false;
  634. }
  635. };
  636. // 选择历史版本
  637. const handleSelectVersion = async (version: number) => {
  638. if (!props.document?.ossId) {
  639. ElMessage.warning(t('document.document.documentAudit.documentInfoIncomplete'));
  640. return;
  641. }
  642. try {
  643. console.log('[历史版本] 选择版本:', version, 'ossId:', props.document.ossId);
  644. // 更新当前版本号
  645. currentVersion.value = version;
  646. // 关闭历史版本对话框
  647. showVersionDialog.value = false;
  648. // 销毁当前 WPS 编辑器
  649. destroyWpsEditor();
  650. // 等待 DOM 更新
  651. await nextTick();
  652. // 使用选择的版本号重新初始化 WPS 编辑器(不调用后端初始化接口)
  653. await initWpsEditor(false);
  654. ElMessage.success(t('document.document.documentAudit.switchVersionSuccess', { version }));
  655. console.log('[历史版本] WPS 编辑器已切换到版本:', version, 'fileId:', `${props.document.ossId}_${version}`);
  656. } catch (err: any) {
  657. console.error('[历史版本] 切换版本失败:', err);
  658. ElMessage.error(t('document.document.documentAudit.switchVersionFailed') + ': ' + (err.message || t('document.document.copySignature.unknownError')));
  659. }
  660. };
  661. // 监听 modelValue 变化
  662. watch(
  663. () => props.modelValue,
  664. (val) => {
  665. dialogVisible.value = val;
  666. if (val && props.document) {
  667. // 重置版本号为 1
  668. currentVersion.value = 1;
  669. auditForm.value = {
  670. id: props.document.id,
  671. result: '3',
  672. reason: ''
  673. };
  674. nextTick(() => {
  675. auditFormRef.value?.clearValidate();
  676. // 自动初始化 WPS 编辑器,使用版本号 1(调用后端初始化接口)
  677. if (props.document?.ossId) {
  678. console.log('[WPS] 对话框打开,初始化编辑器,版本号:', currentVersion.value);
  679. initWpsEditor(true);
  680. }
  681. });
  682. }
  683. }
  684. );
  685. // 监听dialogVisible变化
  686. watch(dialogVisible, async (val) => {
  687. emit('update:modelValue', val);
  688. if (!val) {
  689. // 对话框关闭时,调用取消接口
  690. if (props.document?.ossId) {
  691. try {
  692. console.log('[WPS] 对话框关闭,调用取消接口,ossId:', props.document.ossId);
  693. await cancelWpsDocument(props.document.ossId);
  694. console.log('[WPS] 取消接口调用成功');
  695. } catch (err) {
  696. console.error('[WPS] 取消接口调用失败:', err);
  697. // 取消接口失败不影响关闭流程
  698. }
  699. }
  700. auditForm.value = {
  701. id: 0,
  702. result: '3',
  703. reason: ''
  704. };
  705. // 关闭对话框时重置版本号
  706. currentVersion.value = 1;
  707. destroyWpsEditor();
  708. }
  709. });
  710. // 取消操作
  711. const handleCancel = () => {
  712. dialogVisible.value = false;
  713. };
  714. // 提交表单
  715. const submitForm = () => {
  716. auditFormRef.value?.validate(async (valid: boolean) => {
  717. if (valid) {
  718. loading.value = true;
  719. try {
  720. // 如果使用了 WPS 编辑器,先保存文档
  721. let savedFileInfo = null;
  722. if (wpsInstance) {
  723. try {
  724. savedFileInfo = await saveWpsDocument();
  725. if (savedFileInfo) {
  726. console.log('[审核提交] 文档保存成功:', savedFileInfo);
  727. ElMessage.success(t('document.document.documentAudit.documentSaved'));
  728. }
  729. } catch (err) {
  730. console.error('[审核提交] 保存文档失败:', err);
  731. ElMessage.warning(t('document.document.documentAudit.documentSaveFailed'));
  732. }
  733. }
  734. // 获取当前 fileId
  735. const currentFileId = `${props.document?.ossId}_${currentVersion.value}`;
  736. console.log('[审核提交] 当前 fileId:', currentFileId);
  737. // 调用接口获取最终文档信息
  738. let finalOssId = props.document?.ossId;
  739. try {
  740. console.log('[审核提交] 获取最终文档信息...');
  741. const finalFileRes = await getFinalFile(currentFileId);
  742. finalOssId = finalFileRes.data.ossId;
  743. console.log('[审核提交] 获取到最终 ossId:', finalOssId);
  744. } catch (err) {
  745. console.error('[审核提交] 获取最终文档信息失败:', err);
  746. ElMessage.warning(t('document.document.documentAudit.getFinalFileFailed'));
  747. }
  748. // 构建审核数据
  749. const auditData: AuditData = {
  750. documentId: auditForm.value.id,
  751. result: parseInt(auditForm.value.result),
  752. rejectReason: auditForm.value.reason,
  753. ossId: finalOssId // 使用最终的 ossId
  754. };
  755. console.log('[审核提交] 提交审核数据到父组件:', auditData);
  756. // 关闭对话框
  757. dialogVisible.value = false;
  758. // 通过 emit 将审核数据传递给父组件
  759. emit('submit', auditData);
  760. } catch (error) {
  761. console.error('[审核提交] 处理失败:', error);
  762. ElMessage.error(t('document.document.documentAudit.processFailed') + ': ' + (error as any).message || t('document.document.copySignature.unknownError'));
  763. } finally {
  764. loading.value = false;
  765. }
  766. }
  767. });
  768. };
  769. // 组件卸载前清理
  770. onBeforeUnmount(() => {
  771. destroyWpsEditor();
  772. });
  773. </script>
  774. <style scoped lang="scss">
  775. .audit-dialog {
  776. :deep(.el-dialog__header) {
  777. padding: 20px 24px;
  778. border-bottom: 1px solid #e4e7ed;
  779. margin: 0;
  780. .el-dialog__title {
  781. font-size: 18px;
  782. font-weight: 600;
  783. color: #303133;
  784. }
  785. }
  786. :deep(.el-dialog__body) {
  787. padding: 0;
  788. height: calc(80vh - 140px);
  789. overflow: hidden;
  790. }
  791. :deep(.el-dialog__footer) {
  792. padding: 16px 24px;
  793. border-top: 1px solid #e4e7ed;
  794. }
  795. }
  796. .audit-container {
  797. display: flex;
  798. height: 100%;
  799. gap: 1px;
  800. background: #e4e7ed;
  801. }
  802. .preview-section {
  803. flex: 1;
  804. display: flex;
  805. flex-direction: column;
  806. background: #fff;
  807. min-width: 0;
  808. position: relative;
  809. }
  810. .preview-header {
  811. display: flex;
  812. align-items: center;
  813. justify-content: space-between;
  814. padding: 16px 20px;
  815. background: #fff;
  816. border-bottom: 1px solid #e4e7ed;
  817. flex-shrink: 0;
  818. .file-info {
  819. display: flex;
  820. align-items: center;
  821. gap: 12px;
  822. .file-icon {
  823. font-size: 24px;
  824. color: #409eff;
  825. }
  826. .file-name {
  827. font-size: 16px;
  828. font-weight: 500;
  829. color: #303133;
  830. max-width: 600px;
  831. overflow: hidden;
  832. text-overflow: ellipsis;
  833. white-space: nowrap;
  834. }
  835. }
  836. .header-actions {
  837. display: flex;
  838. gap: 8px;
  839. .copy-avatar-btn {
  840. .el-icon {
  841. margin-right: 4px;
  842. }
  843. }
  844. }
  845. }
  846. .preview-container {
  847. position: absolute;
  848. top: 68px;
  849. left: 0;
  850. right: 0;
  851. bottom: 0;
  852. overflow: hidden;
  853. .wps-container {
  854. width: 100%;
  855. height: 100%;
  856. position: relative;
  857. }
  858. .document-iframe {
  859. width: 100%;
  860. height: 100%;
  861. border: none;
  862. }
  863. .drag-overlay {
  864. position: absolute;
  865. top: 0;
  866. left: 0;
  867. right: 0;
  868. bottom: 0;
  869. background: rgba(64, 158, 255, 0.1);
  870. border: 2px dashed #409eff;
  871. display: flex;
  872. align-items: center;
  873. justify-content: center;
  874. z-index: 9999;
  875. pointer-events: none;
  876. .drag-hint {
  877. text-align: center;
  878. .drag-icon {
  879. font-size: 64px;
  880. color: #409eff;
  881. margin-bottom: 16px;
  882. }
  883. p {
  884. font-size: 18px;
  885. font-weight: 500;
  886. color: #409eff;
  887. }
  888. }
  889. }
  890. .loading-state {
  891. position: absolute;
  892. top: 50%;
  893. left: 50%;
  894. transform: translate(-50%, -50%);
  895. text-align: center;
  896. .el-icon {
  897. font-size: 48px;
  898. color: #409eff;
  899. margin-bottom: 16px;
  900. }
  901. p {
  902. font-size: 14px;
  903. color: #909399;
  904. }
  905. }
  906. :deep(.el-result),
  907. :deep(.el-empty) {
  908. position: absolute;
  909. top: 50%;
  910. left: 50%;
  911. transform: translate(-50%, -50%);
  912. width: 100%;
  913. }
  914. }
  915. .audit-section {
  916. width: 400px;
  917. display: flex;
  918. flex-direction: column;
  919. background: #fff;
  920. flex-shrink: 0;
  921. }
  922. .section-header {
  923. display: flex;
  924. align-items: center;
  925. gap: 8px;
  926. padding: 16px 20px;
  927. background: #f5f7fa;
  928. border-bottom: 1px solid #e4e7ed;
  929. font-size: 15px;
  930. font-weight: 500;
  931. color: #303133;
  932. .el-icon {
  933. font-size: 18px;
  934. color: #409eff;
  935. }
  936. }
  937. .audit-form {
  938. flex: 1;
  939. padding: 20px;
  940. overflow-y: auto;
  941. &::-webkit-scrollbar {
  942. width: 6px;
  943. }
  944. &::-webkit-scrollbar-thumb {
  945. background: #dcdfe6;
  946. border-radius: 3px;
  947. &:hover {
  948. background: #c0c4cc;
  949. }
  950. }
  951. }
  952. .info-card {
  953. margin-bottom: 20px;
  954. :deep(.el-card__header) {
  955. padding: 12px 16px;
  956. background: #f5f7fa;
  957. }
  958. .card-header {
  959. display: flex;
  960. align-items: center;
  961. gap: 8px;
  962. font-size: 14px;
  963. font-weight: 500;
  964. color: #303133;
  965. .el-icon {
  966. font-size: 16px;
  967. color: #409eff;
  968. }
  969. }
  970. :deep(.el-card__body) {
  971. padding: 16px;
  972. }
  973. }
  974. .info-item {
  975. display: flex;
  976. align-items: center;
  977. padding: 8px 0;
  978. font-size: 14px;
  979. &:not(:last-child) {
  980. border-bottom: 1px solid #f0f2f5;
  981. }
  982. .info-label {
  983. color: #909399;
  984. min-width: 80px;
  985. }
  986. .info-value {
  987. color: #303133;
  988. flex: 1;
  989. word-break: break-all;
  990. }
  991. }
  992. .result-radio-group {
  993. width: 100%;
  994. display: flex;
  995. flex-direction: column;
  996. gap: 12px;
  997. :deep(.el-radio) {
  998. margin: 0;
  999. padding: 0;
  1000. height: auto;
  1001. &.is-bordered {
  1002. padding: 16px;
  1003. border-radius: 8px;
  1004. border: 2px solid #dcdfe6;
  1005. transition: all 0.3s;
  1006. &:hover {
  1007. border-color: #409eff;
  1008. }
  1009. &.is-checked {
  1010. border-color: #409eff;
  1011. background: #ecf5ff;
  1012. }
  1013. }
  1014. .el-radio__input {
  1015. display: none;
  1016. }
  1017. .el-radio__label {
  1018. padding: 0;
  1019. }
  1020. }
  1021. }
  1022. .radio-content {
  1023. display: flex;
  1024. align-items: center;
  1025. gap: 12px;
  1026. width: 100%;
  1027. .radio-icon {
  1028. font-size: 32px;
  1029. flex-shrink: 0;
  1030. &.success {
  1031. color: #67c23a;
  1032. }
  1033. &.danger {
  1034. color: #f56c6c;
  1035. }
  1036. }
  1037. .radio-text {
  1038. flex: 1;
  1039. .radio-title {
  1040. font-size: 15px;
  1041. font-weight: 500;
  1042. color: #303133;
  1043. margin-bottom: 4px;
  1044. }
  1045. .radio-desc {
  1046. font-size: 13px;
  1047. color: #909399;
  1048. }
  1049. }
  1050. }
  1051. :deep(.el-form-item) {
  1052. margin-bottom: 20px;
  1053. .el-form-item__label {
  1054. font-weight: 500;
  1055. color: #303133;
  1056. margin-bottom: 8px;
  1057. }
  1058. }
  1059. :deep(.el-alert) {
  1060. margin-top: 12px;
  1061. border-radius: 6px;
  1062. }
  1063. .dialog-footer {
  1064. display: flex;
  1065. justify-content: flex-end;
  1066. gap: 12px;
  1067. .el-button {
  1068. min-width: 100px;
  1069. .el-icon {
  1070. margin-right: 4px;
  1071. }
  1072. }
  1073. }
  1074. // 响应式设计
  1075. @media (max-width: 1400px) {
  1076. .audit-dialog {
  1077. :deep(.el-dialog) {
  1078. width: 95% !important;
  1079. }
  1080. }
  1081. .audit-section {
  1082. width: 350px;
  1083. }
  1084. }
  1085. @media (max-width: 1200px) {
  1086. .audit-container {
  1087. flex-direction: column;
  1088. }
  1089. .preview-section {
  1090. height: 50%;
  1091. }
  1092. .audit-section {
  1093. width: 100%;
  1094. height: 50%;
  1095. }
  1096. }
  1097. </style>