Files
thebet365/apps/admin/src/views/PaymentMethods.vue
Mars e7e938f261 feat: WC2026 赛事 seed、生产上线初始化脚本与目录归档
重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 18:17:00 +08:00

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>