Index.vue 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648
  1. <template>
  2. <div class="documents-container">
  3. <div class="header-section">
  4. <div class="title-info">
  5. <h2>{{ currentTitle }}</h2>
  6. <div class="statistics-bar">
  7. <span class="stat-item">
  8. <el-icon><Document /></el-icon>
  9. 全部数据: <span class="stat-value">{{ statistics.allTotal }}</span>
  10. </span>
  11. <span class="stat-item">
  12. <el-icon><CircleCheck /></el-icon>
  13. 已入库: <span class="stat-value success">{{ statistics.totalEntered }}</span>
  14. </span>
  15. <span class="stat-item" v-if="searchQuery.keyword || searchQuery.table_type || searchQuery.whether_to_enter !== null">
  16. <el-icon><Search /></el-icon>
  17. 检索结果: <span class="stat-value">{{ total }}</span>
  18. </span>
  19. </div>
  20. </div>
  21. <div class="action-buttons">
  22. <el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
  23. <el-icon><Delete /></el-icon> 批量删除
  24. </el-button>
  25. <el-button type="warning" :disabled="selectedIds.length === 0" @click="handleBatchEnter">
  26. <el-icon><CircleCheck /></el-icon> 批量入库
  27. </el-button>
  28. <el-button type="success" class="upload-btn" @click="handleUpload">
  29. <el-icon><Upload /></el-icon> 上传文档
  30. </el-button>
  31. </div>
  32. </div>
  33. <!-- 板块切换 Tabs 移除,改为合并视图 -->
  34. <el-card class="search-card">
  35. <div class="search-bar">
  36. <el-input
  37. v-model="searchQuery.keyword"
  38. placeholder="搜索文档名称..."
  39. class="search-input"
  40. clearable
  41. @keyup.enter="handleSearch"
  42. >
  43. <template #prefix>
  44. <el-icon><Search /></el-icon>
  45. </template>
  46. </el-input>
  47. <div class="filter-group">
  48. <el-select v-model="searchQuery.table_type" placeholder="所属知识库" clearable class="filter-select">
  49. <el-option label="全部知识库" :value="null" />
  50. <el-option label="编制依据知识库" value="basis" />
  51. <el-option label="施工方案知识库" value="work" />
  52. <el-option label="办公制度知识库" value="job" />
  53. </el-select>
  54. <el-select v-model="searchQuery.whether_to_enter" placeholder="入库状态" clearable class="filter-select">
  55. <el-option label="全部状态" :value="null" />
  56. <el-option label="未入库" :value="0" />
  57. <el-option label="已入库" :value="1" />
  58. </el-select>
  59. <el-button type="primary" @click="handleSearch" class="search-btn">
  60. <el-icon><Filter /></el-icon> 检索
  61. </el-button>
  62. </div>
  63. </div>
  64. </el-card>
  65. <div class="content-section" v-loading="loading">
  66. <el-empty v-if="documents.length === 0" description="暂无文档数据" />
  67. <el-table
  68. v-else
  69. :data="documents"
  70. style="width: 100%"
  71. border
  72. stripe
  73. size="small"
  74. @selection-change="handleSelectionChange"
  75. :row-class-name="tableRowClassName"
  76. >
  77. <el-table-column type="selection" width="40" :selectable="canSelect" />
  78. <el-table-column prop="title" label="文档名称" min-width="280" show-overflow-tooltip>
  79. <template #default="scope">
  80. <div class="file-info-cell">
  81. <div class="file-icon-mini" :class="getFileIconClass(scope.row)">
  82. <el-icon v-if="getFileIcon(scope.row) === 'pdf'"><Document /></el-icon>
  83. <el-icon v-else-if="getFileIcon(scope.row) === 'word'"><Document /></el-icon>
  84. <el-icon v-else-if="getFileIcon(scope.row) === 'excel'"><Grid /></el-icon>
  85. <el-icon v-else-if="getFileIcon(scope.row) === 'ppt'"><DataAnalysis /></el-icon>
  86. <el-icon v-else><Document /></el-icon>
  87. </div>
  88. <div class="file-info-content">
  89. <span class="file-name-link" @click="handleView(scope.row)">
  90. {{ scope.row.title }}{{ getFileExtension(scope.row) }}
  91. </span>
  92. <span v-if="scope.row.note" class="file-note">
  93. {{ scope.row.note }}
  94. </span>
  95. </div>
  96. </div>
  97. </template>
  98. </el-table-column>
  99. <el-table-column label="转换状态" min-width="180">
  100. <template #default="scope">
  101. <div class="conversion-cell">
  102. <el-tag
  103. :type="getConversionStatusTag(scope.row)"
  104. size="small"
  105. effect="light"
  106. class="status-tag"
  107. >
  108. {{ getConversionStatusText(scope.row) }}
  109. </el-tag>
  110. <div class="converted-file-links" v-if="scope.row.conversion_status === 2">
  111. <div class="converted-file-name" v-if="scope.row.md_url">
  112. <el-link type="primary" :underline="false" @click="handleDownloadConverted(scope.row)">
  113. <el-icon><Link /></el-icon> {{ scope.row.md_display_name || '下载 Markdown' }}
  114. </el-link>
  115. </div>
  116. <div class="converted-file-name" v-if="scope.row.json_url">
  117. <el-link type="success" :underline="false" @click="handleDownloadJson(scope.row)">
  118. <el-icon><Link /></el-icon> {{ scope.row.json_display_name || '下载 JSON' }}
  119. </el-link>
  120. </div>
  121. </div>
  122. </div>
  123. </template>
  124. </el-table-column>
  125. <el-table-column label="知识库" min-width="120">
  126. <template #default="scope">
  127. <el-tag size="small" effect="plain" :type="getKBTagType(scope.row.source_type)">
  128. {{ getKnowledgeBaseShortName(scope.row.source_type) }}
  129. </el-tag>
  130. </template>
  131. </el-table-column>
  132. <el-table-column prop="whether_to_enter" label="入库" width="80" align="center">
  133. <template #default="scope">
  134. <el-tooltip :content="isEntered(scope.row.whether_to_enter) ? '已入库' : '未入库'" placement="top">
  135. <el-icon :class="isEntered(scope.row.whether_to_enter) ? 'status-icon-success' : 'status-icon-info'">
  136. <CircleCheck v-if="isEntered(scope.row.whether_to_enter)" />
  137. <Warning v-else />
  138. </el-icon>
  139. </el-tooltip>
  140. </template>
  141. </el-table-column>
  142. <el-table-column prop="created_by" label="上传人" min-width="100" show-overflow-tooltip />
  143. <el-table-column label="上传时间" min-width="150" prop="created_time">
  144. <template #default="scope">
  145. {{ formatDate(scope.row.created_time) }}
  146. </template>
  147. </el-table-column>
  148. <el-table-column prop="updated_by" label="修改人" min-width="100" show-overflow-tooltip />
  149. <el-table-column label="修改时间" min-width="150" prop="updated_time">
  150. <template #default="scope">
  151. {{ formatDate(scope.row.updated_time) }}
  152. </template>
  153. </el-table-column>
  154. <el-table-column label="操作" width="260" fixed="right" align="center">
  155. <template #default="scope">
  156. <div class="action-buttons">
  157. <el-tooltip content="编辑" placement="top">
  158. <el-button
  159. link
  160. type="primary"
  161. @click="handleEdit(scope.row)"
  162. class="action-btn-icon"
  163. >
  164. <el-icon><Edit /></el-icon>
  165. </el-button>
  166. </el-tooltip>
  167. <el-tooltip content="下载" placement="top" v-if="scope.row.file_url">
  168. <el-button
  169. link
  170. type="success"
  171. @click="handleDownload(scope.row)"
  172. class="action-btn-icon"
  173. >
  174. <el-icon><Download /></el-icon>
  175. </el-button>
  176. </el-tooltip>
  177. <el-tooltip
  178. :content="scope.row.conversion_status === 1 ? '转换中' : (scope.row.conversion_status === 2 ? '重新转换' : '开始转换')"
  179. placement="top"
  180. >
  181. <el-button
  182. link
  183. type="warning"
  184. @click="handleConvert(scope.row)"
  185. :disabled="scope.row.conversion_status === 1"
  186. class="action-btn-icon"
  187. >
  188. <el-icon><Switch /></el-icon>
  189. </el-button>
  190. </el-tooltip>
  191. <el-tooltip content="删除" placement="top">
  192. <el-button
  193. link
  194. type="danger"
  195. @click="handleDelete(scope.row)"
  196. class="action-btn-icon"
  197. >
  198. <el-icon><Delete /></el-icon>
  199. </el-button>
  200. </el-tooltip>
  201. </div>
  202. </template>
  203. </el-table-column>
  204. </el-table>
  205. <!-- 文档预览对话框 -->
  206. <el-dialog v-model="previewVisible" :title="previewTitle" width="85%" top="5vh" custom-class="preview-dialog" @closed="previewUrl = ''">
  207. <template #header>
  208. <div class="dialog-header-custom">
  209. <span>{{ previewTitle }}</span>
  210. <div class="header-actions">
  211. <el-button type="primary" link @click="openInNewWindow">
  212. <el-icon><TopRight /></el-icon>在新窗口打开原链接
  213. </el-button>
  214. </div>
  215. </div>
  216. </template>
  217. <div v-loading="previewLoading" class="preview-content">
  218. <!-- Office 文档未转换提示 -->
  219. <div v-if="isOfficeDoc && previewDocType === 'original' && !previewLoading" class="unsupported-preview">
  220. <el-result
  221. icon="warning"
  222. title="该格式暂不支持直接预览"
  223. sub-title="Word/Excel/PPT 等 Office 文档需要转换后才能在浏览器中直接预览。"
  224. >
  225. <template #extra>
  226. <div class="unsupported-actions">
  227. <el-button type="primary" @click="handleConvert(currentDoc!)">
  228. <el-icon><Switch /></el-icon> 立即转换
  229. </el-button>
  230. <el-button type="success" @click="handleDownload(currentDoc!)">
  231. <el-icon><Download /></el-icon> 下载原文档
  232. </el-button>
  233. </div>
  234. </template>
  235. </el-result>
  236. </div>
  237. <div v-if="isHtmlPage && !previewLoading && !isOfficeDoc" class="preview-tip">
  238. <el-alert
  239. title="网页预览提示"
  240. type="info"
  241. description="由于外部网站的安全限制,某些网页可能无法在此处正常预览。如果显示异常,请点击右上角按钮在新窗口中查看。"
  242. show-icon
  243. :closable="false"
  244. />
  245. </div>
  246. <iframe
  247. v-if="previewUrl && !(isOfficeDoc && previewDocType === 'original')"
  248. :src="proxyPreviewUrl"
  249. width="100%"
  250. height="100%"
  251. frameborder="0"
  252. allow="fullscreen"
  253. @load="previewLoading = false"
  254. ></iframe>
  255. </div>
  256. </el-dialog>
  257. <div class="pagination-container" v-if="total > 0">
  258. <el-pagination
  259. v-model:current-page="searchQuery.page"
  260. v-model:page-size="searchQuery.size"
  261. :page-sizes="[10, 20, 50, 100]"
  262. layout="total, sizes, prev, pager, next, jumper"
  263. :total="total"
  264. @size-change="handleSizeChange"
  265. @current-change="handleCurrentChange"
  266. />
  267. </div>
  268. </div>
  269. <!-- 上传文档对话框 -->
  270. <el-dialog v-model="uploadDialogVisible" title="上传文档" width="500px">
  271. <el-form :model="uploadForm" label-width="100px">
  272. <el-form-item label="所属知识库" required>
  273. <el-select v-model="uploadForm.table_type" placeholder="请选择知识库">
  274. <el-option label="编制依据知识库" value="basis" />
  275. <el-option label="施工方案知识库" value="work" />
  276. <el-option label="办公制度知识库" value="job" />
  277. </el-select>
  278. </el-form-item>
  279. <el-form-item label="文档标题" required>
  280. <el-input v-model="uploadForm.title" placeholder="请输入文档标题" />
  281. </el-form-item>
  282. <el-form-item label="文档链接" v-if="false">
  283. <el-input v-model="uploadForm.file_url" placeholder="请输入文档链接 (URL) 或通过下方上传文件" />
  284. </el-form-item>
  285. <el-form-item label="文件上传">
  286. <el-upload
  287. class="upload-demo"
  288. action="#"
  289. :http-request="customUpload"
  290. :limit="1"
  291. :on-exceed="handleExceed"
  292. :before-upload="beforeUpload"
  293. >
  294. <el-button type="primary">点击上传</el-button>
  295. <template #tip>
  296. <div class="el-upload__tip">
  297. 支持 PDF, Word, Excel, PPT, TXT 等文件,最大 50MB
  298. </div>
  299. </template>
  300. </el-upload>
  301. </el-form-item>
  302. <el-form-item label="文档备注">
  303. <el-input v-model="uploadForm.note" type="textarea" :rows="4" placeholder="请输入文档备注" />
  304. </el-form-item>
  305. </el-form>
  306. <template #footer>
  307. <el-button @click="uploadDialogVisible = false">取消</el-button>
  308. <el-button type="primary" @click="submitUpload" :loading="submitting">确定</el-button>
  309. </template>
  310. </el-dialog>
  311. <!-- 编辑文档对话框 -->
  312. <el-dialog v-model="editDialogVisible" title="编辑文档" width="700px">
  313. <el-form :model="editForm" label-width="120px">
  314. <el-divider content-position="left">基础信息</el-divider>
  315. <el-form-item label="所属知识库" required>
  316. <el-select v-model="editForm.table_type" disabled placeholder="请选择知识库" style="width: 100%">
  317. <el-option label="编制依据知识库" value="basis" />
  318. <el-option label="施工方案知识库" value="work" />
  319. <el-option label="办公制度知识库" value="job" />
  320. </el-select>
  321. </el-form-item>
  322. <el-form-item label="文档标题" required>
  323. <el-input v-model="editForm.title" placeholder="请输入文档标题" />
  324. </el-form-item>
  325. <el-form-item label="文档链接">
  326. <el-input v-model="editForm.file_url" placeholder="请输入文档链接 (URL)" />
  327. </el-form-item>
  328. <el-divider content-position="left">专业属性补全</el-divider>
  329. <!-- 编制依据特有字段 -->
  330. <template v-if="editForm.table_type === 'basis'">
  331. <el-form-item label="标准编号">
  332. <el-input v-model="editForm.standard_no" placeholder="如:GB/T 50001-2017" />
  333. </el-form-item>
  334. <el-form-item label="发布单位">
  335. <el-input v-model="editForm.issuing_authority" placeholder="请输入发布单位" />
  336. </el-form-item>
  337. <el-form-item label="发布日期">
  338. <el-date-picker v-model="editForm.release_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%" />
  339. </el-form-item>
  340. <el-row :gutter="20">
  341. <el-col :span="12">
  342. <el-form-item label="文件类型">
  343. <el-input v-model="editForm.document_type" placeholder="如:国家标准" />
  344. </el-form-item>
  345. </el-col>
  346. <el-col :span="12">
  347. <el-form-item label="时效性">
  348. <el-select v-model="editForm.validity" placeholder="请选择" style="width: 100%">
  349. <el-option label="现行" value="现行" />
  350. <el-option label="已废止" value="已废止" />
  351. <el-option label="被替代" value="被替代" />
  352. </el-select>
  353. </el-form-item>
  354. </el-col>
  355. </el-row>
  356. <el-form-item label="专业领域">
  357. <el-input v-model="editForm.professional_field" placeholder="如:建筑工程" />
  358. </el-form-item>
  359. </template>
  360. <!-- 施工方案特有字段 -->
  361. <template v-else-if="editForm.table_type === 'work'">
  362. <el-row :gutter="20">
  363. <el-col :span="12">
  364. <el-form-item label="工程名称">
  365. <el-input v-model="editForm.project_name" placeholder="请输入项目名称" />
  366. </el-form-item>
  367. </el-col>
  368. <el-col :span="12">
  369. <el-form-item label="工程标段">
  370. <el-input v-model="editForm.project_section" placeholder="请输入项目标段" />
  371. </el-form-item>
  372. </el-col>
  373. </el-row>
  374. <el-row :gutter="20">
  375. <el-col :span="12">
  376. <el-form-item label="方案类别">
  377. <el-select v-model="editForm.plan_category" placeholder="请选择方案类别" style="width: 100%">
  378. <el-option v-for="item in planCategoryOptions" :key="item" :label="item" :value="item" />
  379. </el-select>
  380. </el-form-item>
  381. </el-col>
  382. <el-col :span="12">
  383. <el-form-item label="一级分类">
  384. <el-input v-model="editForm.level_1_classification" disabled />
  385. </el-form-item>
  386. </el-col>
  387. </el-row>
  388. <el-row :gutter="20">
  389. <el-col :span="12">
  390. <el-form-item label="二级分类">
  391. <el-select v-model="editForm.level_2_classification" placeholder="请选择二级分类" style="width: 100%">
  392. <el-option v-for="item in level2Options" :key="item" :label="item" :value="item" />
  393. </el-select>
  394. </el-form-item>
  395. </el-col>
  396. <el-col :span="12">
  397. <el-form-item label="三级分类">
  398. <el-select v-model="editForm.level_3_classification" placeholder="请选择三级分类" style="width: 100%">
  399. <el-option v-for="item in level3Options" :key="item" :label="item" :value="item" />
  400. </el-select>
  401. </el-form-item>
  402. </el-col>
  403. </el-row>
  404. <el-row :gutter="20">
  405. <el-col :span="12">
  406. <el-form-item label="四级分类">
  407. <el-select v-model="editForm.level_4_classification" placeholder="请选择四级分类" style="width: 100%">
  408. <el-option v-for="item in level4Options" :key="item" :label="item" :value="item" />
  409. </el-select>
  410. </el-form-item>
  411. </el-col>
  412. <el-col :span="12">
  413. <el-form-item label="编制单位">
  414. <el-input v-model="editForm.issuing_authority" placeholder="请输入编制单位" />
  415. </el-form-item>
  416. </el-col>
  417. </el-row>
  418. <el-row :gutter="20">
  419. <el-col :span="12">
  420. <el-form-item label="编制日期">
  421. <el-date-picker v-model="editForm.release_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%" />
  422. </el-form-item>
  423. </el-col>
  424. </el-row>
  425. <el-form-item label="方案摘要">
  426. <el-input v-model="editForm.plan_summary" type="textarea" :rows="3" placeholder="请输入方案摘要" />
  427. </el-form-item>
  428. <el-form-item label="编制依据">
  429. <el-input v-model="editForm.compilation_basis" type="textarea" :rows="3" placeholder="请输入编制依据,多个依据请用逗号分隔" />
  430. </el-form-item>
  431. </template>
  432. <!-- 办公制度特有字段 -->
  433. <template v-else-if="editForm.table_type === 'job'">
  434. <el-form-item label="发布部门">
  435. <el-input v-model="editForm.issuing_authority" placeholder="请输入发布部门" />
  436. </el-form-item>
  437. <el-form-item label="制度类型">
  438. <el-input v-model="editForm.document_type" placeholder="如:行政管理" />
  439. </el-form-item>
  440. <el-form-item label="发布日期">
  441. <el-date-picker v-model="editForm.release_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%" />
  442. </el-form-item>
  443. </template>
  444. <el-divider content-position="left">文档备注</el-divider>
  445. <el-form-item label="文档备注">
  446. <el-input v-model="editForm.note" type="textarea" :rows="6" placeholder="请输入文档备注" />
  447. </el-form-item>
  448. </el-form>
  449. <template #footer>
  450. <el-button @click="editDialogVisible = false">取消</el-button>
  451. <el-button type="primary" @click="submitEdit" :loading="submitting">确定保存</el-button>
  452. </template>
  453. </el-dialog>
  454. <!-- 文档详情对话框 -->
  455. <el-dialog v-model="detailDialogVisible" title="文档详情" width="600px">
  456. <el-descriptions :column="1" border>
  457. <el-descriptions-item label="名称">{{ currentDoc?.title }}</el-descriptions-item>
  458. <el-descriptions-item label="知识库">{{ getKnowledgeBaseName(currentDoc?.source_type) }}</el-descriptions-item>
  459. <el-descriptions-item label="上传人">{{ currentDoc?.created_by }}</el-descriptions-item>
  460. <el-descriptions-item label="上传时间">{{ formatDate(currentDoc?.created_time) }}</el-descriptions-item>
  461. <el-descriptions-item label="入库状态">
  462. <el-tag :type="isEntered(currentDoc?.whether_to_enter) ? 'success' : 'info'">
  463. {{ isEntered(currentDoc?.whether_to_enter) ? '已入库' : '未入库' }}
  464. </el-tag>
  465. </el-descriptions-item>
  466. <el-descriptions-item label="文档备注">
  467. <div class="content-preview">{{ currentDoc?.note || '暂无备注' }}</div>
  468. </el-descriptions-item>
  469. <!-- 施工方案特有详情 -->
  470. <template v-if="currentDoc?.source_type === 'work'">
  471. <el-descriptions-item label="工程名称">{{ currentDoc?.project_name || '-' }}</el-descriptions-item>
  472. <el-descriptions-item label="工程标段">{{ currentDoc?.project_section || '-' }}</el-descriptions-item>
  473. <el-descriptions-item label="方案类别">{{ currentDoc?.plan_category || '-' }}</el-descriptions-item>
  474. <el-descriptions-item label="一级分类">{{ currentDoc?.level_1_classification || '-' }}</el-descriptions-item>
  475. <el-descriptions-item label="二级分类">{{ currentDoc?.level_2_classification || '-' }}</el-descriptions-item>
  476. <el-descriptions-item label="三级分类">{{ currentDoc?.level_3_classification || '-' }}</el-descriptions-item>
  477. <el-descriptions-item label="四级分类">{{ currentDoc?.level_4_classification || '-' }}</el-descriptions-item>
  478. <el-descriptions-item label="编制单位">{{ currentDoc?.issuing_authority || '-' }}</el-descriptions-item>
  479. <el-descriptions-item label="编制日期">{{ formatDate(currentDoc?.release_date) }}</el-descriptions-item>
  480. <el-descriptions-item label="方案摘要">
  481. <div class="content-preview">{{ currentDoc?.plan_summary || '-' }}</div>
  482. </el-descriptions-item>
  483. <el-descriptions-item label="编制依据">
  484. <div class="content-preview">{{ currentDoc?.compilation_basis || '-' }}</div>
  485. </el-descriptions-item>
  486. </template>
  487. </el-descriptions>
  488. <template #footer>
  489. <div class="detail-footer">
  490. <div class="download-group" v-if="currentDoc">
  491. <el-button type="success" plain size="small" @click="handleDownload(currentDoc)" v-if="currentDoc.file_url">
  492. <el-icon><Download /></el-icon> 下载原文件
  493. </el-button>
  494. <el-button type="primary" plain size="small" @click="handleDownloadConverted(currentDoc)" v-if="currentDoc.md_url">
  495. <el-icon><Download /></el-icon> 下载 MD
  496. </el-button>
  497. <el-button type="warning" plain size="small" @click="handleDownloadJson(currentDoc)" v-if="currentDoc.json_url">
  498. <el-icon><Download /></el-icon> 下载 JSON
  499. </el-button>
  500. </div>
  501. <div class="action-group">
  502. <el-button @click="detailDialogVisible = false">关闭</el-button>
  503. <el-button type="primary" @click="handleEditFromDetail" v-if="currentDoc">
  504. <el-icon><Edit /></el-icon> 编辑文档
  505. </el-button>
  506. <el-button type="success" @click="handleSingleEnter(currentDoc)" v-if="currentDoc && !isEntered(currentDoc.whether_to_enter)">加入知识库</el-button>
  507. <el-button type="primary" @click="handlePreview(currentDoc)" v-if="currentDoc?.file_url">预览原文档</el-button>
  508. </div>
  509. </div>
  510. </template>
  511. </el-dialog>
  512. </div>
  513. </template>
  514. <script setup lang="ts">
  515. import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
  516. import { ElMessage, ElMessageBox } from 'element-plus'
  517. import { Search, Filter, Upload, CircleCheck, Delete, Document, Warning, TopRight, Grid, DataAnalysis, Link, View, Switch, Edit, User, Download } from '@element-plus/icons-vue'
  518. import request from '@/api/request'
  519. import axios from 'axios'
  520. import { downloadFile } from '@/utils/download'
  521. import { useAuthStore } from '@/stores/auth'
  522. import dayjs from 'dayjs'
  523. import { documentApi, type DocumentItem } from '@/api/document'
  524. // 接口定义已移至 @/api/document
  525. // 状态变量
  526. const loading = ref(false)
  527. const submitting = ref(false)
  528. const uploadDialogVisible = ref(false)
  529. const editDialogVisible = ref(false)
  530. const detailDialogVisible = ref(false)
  531. const previewVisible = ref(false)
  532. const previewLoading = ref(false)
  533. const previewTitle = ref('')
  534. const previewUrl = ref('')
  535. const previewDocType = ref('') // 'original' or 'md'
  536. const isOfficeDoc = ref(false)
  537. const total = ref(0)
  538. const statistics = ref({
  539. allTotal: 0,
  540. totalEntered: 0
  541. })
  542. const authStore = useAuthStore()
  543. const documents = ref<DocumentItem[]>([])
  544. const currentDoc = ref<DocumentItem | null>(null)
  545. const editForm = reactive({
  546. id: '',
  547. title: '',
  548. note: '',
  549. table_type: 'basis' as 'basis' | 'work' | 'job',
  550. year: new Date().getFullYear(),
  551. // 扩展字段 (子表特有属性)
  552. standard_no: '',
  553. issuing_authority: '',
  554. release_date: '',
  555. document_type: '',
  556. professional_field: '',
  557. validity: '',
  558. project_name: '',
  559. project_section: '',
  560. plan_category: '',
  561. level_1_classification: '施工方案',
  562. level_2_classification: '',
  563. level_3_classification: '',
  564. level_4_classification: '',
  565. plan_summary: '',
  566. compilation_basis: '',
  567. file_url: ''
  568. })
  569. const planCategoryOptions = ['超危大方案', '超危大方案较大II级', '超危大方案特大IV级', '超危大方案一般I级', '超危大方案重大III级', '危大方案', '一般方案']
  570. const level2Options = ['临建工程', '路基工程', '其他', '桥梁工程', '隧道工程']
  571. const level3Options = ['/', 'TBM施工', '拌和站安、拆施工', '不良地质隧道施工', '常规桥梁', '挡土墙工程类', '辅助坑道施工', '复杂洞口工程施工', '钢筋加工场安、拆', '钢栈桥施工', '拱桥', '涵洞工程类', '滑坡体处理类', '路堤', '路堑', '其他', '深基坑', '隧道总体施工', '特殊结构隧道', '斜拉桥', '悬索桥']
  572. const level4Options = ['/', '挡土墙', '顶管', '断层破碎带及软弱围岩', '钢筋砼箱涵', '高填路堤', '抗滑桩', '其他', '软岩大变形隧道', '上部结构', '深基坑开挖与支护', '深挖路堑', '隧道TBM', '隧道进洞', '隧道竖井', '隧道斜井', '特种设备', '瓦斯隧道', '下部结构', '小净距隧道', '岩爆隧道', '岩溶隧道', '涌水突泥隧道', '桩基础']
  573. const currentTitle = computed(() => {
  574. return '文档管理中心'
  575. })
  576. const selectedIds = ref<string[]>([])
  577. const searchQuery = reactive({
  578. keyword: '',
  579. table_type: null as string | null,
  580. whether_to_enter: null as number | null,
  581. page: 1,
  582. size: 10
  583. })
  584. const uploadForm = reactive({
  585. title: '',
  586. note: '',
  587. file_url: '',
  588. table_type: 'basis' as 'basis' | 'work' | 'job',
  589. year: new Date().getFullYear()
  590. })
  591. // 计算属性
  592. const proxyPreviewUrl = computed(() => {
  593. if (!previewUrl.value) return ''
  594. // 使用后端代理接口查看外部网页,附加 token 以通过认证
  595. const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
  596. return `${baseUrl}/api/v1/sample/documents/proxy-view?url=${encodeURIComponent(previewUrl.value)}&token=${authStore.token}`
  597. })
  598. const isHtmlPage = computed(() => {
  599. if (!previewUrl.value) return false
  600. const url = previewUrl.value.toLowerCase()
  601. return url.includes('html') || url.includes('newgbinfo') || !url.split(/[?#]/)[0].includes('.')
  602. })
  603. // 方法
  604. const formatSimpleDate = (date: string | null) => {
  605. if (!date) return '-'
  606. return dayjs(date).format('YYYY-MM-DD')
  607. }
  608. const formatDate = (date: string | undefined | null, formatStr: string = 'YYYY-MM-DD HH:mm:ss') => {
  609. return date ? dayjs(date).format(formatStr) : '-'
  610. }
  611. const getFileExtension = (row: DocumentItem) => {
  612. // 1. 优先使用数据库存储的后缀
  613. if (row.file_extension) {
  614. return row.file_extension.startsWith('.') ? row.file_extension : `.${row.file_extension}`
  615. }
  616. // 2. 如果数据库没有,再尝试从 URL 解析
  617. const url = row.file_url
  618. if (!url) return ''
  619. try {
  620. const path = new URL(url).pathname
  621. const lastDotIndex = path.lastIndexOf('.')
  622. if (lastDotIndex === -1) return ''
  623. const ext = path.slice(lastDotIndex).toLowerCase()
  624. if (ext.length > 6 || ext === '.') return ''
  625. return ext
  626. } catch (e) {
  627. const lastDotIndex = url.lastIndexOf('.')
  628. if (lastDotIndex === -1) return ''
  629. const ext = url.slice(lastDotIndex).split(/[?#]/)[0].toLowerCase()
  630. return ext.length <= 6 ? ext : ''
  631. }
  632. }
  633. const getFileIcon = (row: DocumentItem) => {
  634. const ext = getFileExtension(row).replace('.', '').toLowerCase()
  635. if (['pdf'].includes(ext)) return 'pdf'
  636. if (['doc', 'docx'].includes(ext)) return 'word'
  637. if (['xls', 'xlsx'].includes(ext)) return 'excel'
  638. if (['ppt', 'pptx'].includes(ext)) return 'ppt'
  639. if (['html', 'htm'].includes(ext)) return 'html'
  640. return 'file'
  641. }
  642. const getFileIconClass = (row: DocumentItem) => {
  643. return `icon-${getFileIcon(row)}`
  644. }
  645. const getKnowledgeBaseName = (sourceType: string | null | undefined) => {
  646. const names: Record<string, string> = {
  647. basis: '编制依据知识库',
  648. work: '施工方案知识库',
  649. job: '办公制度知识库'
  650. }
  651. return names[sourceType || ''] || '未知知识库'
  652. }
  653. const getKnowledgeBaseShortName = (sourceType: string | null | undefined) => {
  654. const names: Record<string, string> = {
  655. basis: '编制依据',
  656. work: '施工方案',
  657. job: '办公制度'
  658. }
  659. return names[sourceType || ''] || '未知'
  660. }
  661. const getKBTagType = (sourceType: string | null | undefined) => {
  662. const types: Record<string, string> = {
  663. basis: 'primary',
  664. work: 'success',
  665. job: 'warning'
  666. }
  667. return types[sourceType || ''] || 'info'
  668. }
  669. // 为已入库或未转化的行添加特定类名
  670. const tableRowClassName = ({ row }: { row: DocumentItem }) => {
  671. return ''
  672. }
  673. // 判断是否可以勾选(所有文档均可勾选,用于批量删除等操作)
  674. const canSelect = (row: DocumentItem) => {
  675. return true
  676. }
  677. const handleSelectionChange = (selection: DocumentItem[]) => {
  678. selectedIds.value = selection.map(item => item.id)
  679. }
  680. const handleBatchEnter = async () => {
  681. if (selectedIds.value.length === 0) return
  682. const ids = [...selectedIds.value]
  683. try {
  684. const res = await documentApi.batchEnter(ids)
  685. if (res.code === 0) {
  686. // 如果有详细详情(换行符标识),使用 MessageBox 显示
  687. if (res.message && res.message.includes('\n')) {
  688. ElMessageBox.alert(res.message, '入库结果', {
  689. confirmButtonText: '确定',
  690. customStyle: { 'white-space': 'pre-wrap' },
  691. type: res.message.includes('失败') || res.message.includes('跳过') ? 'warning' : 'success'
  692. })
  693. } else {
  694. ElMessage.success(res.message || '入库成功')
  695. }
  696. fetchDocuments()
  697. } else {
  698. ElMessageBox.alert(res.message || '入库失败', '操作失败', {
  699. confirmButtonText: '确定',
  700. type: 'error'
  701. })
  702. }
  703. } catch (error) {
  704. console.error('批量入库失败:', error)
  705. ElMessage.error('网络连接异常,请稍后重试')
  706. }
  707. }
  708. const handleSingleEnter = async (doc: DocumentItem | null) => {
  709. if (!doc) return
  710. try {
  711. const res = await documentApi.batchEnter([doc.id])
  712. if (res.code === 0) {
  713. if (res.message && res.message.includes('\n')) {
  714. ElMessageBox.alert(res.message, '入库结果', {
  715. confirmButtonText: '确定',
  716. customStyle: { 'white-space': 'pre-wrap' },
  717. type: res.message.includes('失败') || res.message.includes('跳过') ? 'warning' : 'success'
  718. })
  719. } else {
  720. ElMessage.success(res.message || '入库成功')
  721. }
  722. detailDialogVisible.value = false
  723. fetchDocuments()
  724. } else {
  725. ElMessage.error(res.message || '入库失败')
  726. }
  727. } catch (error) {
  728. console.error('入库失败:', error)
  729. ElMessage.error('入库异常')
  730. }
  731. }
  732. const handleDelete = async (row: DocumentItem) => {
  733. try {
  734. await ElMessageBox.confirm(
  735. `确定要删除文档 "${row.title}" 吗?此操作不可恢复。`,
  736. '确认删除',
  737. {
  738. confirmButtonText: '确定',
  739. cancelButtonText: '取消',
  740. type: 'warning',
  741. }
  742. )
  743. const res = await documentApi.batchDelete([row.id])
  744. if (res.code === 0) {
  745. ElMessage.success('删除成功')
  746. fetchDocuments()
  747. } else {
  748. ElMessage.error(res.message || '删除失败')
  749. }
  750. } catch (error: any) {
  751. if (error !== 'cancel') {
  752. console.error('删除文档失败:', error)
  753. ElMessage.error('删除失败')
  754. }
  755. }
  756. }
  757. const handleBatchDelete = async () => {
  758. if (selectedIds.value.length === 0) return
  759. try {
  760. await ElMessageBox.confirm(
  761. `确定要批量删除选中的 ${selectedIds.value.length} 条文档吗?此操作不可恢复。`,
  762. '确认批量删除',
  763. {
  764. confirmButtonText: '确定',
  765. cancelButtonText: '取消',
  766. type: 'warning',
  767. }
  768. )
  769. const res = await documentApi.batchDelete(selectedIds.value)
  770. if (res.code === 0) {
  771. ElMessage.success(res.message || '批量删除成功')
  772. selectedIds.value = []
  773. fetchDocuments()
  774. } else {
  775. ElMessage.error(res.message || '批量删除失败')
  776. }
  777. } catch (error: any) {
  778. if (error !== 'cancel') {
  779. console.error('批量删除失败:', error)
  780. ElMessage.error('操作失败')
  781. }
  782. }
  783. }
  784. const isEntered = (val: any) => {
  785. return val === 1 || val === true
  786. }
  787. const getConversionStatusTag = (row: DocumentItem) => {
  788. switch (row.conversion_status) {
  789. case 1: return 'warning' // 转换中
  790. case 2: return 'success' // 成功
  791. case 3: return 'danger' // 失败
  792. default: return 'info' // 未转换 (0)
  793. }
  794. }
  795. const getConversionStatusText = (row: DocumentItem) => {
  796. switch (row.conversion_status) {
  797. case 1: return '转换中'
  798. case 2: return '转换成功'
  799. case 3: return '转换失败'
  800. default: return '未转换'
  801. }
  802. }
  803. const fetchDocuments = async () => {
  804. loading.value = true
  805. try {
  806. const res = await documentApi.getList(searchQuery)
  807. if (res.code === 0) {
  808. documents.value = res.data.items
  809. total.value = res.data.total
  810. statistics.value.allTotal = res.data.all_total || 0
  811. statistics.value.totalEntered = res.data.total_entered || 0
  812. // 自动检查是否需要开启轮询
  813. const hasConverting = documents.value.some(doc => doc.conversion_status === 1)
  814. if (hasConverting) {
  815. startPolling()
  816. } else {
  817. stopPolling()
  818. }
  819. }
  820. } catch (error) {
  821. console.error('获取文档列表失败:', error)
  822. } finally {
  823. loading.value = false
  824. }
  825. }
  826. const isRefreshing = ref(false)
  827. const refreshTimer = ref<any>(null)
  828. const startPolling = () => {
  829. if (refreshTimer.value === null) {
  830. refreshTimer.value = window.setTimeout(refreshDocumentsSilently, 5000)
  831. }
  832. }
  833. const stopPolling = () => {
  834. if (refreshTimer.value) {
  835. window.clearTimeout(refreshTimer.value)
  836. refreshTimer.value = null
  837. }
  838. }
  839. const refreshDocumentsSilently = async () => {
  840. if (isRefreshing.value) return
  841. isRefreshing.value = true
  842. try {
  843. const res = await documentApi.getList(searchQuery, true)
  844. if (res.code === 0) {
  845. documents.value = res.data.items
  846. total.value = res.data.total
  847. statistics.value.allTotal = res.data.all_total || 0
  848. statistics.value.totalEntered = res.data.total_entered || 0
  849. }
  850. } catch (error) {
  851. console.error('静默刷新失败:', error)
  852. } finally {
  853. isRefreshing.value = false
  854. // 如果没有手动停止,且组件未卸载,则安排下一次刷新
  855. if (refreshTimer.value !== null) {
  856. refreshTimer.value = window.setTimeout(refreshDocumentsSilently, 5000)
  857. }
  858. }
  859. }
  860. const handleSearch = () => {
  861. searchQuery.page = 1
  862. fetchDocuments()
  863. }
  864. const handleSizeChange = (val: number) => {
  865. searchQuery.size = val
  866. fetchDocuments()
  867. }
  868. const handleCurrentChange = (val: number) => {
  869. searchQuery.page = val
  870. fetchDocuments()
  871. }
  872. const beforeUpload = (file: File) => {
  873. const isLt50M = file.size / 1024 / 1024 < 50
  874. if (!isLt50M) {
  875. ElMessage.error('上传文件大小不能超过 50MB!')
  876. return false
  877. }
  878. return true
  879. }
  880. const handleExceed = () => {
  881. ElMessage.warning('只能上传一个文件,请先移除已上传的文件')
  882. }
  883. const customUpload = async (options: any) => {
  884. const { file, onSuccess, onError } = options
  885. try {
  886. // 1. 获取预签名 URL
  887. const res = await documentApi.getUploadUrl(file.name, file.type || 'application/octet-stream')
  888. if (res.code !== 0) {
  889. throw new Error(res.message || '获取上传链接失败')
  890. }
  891. const { upload_url, file_url } = res.data
  892. // 2. 直接上传到 MinIO (PUT 请求)
  893. await axios.put(upload_url, file, {
  894. headers: {
  895. 'Content-Type': file.type || 'application/octet-stream'
  896. }
  897. })
  898. // 3. 上传成功,更新表单
  899. uploadForm.file_url = file_url
  900. if (!uploadForm.title) {
  901. // 如果标题为空,自动填充文件名(去掉后缀)
  902. uploadForm.title = file.name.replace(/\.[^/.]+$/, "")
  903. }
  904. ElMessage.success('文件上传成功')
  905. onSuccess(res.data)
  906. } catch (error: any) {
  907. console.error('文件上传失败:', error)
  908. ElMessage.error(error.message || '文件上传失败')
  909. onError(error)
  910. }
  911. }
  912. const handleUpload = () => {
  913. uploadForm.title = ''
  914. uploadForm.note = ''
  915. uploadForm.file_url = ''
  916. uploadDialogVisible.value = true
  917. }
  918. const submitUpload = async () => {
  919. if (!uploadForm.title) {
  920. return ElMessage.warning('请输入文档标题')
  921. }
  922. submitting.value = true
  923. try {
  924. const res = await documentApi.add(uploadForm)
  925. if (res.code === 0) {
  926. ElMessage.success('上传成功')
  927. uploadDialogVisible.value = false
  928. fetchDocuments()
  929. }
  930. } catch (error) {
  931. console.error('上传失败:', error)
  932. } finally {
  933. submitting.value = false
  934. }
  935. }
  936. const handleEdit = async (row: DocumentItem) => {
  937. try {
  938. const res = await documentApi.getDetail(row.id)
  939. if (res.code === 0 && res.data) {
  940. const data = res.data
  941. editForm.id = data.id
  942. editForm.title = data.title
  943. editForm.note = data.note || ''
  944. editForm.table_type = data.source_type
  945. // 填充扩展字段
  946. editForm.standard_no = data.standard_no || ''
  947. editForm.issuing_authority = data.issuing_authority || ''
  948. editForm.release_date = data.release_date || ''
  949. editForm.document_type = data.document_type || ''
  950. editForm.professional_field = data.professional_field || ''
  951. editForm.validity = data.validity || ''
  952. editForm.project_name = data.project_name || ''
  953. editForm.project_section = data.project_section || ''
  954. editForm.plan_category = data.plan_category || ''
  955. editForm.level_1_classification = data.level_1_classification || '施工方案'
  956. editForm.level_2_classification = data.level_2_classification || ''
  957. editForm.level_3_classification = data.level_3_classification || ''
  958. editForm.level_4_classification = data.level_4_classification || ''
  959. editForm.plan_summary = data.plan_summary || ''
  960. editForm.compilation_basis = data.compilation_basis || ''
  961. editForm.file_url = data.file_url || ''
  962. editDialogVisible.value = true
  963. } else {
  964. ElMessage.error(res.message || '获取文档详情失败')
  965. }
  966. } catch (error) {
  967. console.error('获取文档详情失败:', error)
  968. ElMessage.error('获取文档详情失败')
  969. }
  970. }
  971. const submitEdit = async () => {
  972. if (!editForm.title) {
  973. return ElMessage.warning('请输入文档标题')
  974. }
  975. submitting.value = true
  976. try {
  977. const res = await documentApi.edit(editForm)
  978. if (res.code === 0) {
  979. ElMessage.success('更新成功')
  980. editDialogVisible.value = false
  981. fetchDocuments()
  982. }
  983. } catch (error) {
  984. console.error('编辑失败:', error)
  985. } finally {
  986. submitting.value = false
  987. }
  988. }
  989. const handleView = (row: DocumentItem) => {
  990. if (row.file_url) {
  991. handlePreview(row)
  992. } else {
  993. currentDoc.value = row
  994. detailDialogVisible.value = true
  995. }
  996. }
  997. const handleEditFromDetail = () => {
  998. if (currentDoc.value) {
  999. const doc = currentDoc.value
  1000. detailDialogVisible.value = false
  1001. handleEdit(doc)
  1002. }
  1003. }
  1004. const handlePreview = (row: DocumentItem | null) => {
  1005. if (!row || !row.file_url) return
  1006. currentDoc.value = row
  1007. previewTitle.value = row.title
  1008. const ext = getFileExtension(row).toLowerCase()
  1009. const officeExtensions = ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx']
  1010. isOfficeDoc.value = officeExtensions.includes(ext)
  1011. // 如果是 Office 文档且有转换后的 MD,优先预览 MD
  1012. if (isOfficeDoc.value && row.md_url) {
  1013. previewUrl.value = row.md_url
  1014. previewDocType.value = 'md'
  1015. previewTitle.value = `${row.title} (转换预览)`
  1016. } else {
  1017. previewUrl.value = row.file_url
  1018. previewDocType.value = 'original'
  1019. }
  1020. previewVisible.value = true
  1021. // 如果是无法直接预览的 Office 文档,不需要等待 iframe 加载
  1022. if (isOfficeDoc.value && previewDocType.value === 'original') {
  1023. previewLoading.value = false
  1024. } else {
  1025. previewLoading.value = true
  1026. }
  1027. }
  1028. const handleDownload = (row: DocumentItem) => {
  1029. if (row.file_url) {
  1030. const ext = getFileExtension(row)
  1031. const filename = row.title.endsWith(ext) ? row.title : `${row.title}${ext}`
  1032. downloadFile(row.file_url, filename)
  1033. } else {
  1034. ElMessage.warning('该文档暂无下载链接')
  1035. }
  1036. }
  1037. const handleDownloadConverted = (row: DocumentItem) => {
  1038. if (row.md_url) {
  1039. const filename = row.md_display_name || `${row.title}.md`
  1040. downloadFile(row.md_url, filename)
  1041. } else {
  1042. ElMessage.warning('该文档暂无转换后的文件')
  1043. }
  1044. }
  1045. const handleDownloadJson = (row: DocumentItem) => {
  1046. if (row.json_url) {
  1047. const filename = row.json_display_name || `${row.title}.json`
  1048. downloadFile(row.json_url, filename)
  1049. } else {
  1050. ElMessage.warning('该文档暂无转换后的 JSON 文件')
  1051. }
  1052. }
  1053. const handleConvert = async (row: DocumentItem) => {
  1054. // 如果已经转换成功(status 为 2),弹出确认框
  1055. if (row.conversion_status === 2) {
  1056. try {
  1057. await ElMessageBox.confirm(
  1058. '该数据已经转换,再次转换会覆盖数据',
  1059. '再次转换确认',
  1060. {
  1061. confirmButtonText: '确定',
  1062. cancelButtonText: '取消',
  1063. type: 'warning',
  1064. }
  1065. )
  1066. } catch (error) {
  1067. // 用户取消
  1068. return
  1069. }
  1070. }
  1071. try {
  1072. // 乐观更新:设置状态为转换中
  1073. row.conversion_status = 1
  1074. row.conversion_error = undefined
  1075. // 启动转换任务(静默刷新,不触发全局 loading)
  1076. const res = await documentApi.convert(row.id)
  1077. if (res.code === 0) {
  1078. ElMessage.success(res.message || '转换任务已启动')
  1079. // 触发一次静默刷新以更新状态
  1080. refreshDocumentsSilently()
  1081. } else {
  1082. // 失败了恢复状态并刷新
  1083. refreshDocumentsSilently()
  1084. }
  1085. } catch (error) {
  1086. console.error('启动转换失败:', error)
  1087. // 失败了恢复状态并刷新
  1088. refreshDocumentsSilently()
  1089. }
  1090. }
  1091. const openInNewWindow = () => {
  1092. if (previewUrl.value) {
  1093. // 优先尝试在新窗口打开代理后的链接,这有助于控制 Content-Disposition
  1094. window.open(proxyPreviewUrl.value, '_blank')
  1095. }
  1096. }
  1097. onMounted(() => {
  1098. fetchDocuments()
  1099. // 5秒后开始第一次静默刷新,之后递归调用
  1100. refreshTimer.value = window.setTimeout(refreshDocumentsSilently, 5000)
  1101. })
  1102. onUnmounted(() => {
  1103. if (refreshTimer.value) {
  1104. window.clearTimeout(refreshTimer.value)
  1105. }
  1106. stopPolling()
  1107. })
  1108. </script>
  1109. <style scoped>
  1110. .documents-container {
  1111. padding: 20px;
  1112. }
  1113. .header-section {
  1114. display: flex;
  1115. justify-content: space-between;
  1116. align-items: center;
  1117. margin-bottom: 20px;
  1118. }
  1119. .title-info h2 {
  1120. margin: 0;
  1121. font-size: 24px;
  1122. color: #303133;
  1123. }
  1124. .statistics-bar {
  1125. display: flex;
  1126. gap: 20px;
  1127. margin-top: 8px;
  1128. }
  1129. .conversion-progress-wrapper {
  1130. width: 100%;
  1131. padding: 0 5px;
  1132. display: flex;
  1133. flex-direction: column;
  1134. gap: 4px;
  1135. }
  1136. .error-msg-text {
  1137. font-size: 12px;
  1138. color: #f56c6c;
  1139. display: flex;
  1140. align-items: center;
  1141. gap: 4px;
  1142. white-space: nowrap;
  1143. overflow: hidden;
  1144. text-overflow: ellipsis;
  1145. margin-top: 2px;
  1146. }
  1147. .stat-item {
  1148. display: flex;
  1149. align-items: center;
  1150. gap: 6px;
  1151. font-size: 14px;
  1152. color: #606266;
  1153. }
  1154. .stat-item .el-icon {
  1155. font-size: 16px;
  1156. color: #909399;
  1157. }
  1158. .stat-value {
  1159. font-weight: bold;
  1160. color: #303133;
  1161. }
  1162. .stat-value.success {
  1163. color: #67c23a;
  1164. }
  1165. .clickable-filename {
  1166. color: #409eff;
  1167. cursor: pointer;
  1168. font-weight: 500;
  1169. transition: color 0.2s;
  1170. }
  1171. .clickable-filename:hover {
  1172. color: #66b1ff;
  1173. text-decoration: underline;
  1174. }
  1175. .action-btn {
  1176. padding: 4px 8px;
  1177. height: auto;
  1178. font-size: 13px;
  1179. display: flex;
  1180. align-items: center;
  1181. gap: 4px;
  1182. }
  1183. .action-btn-icon {
  1184. padding: 4px;
  1185. height: 28px;
  1186. width: 28px;
  1187. display: flex;
  1188. align-items: center;
  1189. justify-content: center;
  1190. border-radius: 4px;
  1191. transition: all 0.2s;
  1192. }
  1193. .action-btn-icon:hover {
  1194. background-color: #f5f7fa;
  1195. }
  1196. .action-btn-icon .el-icon {
  1197. font-size: 16px;
  1198. }
  1199. .action-btn .el-icon {
  1200. font-size: 14px;
  1201. }
  1202. .file-info-cell {
  1203. display: flex;
  1204. align-items: center;
  1205. gap: 8px;
  1206. }
  1207. .file-icon-mini {
  1208. display: flex;
  1209. align-items: center;
  1210. justify-content: center;
  1211. width: 24px;
  1212. height: 24px;
  1213. border-radius: 4px;
  1214. font-size: 14px;
  1215. }
  1216. .file-icon-mini.pdf { background-color: #fef0f0; color: #f56c6c; }
  1217. .file-icon-mini.word { background-color: #ecf5ff; color: #409eff; }
  1218. .file-icon-mini.excel { background-color: #f0f9eb; color: #67c23a; }
  1219. .file-icon-mini.ppt { background-color: #fff7e6; color: #e6a23c; }
  1220. .file-info-content {
  1221. display: flex;
  1222. flex-direction: column;
  1223. gap: 4px;
  1224. overflow: hidden;
  1225. }
  1226. .file-note {
  1227. font-size: 12px;
  1228. color: #909399;
  1229. white-space: nowrap;
  1230. overflow: hidden;
  1231. text-overflow: ellipsis;
  1232. line-height: 1.2;
  1233. }
  1234. .file-name-link {
  1235. color: #409eff;
  1236. cursor: pointer;
  1237. font-weight: 500;
  1238. white-space: nowrap;
  1239. overflow: hidden;
  1240. text-overflow: ellipsis;
  1241. }
  1242. .file-name-link:hover {
  1243. text-decoration: underline;
  1244. }
  1245. .compact-info {
  1246. display: flex;
  1247. flex-direction: column;
  1248. gap: 2px;
  1249. line-height: 1.2;
  1250. }
  1251. .info-row {
  1252. display: flex;
  1253. align-items: center;
  1254. gap: 4px;
  1255. font-size: 12px;
  1256. color: #606266;
  1257. }
  1258. .info-row.secondary {
  1259. color: #909399;
  1260. font-size: 11px;
  1261. }
  1262. .date-cell {
  1263. display: flex;
  1264. flex-direction: column;
  1265. line-height: 1.2;
  1266. }
  1267. .time-mini {
  1268. font-size: 11px;
  1269. color: #909399;
  1270. }
  1271. .conversion-cell {
  1272. display: flex;
  1273. flex-direction: column;
  1274. align-items: flex-start;
  1275. gap: 4px;
  1276. padding: 2px 0;
  1277. }
  1278. .status-tag {
  1279. font-weight: 500;
  1280. margin-bottom: 2px;
  1281. }
  1282. .converted-file-links {
  1283. display: flex;
  1284. flex-direction: column;
  1285. gap: 0px;
  1286. width: 100%;
  1287. }
  1288. .converted-file-name {
  1289. font-size: 12px;
  1290. line-height: 1.2;
  1291. white-space: nowrap;
  1292. overflow: hidden;
  1293. text-overflow: ellipsis;
  1294. }
  1295. .converted-file-name :deep(.el-link) {
  1296. font-size: 12px;
  1297. justify-content: flex-start;
  1298. }
  1299. .converted-file-name :deep(.el-link .el-icon) {
  1300. margin-right: 4px;
  1301. }
  1302. .file-name-mini {
  1303. font-size: 11px;
  1304. color: #909399;
  1305. white-space: nowrap;
  1306. overflow: hidden;
  1307. text-overflow: ellipsis;
  1308. }
  1309. .status-icon-success { color: #67c23a; font-size: 18px; }
  1310. .status-icon-info { color: #909399; font-size: 18px; }
  1311. .upload-btn {
  1312. background-color: #67c23a;
  1313. border-color: #67c23a;
  1314. }
  1315. .search-card {
  1316. margin-bottom: 20px;
  1317. background-color: #f8f9fa;
  1318. }
  1319. .search-bar {
  1320. display: flex;
  1321. flex-direction: column;
  1322. gap: 16px;
  1323. }
  1324. .search-input :deep(.el-input__wrapper) {
  1325. padding: 8px 12px;
  1326. font-size: 16px;
  1327. }
  1328. .filter-group {
  1329. display: flex;
  1330. gap: 12px;
  1331. align-items: center;
  1332. flex-wrap: wrap;
  1333. }
  1334. .filter-select {
  1335. width: 200px;
  1336. }
  1337. .filter-select-year {
  1338. width: 140px;
  1339. }
  1340. .search-btn {
  1341. padding: 0 30px;
  1342. height: 40px;
  1343. font-size: 15px;
  1344. font-weight: bold;
  1345. margin-left: auto;
  1346. }
  1347. .content-section {
  1348. background: #fff;
  1349. border-radius: 4px;
  1350. min-height: 400px;
  1351. }
  1352. .pagination-container {
  1353. margin-top: 20px;
  1354. display: flex;
  1355. justify-content: flex-end;
  1356. }
  1357. /* 文件信息单元格布局 */
  1358. .file-info-cell {
  1359. display: flex;
  1360. align-items: center;
  1361. padding: 8px 0;
  1362. }
  1363. .file-icon-wrapper {
  1364. position: relative;
  1365. width: 40px;
  1366. height: 48px;
  1367. margin-right: 16px;
  1368. display: flex;
  1369. flex-direction: column;
  1370. align-items: center;
  1371. justify-content: center;
  1372. border-radius: 4px;
  1373. flex-shrink: 0;
  1374. transition: transform 0.2s;
  1375. }
  1376. .file-icon-wrapper:hover {
  1377. transform: scale(1.05);
  1378. }
  1379. .file-icon-wrapper .el-icon {
  1380. font-size: 24px;
  1381. margin-bottom: 2px;
  1382. color: #fff;
  1383. }
  1384. .file-type-label {
  1385. font-size: 10px;
  1386. font-weight: bold;
  1387. color: #fff;
  1388. text-transform: uppercase;
  1389. }
  1390. /* 不同文件类型的背景色 */
  1391. .icon-pdf { background-color: #ff4d4f; }
  1392. .icon-word { background-color: #1890ff; }
  1393. .icon-excel { background-color: #52c41a; }
  1394. .icon-ppt { background-color: #fa8c16; }
  1395. .icon-html { background-color: #13c2c2; }
  1396. .icon-file { background-color: #8c8c8c; }
  1397. .file-text-content {
  1398. display: flex;
  1399. flex-direction: column;
  1400. overflow: hidden;
  1401. }
  1402. .file-name-title {
  1403. font-size: 15px;
  1404. font-weight: 500;
  1405. color: #303133;
  1406. margin-bottom: 4px;
  1407. cursor: pointer;
  1408. white-space: nowrap;
  1409. overflow: hidden;
  1410. text-overflow: ellipsis;
  1411. transition: color 0.2s;
  1412. }
  1413. .file-name-title:hover {
  1414. color: #409eff;
  1415. }
  1416. .file-description-subtitle {
  1417. font-size: 12px;
  1418. color: #909399;
  1419. white-space: nowrap;
  1420. overflow: hidden;
  1421. text-overflow: ellipsis;
  1422. }
  1423. /* 操作按钮样式 */
  1424. .action-buttons {
  1425. display: flex;
  1426. justify-content: center;
  1427. align-items: center;
  1428. gap: 4px;
  1429. }
  1430. .dialog-header-custom {
  1431. display: flex;
  1432. justify-content: space-between;
  1433. align-items: center;
  1434. padding-right: 30px;
  1435. }
  1436. .header-actions {
  1437. display: flex;
  1438. gap: 10px;
  1439. }
  1440. .preview-tip {
  1441. margin-bottom: 15px;
  1442. }
  1443. .preview-content {
  1444. height: 75vh;
  1445. display: flex;
  1446. flex-direction: column;
  1447. border: 1px solid #dcdfe6;
  1448. border-radius: 4px;
  1449. background-color: #f5f7fa;
  1450. position: relative;
  1451. }
  1452. .unsupported-preview {
  1453. flex: 1;
  1454. display: flex;
  1455. align-items: center;
  1456. justify-content: center;
  1457. background-color: #fff;
  1458. }
  1459. .unsupported-actions {
  1460. display: flex;
  1461. gap: 12px;
  1462. justify-content: center;
  1463. }
  1464. .preview-content iframe {
  1465. flex: 1;
  1466. display: block;
  1467. }
  1468. :deep(.preview-dialog) {
  1469. .el-dialog__body {
  1470. padding: 10px 20px 20px;
  1471. }
  1472. }
  1473. </style>