sync(theme-3): 同步 main 最新 API/Admin 与玩家端逻辑

从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
This commit is contained in:
2026-06-22 14:11:47 +08:00
parent f92c5e8a59
commit d8e08aaed0
50 changed files with 4653 additions and 1186 deletions

View File

@@ -41,6 +41,74 @@ 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;
@@ -64,6 +132,7 @@ async function loadFiles(opts?: { silent?: boolean }) {
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 {
@@ -80,9 +149,15 @@ watch(currentPage, () => {
void loadFiles();
});
onMounted(() => void loadFiles());
onMounted(() => {
void loadFiles();
void loadCleanupConfig();
});
onActivated(() => {
if (files.value.length > 0) void loadFiles({ silent: true });
if (files.value.length > 0) {
void loadFiles({ silent: true });
void loadCleanupConfig();
}
});
async function confirmDelete(file: MediaFile) {
@@ -197,6 +272,58 @@ async function doUpload() {
</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>
@@ -309,6 +436,185 @@ async function doUpload() {
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;