| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792 |
- <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>
|