AssetLibrary.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792
  1. <template>
  2. <div class="asset-library admin-page">
  3. <el-card>
  4. <PageHeader slot="header" title="素材库">
  5. <div class="admin-page-actions">
  6. <el-button icon="el-icon-tag" @click="tagDialogVisible = true">标签管理</el-button>
  7. <el-button icon="el-icon-refresh" @click="loadAll">刷新</el-button>
  8. </div>
  9. </PageHeader>
  10. <!-- 统计 -->
  11. <el-row :gutter="16" class="mb-16">
  12. <el-col :span="6">
  13. <StatCard label="素材总数" :value="stats.total || 0" />
  14. </el-col>
  15. <el-col :span="6">
  16. <StatCard label="占用空间" :value="formatSize(stats.totalBytes || 0)" />
  17. </el-col>
  18. <el-col :span="12">
  19. <el-card shadow="never" class="stat-card">
  20. <div class="stat-chips">
  21. <StatusTag
  22. v-for="c in stats.byFileType || []"
  23. :key="c.file_type"
  24. type="primary"
  25. :text="(c.file_type || 'unknown') + ': ' + c.cnt"
  26. size="small"
  27. />
  28. </div>
  29. <div class="stat-label">类型分布</div>
  30. </el-card>
  31. </el-col>
  32. </el-row>
  33. <!-- 筛选 -->
  34. <div class="filter-bar">
  35. <el-select v-model="query.fileType" placeholder="类型" clearable class="w-120" @change="onFilterChange">
  36. <el-option label="全部" value="" />
  37. <el-option label="图片" value="image" />
  38. <el-option label="文档" value="document" />
  39. <el-option label="视频" value="video" />
  40. <el-option label="音频" value="audio" />
  41. <el-option label="其它" value="other" />
  42. </el-select>
  43. <el-select v-model="query.category" placeholder="分类" clearable class="w-140" @change="onFilterChange">
  44. <el-option label="全部" value="" />
  45. <el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
  46. </el-select>
  47. <el-select v-model="query.tagIds" placeholder="标签(任一命中)" multiple collapse-tags clearable class="w-220" @change="onFilterChange">
  48. <el-option v-for="t in tags" :key="t.id" :label="t.name" :value="t.id">
  49. <span :style="{ color: t.color }">{{ t.name }}</span>
  50. </el-option>
  51. </el-select>
  52. <el-input
  53. v-model="query.search"
  54. placeholder="搜索文件名 / 描述"
  55. clearable
  56. class="w-200"
  57. @keyup.enter.native="onSearch"
  58. @clear="onSearch"
  59. />
  60. <el-date-picker
  61. v-model="dateRange"
  62. type="daterange"
  63. range-separator="至"
  64. start-placeholder="开始日期"
  65. end-placeholder="结束日期"
  66. value-format="yyyy-MM-dd"
  67. class="w-240"
  68. @change="onSearch"
  69. />
  70. <el-button type="primary" icon="el-icon-search" @click="onSearch">查询</el-button>
  71. <el-button icon="el-icon-refresh-left" @click="onReset">重置</el-button>
  72. </div>
  73. <!-- 批量操作条 -->
  74. <div class="batch-bar" :class="{ 'is-active': selectedIds.length > 0 }">
  75. <span class="batch-info">已选 {{ selectedIds.length }} 项</span>
  76. <el-button size="mini" type="primary" :disabled="selectedIds.length === 0" @click="openBatchTagDialog">
  77. 批量打标签
  78. </el-button>
  79. <el-button size="mini" type="danger" :disabled="selectedIds.length === 0" @click="batchDelete">
  80. 批量删除
  81. </el-button>
  82. <el-button size="mini" @click="clearSelection">取消选择</el-button>
  83. </div>
  84. <!-- 网格 -->
  85. <div v-loading="loading" class="grid-wrap">
  86. <el-row v-if="records.length > 0" :gutter="12">
  87. <el-col v-for="r in records" :key="r.id" :span="6" class="grid-col">
  88. <div class="asset-card" :class="{ 'is-selected': isSelected(r) }">
  89. <div class="card-thumb" @click="toggleSelect(r)">
  90. <el-image
  91. v-if="r.fileType === 'image'"
  92. :src="thumbUrl(r)"
  93. fit="cover"
  94. class="thumb-img"
  95. />
  96. <div v-else class="thumb-doc">
  97. <i :class="docIcon(r)"></i>
  98. <span>{{ r.fileType || 'other' }}</span>
  99. </div>
  100. <el-checkbox
  101. class="card-check"
  102. :value="isSelected(r)"
  103. @click.native.stop="toggleSelect(r)"
  104. ></el-checkbox>
  105. </div>
  106. <div class="card-body">
  107. <div class="card-name" :title="r.originalFilename">{{ r.originalFilename }}</div>
  108. <div class="card-meta">
  109. <span>{{ formatSize(r.fileSize) }}</span>
  110. <span>{{ r.category }}</span>
  111. <span>{{ formatTime(r.createdAt) }}</span>
  112. </div>
  113. <div v-if="r.description" class="card-desc" :title="r.description">{{ r.description }}</div>
  114. <div v-if="(r.tags || []).length > 0" class="card-tags">
  115. <el-tag
  116. v-for="t in r.tags"
  117. :key="t.id"
  118. size="mini"
  119. :style="{ color: t.color, borderColor: t.color }"
  120. >{{ t.name }}</el-tag>
  121. </div>
  122. <div class="card-actions">
  123. <el-button size="mini" type="text" @click="openPreview(r)">预览</el-button>
  124. <el-button size="mini" type="text" @click="openDescDialog(r)">改描述</el-button>
  125. <el-button size="mini" type="text" @click="openTagDialog(r)">打标签</el-button>
  126. <el-button size="mini" type="text" class="text-error" @click="removeOne(r)">删除</el-button>
  127. </div>
  128. </div>
  129. </div>
  130. </el-col>
  131. </el-row>
  132. <div v-else-if="!loading" class="empty-box">
  133. <i class="el-icon-picture-outline"></i>
  134. <p>暂无素材</p>
  135. </div>
  136. </div>
  137. <el-pagination
  138. class="mt-16"
  139. background
  140. layout="total, sizes, prev, pager, next, jumper"
  141. :current-page="query.page"
  142. :page-size="query.pageSize"
  143. :page-sizes="[24, 48, 96]"
  144. :total="total"
  145. @size-change="onSizeChange"
  146. @current-change="onPageChange"
  147. />
  148. </el-card>
  149. <!-- 改描述 -->
  150. <el-dialog title="修改描述" :visible.sync="descVisible" width="480px">
  151. <el-form label-width="80px">
  152. <el-form-item label="文件名">
  153. <el-input :value="currentRecord ? currentRecord.originalFilename : ''" disabled />
  154. </el-form-item>
  155. <el-form-item label="描述">
  156. <el-input v-model="descText" type="textarea" :rows="4" maxlength="500" show-word-limit />
  157. </el-form-item>
  158. </el-form>
  159. <span slot="footer">
  160. <el-button @click="descVisible = false">取消</el-button>
  161. <el-button type="primary" :loading="saving" @click="saveDesc">保存</el-button>
  162. </span>
  163. </el-dialog>
  164. <!-- 打标签(单个 / 批量) -->
  165. <el-dialog
  166. :title="assignMode === 'batch' ? '批量打标签' : '打标签'"
  167. :visible.sync="tagAssignVisible"
  168. width="480px"
  169. >
  170. <el-form label-width="80px">
  171. <el-form-item label="目标">
  172. <span class="target-info">
  173. <template v-if="assignMode === 'batch'">已选 {{ selectedIds.length }} 项素材</template>
  174. <template v-else>{{ currentRecord ? currentRecord.originalFilename : '' }}</template>
  175. </span>
  176. </el-form-item>
  177. <el-form-item label="标签">
  178. <el-select v-model="assignTagIds" multiple filterable placeholder="选择要追加的标签" class="full-width">
  179. <el-option v-for="t in tags" :key="t.id" :label="t.name" :value="t.id">
  180. <span :style="{ color: t.color }">{{ t.name }}</span>
  181. </el-option>
  182. </el-select>
  183. <span class="form-hint">追加模式:保留素材原有标签</span>
  184. </el-form-item>
  185. </el-form>
  186. <span slot="footer">
  187. <el-button @click="tagAssignVisible = false">取消</el-button>
  188. <el-button type="primary" :loading="saving" @click="saveAssignTags">保存</el-button>
  189. </span>
  190. </el-dialog>
  191. <!-- 预览 -->
  192. <el-dialog title="预览" :visible.sync="previewVisible" width="640px">
  193. <div v-if="previewRecord">
  194. <el-image
  195. v-if="previewRecord.fileType === 'image'"
  196. :src="thumbUrl(previewRecord)"
  197. fit="contain"
  198. class="preview-img"
  199. />
  200. <video
  201. v-else-if="previewRecord.fileType === 'video'"
  202. :src="thumbUrl(previewRecord)"
  203. controls
  204. class="preview-img"
  205. ></video>
  206. <audio
  207. v-else-if="previewRecord.fileType === 'audio'"
  208. :src="thumbUrl(previewRecord)"
  209. controls
  210. ></audio>
  211. <p v-else class="preview-doc">
  212. 该类型暂不支持在线预览,请下载后查看:
  213. <a :href="thumbUrl(previewRecord)" target="_blank" rel="noopener">{{ previewRecord.originalFilename }}</a>
  214. </p>
  215. </div>
  216. </el-dialog>
  217. <!-- 标签管理 -->
  218. <el-dialog title="标签管理" :visible.sync="tagDialogVisible" width="640px">
  219. <div class="tag-create">
  220. <el-input v-model="tagForm.name" size="small" placeholder="标签名称" class="tag-name-input" />
  221. <el-color-picker v-model="tagForm.color" size="small" />
  222. <el-input-number v-model="tagForm.sortOrder" size="small" :min="0" :max="9999" controls-position="right" class="tag-sort-input" />
  223. <el-button type="primary" size="small" @click="submitTag">{{ tagForm.id ? '保存修改' : '新建标签' }}</el-button>
  224. <el-button v-if="tagForm.id" size="small" @click="resetTagForm">取消编辑</el-button>
  225. </div>
  226. <el-table :data="tags" border size="mini" class="mt-16" max-height="360">
  227. <el-table-column prop="name" label="名称" min-width="140">
  228. <template slot-scope="scope">
  229. <span :style="{ color: scope.row.color }">{{ scope.row.name }}</span>
  230. </template>
  231. </el-table-column>
  232. <el-table-column prop="color" label="颜色" width="110">
  233. <template slot-scope="scope">
  234. <el-tag size="mini" :style="{ color: scope.row.color, borderColor: scope.row.color }">{{ scope.row.color }}</el-tag>
  235. </template>
  236. </el-table-column>
  237. <el-table-column prop="sortOrder" label="排序" width="80" />
  238. <el-table-column label="操作" width="130">
  239. <template slot-scope="scope">
  240. <el-button size="mini" type="text" @click="editTag(scope.row)">编辑</el-button>
  241. <el-button size="mini" type="text" class="text-error" @click="removeTag(scope.row)">删除</el-button>
  242. </template>
  243. </el-table-column>
  244. </el-table>
  245. </el-dialog>
  246. </div>
  247. </template>
  248. <script>
  249. import PageHeader from '@/components/PageHeader.vue'
  250. import StatCard from '@/components/StatCard.vue'
  251. import StatusTag from '@/components/StatusTag.vue'
  252. import {
  253. listLibrary,
  254. getTags,
  255. createTag,
  256. updateTag,
  257. deleteTag,
  258. deleteFile,
  259. updateFile,
  260. getLibraryStats,
  261. resolveAssetUrl
  262. } from '@/utils/asset'
  263. export default {
  264. name: 'AssetLibrary',
  265. components: { PageHeader, StatCard, StatusTag },
  266. data() {
  267. return {
  268. loading: false,
  269. saving: false,
  270. records: [],
  271. tags: [],
  272. stats: {},
  273. total: 0,
  274. dateRange: null,
  275. selectedIds: [],
  276. currentRecord: null,
  277. query: {
  278. page: 1,
  279. pageSize: 24,
  280. fileType: '',
  281. category: '',
  282. tagIds: [],
  283. search: ''
  284. },
  285. descVisible: false,
  286. descText: '',
  287. previewVisible: false,
  288. previewRecord: null,
  289. tagAssignVisible: false,
  290. assignMode: 'single',
  291. assignTagIds: [],
  292. tagDialogVisible: false,
  293. tagForm: { id: null, name: '', color: '#409EFF', sortOrder: 0 }
  294. }
  295. },
  296. mounted() {
  297. this.loadAll()
  298. },
  299. computed: {
  300. // 分类下拉:已知分类 + 数据库中已出现的分类(自动兼容新增分类)
  301. categoryOptions() {
  302. const counts = {}
  303. ;(this.stats.byCategory || []).forEach(r => {
  304. const key = r.category || ''
  305. counts[key] = r.cnt || 0
  306. })
  307. const keys = Object.keys(counts)
  308. ;['general', 'articles', 'activities', 'products', 'banners', 'product-skus', 'guides',
  309. 'kb-images', 'kb-docs', 'dan-reports', 'assessment-reports', 'health-reports']
  310. .forEach(k => { if (keys.indexOf(k) === -1) keys.push(k) })
  311. return keys.map(k => ({
  312. value: k,
  313. label: counts[k] !== undefined ? k + ' (' + counts[k] + ')' : k
  314. }))
  315. }
  316. },
  317. methods: {
  318. loadAll() {
  319. this.loadTags()
  320. this.loadStats()
  321. this.loadData()
  322. },
  323. async loadTags() {
  324. try {
  325. const res = await getTags()
  326. if (res.code === 200 && Array.isArray(res.data)) {
  327. this.tags = res.data
  328. }
  329. } catch (e) { /* 拦截器已提示 */ }
  330. },
  331. async loadStats() {
  332. try {
  333. const res = await getLibraryStats()
  334. if (res.code === 200 && res.data) {
  335. this.stats = res.data
  336. }
  337. } catch (e) { /* 拦截器已提示 */ }
  338. },
  339. async loadData() {
  340. this.loading = true
  341. const params = {
  342. page: this.query.page,
  343. pageSize: this.query.pageSize,
  344. fileType: this.query.fileType || '',
  345. category: this.query.category || '',
  346. search: this.query.search || '',
  347. tagIds: this.query.tagIds || []
  348. }
  349. if (this.dateRange && this.dateRange.length === 2) {
  350. params.dateFrom = this.dateRange[0]
  351. params.dateTo = this.dateRange[1]
  352. }
  353. try {
  354. const res = await listLibrary(params)
  355. if (res.code === 200 && res.data) {
  356. this.records = res.data.records || []
  357. this.total = res.data.total || 0
  358. }
  359. } catch (e) { /* 拦截器已提示 */ } finally {
  360. this.loading = false
  361. }
  362. },
  363. onSearch() {
  364. this.query.page = 1
  365. this.loadData()
  366. },
  367. onFilterChange() {
  368. this.onSearch()
  369. },
  370. onReset() {
  371. this.query.fileType = ''
  372. this.query.category = ''
  373. this.query.tagIds = []
  374. this.query.search = ''
  375. this.dateRange = null
  376. this.selectedIds = []
  377. this.onSearch()
  378. },
  379. onSizeChange(size) {
  380. this.query.pageSize = size
  381. this.query.page = 1
  382. this.loadData()
  383. },
  384. onPageChange(page) {
  385. this.query.page = page
  386. this.loadData()
  387. },
  388. isSelected(r) {
  389. return this.selectedIds.indexOf(r.id) !== -1
  390. },
  391. toggleSelect(r) {
  392. const idx = this.selectedIds.indexOf(r.id)
  393. if (idx === -1) {
  394. this.selectedIds.push(r.id)
  395. } else {
  396. this.selectedIds.splice(idx, 1)
  397. }
  398. },
  399. clearSelection() {
  400. this.selectedIds = []
  401. },
  402. openDescDialog(r) {
  403. this.currentRecord = r
  404. this.descText = r.description || ''
  405. this.descVisible = true
  406. },
  407. openPreview(r) {
  408. this.previewRecord = r
  409. this.previewVisible = true
  410. },
  411. async saveDesc() {
  412. if (!this.currentRecord) return
  413. this.saving = true
  414. try {
  415. const res = await updateFile({ id: this.currentRecord.id, description: this.descText })
  416. if (res.code === 200) {
  417. this.$message.success('已保存')
  418. this.descVisible = false
  419. this.loadData()
  420. } else {
  421. this.$message.error(res.message || '保存失败')
  422. }
  423. } catch (e) { /* 拦截器已提示 */ } finally {
  424. this.saving = false
  425. }
  426. },
  427. openTagDialog(r) {
  428. this.currentRecord = r
  429. this.assignMode = 'single'
  430. this.assignTagIds = []
  431. this.tagAssignVisible = true
  432. },
  433. openBatchTagDialog() {
  434. if (this.selectedIds.length === 0) {
  435. this.$message.warning('请先选择素材')
  436. return
  437. }
  438. this.assignMode = 'batch'
  439. this.assignTagIds = []
  440. this.tagAssignVisible = true
  441. },
  442. async saveAssignTags() {
  443. if (this.assignTagIds.length === 0) {
  444. this.$message.warning('请选择标签')
  445. return
  446. }
  447. this.saving = true
  448. try {
  449. if (this.assignMode === 'batch') {
  450. const targets = this.records.filter(r => this.selectedIds.indexOf(r.id) !== -1)
  451. for (const r of targets) {
  452. const merged = this.mergeTagIds(r, this.assignTagIds)
  453. await updateFile({ id: r.id, tagIds: merged })
  454. }
  455. this.$message.success('已为 ' + targets.length + ' 项素材追加标签')
  456. } else {
  457. if (!this.currentRecord) return
  458. const merged = this.mergeTagIds(this.currentRecord, this.assignTagIds)
  459. await updateFile({ id: this.currentRecord.id, tagIds: merged })
  460. this.$message.success('已保存标签')
  461. }
  462. this.tagAssignVisible = false
  463. this.loadData()
  464. this.loadStats()
  465. } catch (e) { /* 拦截器已提示 */ } finally {
  466. this.saving = false
  467. }
  468. },
  469. mergeTagIds(record, extraIds) {
  470. const ids = ((record && record.tags) || []).map(t => t.id)
  471. extraIds.forEach(id => {
  472. if (ids.indexOf(id) === -1) ids.push(id)
  473. })
  474. return ids
  475. },
  476. async removeOne(r) {
  477. await this.confirmDelete([r])
  478. },
  479. async batchDelete() {
  480. const targets = this.records.filter(r => this.selectedIds.indexOf(r.id) !== -1)
  481. await this.confirmDelete(targets)
  482. },
  483. async confirmDelete(targets) {
  484. if (!targets || targets.length === 0) return
  485. try {
  486. await this.$confirm(
  487. '确定删除选中的 ' + targets.length + ' 个素材?对象存储文件将一并删除,且不可恢复。',
  488. '警告',
  489. { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
  490. )
  491. } catch (e) {
  492. return
  493. }
  494. try {
  495. for (const r of targets) {
  496. await deleteFile({ id: r.id })
  497. }
  498. this.$message.success('已删除 ' + targets.length + ' 项')
  499. this.selectedIds = []
  500. this.loadData()
  501. this.loadStats()
  502. } catch (e) { /* 拦截器已提示 */ }
  503. },
  504. // ===== 标签管理 =====
  505. resetTagForm() {
  506. this.tagForm = { id: null, name: '', color: '#409EFF', sortOrder: 0 }
  507. },
  508. editTag(row) {
  509. this.tagForm = {
  510. id: row.id,
  511. name: row.name,
  512. color: row.color || '#409EFF',
  513. sortOrder: row.sortOrder || 0
  514. }
  515. },
  516. async submitTag() {
  517. const name = (this.tagForm.name || '').trim()
  518. if (!name) {
  519. this.$message.warning('请输入标签名称')
  520. return
  521. }
  522. const payload = {
  523. name,
  524. color: this.tagForm.color || '#409EFF',
  525. sortOrder: this.tagForm.sortOrder || 0
  526. }
  527. try {
  528. let res
  529. if (this.tagForm.id) {
  530. payload.id = this.tagForm.id
  531. res = await updateTag(payload)
  532. } else {
  533. res = await createTag(payload)
  534. }
  535. if (res.code === 200) {
  536. this.$message.success(this.tagForm.id ? '标签已更新' : '标签已创建')
  537. this.resetTagForm()
  538. this.loadTags()
  539. } else {
  540. this.$message.error(res.message || '操作失败')
  541. }
  542. } catch (e) { /* 拦截器已提示 */ }
  543. },
  544. async removeTag(row) {
  545. try {
  546. await this.$confirm('确定删除标签「' + row.name + '」?素材本身不会被删除。', '提示', {
  547. confirmButtonText: '确定',
  548. cancelButtonText: '取消',
  549. type: 'warning'
  550. })
  551. } catch (e) {
  552. return
  553. }
  554. try {
  555. const res = await deleteTag({ id: row.id })
  556. if (res.code === 200) {
  557. this.$message.success('标签已删除')
  558. this.loadTags()
  559. this.loadData()
  560. } else {
  561. this.$message.error(res.message || '删除失败')
  562. }
  563. } catch (e) { /* 拦截器已提示 */ }
  564. },
  565. // ===== 展示辅助 =====
  566. thumbUrl(r) {
  567. return resolveAssetUrl(r && r.url)
  568. },
  569. docIcon(r) {
  570. const type = (r && r.fileType) || 'other'
  571. if (type === 'video') return 'el-icon-video-camera'
  572. if (type === 'audio') return 'el-icon-headphone'
  573. return 'el-icon-document'
  574. },
  575. formatSize(bytes) {
  576. if (!bytes) return '0B'
  577. const units = ['B', 'KB', 'MB', 'GB']
  578. let i = 0
  579. let size = bytes
  580. while (size >= 1024 && i < units.length - 1) {
  581. size /= 1024
  582. i++
  583. }
  584. return size.toFixed(i > 0 ? 1 : 0) + units[i]
  585. },
  586. formatTime(t) {
  587. if (!t) return '-'
  588. return String(t).replace('T', ' ').substring(0, 19)
  589. }
  590. }
  591. }
  592. </script>
  593. <style scoped>
  594. .asset-library {
  595. padding: 20px;
  596. }
  597. .filter-bar {
  598. display: flex;
  599. gap: 10px;
  600. align-items: center;
  601. flex-wrap: wrap;
  602. margin-bottom: 12px;
  603. }
  604. .batch-bar {
  605. display: flex;
  606. align-items: center;
  607. gap: 10px;
  608. padding: 8px 12px;
  609. margin-bottom: 12px;
  610. border-radius: 4px;
  611. background: #f4f4f5;
  612. border: 1px solid #e9e9eb;
  613. }
  614. .batch-bar.is-active {
  615. background: #ecf5ff;
  616. border-color: #b3d8ff;
  617. }
  618. .batch-info {
  619. font-size: 13px;
  620. color: #606266;
  621. }
  622. .grid-wrap {
  623. min-height: 320px;
  624. }
  625. .grid-col {
  626. margin-bottom: 12px;
  627. }
  628. .asset-card {
  629. border: 1px solid #ebeef5;
  630. border-radius: 6px;
  631. overflow: hidden;
  632. background: #fff;
  633. transition: box-shadow 0.2s, border-color 0.2s;
  634. }
  635. .asset-card:hover {
  636. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
  637. }
  638. .asset-card.is-selected {
  639. border-color: #409eff;
  640. box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.25);
  641. }
  642. .card-thumb {
  643. position: relative;
  644. height: 140px;
  645. background: #f5f7fa;
  646. cursor: pointer;
  647. }
  648. .thumb-img {
  649. width: 100%;
  650. height: 100%;
  651. }
  652. .thumb-doc {
  653. height: 100%;
  654. display: flex;
  655. flex-direction: column;
  656. align-items: center;
  657. justify-content: center;
  658. color: #909399;
  659. font-size: 12px;
  660. gap: 6px;
  661. }
  662. .thumb-doc i {
  663. font-size: 38px;
  664. }
  665. .card-check {
  666. position: absolute;
  667. top: 6px;
  668. left: 6px;
  669. background: rgba(255, 255, 255, 0.85);
  670. border-radius: 3px;
  671. }
  672. .card-body {
  673. padding: 8px 10px 10px;
  674. }
  675. .card-name {
  676. font-size: 13px;
  677. color: #303133;
  678. overflow: hidden;
  679. text-overflow: ellipsis;
  680. white-space: nowrap;
  681. }
  682. .card-meta {
  683. margin-top: 4px;
  684. font-size: 11px;
  685. color: #909399;
  686. display: flex;
  687. gap: 10px;
  688. flex-wrap: wrap;
  689. }
  690. .card-desc {
  691. margin-top: 4px;
  692. font-size: 12px;
  693. color: #606266;
  694. overflow: hidden;
  695. text-overflow: ellipsis;
  696. white-space: nowrap;
  697. }
  698. .card-tags {
  699. margin-top: 6px;
  700. display: flex;
  701. flex-wrap: wrap;
  702. gap: 4px;
  703. }
  704. .card-actions {
  705. margin-top: 6px;
  706. display: flex;
  707. justify-content: space-between;
  708. flex-wrap: wrap;
  709. }
  710. .preview-img {
  711. width: 100%;
  712. max-height: 460px;
  713. object-fit: contain;
  714. }
  715. .preview-doc {
  716. font-size: 13px;
  717. color: #606266;
  718. word-break: break-all;
  719. }
  720. .empty-box {
  721. height: 320px;
  722. display: flex;
  723. flex-direction: column;
  724. align-items: center;
  725. justify-content: center;
  726. color: #909399;
  727. }
  728. .empty-box i {
  729. font-size: 46px;
  730. margin-bottom: 8px;
  731. }
  732. .empty-box p {
  733. margin: 0;
  734. }
  735. .tag-create {
  736. display: flex;
  737. align-items: center;
  738. gap: 10px;
  739. }
  740. .tag-name-input {
  741. width: 220px;
  742. }
  743. .tag-sort-input {
  744. width: 120px;
  745. }
  746. .target-info {
  747. font-size: 13px;
  748. color: #606266;
  749. }
  750. .form-hint {
  751. font-size: 12px;
  752. color: #909399;
  753. }
  754. .stat-card {
  755. text-align: center;
  756. }
  757. .stat-label {
  758. font-size: 12px;
  759. color: #909399;
  760. margin-top: 4px;
  761. }
  762. .stat-chips {
  763. display: flex;
  764. gap: 12px;
  765. flex-wrap: wrap;
  766. justify-content: center;
  767. }
  768. .text-error {
  769. color: #f56c6c;
  770. }
  771. /* == C 批内联清理工具类 == */
  772. .w-120 { width: 120px; }
  773. .w-140 { width: 140px; }
  774. .w-200 { width: 200px; }
  775. .w-220 { width: 220px; }
  776. .w-240 { width: 240px; }
  777. .full-width { width: 100%; }
  778. .mt-16 { margin-top: 16px; }
  779. .mb-16 { margin-bottom: 16px; }
  780. </style>