FileManage.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. <template>
  2. <div class="file-manage">
  3. <el-card>
  4. <PageHeader slot="header" title="文件管理" />
  5. <el-row :gutter="16" class="mb-16">
  6. <el-col :span="6">
  7. <StatCard label="总文件数" :value="stats.total || 0" />
  8. </el-col>
  9. <el-col :span="6">
  10. <StatCard label="总大小" :value="formatSize(stats.totalBytes || 0)" />
  11. </el-col>
  12. <el-col :span="12">
  13. <el-card shadow="never" class="stat-card">
  14. <div class="stat-chips">
  15. <StatusTag v-for="c in stats.byCategory || []" :key="c.category" type="primary" :text="c.category + ': ' + c.cnt" size="small" />
  16. </div>
  17. <div class="stat-label">分类分布</div>
  18. </el-card>
  19. </el-col>
  20. </el-row>
  21. <div class="filter-bar">
  22. <el-select v-model="query.category" placeholder="分类" clearable class="w-130">
  23. <el-option label="全部" value=""></el-option>
  24. <el-option label="articles" value="articles"></el-option>
  25. <el-option label="kb-images" value="kb-images"></el-option>
  26. <el-option label="dan-reports" value="dan-reports"></el-option>
  27. <el-option label="assessment-reports" value="assessment-reports"></el-option>
  28. <el-option label="health-reports" value="health-reports"></el-option>
  29. <el-option label="general" value="general"></el-option>
  30. </el-select>
  31. <el-select v-model="query.fileType" placeholder="文件类型" clearable class="w-120">
  32. <el-option label="全部" value=""></el-option>
  33. <el-option label="image" value="image"></el-option>
  34. <el-option label="document" value="document"></el-option>
  35. <el-option label="video" value="video"></el-option>
  36. <el-option label="audio" value="audio"></el-option>
  37. <el-option label="other" value="other"></el-option>
  38. </el-select>
  39. <el-select v-model="query.tagIds" placeholder="标签" multiple clearable class="w-200">
  40. <el-option v-for="t in tagOptions" :key="t.id" :label="t.name" :value="t.id"></el-option>
  41. </el-select>
  42. <el-input v-model="query.search" placeholder="搜索文件名/描述" class="w-200" @keyup.enter.native="loadData"></el-input>
  43. <el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" class="w-240"></el-date-picker>
  44. <el-button type="primary" @click="loadData">查询</el-button>
  45. </div>
  46. <div class="adm-table-wrap">
  47. <el-table :data="tableData" border stripe v-loading="loading" class="mt-16" height="100%">
  48. <el-table-column prop="id" label="ID" width="70">
  49. <template slot="header">
  50. <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
  51. </template>
  52. </el-table-column>
  53. <el-table-column label="预览" width="70">
  54. <template slot-scope="scope">
  55. <el-image v-if="scope.row.fileType === 'image'" :src="scope.row.url" class="file-thumb" fit="cover" :preview-src-list="[scope.row.url]"></el-image>
  56. <i v-else-if="scope.row.fileType === 'document' || scope.row.fileType === 'pdf'" class="el-icon-document file-icon fi-error"></i>
  57. <i v-else-if="scope.row.fileType === 'video'" class="el-icon-video-camera file-icon fi-primary"></i>
  58. <i v-else-if="scope.row.fileType === 'audio'" class="el-icon-headset file-icon fi-success"></i>
  59. <i v-else class="el-icon-document file-icon fi-muted"></i>
  60. </template>
  61. </el-table-column>
  62. <el-table-column prop="originalFilename" label="文件名" min-width="180" show-overflow-tooltip>
  63. <template slot="header">
  64. <span class="sort-header" @click.stop="onSortClick('originalFilename', $event)">文件名<span v-if="sortIndex('originalFilename') !== null" class="sort-index" :class="sortClass('originalFilename') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('originalFilename') }}</span>{{ sortIcon('originalFilename') }}</span>
  65. </template>
  66. </el-table-column>
  67. <el-table-column prop="fileType" label="类型" width="70">
  68. <template slot="header">
  69. <span class="sort-header" @click.stop="onSortClick('fileType', $event)">类型<span v-if="sortIndex('fileType') !== null" class="sort-index" :class="sortClass('fileType') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('fileType') }}</span>{{ sortIcon('fileType') }}</span>
  70. </template>
  71. <template slot-scope="scope">
  72. <StatusTag size="mini" :type="typeTag(scope.row.fileType)" :text="scope.row.fileType || '?'" />
  73. </template>
  74. </el-table-column>
  75. <el-table-column prop="fileSize" label="大小" width="90">
  76. <template slot="header">
  77. <span class="sort-header" @click.stop="onSortClick('fileSize', $event)">大小<span v-if="sortIndex('fileSize') !== null" class="sort-index" :class="sortClass('fileSize') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('fileSize') }}</span>{{ sortIcon('fileSize') }}</span>
  78. </template>
  79. <template slot-scope="scope">{{ formatSize(scope.row.fileSize) }}</template>
  80. </el-table-column>
  81. <el-table-column prop="category" label="分类" width="110">
  82. <template slot="header">
  83. <span class="sort-header" @click.stop="onSortClick('category', $event)">分类<span v-if="sortIndex('category') !== null" class="sort-index" :class="sortClass('category') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('category') }}</span>{{ sortIcon('category') }}</span>
  84. </template>
  85. </el-table-column>
  86. <el-table-column label="标签" min-width="140">
  87. <template slot-scope="scope">
  88. <el-tag v-for="t in (scope.row.tags || [])" :key="t.id" size="mini" class="tag-chip" :style="{ backgroundColor: t.color + '22', color: t.color }">{{ t.name }}</el-tag>
  89. <span v-if="!scope.row.tags || scope.row.tags.length === 0" class="no-tag">-</span>
  90. </template>
  91. </el-table-column>
  92. <el-table-column prop="description" label="描述" min-width="120" show-overflow-tooltip>
  93. <template slot="header">
  94. <span class="sort-header" @click.stop="onSortClick('description', $event)">描述<span v-if="sortIndex('description') !== null" class="sort-index" :class="sortClass('description') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('description') }}</span>{{ sortIcon('description') }}</span>
  95. </template>
  96. </el-table-column>
  97. <el-table-column prop="createdAt" label="上传时间" width="160">
  98. <template slot-scope="scope">{{ scope.row.createdAt }}</template>
  99. <template slot="header">
  100. <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">上传时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
  101. </template>
  102. </el-table-column>
  103. <el-table-column label="操作" width="120" fixed="right">
  104. <template slot-scope="scope">
  105. <el-button size="mini" type="text" @click="openEdit(scope.row)">编辑</el-button>
  106. <el-button size="mini" type="text" class="text-error" @click="handleDelete(scope.row)">删除</el-button>
  107. </template>
  108. </el-table-column>
  109. </el-table>
  110. </div>
  111. <el-pagination @size-change="handleSizeChange" @current-change="handleCurrentChange" :current-page="query.page" :page-size="query.pageSize" :total="total" layout="total, sizes, prev, pager, next, jumper" :page-sizes="[10, 20, 50, 100]" class="mt-16"></el-pagination>
  112. </el-card>
  113. <el-dialog title="编辑文件" :visible.sync="editVisible" width="500px">
  114. <el-form label-width="80px">
  115. <el-form-item label="文件名"><el-input v-model="editForm.originalFilename" disabled></el-input></el-form-item>
  116. <el-form-item label="URL">
  117. <el-input v-model="editForm.url" disabled>
  118. <el-button slot="append" size="mini" @click="copyUrl(editForm.url)">复制</el-button>
  119. </el-input>
  120. </el-form-item>
  121. <el-form-item label="描述"><el-input v-model="editForm.description" type="textarea" :rows="3" maxlength="500"></el-input></el-form-item>
  122. <el-form-item label="标签">
  123. <el-select v-model="editForm.tagIds" multiple placeholder="选择标签" class="full-width">
  124. <el-option v-for="t in tagOptions" :key="t.id" :label="t.name" :value="t.id">
  125. <span :style="{ color: t.color }">{{ t.name }}</span>
  126. </el-option>
  127. </el-select>
  128. </el-form-item>
  129. </el-form>
  130. <span slot="footer">
  131. <el-button @click="editVisible = false">取消</el-button>
  132. <el-button type="primary" @click="saveEdit" :loading="saving">保存</el-button>
  133. </span>
  134. </el-dialog>
  135. </div>
  136. </template>
  137. <script>
  138. import SortMixin from '@/mixins/SortMixin'
  139. import ListStateMixin from '@/mixins/ListStateMixin'
  140. import { listFiles, updateFile, deleteFile, getFileTags, getFileStats } from '@/api/file'
  141. export default {
  142. name: 'FileManage',
  143. mixins: [SortMixin, ListStateMixin],
  144. data() {
  145. return {
  146. tableData: [], total: 0, loading: false, stats: {}, tagOptions: [], dateRange: null,
  147. query: { page: 1, pageSize: 20, category: '', fileType: '', tagIds: [], search: '', dateFrom: '', dateTo: '' },
  148. editVisible: false, saving: false,
  149. editForm: { id: null, url: '', originalFilename: '', description: '', tagIds: [] },
  150. sortableColumns: { id: 'ID', originalFilename: '文件名', fileType: '类型', fileSize: '大小', category: '分类', description: '描述', createdAt: '上传时间' }
  151. }
  152. },
  153. mounted() { this.loadStats(); this.loadTags(); this.loadData() },
  154. methods: {
  155. loadList() { this.loadData() },
  156. async loadData() {
  157. this.loading = true
  158. try {
  159. if (this.dateRange && this.dateRange.length === 2) {
  160. this.query.dateFrom = this.dateRange[0].toISOString().slice(0, 10)
  161. this.query.dateTo = this.dateRange[1].toISOString().slice(0, 10)
  162. } else { this.query.dateFrom = ''; this.query.dateTo = '' }
  163. if (this.sortState.length > 0) this.query.sort = this.sortState
  164. const res = await listFiles(this.query)
  165. if (res.data.code === 200) { this.tableData = res.data.data.records; this.total = res.data.data.total }
  166. } catch (e) { this.$message.error('加载失败') }
  167. this.loading = false
  168. },
  169. async loadStats() { try { const res = await getFileStats(); if (res.data.code === 200) this.stats = res.data.data } catch (e) {} },
  170. async loadTags() { try { const res = await getFileTags(); if (res.data.code === 200) this.tagOptions = res.data.data } catch (e) {} },
  171. openEdit(row) {
  172. this.editForm = { id: row.id, url: row.url, originalFilename: row.originalFilename, description: row.description || '', tagIds: (row.tags || []).map(t => t.id) }
  173. this.editVisible = true
  174. },
  175. async saveEdit() {
  176. this.saving = true
  177. try {
  178. const res = await updateFile({ id: this.editForm.id, description: this.editForm.description, tagIds: this.editForm.tagIds })
  179. if (res.data.code === 200) { this.$message.success('更新成功'); this.editVisible = false; this.loadData() }
  180. else this.$message.error(res.data.message || '更新失败')
  181. } catch (e) { this.$message.error('更新失败') }
  182. this.saving = false
  183. },
  184. handleDelete(row) {
  185. this.$confirm('确定删除该文件?删除后不可恢复。', '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
  186. .then(async () => {
  187. try { const res = await deleteFile({ id: row.id }); if (res.data.code === 200) { this.$message.success('删除成功'); this.loadData(); this.loadStats() } else this.$message.error(res.data.message || '删除失败') }
  188. catch (e) { this.$message.error('删除失败') }
  189. }).catch(() => {})
  190. },
  191. copyUrl(url) { const input = document.createElement('input'); input.value = url; document.body.appendChild(input); input.select(); document.execCommand('copy'); document.body.removeChild(input); this.$message.success('已复制') },
  192. handleSizeChange(size) { this.query.pageSize = size; this.query.page = 1; this.loadData() },
  193. handleCurrentChange(page) { this.query.page = page; this.loadData() },
  194. formatSize(bytes) {
  195. if (!bytes) return '0B'; const units = ['B', 'KB', 'MB', 'GB']; let i = 0; let size = bytes
  196. while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
  197. return size.toFixed(i > 0 ? 1 : 0) + units[i]
  198. },
  199. typeTag(type) { return { image: 'success', document: 'danger', pdf: 'danger', video: 'primary', audio: 'warning' }[type] || 'info' }
  200. }
  201. }
  202. </script>
  203. <style scoped>
  204. .filter-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
  205. .stat-card { text-align: center; }
  206. .stat-label { font-size: 12px; color: var(--adm-text-45); margin-top: 4px; }
  207. .no-tag { color: var(--adm-text-25); }
  208. /* == C 批内联清理工具类 == */
  209. .w-130 { width: 130px; }
  210. .w-120 { width: 120px; }
  211. .w-200 { width: 200px; }
  212. .w-240 { width: 240px; }
  213. .full-width { width: 100%; }
  214. .mt-16 { margin-top: 16px; }
  215. .mb-16 { margin-bottom: 16px; }
  216. .stat-chips { display: flex; gap: 12px; flex-wrap: wrap; }
  217. .file-thumb { width: 40px; height: 40px; border-radius: 4px; }
  218. .file-icon { font-size: 28px; display: block; text-align: center; }
  219. .fi-error { color: var(--adm-error); }
  220. .fi-primary { color: var(--adm-primary); }
  221. .fi-success { color: var(--adm-success); }
  222. .fi-muted { color: var(--adm-text-45); }
  223. .tag-chip { margin-right: 4px; margin-bottom: 2px; border: 0; }
  224. .text-error { color: var(--adm-error); }
  225. </style>