| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- <template>
- <div class="file-manage">
- <el-card>
- <el-row :gutter="16" style="margin-bottom:16px">
- <el-col :span="6">
- <el-card shadow="never" class="stat-card">
- <div class="stat-value">{{ stats.total || 0 }}</div>
- <div class="stat-label">总文件数</div>
- </el-card>
- </el-col>
- <el-col :span="6">
- <el-card shadow="never" class="stat-card">
- <div class="stat-value">{{ formatSize(stats.totalBytes || 0) }}</div>
- <div class="stat-label">总大小</div>
- </el-card>
- </el-col>
- <el-col :span="12">
- <el-card shadow="never" class="stat-card">
- <div style="display:flex;gap:12px;flex-wrap:wrap">
- <span v-for="c in stats.byCategory || []" :key="c.category">
- <el-tag size="small">{{ c.category }}: {{ c.cnt }}</el-tag>
- </span>
- </div>
- <div class="stat-label">分类分布</div>
- </el-card>
- </el-col>
- </el-row>
- <div class="filter-bar">
- <el-select v-model="query.category" placeholder="分类" clearable style="width:130px">
- <el-option label="全部" value=""></el-option>
- <el-option label="articles" value="articles"></el-option>
- <el-option label="kb-images" value="kb-images"></el-option>
- <el-option label="dan-reports" value="dan-reports"></el-option>
- <el-option label="assessment-reports" value="assessment-reports"></el-option>
- <el-option label="health-reports" value="health-reports"></el-option>
- <el-option label="general" value="general"></el-option>
- </el-select>
- <el-select v-model="query.fileType" placeholder="文件类型" clearable style="width:120px">
- <el-option label="全部" value=""></el-option>
- <el-option label="image" value="image"></el-option>
- <el-option label="pdf" value="pdf"></el-option>
- <el-option label="video" value="video"></el-option>
- <el-option label="audio" value="audio"></el-option>
- <el-option label="other" value="other"></el-option>
- </el-select>
- <el-select v-model="query.tagIds" placeholder="标签" multiple clearable style="width:200px">
- <el-option v-for="t in tagOptions" :key="t.id" :label="t.name" :value="t.id"></el-option>
- </el-select>
- <el-input v-model="query.search" placeholder="搜索文件名/描述" style="width:200px" @keyup.enter.native="loadData"></el-input>
- <el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width:240px"></el-date-picker>
- <el-button type="primary" @click="loadData">查询</el-button>
- </div>
- <el-table :data="tableData" border stripe v-loading="loading" style="margin-top:16px">
- <el-table-column prop="id" label="ID" width="70">
- <template slot="header">
- <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>
- </template>
- </el-table-column>
- <el-table-column label="预览" width="70">
- <template slot-scope="scope">
- <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>
- <i v-else-if="scope.row.fileType === 'pdf'" class="el-icon-document" style="font-size:28px;color:#F56C6C;display:block;text-align:center"></i>
- <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>
- <i v-else-if="scope.row.fileType === 'audio'" class="el-icon-headset" style="font-size:28px;color:#67C23A;display:block;text-align:center"></i>
- <i v-else class="el-icon-document" style="font-size:28px;color:#909399;display:block;text-align:center"></i>
- </template>
- </el-table-column>
- <el-table-column prop="originalFilename" label="文件名" min-width="180" show-overflow-tooltip>
- <template slot="header">
- <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>
- </template>
- </el-table-column>
- <el-table-column prop="fileType" label="类型" width="70">
- <template slot="header">
- <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>
- </template>
- <template slot-scope="scope">
- <el-tag size="mini" :type="typeTag(scope.row.fileType)">{{ scope.row.fileType || '?' }}</el-tag>
- </template>
- </el-table-column>
- <el-table-column prop="fileSize" label="大小" width="90">
- <template slot="header">
- <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>
- </template>
- <template slot-scope="scope">{{ formatSize(scope.row.fileSize) }}</template>
- </el-table-column>
- <el-table-column prop="category" label="分类" width="110">
- <template slot="header">
- <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>
- </template>
- </el-table-column>
- <el-table-column label="标签" min-width="140">
- <template slot-scope="scope">
- <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>
- <span v-if="!scope.row.tags || scope.row.tags.length === 0" class="no-tag">-</span>
- </template>
- </el-table-column>
- <el-table-column prop="description" label="描述" min-width="120" show-overflow-tooltip>
- <template slot="header">
- <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>
- </template>
- </el-table-column>
- <el-table-column prop="createdAt" label="上传时间" width="160">
- <template slot-scope="scope">{{ scope.row.createdAt }}</template>
- <template slot="header">
- <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>
- </template>
- </el-table-column>
- <el-table-column label="操作" width="120" fixed="right">
- <template slot-scope="scope">
- <el-button size="mini" type="text" @click="openEdit(scope.row)">编辑</el-button>
- <el-button size="mini" type="text" style="color:#F56C6C" @click="handleDelete(scope.row)">删除</el-button>
- </template>
- </el-table-column>
- </el-table>
- <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>
- </el-card>
- <el-dialog title="编辑文件" :visible.sync="editVisible" width="500px">
- <el-form label-width="80px">
- <el-form-item label="文件名"><el-input v-model="editForm.originalFilename" disabled></el-input></el-form-item>
- <el-form-item label="URL">
- <el-input v-model="editForm.url" disabled>
- <el-button slot="append" size="mini" @click="copyUrl(editForm.url)">复制</el-button>
- </el-input>
- </el-form-item>
- <el-form-item label="描述"><el-input v-model="editForm.description" type="textarea" :rows="3" maxlength="500"></el-input></el-form-item>
- <el-form-item label="标签">
- <el-select v-model="editForm.tagIds" multiple placeholder="选择标签" style="width:100%">
- <el-option v-for="t in tagOptions" :key="t.id" :label="t.name" :value="t.id">
- <span :style="{ color: t.color }">{{ t.name }}</span>
- </el-option>
- </el-select>
- </el-form-item>
- </el-form>
- <span slot="footer">
- <el-button @click="editVisible = false">取消</el-button>
- <el-button type="primary" @click="saveEdit" :loading="saving">保存</el-button>
- </span>
- </el-dialog>
- </div>
- </template>
- <script>
- import SortMixin from '@/mixins/SortMixin'
- import { listFiles, updateFile, deleteFile, getFileTags, getFileStats } from '@/api/file'
- export default {
- name: 'FileManage',
- mixins: [SortMixin],
- data() {
- return {
- tableData: [], total: 0, loading: false, stats: {}, tagOptions: [], dateRange: null,
- query: { page: 1, pageSize: 20, category: '', fileType: '', tagIds: [], search: '', dateFrom: '', dateTo: '' },
- editVisible: false, saving: false,
- editForm: { id: null, url: '', originalFilename: '', description: '', tagIds: [] },
- sortableColumns: { id: 'ID', originalFilename: '文件名', fileType: '类型', fileSize: '大小', category: '分类', description: '描述', createdAt: '上传时间' }
- }
- },
- mounted() { this.loadStats(); this.loadTags(); this.loadData() },
- methods: {
- loadList() { this.loadData() },
- async loadData() {
- this.loading = true
- try {
- if (this.dateRange && this.dateRange.length === 2) {
- this.query.dateFrom = this.dateRange[0].toISOString().slice(0, 10)
- this.query.dateTo = this.dateRange[1].toISOString().slice(0, 10)
- } else { this.query.dateFrom = ''; this.query.dateTo = '' }
- if (this.sortState.length > 0) this.query.sort = this.sortState
- const res = await listFiles(this.query)
- if (res.data.code === 200) { this.tableData = res.data.data.records; this.total = res.data.data.total }
- } catch (e) { this.$message.error('加载失败') }
- this.loading = false
- },
- async loadStats() { try { const res = await getFileStats(); if (res.data.code === 200) this.stats = res.data.data } catch (e) {} },
- async loadTags() { try { const res = await getFileTags(); if (res.data.code === 200) this.tagOptions = res.data.data } catch (e) {} },
- openEdit(row) {
- this.editForm = { id: row.id, url: row.url, originalFilename: row.originalFilename, description: row.description || '', tagIds: (row.tags || []).map(t => t.id) }
- this.editVisible = true
- },
- async saveEdit() {
- this.saving = true
- try {
- const res = await updateFile({ id: this.editForm.id, description: this.editForm.description, tagIds: this.editForm.tagIds })
- if (res.data.code === 200) { this.$message.success('更新成功'); this.editVisible = false; this.loadData() }
- else this.$message.error(res.data.message || '更新失败')
- } catch (e) { this.$message.error('更新失败') }
- this.saving = false
- },
- handleDelete(row) {
- this.$confirm('确定删除该文件?删除后不可恢复。', '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
- .then(async () => {
- 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 || '删除失败') }
- catch (e) { this.$message.error('删除失败') }
- }).catch(() => {})
- },
- 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('已复制') },
- handleSizeChange(size) { this.query.pageSize = size; this.query.page = 1; this.loadData() },
- handleCurrentChange(page) { this.query.page = page; this.loadData() },
- formatSize(bytes) {
- if (!bytes) return '0B'; const units = ['B', 'KB', 'MB', 'GB']; let i = 0; let size = bytes
- while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
- return size.toFixed(i > 0 ? 1 : 0) + units[i]
- },
- typeTag(type) { return { image: 'success', pdf: 'danger', video: 'primary', audio: 'warning' }[type] || 'info' }
- }
- }
- </script>
- <style scoped>
- .filter-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
- .stat-card { text-align: center; }
- .stat-value { font-size: 24px; font-weight: bold; color: #303133; }
- .stat-label { font-size: 12px; color: #909399; margin-top: 4px; }
- .sort-header {
- cursor: pointer;
- user-select: none;
- font-weight: 500;
- font-size: 12px;
- }
- .sort-header:hover {
- color: #409eff;
- }
- .no-tag { color: #C0C4CC; }
- </style>
|