重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
369 lines
14 KiB
Vue
369 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue';
|
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
|
import api from '../api';
|
|
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
|
|
|
const { t } = useAdminLocale();
|
|
|
|
interface PaymentMethod {
|
|
id: string;
|
|
methodType: string;
|
|
bankName: string | null;
|
|
accountHolder: string | null;
|
|
accountNumber: string | null;
|
|
usdtAddress: string | null;
|
|
qrCodeUrl: string | null;
|
|
displayName: string | null;
|
|
sortOrder: number;
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
translations?: {
|
|
displayName?: Record<string, string>;
|
|
bankName?: Record<string, string>;
|
|
};
|
|
}
|
|
|
|
const items = ref<PaymentMethod[]>([]);
|
|
const loading = ref(false);
|
|
const typeFilter = ref<'' | 'BANK' | 'USDT'>('');
|
|
|
|
// Dialog
|
|
const dialogVisible = ref(false);
|
|
const editingId = ref<string | null>(null);
|
|
const form = ref({
|
|
methodType: 'BANK' as 'BANK' | 'USDT',
|
|
bankName: '',
|
|
accountHolder: '',
|
|
accountNumber: '',
|
|
usdtAddress: '',
|
|
qrCodeUrl: '',
|
|
displayName: '',
|
|
sortOrder: 0,
|
|
isActive: true,
|
|
translations: {
|
|
displayName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
|
|
bankName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
|
|
},
|
|
});
|
|
|
|
const filteredItems = computed(() => {
|
|
if (!typeFilter.value) return items.value;
|
|
return items.value.filter((m) => m.methodType === typeFilter.value);
|
|
});
|
|
|
|
async function fetchList() {
|
|
loading.value = true;
|
|
try {
|
|
const { data } = await api.get('/admin/payment-methods');
|
|
items.value = (data.data ?? []).map((m: any) => ({ ...m, id: String(m.id) }));
|
|
} catch { /* */ } finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function openCreate() {
|
|
editingId.value = null;
|
|
form.value = {
|
|
methodType: 'BANK',
|
|
bankName: '',
|
|
accountHolder: '',
|
|
accountNumber: '',
|
|
usdtAddress: '',
|
|
qrCodeUrl: '',
|
|
displayName: '',
|
|
sortOrder: 0,
|
|
isActive: true,
|
|
translations: {
|
|
displayName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
|
|
bankName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
|
|
},
|
|
};
|
|
dialogVisible.value = true;
|
|
}
|
|
|
|
function openEdit(row: PaymentMethod) {
|
|
editingId.value = row.id;
|
|
const t = row.translations ?? {};
|
|
form.value = {
|
|
methodType: row.methodType as 'BANK' | 'USDT',
|
|
bankName: row.bankName ?? '',
|
|
accountHolder: row.accountHolder ?? '',
|
|
accountNumber: row.accountNumber ?? '',
|
|
usdtAddress: row.usdtAddress ?? '',
|
|
qrCodeUrl: row.qrCodeUrl ?? '',
|
|
displayName: row.displayName ?? '',
|
|
sortOrder: row.sortOrder,
|
|
isActive: row.isActive,
|
|
translations: {
|
|
displayName: {
|
|
'zh-CN': t.displayName?.['zh-CN'] ?? '',
|
|
'en-US': t.displayName?.['en-US'] ?? '',
|
|
'ms-MY': t.displayName?.['ms-MY'] ?? '',
|
|
},
|
|
bankName: {
|
|
'zh-CN': t.bankName?.['zh-CN'] ?? '',
|
|
'en-US': t.bankName?.['en-US'] ?? '',
|
|
'ms-MY': t.bankName?.['ms-MY'] ?? '',
|
|
},
|
|
},
|
|
};
|
|
dialogVisible.value = true;
|
|
}
|
|
|
|
async function handleSave() {
|
|
const { translations, ...rest } = form.value;
|
|
// Only send non-empty translations
|
|
const cleanTranslations = {
|
|
displayName: Object.fromEntries(
|
|
Object.entries(translations.displayName).filter(([, v]) => v.trim()),
|
|
),
|
|
bankName: Object.fromEntries(
|
|
Object.entries(translations.bankName).filter(([, v]) => v.trim()),
|
|
),
|
|
};
|
|
const payload: any = {
|
|
...rest,
|
|
translations: (Object.keys(cleanTranslations.displayName).length || Object.keys(cleanTranslations.bankName).length)
|
|
? cleanTranslations
|
|
: undefined,
|
|
};
|
|
try {
|
|
if (editingId.value) {
|
|
await api.put(`/admin/payment-methods/${editingId.value}`, payload);
|
|
} else {
|
|
await api.post('/admin/payment-methods', payload);
|
|
}
|
|
dialogVisible.value = false;
|
|
await fetchList();
|
|
} catch (e: any) {
|
|
alert(e.response?.data?.message || 'Error');
|
|
}
|
|
}
|
|
|
|
async function handleDelete(row: PaymentMethod) {
|
|
if (!confirm(t('deposit.confirm_deactivate'))) return;
|
|
try {
|
|
await api.delete(`/admin/payment-methods/${row.id}`);
|
|
await fetchList();
|
|
} catch { /* */ }
|
|
}
|
|
|
|
async function toggleActive(row: PaymentMethod) {
|
|
try {
|
|
await api.put(`/admin/payment-methods/${row.id}`, { isActive: !row.isActive });
|
|
await fetchList();
|
|
} catch { /* */ }
|
|
}
|
|
|
|
// QR code upload for USDT
|
|
const uploading = ref(false);
|
|
async function handleQrUpload(event: Event) {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
uploading.value = true;
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
const { data } = await api.post('/admin/uploads?category=payments', fd);
|
|
form.value.qrCodeUrl = data.data?.url ?? '';
|
|
} catch { /* */ } finally {
|
|
uploading.value = false;
|
|
input.value = '';
|
|
}
|
|
}
|
|
|
|
onMounted(fetchList);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page-payment-methods">
|
|
<div class="toolbar">
|
|
<h2>{{ t('deposit.payment_methods_title') }}</h2>
|
|
<div class="actions">
|
|
<select v-model="typeFilter" class="filter-select">
|
|
<option value="">{{ t('common.all') }}</option>
|
|
<option value="BANK">Bank</option>
|
|
<option value="USDT">USDT</option>
|
|
</select>
|
|
<button class="btn-primary" @click="openCreate">{{ t('deposit.add_method') }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<AdminTableEmpty v-if="!loading && !filteredItems.length" />
|
|
|
|
<table v-if="filteredItems.length" class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t('common.type') }}</th>
|
|
<th>{{ t('deposit.display_name') }}</th>
|
|
<th>{{ t('deposit.details') }}</th>
|
|
<th>{{ t('deposit.sort') }}</th>
|
|
<th>{{ t('deposit.active') }}</th>
|
|
<th>{{ t('common.actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="row in filteredItems" :key="row.id">
|
|
<td><span :class="['badge', row.methodType === 'BANK' ? 'badge-blue' : 'badge-green']">{{ row.methodType }}</span></td>
|
|
<td>{{ row.displayName || '-' }}</td>
|
|
<td class="details-cell">
|
|
<template v-if="row.methodType === 'BANK'">
|
|
<div>{{ row.bankName }}</div>
|
|
<div class="sub">{{ row.accountHolder }} · {{ row.accountNumber }}</div>
|
|
</template>
|
|
<template v-else>
|
|
<div>{{ row.usdtAddress }}</div>
|
|
<img v-if="row.qrCodeUrl" :src="row.qrCodeUrl" class="qr-thumb" />
|
|
</template>
|
|
</td>
|
|
<td>{{ row.sortOrder }}</td>
|
|
<td>
|
|
<span :class="row.isActive ? 'status-on' : 'status-off'">{{ row.isActive ? 'ON' : 'OFF' }}</span>
|
|
</td>
|
|
<td class="actions-cell">
|
|
<button class="btn-sm" @click="openEdit(row)">{{ t('common.edit') }}</button>
|
|
<button class="btn-sm btn-toggle" @click="toggleActive(row)">
|
|
{{ row.isActive ? t('common.disable') : t('common.enable') }}
|
|
</button>
|
|
<button class="btn-sm btn-danger" @click="handleDelete(row)">{{ t('common.delete') }}</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- Create/Edit Dialog -->
|
|
<div v-if="dialogVisible" class="dialog-overlay" @click.self="dialogVisible = false">
|
|
<div class="dialog-box">
|
|
<h3>{{ editingId ? t('deposit.edit_method') : t('deposit.create_method') }}</h3>
|
|
<div class="form-group" v-if="!editingId">
|
|
<label>{{ t('common.type') }}</label>
|
|
<select v-model="form.methodType">
|
|
<option value="BANK">Bank</option>
|
|
<option value="USDT">USDT</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{{ t('deposit.display_name') }}</label>
|
|
<input v-model="form.displayName" :placeholder="t('deposit.display_name')" />
|
|
<div class="lang-section">
|
|
<div class="lang-hint">{{ t('deposit.multilingual_hint') }}</div>
|
|
<div class="lang-row">
|
|
<span class="lang-tag">{{ t('deposit.lang_zh') }}</span>
|
|
<input v-model="form.translations.displayName['zh-CN']" placeholder="中文名称" />
|
|
</div>
|
|
<div class="lang-row">
|
|
<span class="lang-tag">{{ t('deposit.lang_en') }}</span>
|
|
<input v-model="form.translations.displayName['en-US']" placeholder="English name" />
|
|
</div>
|
|
<div class="lang-row">
|
|
<span class="lang-tag">{{ t('deposit.lang_ms') }}</span>
|
|
<input v-model="form.translations.displayName['ms-MY']" placeholder="Nama Bahasa Melayu" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template v-if="form.methodType === 'BANK'">
|
|
<div class="form-group">
|
|
<label>{{ t('deposit.bank_name') }}</label>
|
|
<input v-model="form.bankName" :placeholder="t('deposit.bank_name')" />
|
|
<div class="lang-section">
|
|
<div class="lang-row">
|
|
<span class="lang-tag">{{ t('deposit.lang_zh') }}</span>
|
|
<input v-model="form.translations.bankName['zh-CN']" placeholder="银行名称(中文)" />
|
|
</div>
|
|
<div class="lang-row">
|
|
<span class="lang-tag">{{ t('deposit.lang_en') }}</span>
|
|
<input v-model="form.translations.bankName['en-US']" placeholder="Bank name (English)" />
|
|
</div>
|
|
<div class="lang-row">
|
|
<span class="lang-tag">{{ t('deposit.lang_ms') }}</span>
|
|
<input v-model="form.translations.bankName['ms-MY']" placeholder="Nama bank (Melayu)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{{ t('deposit.account_holder') }}</label>
|
|
<input v-model="form.accountHolder" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{{ t('deposit.account_number') }}</label>
|
|
<input v-model="form.accountNumber" />
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="form-group">
|
|
<label>{{ t('deposit.usdt_address') }}</label>
|
|
<input v-model="form.usdtAddress" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{{ t('deposit.qr_code') }}</label>
|
|
<div class="qr-upload-row">
|
|
<img v-if="form.qrCodeUrl" :src="form.qrCodeUrl" class="qr-preview" />
|
|
<input type="file" accept="image/*" @change="handleQrUpload" :disabled="uploading" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="form-group">
|
|
<label>{{ t('deposit.sort_order') }}</label>
|
|
<input v-model.number="form.sortOrder" type="number" />
|
|
</div>
|
|
<div class="form-group row-checks">
|
|
<label><input type="checkbox" v-model="form.isActive" /> {{ t('deposit.active') }}</label>
|
|
</div>
|
|
<div class="dialog-actions">
|
|
<button class="btn-cancel" @click="dialogVisible = false">{{ t('common.cancel') }}</button>
|
|
<button class="btn-primary" @click="handleSave">{{ t('deposit.save') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.page-payment-methods { padding: 16px; }
|
|
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
|
.toolbar h2 { margin: 0; font-size: 18px; }
|
|
.actions { display: flex; gap: 8px; align-items: center; }
|
|
.filter-select { padding: 6px 10px; border-radius: 4px; border: 1px solid #444; background: #1e1e1e; color: #eee; }
|
|
.btn-primary { background: #409eff; color: #fff; border: none; border-radius: 4px; padding: 8px 16px; cursor: pointer; font-weight: 600; }
|
|
.btn-primary:hover { background: #337ecc; }
|
|
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.data-table th, .data-table td { padding: 10px 8px; border-bottom: 1px solid #333; text-align: left; }
|
|
.data-table th { font-weight: 700; color: #aaa; font-size: 12px; text-transform: uppercase; }
|
|
.details-cell .sub { font-size: 11px; color: #888; margin-top: 2px; }
|
|
.qr-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; margin-top: 4px; }
|
|
.badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
|
|
.badge-blue { background: #1e3a5f; color: #66b1ff; }
|
|
.badge-green { background: rgba(201, 162, 39, 0.12); color: var(--gold-text); }
|
|
.status-on { font-size: 12px; font-weight: 700; color: var(--success-text); }
|
|
.status-off { font-size: 12px; font-weight: 700; color: #666; }
|
|
.actions-cell { white-space: nowrap; }
|
|
.btn-sm { border: none; border-radius: 4px; padding: 4px 10px; font-size: 11px; cursor: pointer; background: #2d2d2d; color: #ddd; margin-right: 4px; }
|
|
.btn-sm:hover { background: #444; }
|
|
.btn-toggle { background: #1e2a3a; color: #66b1ff; }
|
|
.btn-toggle:hover { background: #253a4f; }
|
|
.btn-danger { color: #f56c6c; }
|
|
.btn-danger:hover { background: #3a1a1a; }
|
|
|
|
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 999; }
|
|
.dialog-box { background: #1e1e1e; border-radius: 8px; padding: 24px; min-width: 420px; max-width: 560px; max-height: 85vh; overflow-y: auto; }
|
|
.dialog-box h3 { margin: 0 0 16px; font-size: 16px; }
|
|
.form-group { margin-bottom: 12px; }
|
|
.form-group label { display: block; font-size: 12px; color: #aaa; margin-bottom: 4px; font-weight: 600; }
|
|
.form-group input, .form-group select { width: 100%; padding: 8px; border: 1px solid #444; border-radius: 4px; background: #111; color: #eee; box-sizing: border-box; }
|
|
.row-checks { display: flex; gap: 16px; }
|
|
.row-checks label { font-size: 13px; color: #ddd; display: flex; align-items: center; gap: 4px; }
|
|
.qr-upload-row { display: flex; align-items: center; gap: 12px; }
|
|
.qr-preview { width: 80px; height: 80px; object-fit: cover; border-radius: 4px; }
|
|
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
|
.btn-cancel { background: #333; color: #ccc; border: none; border-radius: 4px; padding: 8px 16px; cursor: pointer; }
|
|
|
|
.lang-section { margin-top: 6px; padding: 8px 10px; background: #161616; border-radius: 4px; border: 1px solid #2a2a2a; }
|
|
.lang-hint { font-size: 10px; color: #666; margin-bottom: 6px; line-height: 1.4; }
|
|
.lang-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
|
.lang-row:last-child { margin-bottom: 0; }
|
|
.lang-tag { flex-shrink: 0; font-size: 10px; font-weight: 700; color: #aaa; background: #252525; padding: 2px 6px; border-radius: 3px; min-width: 48px; text-align: center; }
|
|
.lang-row input { flex: 1; padding: 5px 8px; border: 1px solid #333; border-radius: 3px; background: #0e0e0e; color: #ddd; font-size: 12px; box-sizing: border-box; }
|
|
</style>
|