|
|
@@ -0,0 +1,792 @@
|
|
|
+<template>
|
|
|
+ <div class="asset-library admin-page">
|
|
|
+ <el-card>
|
|
|
+ <PageHeader slot="header" title="素材库">
|
|
|
+ <div class="admin-page-actions">
|
|
|
+ <el-button icon="el-icon-tag" @click="tagDialogVisible = true">标签管理</el-button>
|
|
|
+ <el-button icon="el-icon-refresh" @click="loadAll">刷新</el-button>
|
|
|
+ </div>
|
|
|
+ </PageHeader>
|
|
|
+
|
|
|
+ <!-- 统计 -->
|
|
|
+ <el-row :gutter="16" class="mb-16">
|
|
|
+ <el-col :span="6">
|
|
|
+ <StatCard label="素材总数" :value="stats.total || 0" />
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="6">
|
|
|
+ <StatCard label="占用空间" :value="formatSize(stats.totalBytes || 0)" />
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-card shadow="never" class="stat-card">
|
|
|
+ <div class="stat-chips">
|
|
|
+ <StatusTag
|
|
|
+ v-for="c in stats.byFileType || []"
|
|
|
+ :key="c.file_type"
|
|
|
+ type="primary"
|
|
|
+ :text="(c.file_type || 'unknown') + ': ' + c.cnt"
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="stat-label">类型分布</div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <!-- 筛选 -->
|
|
|
+ <div class="filter-bar">
|
|
|
+ <el-select v-model="query.fileType" placeholder="类型" clearable class="w-120" @change="onFilterChange">
|
|
|
+ <el-option label="全部" value="" />
|
|
|
+ <el-option label="图片" value="image" />
|
|
|
+ <el-option label="文档" value="document" />
|
|
|
+ <el-option label="视频" value="video" />
|
|
|
+ <el-option label="音频" value="audio" />
|
|
|
+ <el-option label="其它" value="other" />
|
|
|
+ </el-select>
|
|
|
+ <el-select v-model="query.category" placeholder="分类" clearable class="w-140" @change="onFilterChange">
|
|
|
+ <el-option label="全部" value="" />
|
|
|
+ <el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
|
|
|
+ </el-select>
|
|
|
+ <el-select v-model="query.tagIds" placeholder="标签(任一命中)" multiple collapse-tags clearable class="w-220" @change="onFilterChange">
|
|
|
+ <el-option v-for="t in tags" :key="t.id" :label="t.name" :value="t.id">
|
|
|
+ <span :style="{ color: t.color }">{{ t.name }}</span>
|
|
|
+ </el-option>
|
|
|
+ </el-select>
|
|
|
+ <el-input
|
|
|
+ v-model="query.search"
|
|
|
+ placeholder="搜索文件名 / 描述"
|
|
|
+ clearable
|
|
|
+ class="w-200"
|
|
|
+ @keyup.enter.native="onSearch"
|
|
|
+ @clear="onSearch"
|
|
|
+ />
|
|
|
+ <el-date-picker
|
|
|
+ v-model="dateRange"
|
|
|
+ type="daterange"
|
|
|
+ range-separator="至"
|
|
|
+ start-placeholder="开始日期"
|
|
|
+ end-placeholder="结束日期"
|
|
|
+ value-format="yyyy-MM-dd"
|
|
|
+ class="w-240"
|
|
|
+ @change="onSearch"
|
|
|
+ />
|
|
|
+ <el-button type="primary" icon="el-icon-search" @click="onSearch">查询</el-button>
|
|
|
+ <el-button icon="el-icon-refresh-left" @click="onReset">重置</el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 批量操作条 -->
|
|
|
+ <div class="batch-bar" :class="{ 'is-active': selectedIds.length > 0 }">
|
|
|
+ <span class="batch-info">已选 {{ selectedIds.length }} 项</span>
|
|
|
+ <el-button size="mini" type="primary" :disabled="selectedIds.length === 0" @click="openBatchTagDialog">
|
|
|
+ 批量打标签
|
|
|
+ </el-button>
|
|
|
+ <el-button size="mini" type="danger" :disabled="selectedIds.length === 0" @click="batchDelete">
|
|
|
+ 批量删除
|
|
|
+ </el-button>
|
|
|
+ <el-button size="mini" @click="clearSelection">取消选择</el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 网格 -->
|
|
|
+ <div v-loading="loading" class="grid-wrap">
|
|
|
+ <el-row v-if="records.length > 0" :gutter="12">
|
|
|
+ <el-col v-for="r in records" :key="r.id" :span="6" class="grid-col">
|
|
|
+ <div class="asset-card" :class="{ 'is-selected': isSelected(r) }">
|
|
|
+ <div class="card-thumb" @click="toggleSelect(r)">
|
|
|
+ <el-image
|
|
|
+ v-if="r.fileType === 'image'"
|
|
|
+ :src="thumbUrl(r)"
|
|
|
+ fit="cover"
|
|
|
+ class="thumb-img"
|
|
|
+ />
|
|
|
+ <div v-else class="thumb-doc">
|
|
|
+ <i :class="docIcon(r)"></i>
|
|
|
+ <span>{{ r.fileType || 'other' }}</span>
|
|
|
+ </div>
|
|
|
+ <el-checkbox
|
|
|
+ class="card-check"
|
|
|
+ :value="isSelected(r)"
|
|
|
+ @click.native.stop="toggleSelect(r)"
|
|
|
+ ></el-checkbox>
|
|
|
+ </div>
|
|
|
+ <div class="card-body">
|
|
|
+ <div class="card-name" :title="r.originalFilename">{{ r.originalFilename }}</div>
|
|
|
+ <div class="card-meta">
|
|
|
+ <span>{{ formatSize(r.fileSize) }}</span>
|
|
|
+ <span>{{ r.category }}</span>
|
|
|
+ <span>{{ formatTime(r.createdAt) }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="r.description" class="card-desc" :title="r.description">{{ r.description }}</div>
|
|
|
+ <div v-if="(r.tags || []).length > 0" class="card-tags">
|
|
|
+ <el-tag
|
|
|
+ v-for="t in r.tags"
|
|
|
+ :key="t.id"
|
|
|
+ size="mini"
|
|
|
+ :style="{ color: t.color, borderColor: t.color }"
|
|
|
+ >{{ t.name }}</el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="card-actions">
|
|
|
+ <el-button size="mini" type="text" @click="openPreview(r)">预览</el-button>
|
|
|
+ <el-button size="mini" type="text" @click="openDescDialog(r)">改描述</el-button>
|
|
|
+ <el-button size="mini" type="text" @click="openTagDialog(r)">打标签</el-button>
|
|
|
+ <el-button size="mini" type="text" class="text-error" @click="removeOne(r)">删除</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+ <div v-else-if="!loading" class="empty-box">
|
|
|
+ <i class="el-icon-picture-outline"></i>
|
|
|
+ <p>暂无素材</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-pagination
|
|
|
+ class="mt-16"
|
|
|
+ background
|
|
|
+ layout="total, sizes, prev, pager, next, jumper"
|
|
|
+ :current-page="query.page"
|
|
|
+ :page-size="query.pageSize"
|
|
|
+ :page-sizes="[24, 48, 96]"
|
|
|
+ :total="total"
|
|
|
+ @size-change="onSizeChange"
|
|
|
+ @current-change="onPageChange"
|
|
|
+ />
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 改描述 -->
|
|
|
+ <el-dialog title="修改描述" :visible.sync="descVisible" width="480px">
|
|
|
+ <el-form label-width="80px">
|
|
|
+ <el-form-item label="文件名">
|
|
|
+ <el-input :value="currentRecord ? currentRecord.originalFilename : ''" disabled />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="描述">
|
|
|
+ <el-input v-model="descText" type="textarea" :rows="4" maxlength="500" show-word-limit />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <span slot="footer">
|
|
|
+ <el-button @click="descVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" :loading="saving" @click="saveDesc">保存</el-button>
|
|
|
+ </span>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 打标签(单个 / 批量) -->
|
|
|
+ <el-dialog
|
|
|
+ :title="assignMode === 'batch' ? '批量打标签' : '打标签'"
|
|
|
+ :visible.sync="tagAssignVisible"
|
|
|
+ width="480px"
|
|
|
+ >
|
|
|
+ <el-form label-width="80px">
|
|
|
+ <el-form-item label="目标">
|
|
|
+ <span class="target-info">
|
|
|
+ <template v-if="assignMode === 'batch'">已选 {{ selectedIds.length }} 项素材</template>
|
|
|
+ <template v-else>{{ currentRecord ? currentRecord.originalFilename : '' }}</template>
|
|
|
+ </span>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="标签">
|
|
|
+ <el-select v-model="assignTagIds" multiple filterable placeholder="选择要追加的标签" class="full-width">
|
|
|
+ <el-option v-for="t in tags" :key="t.id" :label="t.name" :value="t.id">
|
|
|
+ <span :style="{ color: t.color }">{{ t.name }}</span>
|
|
|
+ </el-option>
|
|
|
+ </el-select>
|
|
|
+ <span class="form-hint">追加模式:保留素材原有标签</span>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <span slot="footer">
|
|
|
+ <el-button @click="tagAssignVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" :loading="saving" @click="saveAssignTags">保存</el-button>
|
|
|
+ </span>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 预览 -->
|
|
|
+ <el-dialog title="预览" :visible.sync="previewVisible" width="640px">
|
|
|
+ <div v-if="previewRecord">
|
|
|
+ <el-image
|
|
|
+ v-if="previewRecord.fileType === 'image'"
|
|
|
+ :src="thumbUrl(previewRecord)"
|
|
|
+ fit="contain"
|
|
|
+ class="preview-img"
|
|
|
+ />
|
|
|
+ <video
|
|
|
+ v-else-if="previewRecord.fileType === 'video'"
|
|
|
+ :src="thumbUrl(previewRecord)"
|
|
|
+ controls
|
|
|
+ class="preview-img"
|
|
|
+ ></video>
|
|
|
+ <audio
|
|
|
+ v-else-if="previewRecord.fileType === 'audio'"
|
|
|
+ :src="thumbUrl(previewRecord)"
|
|
|
+ controls
|
|
|
+ ></audio>
|
|
|
+ <p v-else class="preview-doc">
|
|
|
+ 该类型暂不支持在线预览,请下载后查看:
|
|
|
+ <a :href="thumbUrl(previewRecord)" target="_blank" rel="noopener">{{ previewRecord.originalFilename }}</a>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 标签管理 -->
|
|
|
+ <el-dialog title="标签管理" :visible.sync="tagDialogVisible" width="640px">
|
|
|
+ <div class="tag-create">
|
|
|
+ <el-input v-model="tagForm.name" size="small" placeholder="标签名称" class="tag-name-input" />
|
|
|
+ <el-color-picker v-model="tagForm.color" size="small" />
|
|
|
+ <el-input-number v-model="tagForm.sortOrder" size="small" :min="0" :max="9999" controls-position="right" class="tag-sort-input" />
|
|
|
+ <el-button type="primary" size="small" @click="submitTag">{{ tagForm.id ? '保存修改' : '新建标签' }}</el-button>
|
|
|
+ <el-button v-if="tagForm.id" size="small" @click="resetTagForm">取消编辑</el-button>
|
|
|
+ </div>
|
|
|
+ <el-table :data="tags" border size="mini" class="mt-16" max-height="360">
|
|
|
+ <el-table-column prop="name" label="名称" min-width="140">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <span :style="{ color: scope.row.color }">{{ scope.row.name }}</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="color" label="颜色" width="110">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-tag size="mini" :style="{ color: scope.row.color, borderColor: scope.row.color }">{{ scope.row.color }}</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="sortOrder" label="排序" width="80" />
|
|
|
+ <el-table-column label="操作" width="130">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-button size="mini" type="text" @click="editTag(scope.row)">编辑</el-button>
|
|
|
+ <el-button size="mini" type="text" class="text-error" @click="removeTag(scope.row)">删除</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import PageHeader from '@/components/PageHeader.vue'
|
|
|
+import StatCard from '@/components/StatCard.vue'
|
|
|
+import StatusTag from '@/components/StatusTag.vue'
|
|
|
+import {
|
|
|
+ listLibrary,
|
|
|
+ getTags,
|
|
|
+ createTag,
|
|
|
+ updateTag,
|
|
|
+ deleteTag,
|
|
|
+ deleteFile,
|
|
|
+ updateFile,
|
|
|
+ getLibraryStats,
|
|
|
+ resolveAssetUrl
|
|
|
+} from '@/utils/asset'
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'AssetLibrary',
|
|
|
+ components: { PageHeader, StatCard, StatusTag },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ loading: false,
|
|
|
+ saving: false,
|
|
|
+ records: [],
|
|
|
+ tags: [],
|
|
|
+ stats: {},
|
|
|
+ total: 0,
|
|
|
+ dateRange: null,
|
|
|
+ selectedIds: [],
|
|
|
+ currentRecord: null,
|
|
|
+ query: {
|
|
|
+ page: 1,
|
|
|
+ pageSize: 24,
|
|
|
+ fileType: '',
|
|
|
+ category: '',
|
|
|
+ tagIds: [],
|
|
|
+ search: ''
|
|
|
+ },
|
|
|
+ descVisible: false,
|
|
|
+ descText: '',
|
|
|
+ previewVisible: false,
|
|
|
+ previewRecord: null,
|
|
|
+ tagAssignVisible: false,
|
|
|
+ assignMode: 'single',
|
|
|
+ assignTagIds: [],
|
|
|
+ tagDialogVisible: false,
|
|
|
+ tagForm: { id: null, name: '', color: '#409EFF', sortOrder: 0 }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ this.loadAll()
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ // 分类下拉:已知分类 + 数据库中已出现的分类(自动兼容新增分类)
|
|
|
+ categoryOptions() {
|
|
|
+ const counts = {}
|
|
|
+ ;(this.stats.byCategory || []).forEach(r => {
|
|
|
+ const key = r.category || ''
|
|
|
+ counts[key] = r.cnt || 0
|
|
|
+ })
|
|
|
+ const keys = Object.keys(counts)
|
|
|
+ ;['general', 'articles', 'activities', 'products', 'banners', 'product-skus', 'guides',
|
|
|
+ 'kb-images', 'kb-docs', 'dan-reports', 'assessment-reports', 'health-reports']
|
|
|
+ .forEach(k => { if (keys.indexOf(k) === -1) keys.push(k) })
|
|
|
+ return keys.map(k => ({
|
|
|
+ value: k,
|
|
|
+ label: counts[k] !== undefined ? k + ' (' + counts[k] + ')' : k
|
|
|
+ }))
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ loadAll() {
|
|
|
+ this.loadTags()
|
|
|
+ this.loadStats()
|
|
|
+ this.loadData()
|
|
|
+ },
|
|
|
+ async loadTags() {
|
|
|
+ try {
|
|
|
+ const res = await getTags()
|
|
|
+ if (res.code === 200 && Array.isArray(res.data)) {
|
|
|
+ this.tags = res.data
|
|
|
+ }
|
|
|
+ } catch (e) { /* 拦截器已提示 */ }
|
|
|
+ },
|
|
|
+ async loadStats() {
|
|
|
+ try {
|
|
|
+ const res = await getLibraryStats()
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.stats = res.data
|
|
|
+ }
|
|
|
+ } catch (e) { /* 拦截器已提示 */ }
|
|
|
+ },
|
|
|
+ async loadData() {
|
|
|
+ this.loading = true
|
|
|
+ const params = {
|
|
|
+ page: this.query.page,
|
|
|
+ pageSize: this.query.pageSize,
|
|
|
+ fileType: this.query.fileType || '',
|
|
|
+ category: this.query.category || '',
|
|
|
+ search: this.query.search || '',
|
|
|
+ tagIds: this.query.tagIds || []
|
|
|
+ }
|
|
|
+ if (this.dateRange && this.dateRange.length === 2) {
|
|
|
+ params.dateFrom = this.dateRange[0]
|
|
|
+ params.dateTo = this.dateRange[1]
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await listLibrary(params)
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.records = res.data.records || []
|
|
|
+ this.total = res.data.total || 0
|
|
|
+ }
|
|
|
+ } catch (e) { /* 拦截器已提示 */ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onSearch() {
|
|
|
+ this.query.page = 1
|
|
|
+ this.loadData()
|
|
|
+ },
|
|
|
+ onFilterChange() {
|
|
|
+ this.onSearch()
|
|
|
+ },
|
|
|
+ onReset() {
|
|
|
+ this.query.fileType = ''
|
|
|
+ this.query.category = ''
|
|
|
+ this.query.tagIds = []
|
|
|
+ this.query.search = ''
|
|
|
+ this.dateRange = null
|
|
|
+ this.selectedIds = []
|
|
|
+ this.onSearch()
|
|
|
+ },
|
|
|
+ onSizeChange(size) {
|
|
|
+ this.query.pageSize = size
|
|
|
+ this.query.page = 1
|
|
|
+ this.loadData()
|
|
|
+ },
|
|
|
+ onPageChange(page) {
|
|
|
+ this.query.page = page
|
|
|
+ this.loadData()
|
|
|
+ },
|
|
|
+ isSelected(r) {
|
|
|
+ return this.selectedIds.indexOf(r.id) !== -1
|
|
|
+ },
|
|
|
+ toggleSelect(r) {
|
|
|
+ const idx = this.selectedIds.indexOf(r.id)
|
|
|
+ if (idx === -1) {
|
|
|
+ this.selectedIds.push(r.id)
|
|
|
+ } else {
|
|
|
+ this.selectedIds.splice(idx, 1)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ clearSelection() {
|
|
|
+ this.selectedIds = []
|
|
|
+ },
|
|
|
+ openDescDialog(r) {
|
|
|
+ this.currentRecord = r
|
|
|
+ this.descText = r.description || ''
|
|
|
+ this.descVisible = true
|
|
|
+ },
|
|
|
+ openPreview(r) {
|
|
|
+ this.previewRecord = r
|
|
|
+ this.previewVisible = true
|
|
|
+ },
|
|
|
+ async saveDesc() {
|
|
|
+ if (!this.currentRecord) return
|
|
|
+ this.saving = true
|
|
|
+ try {
|
|
|
+ const res = await updateFile({ id: this.currentRecord.id, description: this.descText })
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.$message.success('已保存')
|
|
|
+ this.descVisible = false
|
|
|
+ this.loadData()
|
|
|
+ } else {
|
|
|
+ this.$message.error(res.message || '保存失败')
|
|
|
+ }
|
|
|
+ } catch (e) { /* 拦截器已提示 */ } finally {
|
|
|
+ this.saving = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ openTagDialog(r) {
|
|
|
+ this.currentRecord = r
|
|
|
+ this.assignMode = 'single'
|
|
|
+ this.assignTagIds = []
|
|
|
+ this.tagAssignVisible = true
|
|
|
+ },
|
|
|
+ openBatchTagDialog() {
|
|
|
+ if (this.selectedIds.length === 0) {
|
|
|
+ this.$message.warning('请先选择素材')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ this.assignMode = 'batch'
|
|
|
+ this.assignTagIds = []
|
|
|
+ this.tagAssignVisible = true
|
|
|
+ },
|
|
|
+ async saveAssignTags() {
|
|
|
+ if (this.assignTagIds.length === 0) {
|
|
|
+ this.$message.warning('请选择标签')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ this.saving = true
|
|
|
+ try {
|
|
|
+ if (this.assignMode === 'batch') {
|
|
|
+ const targets = this.records.filter(r => this.selectedIds.indexOf(r.id) !== -1)
|
|
|
+ for (const r of targets) {
|
|
|
+ const merged = this.mergeTagIds(r, this.assignTagIds)
|
|
|
+ await updateFile({ id: r.id, tagIds: merged })
|
|
|
+ }
|
|
|
+ this.$message.success('已为 ' + targets.length + ' 项素材追加标签')
|
|
|
+ } else {
|
|
|
+ if (!this.currentRecord) return
|
|
|
+ const merged = this.mergeTagIds(this.currentRecord, this.assignTagIds)
|
|
|
+ await updateFile({ id: this.currentRecord.id, tagIds: merged })
|
|
|
+ this.$message.success('已保存标签')
|
|
|
+ }
|
|
|
+ this.tagAssignVisible = false
|
|
|
+ this.loadData()
|
|
|
+ this.loadStats()
|
|
|
+ } catch (e) { /* 拦截器已提示 */ } finally {
|
|
|
+ this.saving = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mergeTagIds(record, extraIds) {
|
|
|
+ const ids = ((record && record.tags) || []).map(t => t.id)
|
|
|
+ extraIds.forEach(id => {
|
|
|
+ if (ids.indexOf(id) === -1) ids.push(id)
|
|
|
+ })
|
|
|
+ return ids
|
|
|
+ },
|
|
|
+ async removeOne(r) {
|
|
|
+ await this.confirmDelete([r])
|
|
|
+ },
|
|
|
+ async batchDelete() {
|
|
|
+ const targets = this.records.filter(r => this.selectedIds.indexOf(r.id) !== -1)
|
|
|
+ await this.confirmDelete(targets)
|
|
|
+ },
|
|
|
+ async confirmDelete(targets) {
|
|
|
+ if (!targets || targets.length === 0) return
|
|
|
+ try {
|
|
|
+ await this.$confirm(
|
|
|
+ '确定删除选中的 ' + targets.length + ' 个素材?对象存储文件将一并删除,且不可恢复。',
|
|
|
+ '警告',
|
|
|
+ { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
|
|
+ )
|
|
|
+ } catch (e) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ for (const r of targets) {
|
|
|
+ await deleteFile({ id: r.id })
|
|
|
+ }
|
|
|
+ this.$message.success('已删除 ' + targets.length + ' 项')
|
|
|
+ this.selectedIds = []
|
|
|
+ this.loadData()
|
|
|
+ this.loadStats()
|
|
|
+ } catch (e) { /* 拦截器已提示 */ }
|
|
|
+ },
|
|
|
+ // ===== 标签管理 =====
|
|
|
+ resetTagForm() {
|
|
|
+ this.tagForm = { id: null, name: '', color: '#409EFF', sortOrder: 0 }
|
|
|
+ },
|
|
|
+ editTag(row) {
|
|
|
+ this.tagForm = {
|
|
|
+ id: row.id,
|
|
|
+ name: row.name,
|
|
|
+ color: row.color || '#409EFF',
|
|
|
+ sortOrder: row.sortOrder || 0
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async submitTag() {
|
|
|
+ const name = (this.tagForm.name || '').trim()
|
|
|
+ if (!name) {
|
|
|
+ this.$message.warning('请输入标签名称')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const payload = {
|
|
|
+ name,
|
|
|
+ color: this.tagForm.color || '#409EFF',
|
|
|
+ sortOrder: this.tagForm.sortOrder || 0
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ let res
|
|
|
+ if (this.tagForm.id) {
|
|
|
+ payload.id = this.tagForm.id
|
|
|
+ res = await updateTag(payload)
|
|
|
+ } else {
|
|
|
+ res = await createTag(payload)
|
|
|
+ }
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.$message.success(this.tagForm.id ? '标签已更新' : '标签已创建')
|
|
|
+ this.resetTagForm()
|
|
|
+ this.loadTags()
|
|
|
+ } else {
|
|
|
+ this.$message.error(res.message || '操作失败')
|
|
|
+ }
|
|
|
+ } catch (e) { /* 拦截器已提示 */ }
|
|
|
+ },
|
|
|
+ async removeTag(row) {
|
|
|
+ try {
|
|
|
+ await this.$confirm('确定删除标签「' + row.name + '」?素材本身不会被删除。', '提示', {
|
|
|
+ confirmButtonText: '确定',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ })
|
|
|
+ } catch (e) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await deleteTag({ id: row.id })
|
|
|
+ if (res.code === 200) {
|
|
|
+ this.$message.success('标签已删除')
|
|
|
+ this.loadTags()
|
|
|
+ this.loadData()
|
|
|
+ } else {
|
|
|
+ this.$message.error(res.message || '删除失败')
|
|
|
+ }
|
|
|
+ } catch (e) { /* 拦截器已提示 */ }
|
|
|
+ },
|
|
|
+ // ===== 展示辅助 =====
|
|
|
+ thumbUrl(r) {
|
|
|
+ return resolveAssetUrl(r && r.url)
|
|
|
+ },
|
|
|
+ docIcon(r) {
|
|
|
+ const type = (r && r.fileType) || 'other'
|
|
|
+ if (type === 'video') return 'el-icon-video-camera'
|
|
|
+ if (type === 'audio') return 'el-icon-headphone'
|
|
|
+ return 'el-icon-document'
|
|
|
+ },
|
|
|
+ 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]
|
|
|
+ },
|
|
|
+ formatTime(t) {
|
|
|
+ if (!t) return '-'
|
|
|
+ return String(t).replace('T', ' ').substring(0, 19)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.asset-library {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+.filter-bar {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ align-items: center;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+.batch-bar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: #f4f4f5;
|
|
|
+ border: 1px solid #e9e9eb;
|
|
|
+}
|
|
|
+.batch-bar.is-active {
|
|
|
+ background: #ecf5ff;
|
|
|
+ border-color: #b3d8ff;
|
|
|
+}
|
|
|
+.batch-info {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+.grid-wrap {
|
|
|
+ min-height: 320px;
|
|
|
+}
|
|
|
+.grid-col {
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+.asset-card {
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ border-radius: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #fff;
|
|
|
+ transition: box-shadow 0.2s, border-color 0.2s;
|
|
|
+}
|
|
|
+.asset-card:hover {
|
|
|
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
|
|
+}
|
|
|
+.asset-card.is-selected {
|
|
|
+ border-color: #409eff;
|
|
|
+ box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.25);
|
|
|
+}
|
|
|
+.card-thumb {
|
|
|
+ position: relative;
|
|
|
+ height: 140px;
|
|
|
+ background: #f5f7fa;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.thumb-img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+.thumb-doc {
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #909399;
|
|
|
+ font-size: 12px;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+.thumb-doc i {
|
|
|
+ font-size: 38px;
|
|
|
+}
|
|
|
+.card-check {
|
|
|
+ position: absolute;
|
|
|
+ top: 6px;
|
|
|
+ left: 6px;
|
|
|
+ background: rgba(255, 255, 255, 0.85);
|
|
|
+ border-radius: 3px;
|
|
|
+}
|
|
|
+.card-body {
|
|
|
+ padding: 8px 10px 10px;
|
|
|
+}
|
|
|
+.card-name {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #303133;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.card-meta {
|
|
|
+ margin-top: 4px;
|
|
|
+ font-size: 11px;
|
|
|
+ color: #909399;
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+.card-desc {
|
|
|
+ margin-top: 4px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.card-tags {
|
|
|
+ margin-top: 6px;
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+.card-actions {
|
|
|
+ margin-top: 6px;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+.preview-img {
|
|
|
+ width: 100%;
|
|
|
+ max-height: 460px;
|
|
|
+ object-fit: contain;
|
|
|
+}
|
|
|
+.preview-doc {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #606266;
|
|
|
+ word-break: break-all;
|
|
|
+}
|
|
|
+.empty-box {
|
|
|
+ height: 320px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+.empty-box i {
|
|
|
+ font-size: 46px;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+.empty-box p {
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+.tag-create {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.tag-name-input {
|
|
|
+ width: 220px;
|
|
|
+}
|
|
|
+.tag-sort-input {
|
|
|
+ width: 120px;
|
|
|
+}
|
|
|
+.target-info {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+.form-hint {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+.stat-card {
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.stat-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ margin-top: 4px;
|
|
|
+}
|
|
|
+.stat-chips {
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+.text-error {
|
|
|
+ color: #f56c6c;
|
|
|
+}
|
|
|
+/* == C 批内联清理工具类 == */
|
|
|
+.w-120 { width: 120px; }
|
|
|
+.w-140 { width: 140px; }
|
|
|
+.w-200 { width: 200px; }
|
|
|
+.w-220 { width: 220px; }
|
|
|
+.w-240 { width: 240px; }
|
|
|
+.full-width { width: 100%; }
|
|
|
+.mt-16 { margin-top: 16px; }
|
|
|
+.mb-16 { margin-bottom: 16px; }
|
|
|
+</style>
|