Index.vue 51 KB

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