Files
thebet365/apps/admin/src/views/MediaLibrary.vue
Mars 03f54ca689 feat: split admin dashboard, improve match ops, and player closed-match UX
Admin: add match/player overview sub-nav; refine settlement flow and league
match management UI; improve action button enabled/disabled styles; enhance
logo upload and outright odds sync.

API: expose matchPhase/bettingOpen for closed matches; league publish guards;
settlement preview with auto score save; outright team auto-sync.

Player: watermark for closed/settled states; keep match and bet details visible;
remove default login credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:00:14 +08:00

653 lines
16 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, 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);
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() {
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;
} catch {
ElMessage.error(t('common.loading'));
} finally {
loading.value = false;
}
}
watch(activeCategory, () => {
currentPage.value = 1;
loadFiles();
});
watch(currentPage, loadFiles);
onMounted(loadFiles);
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>
<!-- 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" @click.self="uploadDialogVisible = false">
<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;
}
/* ── 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(47, 181, 106, 0.14);
border-color: rgba(47, 181, 106, 0.5);
color: #2fb56a;
font-weight: 600;
}
.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: linear-gradient(135deg, #2fb56a, #248f54);
color: #fff;
font-weight: 600;
border-color: transparent;
box-shadow: 0 2px 8px rgba(47, 181, 106, 0.3);
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
/* ── 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(47, 181, 106, 0.2);
color: #2fb56a;
border: 1px solid rgba(47, 181, 106, 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(47, 181, 106, 0.5);
background: rgba(47, 181, 106, 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;
}
</style>