Files
thebet365/apps/admin/src/views/MediaLibrary.vue
Mars d8e08aaed0 sync(theme-3): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
2026-06-22 14:11:47 +08:00

1185 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
defineOptions({ name: 'AdminMediaLibrary' });
import { ref, computed, onMounted, onActivated, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
const { t } = useAdminLocale();
interface MediaFile {
id: string;
filename: string;
category: string;
mimeType: string;
size: number;
url: string;
uploadedBy: string | null;
createdAt: string;
inUse: boolean;
}
const CATEGORIES = ['banners', 'teams', 'contents'] as const;
type Category = (typeof CATEGORIES)[number];
const files = ref<MediaFile[]>([]);
const total = ref(0);
const loading = ref(false);
const purging = ref(false);
const uploading = ref(false);
const activeCategory = ref<Category | ''>('');
const currentPage = ref(1);
const pageSize = 40;
const uploadDialogVisible = ref(false);
const uploadCategory = ref<Category>('banners');
const uploadFile = ref<File | null>(null);
const dropActive = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const unusedCount = computed(() => files.value.filter((f) => !f.inUse).length);
const storageStats = ref<{
categories: Array<{ category: string; count: number; sizeBytes: number }>;
total: { count: number; sizeBytes: number };
} | null>(null);
const cleanupConfig = ref({ enabled: false, keepDays: 180 });
const manualCleanupBefore = ref('');
function getCategoryLabel(cat: string) {
if (cat === 'deposits') return t('media.deposits_on_disk');
return categoryLabel(cat);
}
async function loadStorageStats() {
try {
const res = await api.get('/admin/files/storage-stats');
storageStats.value = res.data.data;
} catch (err) {
console.error('Failed to load storage stats:', err);
}
}
async function loadCleanupConfig() {
try {
const res = await api.get('/admin/deposits/screenshot-cleanup-config');
cleanupConfig.value = res.data.data;
} catch (err) {
console.error('Failed to load cleanup config:', err);
}
}
async function saveCleanupConfig() {
try {
const res = await api.put('/admin/deposits/screenshot-cleanup-config', {
enabled: cleanupConfig.value.enabled,
keepDays: cleanupConfig.value.keepDays,
});
cleanupConfig.value = res.data.data;
ElMessage.success(t('msg.saved'));
} catch (err: any) {
ElMessage.error(err?.response?.data?.message || t('msg.save_failed'));
}
}
async function runManualCleanup() {
if (!manualCleanupBefore.value) return;
const beforeDate = manualCleanupBefore.value;
await ElMessageBox.confirm(
`${t('media.delete_confirm')} (${beforeDate} ${t('common.to')})`,
{ type: 'warning' }
);
try {
const res = await api.delete(`/admin/deposits/screenshots?before=${beforeDate}T00:00:00.000Z`);
const cleaned = res.data.data.cleaned;
const freedBytes = res.data.data.freedBytes;
const msg = t('media.cleanup_result')
.replace('{cleaned}', String(cleaned))
.replace('{size}', formatSize(freedBytes));
ElMessage.success(msg);
loadStorageStats();
loadFiles();
} catch (err: any) {
ElMessage.error(err?.response?.data?.message || t('msg.delete_failed'));
}
}
function categoryLabel(cat: string) {
const key = `media.category.${cat}` as const;
return t(key as any) || cat;
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString();
}
async function loadFiles(opts?: { silent?: boolean }) {
if (!opts?.silent) loading.value = true;
try {
const params: Record<string, string | number> = { page: currentPage.value, pageSize };
if (activeCategory.value) params.category = activeCategory.value;
const res = await api.get('/admin/files', { params });
files.value = res.data.data.items;
total.value = res.data.data.total;
void loadStorageStats();
} catch {
ElMessage.error(t('common.loading'));
} finally {
if (!opts?.silent) loading.value = false;
}
}
watch(activeCategory, () => {
currentPage.value = 1;
loadFiles();
});
watch(currentPage, () => {
void loadFiles();
});
onMounted(() => {
void loadFiles();
void loadCleanupConfig();
});
onActivated(() => {
if (files.value.length > 0) {
void loadFiles({ silent: true });
void loadCleanupConfig();
}
});
async function confirmDelete(file: MediaFile) {
await ElMessageBox.confirm(t('media.delete_confirm'), { type: 'warning' });
try {
await api.delete(`/admin/files/${file.id}`);
ElMessage.success(t('media.delete_success'));
loadFiles();
} catch {
ElMessage.error(t('media.upload_failed'));
}
}
async function purgeUnused() {
if (unusedCount.value === 0) {
ElMessage.info(t('media.purge_none'));
return;
}
const msg = t('media.purge_confirm').replace('{n}', String(unusedCount.value));
await ElMessageBox.confirm(msg, { type: 'warning' });
purging.value = true;
try {
const res = await api.delete('/admin/files/unused');
const deleted = res.data.data.deleted;
ElMessage.success(t('media.purge_success').replace('{n}', String(deleted)));
loadFiles();
} catch {
ElMessage.error(t('media.upload_failed'));
} finally {
purging.value = false;
}
}
function copyUrl(url: string) {
navigator.clipboard.writeText(location.origin + url).then(
() => ElMessage.success(t('media.url_copied')),
() => ElMessage.error(t('media.upload_failed')),
);
}
function openUploadDialog() {
uploadFile.value = null;
uploadDialogVisible.value = true;
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files?.[0]) {
uploadFile.value = input.files[0];
}
}
function onDrop(e: DragEvent) {
e.preventDefault();
dropActive.value = false;
if (e.dataTransfer?.files?.[0]) {
uploadFile.value = e.dataTransfer.files[0];
}
}
async function doUpload() {
if (!uploadFile.value) return;
uploading.value = true;
try {
const fd = new FormData();
fd.append('file', uploadFile.value);
await api.post(`/admin/uploads?category=${uploadCategory.value}`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
ElMessage.success(t('media.upload_success'));
uploadDialogVisible.value = false;
uploadFile.value = null;
if (fileInputRef.value) fileInputRef.value.value = '';
loadFiles();
} catch (err: any) {
const msg = err?.response?.data?.message || t('media.upload_failed');
ElMessage.error(msg);
} finally {
uploading.value = false;
}
}
</script>
<template>
<div class="media-page">
<!-- Header toolbar -->
<div class="toolbar">
<div class="filter-tabs">
<button
class="tab-btn"
:class="{ active: activeCategory === '' }"
@click="activeCategory = ''"
>{{ t('media.category.all') }}</button>
<button
v-for="cat in CATEGORIES"
:key="cat"
class="tab-btn"
:class="{ active: activeCategory === cat }"
@click="activeCategory = cat"
>{{ categoryLabel(cat) }}</button>
</div>
<div class="toolbar-right">
<span v-if="unusedCount > 0" class="unused-badge">
{{ t('media.unused_count').replace('{n}', String(unusedCount)) }}
</span>
<button class="btn btn-ghost" :disabled="purging" @click="purgeUnused">
{{ t('media.purge_btn') }}
</button>
<button class="btn btn-primary" @click="openUploadDialog">
+ {{ t('media.upload_btn') }}
</button>
</div>
</div>
<!-- Storage Stats -->
<div class="stats-banner" v-if="storageStats">
<div v-for="item in storageStats.categories" :key="item.category" class="stat-card">
<span class="stat-label">{{ getCategoryLabel(item.category) }}</span>
<span class="stat-value">{{ item.count }} <span class="stat-unit">{{ t('common.times') }}</span></span>
<span class="stat-size">{{ formatSize(item.sizeBytes) }}</span>
</div>
<div class="stat-card total-card">
<span class="stat-label">{{ t('media.storage_stats') }}</span>
<span class="stat-value">{{ storageStats.total.count }} <span class="stat-unit">{{ t('common.times') }}</span></span>
<span class="stat-size">{{ formatSize(storageStats.total.sizeBytes) }}</span>
</div>
</div>
<!-- Cleanup Panel -->
<div class="cleanup-card">
<div class="cleanup-title">
<span>{{ t('media.screenshot_cleanup') }}</span>
</div>
<div class="cleanup-grid">
<!-- Auto Cleanup Config -->
<div class="cleanup-section">
<h4 class="section-subtitle">{{ t('media.cleanup_auto_enabled') }}</h4>
<div class="config-row">
<label class="switch-container">
<input type="checkbox" v-model="cleanupConfig.enabled" @change="saveCleanupConfig" />
<span class="switch-slider"></span>
</label>
<div class="days-input-group" v-if="cleanupConfig.enabled">
<span>{{ t('media.cleanup_keep_days') }}</span>
<input type="number" v-model.number="cleanupConfig.keepDays" min="1" class="num-input" />
<button class="btn btn-ghost btn-sm" @click="saveCleanupConfig">{{ t('common.save') }}</button>
</div>
</div>
</div>
<!-- Manual Cleanup -->
<div class="cleanup-section manual-section">
<h4 class="section-subtitle">{{ t('media.cleanup_before_date') }}</h4>
<div class="config-row">
<input type="date" v-model="manualCleanupBefore" class="date-input" />
<button class="btn btn-primary" :disabled="!manualCleanupBefore" @click="runManualCleanup">
{{ t('media.cleanup_run_now') }}
</button>
</div>
</div>
</div>
<div class="cleanup-tip">
* 仅清理已同意已拒绝的充值订单截图待处理的截图绝不会被删除清理后文件会被替换为已过期占位图
</div>
</div>
<!-- File grid -->
<div v-if="loading" class="state-center">{{ t('common.loading') }}</div>
<div v-else-if="files.length === 0" class="state-center muted">{{ t('media.no_files') }}</div>
<div v-else class="file-grid">
<div v-for="file in files" :key="file.id" class="file-card">
<div class="card-thumb">
<img
v-if="file.mimeType !== 'image/svg+xml'"
:src="file.url"
:alt="file.filename"
loading="lazy"
/>
<div v-else class="svg-badge">SVG</div>
<span class="use-badge" :class="file.inUse ? 'badge-used' : 'badge-unused'">
{{ file.inUse ? t('media.status.used') : t('media.status.unused') }}
</span>
</div>
<div class="card-body">
<div class="card-filename" :title="file.filename">{{ file.filename }}</div>
<div class="card-meta">
<span class="cat-tag">{{ categoryLabel(file.category) }}</span>
<span>{{ formatSize(file.size) }}</span>
</div>
<div class="card-date">{{ formatDate(file.createdAt) }}</div>
</div>
<div class="card-actions">
<button class="act-btn" @click="copyUrl(file.url)">{{ t('media.copy_url') }}</button>
<button class="act-btn act-delete" @click="confirmDelete(file)">{{ t('common.delete') }}</button>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="total > pageSize" class="pagination">
<button
class="btn btn-ghost"
:disabled="currentPage <= 1"
@click="currentPage--"
>&laquo;</button>
<span class="page-info">{{ currentPage }} / {{ Math.ceil(total / pageSize) }}</span>
<button
class="btn btn-ghost"
:disabled="currentPage >= Math.ceil(total / pageSize)"
@click="currentPage++"
>&raquo;</button>
</div>
<!-- Upload dialog -->
<div v-if="uploadDialogVisible" class="dialog-overlay">
<div class="dialog">
<div class="dialog-header">
<span>{{ t('media.upload_dialog') }}</span>
<button class="dialog-close" @click="uploadDialogVisible = false">&times;</button>
</div>
<div class="dialog-body">
<div class="form-row">
<label>{{ t('media.upload_category') }}</label>
<div class="cat-buttons">
<button
v-for="cat in CATEGORIES"
:key="cat"
class="tab-btn"
:class="{ active: uploadCategory === cat }"
@click="uploadCategory = cat"
>{{ categoryLabel(cat) }}</button>
</div>
</div>
<div
class="drop-zone"
:class="{ 'drop-active': dropActive }"
@dragover.prevent="dropActive = true"
@dragleave="dropActive = false"
@drop="onDrop"
@click="fileInputRef?.click()"
>
<div v-if="!uploadFile" class="drop-hint">{{ t('media.drop_hint') }}</div>
<div v-else class="drop-selected">
<span class="sel-name">{{ uploadFile.name }}</span>
<span class="sel-size">{{ formatSize(uploadFile.size) }}</span>
</div>
<input
ref="fileInputRef"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
style="display:none"
@change="onFileChange"
/>
</div>
<div class="upload-hint-text">{{ t('media.upload_hint') }}</div>
</div>
<div class="dialog-footer">
<button class="btn btn-ghost" @click="uploadDialogVisible = false">{{ t('common.cancel') }}</button>
<button
class="btn btn-primary"
:disabled="!uploadFile || uploading"
@click="doUpload"
>{{ uploading ? t('common.loading') : t('common.confirm') }}</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.media-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
min-height: 0;
}
/* ── Storage Stats ── */
.stats-banner {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
flex-shrink: 0;
}
.stat-card {
background: #ffffff;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 4px;
box-shadow: var(--shadow);
transition: all 0.15s ease-in-out;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(56, 49, 37, 0.06);
}
.total-card {
border-color: var(--primary);
background: var(--accent-hover);
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 550;
}
.stat-value {
font-size: 20px;
font-weight: 800;
color: var(--text);
}
.stat-unit {
font-size: 12px;
font-weight: 400;
color: var(--text-muted);
}
.stat-size {
font-size: 12px;
color: var(--text-muted);
}
/* ── Cleanup Panel ── */
.cleanup-card {
background: #ffffff;
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 14px;
flex-shrink: 0;
}
.cleanup-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 800;
color: var(--text);
border-bottom: 1px solid var(--border-soft);
padding-bottom: 8px;
}
.cleanup-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.cleanup-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-subtitle {
margin: 0;
font-size: 13px;
color: var(--text);
font-weight: 700;
}
.config-row {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.days-input-group {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-muted);
}
.num-input {
width: 75px;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: #ffffff;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
}
.num-input:focus {
border-color: var(--primary);
}
.date-input {
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #ffffff;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
}
.date-input:focus {
border-color: var(--primary);
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.cleanup-tip {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
padding: 8px 12px;
background: var(--accent-hover);
border-radius: 6px;
border-left: 3px solid var(--primary);
}
/* ── Custom Toggle Switch ── */
.switch-container {
position: relative;
display: inline-block;
width: 44px;
height: 22px;
}
.switch-container input {
opacity: 0;
width: 0;
height: 0;
}
.switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #d5cfc3;
transition: .2s;
border-radius: 22px;
}
.switch-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .2s;
border-radius: 50%;
}
input:checked + .switch-slider {
background-color: var(--primary);
}
input:checked + .switch-slider:before {
transform: translateX(22px);
}
/* ── Toolbar ── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
flex-shrink: 0;
}
.filter-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.unused-badge {
font-size: 12px;
color: #f0a020;
background: rgba(240, 160, 32, 0.12);
border: 1px solid rgba(240, 160, 32, 0.3);
border-radius: 4px;
padding: 3px 8px;
}
/* ── Tabs / Buttons ── */
.tab-btn {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid #2a2a2a;
background: transparent;
color: #888;
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.tab-btn:hover { border-color: #444; color: #ccc; }
.tab-btn.active {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: #f5f5f5;
font-weight: 500;
}
.btn {
padding: 7px 16px;
border-radius: 6px;
font-size: 13px;
font-family: inherit;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
white-space: nowrap;
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-ghost {
background: transparent;
border-color: #2a2a2a;
color: #888;
}
.btn-ghost:hover:not(:disabled) { border-color: #444; color: #ccc; }
.btn-primary {
background: #f5f5f5;
color: #0a0a0a;
font-weight: 500;
border-color: #f5f5f5;
box-shadow: none;
}
.btn-primary:hover:not(:disabled) { background: #ffffff; border-color: #ffffff; }
/* ── Grid ── */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
align-content: start;
gap: 14px;
overflow-y: auto;
flex: 1;
padding-right: 4px;
padding-bottom: 8px;
}
.file-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #1e1e1e;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color 0.15s, box-shadow 0.15s;
}
.file-card:hover {
border-color: #333;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.card-thumb {
position: relative;
height: 120px;
background: #111;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.card-thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.svg-badge {
font-size: 14px;
font-weight: 700;
color: #666;
letter-spacing: 0.1em;
}
.use-badge {
position: absolute;
top: 6px;
right: 6px;
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.04em;
}
.badge-used {
background: rgba(212, 175, 55, 0.2);
color: #c9a227;
border: 1px solid rgba(212, 175, 55, 0.35);
}
.badge-unused {
background: rgba(120, 120, 120, 0.18);
color: #666;
border: 1px solid rgba(120, 120, 120, 0.2);
}
.card-body {
padding: 10px 12px 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.card-filename {
font-size: 12px;
color: #ccc;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.card-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #555;
}
.cat-tag {
background: rgba(255, 255, 255, 0.05);
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
color: #666;
}
.card-date {
font-size: 10px;
color: #444;
}
.card-actions {
display: flex;
border-top: 1px solid #1a1a1a;
}
.act-btn {
flex: 1;
padding: 7px 4px;
background: transparent;
border: none;
border-right: 1px solid #1a1a1a;
color: #666;
font-size: 11px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.act-btn:last-child { border-right: none; }
.act-btn:hover { background: rgba(255, 255, 255, 0.04); color: #ccc; }
.act-delete:hover { color: #e05555; }
/* ── Pagination ── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-shrink: 0;
padding-bottom: 4px;
}
.page-info {
font-size: 13px;
color: #666;
min-width: 60px;
text-align: center;
}
/* ── Empty / loading states ── */
.state-center {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
font-size: 14px;
color: #444;
}
.muted { color: #333; }
.file-grid::-webkit-scrollbar { width: 6px; }
.file-grid::-webkit-scrollbar-track { background: transparent; }
.file-grid::-webkit-scrollbar-thumb {
background: #2a2a2a;
border-radius: 3px;
}
.file-grid::-webkit-scrollbar-thumb:hover { background: #444; }
/* ── Upload dialog ── */
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
padding: 20px;
}
.dialog {
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 12px;
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #1e1e1e;
font-size: 15px;
font-weight: 600;
color: #e0e0e0;
}
.dialog-close {
background: transparent;
border: none;
color: #555;
font-size: 20px;
cursor: pointer;
line-height: 1;
padding: 0 4px;
}
.dialog-close:hover { color: #ccc; }
.dialog-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.form-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-row label {
font-size: 13px;
color: #888;
}
.cat-buttons {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.drop-zone {
border: 2px dashed #2a2a2a;
border-radius: 10px;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
padding: 16px;
}
.drop-zone:hover,
.drop-zone.drop-active {
border-color: rgba(212, 175, 55, 0.5);
background: rgba(212, 175, 55, 0.04);
}
.drop-hint {
font-size: 13px;
color: #444;
text-align: center;
}
.drop-selected {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.sel-name {
font-size: 13px;
color: #ccc;
font-weight: 500;
word-break: break-all;
text-align: center;
}
.sel-size {
font-size: 11px;
color: #555;
}
.upload-hint-text {
font-size: 11px;
color: #444;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #1e1e1e;
}
.media-page {
color: var(--text);
}
.toolbar {
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: #ffffff;
box-shadow: var(--shadow);
}
.unused-badge {
border-color: var(--warning-border);
background: var(--warning-bg);
color: var(--warning-text);
}
.tab-btn,
.btn {
border-radius: 7px;
font-weight: 700;
}
.tab-btn {
border-color: var(--border);
background: #ffffff;
color: var(--text-muted);
}
.tab-btn:hover {
border-color: #d5cfc3;
background: var(--accent-hover);
color: var(--text);
}
.tab-btn.active {
border-color: var(--primary);
background: var(--primary);
color: #ffffff;
}
.btn-ghost {
border-color: var(--border);
background: #ffffff;
color: var(--text);
}
.btn-ghost:hover:not(:disabled) {
border-color: #d5cfc3;
background: var(--accent-hover);
color: var(--text);
}
.btn-primary {
border-color: var(--primary);
background: var(--primary);
color: #ffffff;
}
.btn-primary:hover:not(:disabled) {
border-color: var(--primary-light);
background: var(--primary-light);
}
.file-grid {
padding: 2px 2px 8px;
}
.file-card {
border-color: var(--border);
border-radius: 8px;
background: #ffffff;
box-shadow: var(--shadow);
}
.file-card:hover {
border-color: #d5cfc3;
box-shadow: 0 10px 26px rgba(56, 49, 37, 0.08);
}
.card-thumb,
.media-picker-thumb {
background: #f4f0e8;
}
.svg-badge,
.media-picker-svg {
color: var(--text-muted);
}
.badge-used {
border-color: var(--warning-border);
background: var(--warning-bg);
color: var(--warning-text);
}
.badge-unused {
border-color: var(--border);
background: #f0ede6;
color: var(--text-muted);
}
.card-filename {
color: var(--text);
font-weight: 700;
}
.card-meta,
.card-date,
.page-info,
.state-center,
.muted,
.drop-hint,
.sel-size,
.upload-hint-text,
.form-row label {
color: var(--text-muted);
}
.cat-tag {
border-color: var(--border-soft);
background: #f0ede6;
color: var(--text-muted);
}
.card-actions {
border-top-color: var(--border-soft);
}
.act-btn {
border-right-color: var(--border-soft);
color: var(--text-muted);
font-weight: 700;
}
.act-btn:hover {
background: var(--accent-hover);
color: var(--text);
}
.act-delete:hover {
color: var(--danger-text);
}
.file-grid::-webkit-scrollbar-thumb {
background: #d5cfc3;
}
.file-grid::-webkit-scrollbar-thumb:hover {
background: #bdb6a9;
}
.dialog-overlay {
background: rgba(31, 35, 32, 0.28);
backdrop-filter: blur(3px);
}
.dialog {
border-color: var(--border);
background: #ffffff;
box-shadow: 0 24px 64px rgba(56, 49, 37, 0.14);
}
.dialog-header {
border-bottom-color: var(--border-soft);
color: var(--text);
font-weight: 800;
}
.dialog-close {
color: var(--text-muted);
}
.dialog-close:hover {
color: var(--danger-text);
}
.drop-zone {
border-color: #d5cfc3;
background: #fbfaf7;
}
.drop-zone:hover,
.drop-zone.drop-active {
border-color: var(--primary);
background: var(--accent-hover);
}
.sel-name {
color: var(--text);
}
.dialog-footer {
border-top-color: var(--border-soft);
}
@media (max-width: 700px) {
.toolbar,
.toolbar-right,
.filter-tabs {
align-items: stretch;
width: 100%;
}
.tab-btn,
.toolbar-right .btn {
flex: 1 1 auto;
}
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
}
.dialog {
max-height: calc(100dvh - 32px);
}
}
</style>