FileManage.vue 14 KB

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