feat: add finance logs page, banner upload, and admin withdraw fix

## 财务流水
- 新增 FinanceLogs.vue(/finance-logs):额度流水 + 上下分流水双 Tab,支持时间/代理/玩家/操作人筛选与分页
- 管理员与代理共用页面,API 按角色自动切换(/admin/* 或 /agent/*)
- 侧栏「财务流水」替代原「额度流水」;代理侧栏同步新增入口
- /agent-credit-transactions 重定向至 /finance-logs?tab=credit,旧链接仍可用
- 后端:新增 GET /admin/wallet/transfer-transactions;增强额度/上下分列表筛选
- 代理端:新增 GET /agent/credit-transactions;GET /agent/wallet-transactions 支持分页与筛选
- 修复:管理员下分改为 adminWithdrawFromPlayer(),下分后重算上级代理 usedCredit

## 内容管理 Banner
- Contents.vue:各语言 Banner 支持本地上传、媒体库选择、手动填 URL(≤5MB)
- vite 开发代理 /uploads;生产 nginx 反代 /uploads/ 至 API

## 玩家端 Banner
- BannerCarousel:外链无协议时自动补 https://
- defaultBanner:API 加载中不闪默认图,仅空列表时展示默认 Banner

## 其他
- AgentManager:查看额度流水链接改为 /finance-logs
- i18n:finance.*、nav.finance_logs、content.upload.*(中/英/马来)

未纳入本次提交:.pnpm-store/、release/ 部署包、uploads/banners/ 下测试上传图片

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-10 10:02:53 +08:00
parent df20444be9
commit 6124313369
17 changed files with 1233 additions and 25 deletions

View File

@@ -8,6 +8,89 @@ import AdminTableEmpty from '../components/AdminTableEmpty.vue';
const { t, localeTag } = useAdminLocale();
/* ── Image upload helpers ── */
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
interface MediaFile {
id: string;
filename: string;
category: string;
mimeType: string;
size: number;
url: string;
inUse: boolean;
createdAt: string;
}
/** Per-locale uploading state */
const uploadingLocale = ref<string | null>(null);
/** Media picker state */
const mediaPickerVisible = ref(false);
const mediaPickerLocale = ref('');
const mediaFiles = ref<MediaFile[]>([]);
const mediaLoading = ref(false);
async function uploadBannerImage(locale: string, file: File) {
if (file.size > MAX_UPLOAD_SIZE) {
ElMessage.error(t('content.upload.size_error'));
return;
}
uploadingLocale.value = locale;
try {
const fd = new FormData();
fd.append('file', file);
const { data } = await api.post('/admin/uploads?category=banners', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const url = data.data?.url as string;
if (url) {
const tr = form.value.translations.find((item) => item.locale === locale);
if (tr) tr.imageUrl = url;
ElMessage.success(t('content.upload.success'));
}
} catch (err: any) {
const msg = err?.response?.data?.message || t('content.upload.failed');
ElMessage.error(String(msg));
} finally {
uploadingLocale.value = null;
}
}
function onBannerFileChange(e: Event, locale: string) {
const input = e.target as HTMLInputElement;
if (input.files?.[0]) {
void uploadBannerImage(locale, input.files[0]);
input.value = '';
}
}
function removeBannerImage(locale: string) {
const tr = form.value.translations.find((item) => item.locale === locale);
if (tr) tr.imageUrl = '';
}
async function openMediaPicker(locale: string) {
mediaPickerLocale.value = locale;
mediaPickerVisible.value = true;
mediaLoading.value = true;
try {
const res = await api.get('/admin/files', { params: { category: 'banners', pageSize: 200 } });
mediaFiles.value = res.data.data.items ?? [];
} catch {
mediaFiles.value = [];
} finally {
mediaLoading.value = false;
}
}
function pickMediaFile(file: MediaFile) {
const tr = form.value.translations.find((item) => item.locale === mediaPickerLocale.value);
if (tr) tr.imageUrl = file.url;
mediaPickerVisible.value = false;
}
type StoredContentType = 'BANNER' | 'NOTICE' | 'TICKER';
type AdminTab = 'BANNER' | 'ANNOUNCEMENT';
type ContentStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE';
@@ -269,6 +352,10 @@ function buildPayload() {
}
async function submitForm() {
if (uploadingLocale.value) {
ElMessage.warning(t('content.upload.uploading'));
return;
}
saving.value = true;
try {
const payload = buildPayload();
@@ -532,7 +619,39 @@ void load();
:label="t('content.field.image_url')"
:required="form.status === 'ACTIVE'"
>
<el-input v-model="tr.imageUrl" placeholder="/uploads/banners/welcome.svg" />
<div class="banner-upload-field">
<!-- Image preview -->
<div v-if="tr.imageUrl" class="banner-preview">
<img :src="tr.imageUrl" alt="" class="banner-preview-img" />
<button type="button" class="banner-preview-remove" :title="t('content.upload.remove')" @click="removeBannerImage(tr.locale)">&times;</button>
</div>
<!-- Upload actions -->
<div class="banner-upload-actions">
<label
class="banner-upload-btn"
:class="{ 'is-uploading': uploadingLocale === tr.locale }"
>
<input
type="file"
:accept="IMAGE_ACCEPT"
style="display: none"
:disabled="uploadingLocale === tr.locale"
@change="onBannerFileChange($event, tr.locale)"
/>
{{ uploadingLocale === tr.locale ? t('content.upload.uploading') : t('content.upload.upload_btn') }}
</label>
<button type="button" class="banner-pick-btn" @click="openMediaPicker(tr.locale)">
{{ t('content.upload.pick_media') }}
</button>
</div>
<!-- Manual URL fallback -->
<el-input
v-model="tr.imageUrl"
:placeholder="t('content.upload.url_placeholder')"
size="small"
class="banner-url-input"
/>
</div>
</el-form-item>
<el-form-item
:label="isAnnouncement ? t('content.field.announce_text') : t('content.field.body')"
@@ -544,11 +663,31 @@ void load();
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">
<el-button type="primary" :loading="saving" :disabled="!!uploadingLocale" @click="submitForm">
{{ t('common.save') }}
</el-button>
</template>
</el-dialog>
<!-- Media picker dialog -->
<el-dialog v-model="mediaPickerVisible" :title="t('content.upload.pick_media_title')" width="680px" destroy-on-close append-to-body>
<div v-if="mediaLoading" class="media-picker-loading">{{ t('common.loading') }}</div>
<div v-else-if="mediaFiles.length === 0" class="media-picker-empty">{{ t('content.upload.no_media') }}</div>
<div v-else class="media-picker-grid">
<div
v-for="file in mediaFiles"
:key="file.id"
class="media-picker-card"
@click="pickMediaFile(file)"
>
<div class="media-picker-thumb">
<img v-if="file.mimeType !== 'image/svg+xml'" :src="file.url" :alt="file.filename" loading="lazy" />
<div v-else class="media-picker-svg">SVG</div>
</div>
<div class="media-picker-name" :title="file.filename">{{ file.filename }}</div>
</div>
</div>
</el-dialog>
</div>
</template>
@@ -633,4 +772,171 @@ void load();
color: #888;
margin-bottom: 8px;
}
/* ── Banner image upload widget ── */
.banner-upload-field {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.banner-preview {
position: relative;
width: 100%;
max-width: 320px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #252525;
background: #111;
}
.banner-preview-img {
width: 100%;
height: 120px;
object-fit: cover;
display: block;
}
.banner-preview-remove {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
color: #fff;
border: 1px solid #333;
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.banner-preview-remove:hover {
background: rgba(224, 85, 85, 0.85);
}
.banner-upload-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.banner-upload-btn {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
border: 1px solid rgba(47, 181, 106, 0.5);
background: rgba(47, 181, 106, 0.1);
color: #2fb56a;
font-weight: 600;
transition: all 0.15s;
}
.banner-upload-btn:hover {
background: rgba(47, 181, 106, 0.2);
}
.banner-upload-btn.is-uploading {
opacity: 0.6;
cursor: wait;
}
.banner-pick-btn {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
border: 1px solid #2a2a2a;
background: transparent;
color: #888;
transition: all 0.15s;
}
.banner-pick-btn:hover {
border-color: #444;
color: #ccc;
}
.banner-url-input {
max-width: 320px;
}
.banner-url-input :deep(.el-input__wrapper) {
font-size: 12px;
}
/* ── Media picker ── */
.media-picker-loading,
.media-picker-empty {
text-align: center;
padding: 40px 0;
color: #555;
font-size: 14px;
}
.media-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
max-height: 420px;
overflow-y: auto;
}
.media-picker-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #1e1e1e;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.media-picker-card:hover {
border-color: rgba(47, 181, 106, 0.5);
box-shadow: 0 2px 12px rgba(47, 181, 106, 0.15);
}
.media-picker-thumb {
height: 80px;
background: #111;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.media-picker-thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.media-picker-svg {
font-size: 14px;
font-weight: 700;
color: #666;
letter-spacing: 0.1em;
}
.media-picker-name {
padding: 6px 8px;
font-size: 11px;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>