feat(i18n): 管理端与玩家端三语支持(中/英/马来语)
- 管理后台 adminT 文案库、结算与代理端页面、表单校验 - 玩家端 vue-i18n 补全首页/公告/串关与 ms 文案 - Element Plus ms 语言包与共享 locale 工具
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
emptyAgentCreateForm,
|
||||
@@ -102,18 +106,18 @@ async function submitCreate() {
|
||||
try {
|
||||
payload = buildCreateAgentPayload(createForm.value);
|
||||
} catch (e) {
|
||||
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
|
||||
ElMessage.warning(resolveFormError(e, t));
|
||||
return;
|
||||
}
|
||||
createLoading.value = true;
|
||||
try {
|
||||
await api.post('/admin/agents', payload);
|
||||
ElMessage.success('一级代理已创建');
|
||||
ElMessage.success(t('msg.agent_created'));
|
||||
createVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? '创建失败');
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
@@ -128,12 +132,12 @@ async function submitEdit() {
|
||||
email: editForm.value.email.trim() || undefined,
|
||||
cashbackRate: editForm.value.cashbackRate,
|
||||
});
|
||||
ElMessage.success('已保存');
|
||||
ElMessage.success(t('msg.saved'));
|
||||
editVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? '保存失败');
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
editLoading.value = false;
|
||||
}
|
||||
@@ -141,7 +145,7 @@ async function submitEdit() {
|
||||
|
||||
async function submitCredit() {
|
||||
if (creditForm.value.amount === 0) {
|
||||
ElMessage.warning('调整金额不能为 0');
|
||||
ElMessage.warning(t('msg.credit_zero'));
|
||||
return;
|
||||
}
|
||||
creditLoading.value = true;
|
||||
@@ -151,12 +155,12 @@ async function submitCredit() {
|
||||
requestId: `credit-${editingId.value}-${Date.now()}`,
|
||||
remark: creditForm.value.remark || undefined,
|
||||
});
|
||||
ElMessage.success('授信已调整');
|
||||
ElMessage.success(t('msg.credit_adjusted'));
|
||||
creditVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? '调整失败');
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.credit_adjust_failed'));
|
||||
} finally {
|
||||
creditLoading.value = false;
|
||||
}
|
||||
@@ -172,13 +176,15 @@ function statusTagType(s: string) {
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
return s === 'ACTIVE' ? '正常' : s === 'SUSPENDED' ? '停用' : s;
|
||||
const key = `user.status.${s}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : s;
|
||||
}
|
||||
|
||||
function creditTypeLabel(t: string) {
|
||||
if (t === 'CREDIT_INCREASE') return '增加';
|
||||
if (t === 'CREDIT_DECREASE') return '减少';
|
||||
return t;
|
||||
function creditTypeLabel(type: string) {
|
||||
if (type === 'CREDIT_INCREASE') return t('agent.credit.increase');
|
||||
if (type === 'CREDIT_DECREASE') return t('agent.credit.decrease');
|
||||
return type;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -186,25 +192,25 @@ function creditTypeLabel(t: string) {
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">代理管理</h2>
|
||||
<span class="page-desc">创建一级代理、调整授信额度、查看直属玩家与额度占用</span>
|
||||
<h2 class="page-title">{{ t('page.agents.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agents.desc') }}</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">+ 新建一级代理</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ t('agent.create_btn') }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item label="关键词">
|
||||
<el-form-item :label="t('common.keyword')">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="用户名"
|
||||
:placeholder="t('agent.filter.username_ph')"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="load"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
@@ -213,37 +219,37 @@ function creditTypeLabel(t: string) {
|
||||
<div class="table-wrap">
|
||||
<el-table :data="agents" stripe>
|
||||
<el-table-column prop="userId" label="ID" width="72" />
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column label="状态" width="88">
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||
<el-table-column :label="t('common.status')" width="88">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">
|
||||
{{ statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="level" label="层级" width="72" align="center" />
|
||||
<el-table-column label="授信 / 已用 / 可用" min-width="168" align="right">
|
||||
<el-table-column prop="level" :label="t('agent.col.level')" width="72" align="center" />
|
||||
<el-table-column :label="t('agent.col.credit')" min-width="168" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="creditLineFull(row)" placement="top">
|
||||
<span class="amount-compact">{{ creditLine(row) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="directPlayerCount" label="直属玩家" width="96" align="center" />
|
||||
<el-table-column label="返水率" width="88" align="right">
|
||||
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="96" align="center" />
|
||||
<el-table-column :label="t('agent.col.cashback')" width="88" align="right">
|
||||
<template #default="{ row }">{{ row.cashbackRate }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="手机" min-width="110">
|
||||
<el-table-column prop="phone" :label="t('agent.col.phone')" min-width="110">
|
||||
<template #default="{ row }">{{ row.phone ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="158">
|
||||
<el-table-column :label="t('agent.col.created')" min-width="158">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right" align="center">
|
||||
<el-table-column :label="t('common.actions')" width="240" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openDetail(row.userId)">详情</el-button>
|
||||
<el-button size="small" link type="primary" @click="openEdit(row.userId)">编辑</el-button>
|
||||
<el-button size="small" link type="primary" @click="openCredit(row)">调额</el-button>
|
||||
<el-button size="small" link type="primary" @click="openDetail(row.userId)">{{ t('common.detail') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openEdit(row.userId)">{{ t('common.edit') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openCredit(row)">{{ t('common.adjust_credit') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -262,27 +268,27 @@ function creditTypeLabel(t: string) {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" title="新建一级代理" width="520px" destroy-on-close>
|
||||
<el-dialog v-model="createVisible" :title="t('agent.dialog.create')" width="520px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="createForm.username" placeholder="登录用户名,唯一" />
|
||||
<el-form-item :label="t('user.col.username')" required>
|
||||
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_unique')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="登录密码" required>
|
||||
<el-form-item :label="t('user.field.password')" required>
|
||||
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" required>
|
||||
<el-form-item :label="t('user.field.confirm_password')" required>
|
||||
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="授信额度" required>
|
||||
<el-form-item :label="t('agent.field.credit_limit')" required>
|
||||
<el-input-number
|
||||
v-model="createForm.creditLimit"
|
||||
:min="0"
|
||||
:step="10000"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="field-hint">代理可向直属玩家上分的总额度上限</div>
|
||||
<div class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="返水比例">
|
||||
<el-form-item :label="t('agent.field.cashback_rate')">
|
||||
<el-input-number
|
||||
v-model="createForm.cashbackRate"
|
||||
:min="0"
|
||||
@@ -291,30 +297,30 @@ function creditTypeLabel(t: string) {
|
||||
:precision="4"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="field-hint">例如 0.01 表示 1%</div>
|
||||
<div class="field-hint">{{ t('agent.hint.cashback_example') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="createForm.phone" placeholder="选填" />
|
||||
<el-form-item :label="t('user.field.phone')">
|
||||
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="createForm.email" placeholder="选填" />
|
||||
<el-form-item :label="t('user.field.email')">
|
||||
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
|
||||
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="createLoading" @click="submitCreate">{{ t('user.btn.create') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑代理" width="480px" destroy-on-close>
|
||||
<el-dialog v-model="editVisible" :title="t('agent.dialog.edit')" width="480px" destroy-on-close>
|
||||
<el-form label-width="88px">
|
||||
<el-form-item label="账号状态">
|
||||
<el-form-item :label="t('user.field.account_status')">
|
||||
<el-radio-group v-model="editForm.status">
|
||||
<el-radio value="ACTIVE">正常</el-radio>
|
||||
<el-radio value="SUSPENDED">停用</el-radio>
|
||||
<el-radio value="ACTIVE">{{ t('user.status.ACTIVE') }}</el-radio>
|
||||
<el-radio value="SUSPENDED">{{ t('user.status.SUSPENDED') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="返水比例">
|
||||
<el-form-item :label="t('agent.field.cashback_rate')">
|
||||
<el-input-number
|
||||
v-model="editForm.cashbackRate"
|
||||
:min="0"
|
||||
@@ -324,111 +330,111 @@ function creditTypeLabel(t: string) {
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-form-item :label="t('user.field.phone')">
|
||||
<el-input v-model="editForm.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-form-item :label="t('user.field.email')">
|
||||
<el-input v-model="editForm.email" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editLoading" @click="submitEdit">保存</el-button>
|
||||
<el-button @click="editVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="editLoading" @click="submitEdit">{{ t('common.save') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="creditVisible" title="调整授信额度" width="420px" destroy-on-close>
|
||||
<el-dialog v-model="creditVisible" :title="t('agent.dialog.credit')" width="420px" destroy-on-close>
|
||||
<el-form label-width="88px">
|
||||
<el-form-item label="代理 ID">
|
||||
<el-form-item :label="t('agent.field.agent_id')">
|
||||
<el-input :model-value="editingId" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="调整金额">
|
||||
<el-form-item :label="t('agent.field.adjust_amount')">
|
||||
<el-input-number v-model="creditForm.amount" :step="1000" style="width: 100%" />
|
||||
<div class="field-hint">正数为增加授信,负数为减少</div>
|
||||
<div class="field-hint">{{ t('agent.hint.credit_adjust') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="creditForm.remark" placeholder="选填,写入额度流水" />
|
||||
<el-form-item :label="t('user.field.remark')">
|
||||
<el-input v-model="creditForm.remark" :placeholder="t('agent.hint.credit_remark')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="creditVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creditLoading" @click="submitCredit">确认调整</el-button>
|
||||
<el-button @click="creditVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="creditLoading" @click="submitCredit">{{ t('agent.btn.confirm_adjust') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="代理详情" width="640px" destroy-on-close>
|
||||
<el-dialog v-model="detailVisible" :title="t('agent.dialog.detail')" width="640px" destroy-on-close>
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="2" border size="small" class="detail-block">
|
||||
<el-descriptions-item label="ID">{{ detail.userId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ detail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-descriptions-item :label="t('common.col_id')">{{ detail.userId }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.username')">{{ detail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('common.status')">
|
||||
<el-tag :type="statusTagType(detail.status)" size="small">
|
||||
{{ statusLabel(detail.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="层级">L{{ detail.level }}</el-descriptions-item>
|
||||
<el-descriptions-item label="授信额度">
|
||||
<el-descriptions-item :label="t('agent.col.level')">L{{ detail.level }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.credit_limit')">
|
||||
{{ formatAmount(detail.creditLimit) }}
|
||||
<span v-if="shouldCompact(detail.creditLimit)" class="amount-full-hint">
|
||||
({{ formatAmountFull(detail.creditLimit) }})
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已用额度">
|
||||
<el-descriptions-item :label="t('agent.field.used_credit')">
|
||||
{{ formatAmount(detail.usedCredit) }}
|
||||
<span v-if="shouldCompact(detail.usedCredit)" class="amount-full-hint">
|
||||
({{ formatAmountFull(detail.usedCredit) }})
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="可用授信">
|
||||
<el-descriptions-item :label="t('agent.field.available_credit')">
|
||||
{{ formatAmount(detail.availableCredit) }}
|
||||
<span v-if="shouldCompact(detail.availableCredit)" class="amount-full-hint">
|
||||
({{ formatAmountFull(detail.availableCredit) }})
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="直属玩家">{{ detail.directPlayerCount }} 人</el-descriptions-item>
|
||||
<el-descriptions-item label="玩家负债">
|
||||
<el-descriptions-item :label="t('agent.col.direct_players')">{{ detail.directPlayerCount }} {{ t('common.people') }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.player_liability')">
|
||||
{{ formatAmount(detail.directPlayerLiability) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="下级代理敞口">
|
||||
<el-descriptions-item :label="t('agent.field.sub_agent_exposure')">
|
||||
{{ formatAmount(detail.childAgentExposure) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="返水率">{{ detail.cashbackRate }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">{{ detail.phone ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ detail.email ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后登录" :span="2">
|
||||
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : '从未登录' }}
|
||||
<el-descriptions-item :label="t('agent.col.cashback')">{{ detail.cashbackRate }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.phone')">{{ detail.phone ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.email')">{{ detail.email ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
|
||||
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : t('common.never_login') }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">
|
||||
<el-descriptions-item :label="t('agent.col.created')" :span="2">
|
||||
{{ formatTime(detail.createdAt) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="section-title">最近额度变动</div>
|
||||
<div class="section-title">{{ t('agent.section.credit_log') }}</div>
|
||||
<el-table
|
||||
:data="detail.recentCreditTransactions"
|
||||
size="small"
|
||||
stripe
|
||||
empty-text="暂无记录"
|
||||
:empty-text="t('agent.col.no_records')"
|
||||
>
|
||||
<el-table-column label="类型" width="80">
|
||||
<el-table-column :label="t('agent.col.credit_type')" width="80">
|
||||
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动" width="96" align="right">
|
||||
<el-table-column :label="t('agent.col.credit_change')" width="96" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
|
||||
<span>{{ formatAmount(row.amount) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动后" width="96" align="right">
|
||||
<el-table-column :label="t('agent.col.credit_after')" width="96" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.creditAfter)" placement="top">
|
||||
<span>{{ formatAmount(row.creditAfter) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="时间" min-width="150">
|
||||
<el-table-column prop="remark" :label="t('user.field.remark')" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column :label="t('audit.col.time')" min-width="150">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const logs = ref<unknown[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
@@ -37,23 +40,23 @@ function onSizeChange(size: number) {
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">操作日志</h2>
|
||||
<span class="page-desc">记录所有管理员操作行为</span>
|
||||
<h2 class="page-title">{{ t('page.audit.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.audit.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item label="模块">
|
||||
<el-form-item :label="t('common.module')">
|
||||
<el-input
|
||||
v-model="filterModule"
|
||||
placeholder="如 USERS、AGENTS"
|
||||
:placeholder="t('audit.module_ph')"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
@keyup.enter="load"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
@@ -61,10 +64,10 @@ function onSizeChange(size: number) {
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="logs" stripe>
|
||||
<el-table-column prop="action" label="操作" min-width="140" />
|
||||
<el-table-column prop="module" label="模块" width="120" />
|
||||
<el-table-column prop="targetId" label="目标ID" min-width="100" />
|
||||
<el-table-column label="时间" min-width="160">
|
||||
<el-table-column prop="action" :label="t('audit.col.action')" min-width="140" />
|
||||
<el-table-column prop="module" :label="t('audit.col.module')" width="120" />
|
||||
<el-table-column prop="targetId" :label="t('audit.col.target_id')" min-width="100" />
|
||||
<el-table-column :label="t('audit.col.time')" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ new Date((row as { createdAt: string }).createdAt).toLocaleString() }}
|
||||
</template>
|
||||
|
||||
@@ -7,9 +7,13 @@ import {
|
||||
betStatusTagType,
|
||||
betTypeLabel,
|
||||
betSettlementLabel,
|
||||
BET_STATUS_OPTIONS,
|
||||
BET_TYPE_OPTIONS,
|
||||
betResultLabel,
|
||||
useBetFilterOptions,
|
||||
} from '../utils/bet-labels';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const { statusOptions, typeOptions } = useBetFilterOptions();
|
||||
import type { BetListRow, BetDetail } from './bet-form';
|
||||
|
||||
const bets = ref<BetListRow[]>([]);
|
||||
@@ -67,12 +71,12 @@ function resetFilters() {
|
||||
}
|
||||
|
||||
function parentLabel(row: BetListRow) {
|
||||
return row.parentUsername ?? '平台直属';
|
||||
return row.parentUsername ?? t('common.platform_direct');
|
||||
}
|
||||
|
||||
function formatTime(v: string | null | undefined) {
|
||||
if (!v) return '—';
|
||||
return new Date(v).toLocaleString('zh-CN', {
|
||||
return new Date(v).toLocaleString(localeTag.value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@@ -83,16 +87,7 @@ function formatTime(v: string | null | undefined) {
|
||||
}
|
||||
|
||||
function resultStatusLabel(s: string | null | undefined) {
|
||||
if (!s) return '—';
|
||||
const map: Record<string, string> = {
|
||||
WON: '赢',
|
||||
LOST: '输',
|
||||
VOID: '走水',
|
||||
PUSH: '走盘',
|
||||
HALF_WON: '半赢',
|
||||
HALF_LOST: '半输',
|
||||
};
|
||||
return map[s] ?? s;
|
||||
return betResultLabel(s);
|
||||
}
|
||||
|
||||
async function openDetail(row: BetListRow) {
|
||||
@@ -111,62 +106,62 @@ async function openDetail(row: BetListRow) {
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">注单管理</h2>
|
||||
<span class="page-desc">筛选、分页查看全平台注单,支持详情与投注项</span>
|
||||
<h2 class="page-title">{{ t('page.bets.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.bets.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item label="关键词">
|
||||
<el-form-item :label="t('common.keyword')">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="流水编号 / 玩家用户名"
|
||||
:placeholder="t('bet.filter.keyword_ph')"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="load"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filterStatus" placeholder="全部" clearable style="width: 120px">
|
||||
<el-form-item :label="t('common.status')">
|
||||
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||||
<el-option
|
||||
v-for="o in BET_STATUS_OPTIONS.filter((x) => x.value !== '')"
|
||||
v-for="o in statusOptions.filter((x) => x.value !== '')"
|
||||
:key="o.value"
|
||||
:label="o.label"
|
||||
:value="o.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="filterBetType" placeholder="全部" clearable style="width: 100px">
|
||||
<el-form-item :label="t('common.type')">
|
||||
<el-select v-model="filterBetType" :placeholder="t('common.all')" clearable style="width: 100px">
|
||||
<el-option
|
||||
v-for="o in BET_TYPE_OPTIONS.filter((x) => x.value !== '')"
|
||||
v-for="o in typeOptions.filter((x) => x.value !== '')"
|
||||
:key="o.value"
|
||||
:label="o.label"
|
||||
:value="o.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="投注日起">
|
||||
<el-form-item :label="t('bet.filter.date_from')">
|
||||
<el-date-picker
|
||||
v-model="placedFrom"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="开始"
|
||||
:placeholder="t('bet.filter.date_start_ph')"
|
||||
style="width: 140px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="止">
|
||||
<el-form-item :label="t('common.to')">
|
||||
<el-date-picker
|
||||
v-model="placedTo"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="结束"
|
||||
:placeholder="t('bet.filter.date_end_ph')"
|
||||
style="width: 140px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
|
||||
<el-button @click="resetFilters">{{ t('common.reset') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
@@ -174,54 +169,54 @@ async function openDetail(row: BetListRow) {
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="bets" stripe>
|
||||
<el-table-column prop="id" label="单号" width="56" align="center" />
|
||||
<el-table-column prop="betNo" label="流水编号" width="168" show-overflow-tooltip>
|
||||
<el-table-column prop="id" :label="t('bet.col.serial')" width="56" align="center" />
|
||||
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="168" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="bet-no">{{ row.betNo }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="玩家" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="所属代理" width="100" show-overflow-tooltip>
|
||||
<el-table-column prop="username" :label="t('bet.col.player')" width="100" show-overflow-tooltip />
|
||||
<el-table-column :label="t('bet.col.agent')" width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ parentLabel(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="72" align="center">
|
||||
<el-table-column :label="t('common.type')" width="72" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="info" size="small" effect="plain">{{ betTypeLabel(row.betType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="选项" width="52" align="center">
|
||||
<el-table-column :label="t('bet.col.selection')" width="52" align="center">
|
||||
<template #default="{ row }">{{ row.selectionCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="投注额" width="96" align="right">
|
||||
<el-table-column :label="t('bet.col.stake')" width="96" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.stake)" placement="top">
|
||||
<span>{{ formatAmount(row.stake) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="赔率" width="72" align="right">
|
||||
<el-table-column :label="t('bet.col.odds')" width="72" align="right">
|
||||
<template #default="{ row }">{{ row.totalOdds ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="派彩" width="96" align="right">
|
||||
<el-table-column :label="t('bet.col.payout')" width="96" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.actualReturn)" placement="top">
|
||||
<span>{{ formatAmount(row.actualReturn) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="88" align="center">
|
||||
<el-table-column :label="t('common.status')" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="betStatusTagType(row.status)" size="small">
|
||||
{{ betStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="投注时间" width="160">
|
||||
<el-table-column :label="t('bet.col.placed_at')" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="88" fixed="right" align="center">
|
||||
<el-table-column :label="t('common.actions')" width="88" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button type="primary" link size="small" @click="openDetail(row)">{{ t('common.detail') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -241,61 +236,61 @@ async function openDetail(row: BetListRow) {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="注单详情" width="720px" destroy-on-close>
|
||||
<el-dialog v-model="detailVisible" :title="t('bet.dialog.detail')" width="720px" destroy-on-close>
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="2" border size="small" class="detail-desc">
|
||||
<el-descriptions-item label="单号">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="流水编号">{{ detail.betNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="玩家">{{ detail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属代理">{{ parentLabel(detail) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">{{ betTypeLabel(detail.betType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="币种">{{ detail.currency }}</el-descriptions-item>
|
||||
<el-descriptions-item label="投注额">
|
||||
<el-descriptions-item :label="t('bet.col.serial')">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.col.bet_no')">{{ detail.betNo }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.col.player')">{{ detail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.col.agent')">{{ parentLabel(detail) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('common.type')">{{ betTypeLabel(detail.betType) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.field.currency')">{{ detail.currency }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.col.stake')">
|
||||
{{ formatAmountFull(detail.stake) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="总赔率">{{ detail.totalOdds ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="可赢额">
|
||||
<el-descriptions-item :label="t('bet.field.total_odds')">{{ detail.totalOdds ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.field.potential_win')">
|
||||
{{ detail.potentialReturn ? formatAmountFull(detail.potentialReturn) : '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="实际派彩">
|
||||
<el-descriptions-item :label="t('bet.field.actual_payout')">
|
||||
{{ formatAmountFull(detail.actualReturn) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="注单状态">
|
||||
<el-descriptions-item :label="t('bet.field.bet_status')">
|
||||
<el-tag :type="betStatusTagType(detail.status)" size="small">
|
||||
{{ betStatusLabel(detail.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="结算状态">
|
||||
<el-descriptions-item :label="t('bet.field.settlement_status')">
|
||||
{{ betSettlementLabel(detail.settlementStatus) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="投注时间">{{ formatTime(detail.placedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结算时间">{{ formatTime(detail.settledAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="请求 ID" :span="2">{{ detail.requestId }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.col.placed_at')">{{ formatTime(detail.placedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.field.settled_at')">{{ formatTime(detail.settledAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.field.request_id')" :span="2">{{ detail.requestId }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="selections-title">投注项({{ detail.selections.length }})</div>
|
||||
<div class="selections-title">{{ t('bet.selections_title', { n: detail.selections.length }) }}</div>
|
||||
<el-table :data="detail.selections" size="small" stripe border>
|
||||
<el-table-column type="index" label="#" width="44" />
|
||||
<el-table-column prop="selectionName" label="选项" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="marketType" label="玩法" width="100" />
|
||||
<el-table-column prop="period" label="时段" width="72">
|
||||
<el-table-column prop="selectionName" :label="t('bet.col.selection')" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="marketType" :label="t('bet.col.market')" width="100" />
|
||||
<el-table-column prop="period" :label="t('bet.col.period')" width="72">
|
||||
<template #default="{ row }">{{ row.period ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="odds" label="赔率" width="72" align="right" />
|
||||
<el-table-column label="盘口" width="88">
|
||||
<el-table-column prop="odds" :label="t('bet.col.odds')" width="72" align="right" />
|
||||
<el-table-column :label="t('bet.col.line')" width="88">
|
||||
<template #default="{ row }">
|
||||
{{ row.handicapLine ?? row.totalLine ?? '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="赛果" width="72" align="center">
|
||||
<el-table-column :label="t('bet.col.result')" width="72" align="center">
|
||||
<template #default="{ row }">{{ resultStatusLabel(row.resultStatus) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
<el-button @click="detailVisible = false">{{ t('common.close') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const preview = ref<Record<string, unknown> | null>(null);
|
||||
const period = ref({
|
||||
@@ -20,7 +23,7 @@ async function generatePreview() {
|
||||
async function confirm() {
|
||||
if (!preview.value?.batch) return;
|
||||
await api.post(`/admin/cashbacks/${(preview.value.batch as { id: string }).id}/confirm`);
|
||||
ElMessage.success('返水已发放');
|
||||
ElMessage.success(t('msg.cashback_issued'));
|
||||
preview.value = null;
|
||||
}
|
||||
</script>
|
||||
@@ -28,43 +31,43 @@ async function confirm() {
|
||||
<template>
|
||||
<div class="page-scroll">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">返水管理</h2>
|
||||
<span class="page-desc">按周期生成返水并发放</span>
|
||||
<h2 class="page-title">{{ t('page.cashback.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.cashback.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="filter-row">
|
||||
<el-form inline>
|
||||
<el-form-item label="开始日期">
|
||||
<el-form-item :label="t('cashback.start_date')">
|
||||
<el-date-picker v-model="period.start" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束日期">
|
||||
<el-form-item :label="t('cashback.end_date')">
|
||||
<el-date-picker v-model="period.end" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="generatePreview">生成预览</el-button>
|
||||
<el-button type="primary" @click="generatePreview">{{ t('cashback.preview_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="preview" class="preview-card" shadow="never">
|
||||
<div class="preview-title">返水预览</div>
|
||||
<div class="preview-title">{{ t('cashback.preview_title') }}</div>
|
||||
<el-row :gutter="20" class="preview-stats">
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ (preview.batch as { playerCount: number })?.playerCount ?? 0 }}</div>
|
||||
<div class="pstat-label">涉及玩家数</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ preview.totalAmount }}</div>
|
||||
<div class="pstat-label">返水总金额</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button type="success" @click="confirm" style="margin-top: 20px">确认发放</el-button>
|
||||
<el-button type="success" @click="confirm" style="margin-top: 20px">{{ t('cashback.confirm_issue') }}</el-button>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,9 @@ import type { AdminDashboard } from './dashboard-types';
|
||||
import EChartPanel from '../components/dashboard/EChartPanel.vue';
|
||||
import { buildCombinedTrendOption, buildTriplePieOption } from '../utils/dashboard-charts';
|
||||
import { betStatusLabel } from '../utils/bet-labels';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
|
||||
const { t, locale, localeTag } = useAdminLocale();
|
||||
|
||||
const stats = ref<AdminDashboard | null>(null);
|
||||
const loading = ref(true);
|
||||
@@ -25,11 +28,11 @@ async function load() {
|
||||
const s = computed(() => stats.value);
|
||||
|
||||
function fmtCount(val: number | undefined) {
|
||||
return (val ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
return (val ?? 0).toLocaleString(localeTag.value, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function formatTime(v: string) {
|
||||
return new Date(v).toLocaleString('zh-CN', {
|
||||
return new Date(v).toLocaleString(localeTag.value, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
@@ -43,14 +46,25 @@ function toNum(v: string | number | undefined) {
|
||||
}
|
||||
|
||||
function pctChange(today: string | number, yesterday: string | number) {
|
||||
const t = toNum(today);
|
||||
const t0 = toNum(today);
|
||||
const y = toNum(yesterday);
|
||||
if (y === 0) return t > 0 ? '+100%' : '—';
|
||||
const p = ((t - y) / y) * 100;
|
||||
if (y === 0) return t0 > 0 ? '+100%' : '—';
|
||||
const p = ((t0 - y) / y) * 100;
|
||||
const sign = p > 0 ? '+' : '';
|
||||
return `${sign}${p.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const chartI18n = computed(() => ({
|
||||
locale: locale.value,
|
||||
betCountSeries: t('dash.chart_bet_count'),
|
||||
axisAmount: t('dash.axis_amount'),
|
||||
axisCount: t('dash.axis_count'),
|
||||
countSuffix: t('dash.count_suffix'),
|
||||
pieTooltip: t('dash.chart_tooltip'),
|
||||
noData: t('common.no_data'),
|
||||
pieEmpty: t('dash.pie_empty'),
|
||||
}));
|
||||
|
||||
const trendLabels = computed(() => s.value?.trend7d?.map((d) => d.label) ?? []);
|
||||
|
||||
const mainTrendOption = computed(() =>
|
||||
@@ -58,22 +72,23 @@ const mainTrendOption = computed(() =>
|
||||
trendLabels.value,
|
||||
[
|
||||
{
|
||||
name: '投注额',
|
||||
name: t('dash.chart_stake'),
|
||||
color: '#248f54',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
|
||||
},
|
||||
{
|
||||
name: '派彩',
|
||||
name: t('dash.chart_payout'),
|
||||
color: '#60a5fa',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.payout)) ?? [],
|
||||
},
|
||||
{
|
||||
name: '毛利',
|
||||
name: t('dash.chart_ggr'),
|
||||
color: '#a78bfa',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.ggr)) ?? [],
|
||||
},
|
||||
],
|
||||
s.value?.trend7d?.map((d) => d.betCount) ?? [],
|
||||
chartI18n.value,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -91,11 +106,11 @@ const distributionOption = computed(() => {
|
||||
|
||||
const matchSegs = m
|
||||
? [
|
||||
{ label: '草稿', value: m.draft, color: '#6b7280' },
|
||||
{ label: '已发布', value: m.published, color: '#248f54' },
|
||||
{ label: '已封盘', value: m.closed, color: '#60a5fa' },
|
||||
{ label: '待结算', value: m.pendingSettlement, color: '#fb923c' },
|
||||
{ label: '已结算', value: m.settled ?? 0, color: '#5eead4' },
|
||||
{ label: t('dash.match_draft'), value: m.draft, color: '#6b7280' },
|
||||
{ label: t('dash.match_published'), value: m.published, color: '#248f54' },
|
||||
{ label: t('dash.match_closed'), value: m.closed, color: '#60a5fa' },
|
||||
{ label: t('dash.match_pending_settle'), value: m.pendingSettlement, color: '#fb923c' },
|
||||
{ label: t('dash.match_settled'), value: m.settled ?? 0, color: '#5eead4' },
|
||||
].filter((x) => x.value > 0)
|
||||
: [];
|
||||
|
||||
@@ -109,39 +124,82 @@ const distributionOption = computed(() => {
|
||||
|
||||
const userSegs = u
|
||||
? [
|
||||
{ label: '正常玩家', value: u.playersActive, color: '#248f54' },
|
||||
{ label: '停用', value: u.playersSuspended, color: '#f87171' },
|
||||
{ label: '直属', value: u.playersDirect, color: '#60a5fa' },
|
||||
{ label: '代理', value: u.agentsTotal, color: '#a78bfa' },
|
||||
{ label: t('dash.user_active'), value: u.playersActive, color: '#248f54' },
|
||||
{ label: t('dash.user_suspended'), value: u.playersSuspended, color: '#f87171' },
|
||||
{ label: t('dash.user_direct'), value: u.playersDirect, color: '#60a5fa' },
|
||||
{ label: t('dash.user_agents'), value: u.agentsTotal, color: '#a78bfa' },
|
||||
].filter((x) => x.value > 0)
|
||||
: [];
|
||||
|
||||
return buildTriplePieOption([
|
||||
{ title: '赛事', segments: matchSegs },
|
||||
{ title: '今日注单', segments: betSegs },
|
||||
{ title: '用户', segments: userSegs },
|
||||
]);
|
||||
return buildTriplePieOption(
|
||||
[
|
||||
{ title: t('dash.pie_matches'), segments: matchSegs },
|
||||
{ title: t('dash.pie_bets'), segments: betSegs },
|
||||
{ title: t('dash.pie_users'), segments: userSegs },
|
||||
],
|
||||
chartI18n.value,
|
||||
);
|
||||
});
|
||||
|
||||
const kpiPrimary = computed(() => {
|
||||
if (!s.value) return [];
|
||||
const t = s.value.today;
|
||||
const td = s.value.today;
|
||||
const y = s.value.yesterday;
|
||||
const yLabel = t('common.yesterday');
|
||||
return [
|
||||
{ label: '今日投注笔数', value: fmtCount(t.betCount), sub: `昨日 ${fmtCount(y.betCount)}`, delta: pctChange(t.betCount, y.betCount) },
|
||||
{ label: '今日投注额', value: formatAmount(t.stake), sub: formatAmountFull(t.stake), delta: pctChange(t.stake, y.stake) },
|
||||
{ label: '今日派彩', value: formatAmount(t.payout), sub: `昨日 ${formatAmount(y.payout)}`, delta: pctChange(t.payout, y.payout) },
|
||||
{ label: '今日毛利', value: formatAmount(t.ggr), sub: `昨日 ${formatAmount(y.ggr)}`, delta: pctChange(t.ggr, y.ggr) },
|
||||
{
|
||||
label: t('dash.kpi_bet_count'),
|
||||
value: fmtCount(td.betCount),
|
||||
sub: `${yLabel} ${fmtCount(y.betCount)}`,
|
||||
delta: pctChange(td.betCount, y.betCount),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_stake'),
|
||||
value: formatAmount(td.stake, 2, locale.value),
|
||||
sub: formatAmountFull(td.stake, locale.value),
|
||||
delta: pctChange(td.stake, y.stake),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_payout'),
|
||||
value: formatAmount(td.payout, 2, locale.value),
|
||||
sub: `${yLabel} ${formatAmount(y.payout, 2, locale.value)}`,
|
||||
delta: pctChange(td.payout, y.payout),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_ggr'),
|
||||
value: formatAmount(td.ggr, 2, locale.value),
|
||||
sub: `${yLabel} ${formatAmount(y.ggr, 2, locale.value)}`,
|
||||
delta: pctChange(td.ggr, y.ggr),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const kpiSecondary = computed(() => {
|
||||
if (!s.value) return [];
|
||||
return [
|
||||
{ label: '玩家 / 代理', value: `${fmtCount(s.value.users.playersTotal)} / ${fmtCount(s.value.users.agentsTotal)}`, sub: `今日新增 ${fmtCount(s.value.today.newPlayers)} 人` },
|
||||
{ label: '待结算', value: `${fmtCount(s.value.bets.pendingTotal)} 单`, sub: `${fmtCount(s.value.matches.pendingSettlement)} 场赛事` },
|
||||
{ label: '玩家余额', value: formatAmount(s.value.wallets.totalAvailable), sub: `冻结 ${formatAmount(s.value.wallets.totalFrozen)}` },
|
||||
{ label: '代理授信', value: formatAmount(s.value.agents.totalAvailableCredit), sub: `已用 ${formatAmount(s.value.agents.totalUsedCredit)}` },
|
||||
{
|
||||
label: t('dash.kpi_users'),
|
||||
value: `${fmtCount(s.value.users.playersTotal)} / ${fmtCount(s.value.users.agentsTotal)}`,
|
||||
sub: t('dash.kpi_new_players', { n: fmtCount(s.value.today.newPlayers) }),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_pending'),
|
||||
value: `${fmtCount(s.value.bets.pendingTotal)} ${t('common.bets_unit')}`,
|
||||
sub: t('dash.kpi_pending_sub', {
|
||||
bets: fmtCount(s.value.bets.pendingTotal),
|
||||
matches: fmtCount(s.value.matches.pendingSettlement),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_wallet'),
|
||||
value: formatAmount(s.value.wallets.totalAvailable, 2, locale.value),
|
||||
sub: `${t('common.frozen')} ${formatAmount(s.value.wallets.totalFrozen, 2, locale.value)}`,
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_credit'),
|
||||
value: formatAmount(s.value.agents.totalAvailableCredit, 2, locale.value),
|
||||
sub: `${t('common.used')} ${formatAmount(s.value.agents.totalUsedCredit, 2, locale.value)}`,
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
@@ -150,10 +208,12 @@ const kpiSecondary = computed(() => {
|
||||
<div class="dashboard-page" v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">控制台</h2>
|
||||
<h2 class="page-title">{{ t('dash.title') }}</h2>
|
||||
<span class="page-desc">
|
||||
平台整体运行概况
|
||||
<template v-if="s?.generatedAt"> · 更新于 {{ formatTime(s.generatedAt) }}</template>
|
||||
{{ t('dash.desc') }}
|
||||
<template v-if="s?.generatedAt">
|
||||
· {{ t('common.updated_at') }} {{ formatTime(s.generatedAt) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,8 +221,8 @@ const kpiSecondary = computed(() => {
|
||||
<template v-if="s">
|
||||
<el-card class="overview-board" shadow="never">
|
||||
<div class="board-head">
|
||||
<span class="board-title">整体概览</span>
|
||||
<span class="board-hint">一屏查看经营趋势与平台分布</span>
|
||||
<span class="board-title">{{ t('dash.board_title') }}</span>
|
||||
<span class="board-hint">{{ t('dash.board_hint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="kpi-grid kpi-primary">
|
||||
@@ -174,7 +234,7 @@ const kpiSecondary = computed(() => {
|
||||
class="kpi-delta"
|
||||
:class="{ up: item.delta.startsWith('+'), down: item.delta.startsWith('-') }"
|
||||
>
|
||||
较昨日 {{ item.delta }}
|
||||
{{ t('common.vs_yesterday') }} {{ item.delta }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,14 +248,8 @@ const kpiSecondary = computed(() => {
|
||||
</div>
|
||||
|
||||
<div class="charts-stack">
|
||||
<EChartPanel
|
||||
title=""
|
||||
:option="mainTrendOption"
|
||||
height="300px"
|
||||
class="chart-main"
|
||||
/>
|
||||
<div class="chart-main-caption">近 7 日经营趋势(金额折线 + 注单柱)</div>
|
||||
|
||||
<EChartPanel title="" :option="mainTrendOption" height="300px" class="chart-main" />
|
||||
<div class="chart-main-caption">{{ t('dash.trend_caption') }}</div>
|
||||
<EChartPanel title="" :option="distributionOption" height="200px" class="chart-dist" />
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -325,5 +379,4 @@ const kpiSecondary = computed(() => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -5,11 +5,14 @@ import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAuthStore, type StaffUser } from '../stores/auth';
|
||||
import RobotVerify from '../components/RobotVerify.vue';
|
||||
import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import bgImage from '../assets/images/bg.png';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const form = ref({ username: '', password: '' });
|
||||
const loading = ref(false);
|
||||
@@ -23,7 +26,7 @@ async function quickLogin(username: string, password: string) {
|
||||
auth.setSession(payload.token, payload.user);
|
||||
router.push((route.query.redirect as string) || '/');
|
||||
} catch {
|
||||
ElMessage.error('快速登录失败');
|
||||
ElMessage.error(t('login.err_quick'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -31,7 +34,7 @@ async function quickLogin(username: string, password: string) {
|
||||
|
||||
async function login() {
|
||||
if (!captchaRef.value?.validate()) {
|
||||
ElMessage.error('验证码错误,请重试');
|
||||
ElMessage.error(t('login.err_captcha'));
|
||||
captchaRef.value?.refresh();
|
||||
return;
|
||||
}
|
||||
@@ -42,7 +45,7 @@ async function login() {
|
||||
auth.setSession(payload.token, payload.user);
|
||||
router.push((route.query.redirect as string) || '/');
|
||||
} catch {
|
||||
ElMessage.error('登录失败,请检查账号与密码');
|
||||
ElMessage.error(t('login.err_failed'));
|
||||
captchaRef.value?.refresh();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -54,29 +57,32 @@ async function login() {
|
||||
<div class="login-page" :style="{ backgroundImage: `url(${bgImage})` }">
|
||||
<div class="login-mask" />
|
||||
<div class="login-wrap">
|
||||
<div class="login-lang">
|
||||
<AdminLocaleSwitcher />
|
||||
</div>
|
||||
<form @submit.prevent="login" class="login-form" autocomplete="off">
|
||||
<img src="/logo.png" alt="TheBet365" class="logo" />
|
||||
<h2 class="title">管理后台</h2>
|
||||
<h2 class="title">{{ t('login.title') }}</h2>
|
||||
|
||||
<label>账号</label>
|
||||
<input v-model="form.username" class="field" placeholder="请输入用户名" autocomplete="off" required />
|
||||
<label>密码</label>
|
||||
<input v-model="form.password" class="field" type="password" placeholder="请输入密码" autocomplete="off" required />
|
||||
<label>{{ t('login.username') }}</label>
|
||||
<input v-model="form.username" class="field" :placeholder="t('login.username_ph')" autocomplete="off" required />
|
||||
<label>{{ t('login.password') }}</label>
|
||||
<input v-model="form.password" class="field" type="password" :placeholder="t('login.password_ph')" autocomplete="off" required />
|
||||
|
||||
<RobotVerify ref="captchaRef" />
|
||||
|
||||
<button type="submit" class="btn-login" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
{{ loading ? '...' : t('login.submit') }}
|
||||
</button>
|
||||
|
||||
<div class="quick-label">快速登录(调试)</div>
|
||||
<div class="quick-label">{{ t('login.quick_label') }}</div>
|
||||
<div class="quick-btns">
|
||||
<button type="button" class="quick-btn" :disabled="loading" @click="quickLogin('admin', 'Admin@123')">
|
||||
<span class="quick-role">管理员</span>
|
||||
<span class="quick-role">{{ t('login.quick_admin') }}</span>
|
||||
<span class="quick-acc">admin</span>
|
||||
</button>
|
||||
<button type="button" class="quick-btn" :disabled="loading" @click="quickLogin('agent1', 'Agent@123')">
|
||||
<span class="quick-role">一级代理</span>
|
||||
<span class="quick-role">{{ t('login.quick_agent') }}</span>
|
||||
<span class="quick-acc">agent1</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -109,6 +115,12 @@ async function login() {
|
||||
max-width: 400px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.login-lang {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
import {
|
||||
emptyMatchForm,
|
||||
buildPlatformPayload,
|
||||
@@ -74,7 +79,7 @@ async function openEdit(id: string) {
|
||||
const { data } = await api.get(`/admin/matches/${id}`);
|
||||
const detail = data.data as AdminMatchDetail;
|
||||
if (detail.isOutright) {
|
||||
ElMessage.warning('冠军盘不支持在此编辑');
|
||||
ElMessage.warning(t('msg.outright_no_edit'));
|
||||
return;
|
||||
}
|
||||
editingId.value = id;
|
||||
@@ -83,7 +88,7 @@ async function openEdit(id: string) {
|
||||
editVisible.value = true;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? '加载赛事失败');
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_matches_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,18 +97,18 @@ async function submitCreate() {
|
||||
try {
|
||||
payload = buildPlatformPayload(form.value);
|
||||
} catch (e) {
|
||||
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
|
||||
ElMessage.warning(resolveFormError(e, t));
|
||||
return;
|
||||
}
|
||||
createLoading.value = true;
|
||||
try {
|
||||
await api.post('/admin/matches', payload);
|
||||
ElMessage.success('赛事已创建(草稿)');
|
||||
ElMessage.success(t('msg.match_created_draft'));
|
||||
createVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? '创建失败');
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
@@ -114,18 +119,18 @@ async function submitEdit() {
|
||||
try {
|
||||
payload = buildPlatformPayload(form.value);
|
||||
} catch (e) {
|
||||
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
|
||||
ElMessage.warning(resolveFormError(e, t));
|
||||
return;
|
||||
}
|
||||
editLoading.value = true;
|
||||
try {
|
||||
await api.put(`/admin/matches/${editingId.value}`, payload);
|
||||
ElMessage.success('已保存');
|
||||
ElMessage.success(t('msg.saved'));
|
||||
editVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? '保存失败');
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
editLoading.value = false;
|
||||
}
|
||||
@@ -135,18 +140,18 @@ async function confirmDelete(row: unknown) {
|
||||
const id = matchId(row);
|
||||
const title = matchTitle(row);
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除赛事「${title}」?仅草稿且无注单时可删除。`, '删除确认', {
|
||||
await ElMessageBox.confirm(t('match.delete_confirm_body', { title }), t('match.delete_confirm_title'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonText: t('common.delete'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
await api.delete(`/admin/matches/${id}`);
|
||||
ElMessage.success('已删除');
|
||||
ElMessage.success(t('msg.deleted'));
|
||||
load();
|
||||
} catch (e) {
|
||||
if (e === 'cancel' || (e as { message?: string })?.message === 'cancel') return;
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? '删除失败');
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +160,7 @@ async function submitImport() {
|
||||
try {
|
||||
payload = JSON.parse(importJson.value);
|
||||
} catch {
|
||||
ElMessage.error('JSON 格式无效');
|
||||
ElMessage.error(t('msg.invalid_json'));
|
||||
return;
|
||||
}
|
||||
importLoading.value = true;
|
||||
@@ -168,13 +173,18 @@ async function submitImport() {
|
||||
total: number;
|
||||
};
|
||||
ElMessage.success(
|
||||
`导入完成:成功 ${r.imported},跳过 ${r.skipped},失败 ${r.failed} / 共 ${r.total}`,
|
||||
t('msg.import_done', {
|
||||
imported: r.imported,
|
||||
skipped: r.skipped,
|
||||
failed: r.failed,
|
||||
total: r.total,
|
||||
}),
|
||||
);
|
||||
importVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? '导入失败');
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.import_failed'));
|
||||
} finally {
|
||||
importLoading.value = false;
|
||||
}
|
||||
@@ -183,15 +193,26 @@ async function submitImport() {
|
||||
async function publish(id: string) {
|
||||
await api.post(`/admin/matches/${id}/publish`);
|
||||
await api.post(`/admin/matches/${id}/markets/templates`, {
|
||||
marketTypes: ['FT_1X2', 'FT_HANDICAP', 'FT_OVER_UNDER', 'FT_ODD_EVEN'],
|
||||
marketTypes: [
|
||||
'FT_1X2',
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_1X2',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
],
|
||||
});
|
||||
ElMessage.success('已发布并生成盘口');
|
||||
ElMessage.success(t('msg.published'));
|
||||
load();
|
||||
}
|
||||
|
||||
async function close(id: string) {
|
||||
await api.post(`/admin/matches/${id}/close`);
|
||||
ElMessage.success('已封盘');
|
||||
ElMessage.success(t('msg.closed'));
|
||||
load();
|
||||
}
|
||||
|
||||
@@ -200,12 +221,11 @@ function settle(id: string) {
|
||||
}
|
||||
|
||||
type TagType = '' | 'info' | 'success' | 'warning' | 'danger';
|
||||
const statusLabels: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
PUBLISHED: '已发布',
|
||||
CLOSED: '已封盘',
|
||||
SETTLED: '已结算',
|
||||
};
|
||||
function matchStatusText(status: string) {
|
||||
const key = `match.status.${status}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : status;
|
||||
}
|
||||
const statusTagTypes: Record<string, TagType> = {
|
||||
DRAFT: 'info',
|
||||
PUBLISHED: 'warning',
|
||||
@@ -220,7 +240,7 @@ function matchStatus(row: unknown) {
|
||||
return String(rowOf(row).status ?? '');
|
||||
}
|
||||
function matchStatusLabel(row: unknown) {
|
||||
return statusLabels[matchStatus(row)] ?? matchStatus(row);
|
||||
return matchStatusText(matchStatus(row));
|
||||
}
|
||||
function matchStatusType(row: unknown): TagType {
|
||||
return statusTagTypes[matchStatus(row)] ?? 'info';
|
||||
@@ -254,36 +274,36 @@ function canDelete(row: unknown) {
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">赛事管理</h2>
|
||||
<span class="page-desc">草稿可编辑、删除;已发布可改开赛时间与热门</span>
|
||||
<h2 class="page-title">{{ t('page.matches.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.matches.desc') }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="openImport">导入</el-button>
|
||||
<el-button type="primary" @click="openCreate">+ 新增赛事</el-button>
|
||||
<el-button @click="openImport">{{ t('common.import') }}</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ t('match.create_btn') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item label="关键词">
|
||||
<el-form-item :label="t('common.keyword')">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="赛事名 / 球队代码"
|
||||
:placeholder="t('match.filter.keyword_ph')"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="load"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filterStatus" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="草稿" value="DRAFT" />
|
||||
<el-option label="已发布" value="PUBLISHED" />
|
||||
<el-option label="已封盘" value="CLOSED" />
|
||||
<el-option label="已结算" value="SETTLED" />
|
||||
<el-form-item :label="t('common.status')">
|
||||
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||||
<el-option :label="t('match.status.DRAFT')" value="DRAFT" />
|
||||
<el-option :label="t('match.status.PUBLISHED')" value="PUBLISHED" />
|
||||
<el-option :label="t('match.status.CLOSED')" value="CLOSED" />
|
||||
<el-option :label="t('match.status.SETTLED')" value="SETTLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
@@ -292,18 +312,18 @@ function canDelete(row: unknown) {
|
||||
<div class="table-wrap">
|
||||
<el-table :data="matches" stripe>
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column label="对阵" min-width="200">
|
||||
<el-table-column :label="t('match.col.matchup')" min-width="200">
|
||||
<template #default="{ row }">{{ matchTitle(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="96">
|
||||
<el-table-column :label="t('common.status')" width="96">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="matchStatusType(row)" size="small">{{ matchStatusLabel(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开赛时间" min-width="160">
|
||||
<el-table-column :label="t('match.col.kickoff')" min-width="160">
|
||||
<template #default="{ row }">{{ matchTime(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="340" align="center" fixed="right">
|
||||
<el-table-column :label="t('common.actions')" width="340" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="canEdit(row)"
|
||||
@@ -311,7 +331,7 @@ function canDelete(row: unknown) {
|
||||
plain
|
||||
@click="openEdit(matchId(row))"
|
||||
>
|
||||
编辑
|
||||
{{ t('common.edit') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canDelete(row)"
|
||||
@@ -320,7 +340,7 @@ function canDelete(row: unknown) {
|
||||
plain
|
||||
@click="confirmDelete(row)"
|
||||
>
|
||||
删除
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="matchStatus(row) === 'DRAFT'"
|
||||
@@ -329,7 +349,7 @@ function canDelete(row: unknown) {
|
||||
plain
|
||||
@click="publish(matchId(row))"
|
||||
>
|
||||
发布
|
||||
{{ t('common.publish') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="matchStatus(row) === 'PUBLISHED'"
|
||||
@@ -338,10 +358,10 @@ function canDelete(row: unknown) {
|
||||
plain
|
||||
@click="close(matchId(row))"
|
||||
>
|
||||
封盘
|
||||
{{ t('common.close_betting') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="warning" plain @click="settle(matchId(row))">
|
||||
结算
|
||||
{{ t('common.settle') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -361,87 +381,87 @@ function canDelete(row: unknown) {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" title="新增赛事" width="520px" destroy-on-close>
|
||||
<el-dialog v-model="createVisible" :title="t('match.dialog.create')" width="520px" destroy-on-close>
|
||||
<el-form label-width="96px">
|
||||
<el-form-item label="联赛(英)">
|
||||
<el-input v-model="form.leagueEn" placeholder="FIFA World Cup 2026" />
|
||||
<el-form-item :label="t('match.field.league_en')">
|
||||
<el-input v-model="form.leagueEn" :placeholder="t('match.ph.league_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联赛(中)">
|
||||
<el-input v-model="form.leagueZh" placeholder="2026 世界杯" />
|
||||
<el-form-item :label="t('match.field.league_zh')">
|
||||
<el-input v-model="form.leagueZh" :placeholder="t('match.ph.league_zh')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开赛时间" required>
|
||||
<el-input v-model="form.startTime" placeholder="2026-06-11T19:00:00Z" />
|
||||
<el-form-item :label="t('match.field.kickoff')" required>
|
||||
<el-input v-model="form.startTime" :placeholder="t('match.ph.kickoff')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="主队(英)">
|
||||
<el-input v-model="form.homeTeamEn" placeholder="Mexico" />
|
||||
<el-form-item :label="t('match.field.home_en')">
|
||||
<el-input v-model="form.homeTeamEn" :placeholder="t('match.ph.home_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="主队(中)">
|
||||
<el-input v-model="form.homeTeamZh" placeholder="墨西哥" />
|
||||
<el-form-item :label="t('match.field.home_zh')">
|
||||
<el-input v-model="form.homeTeamZh" :placeholder="t('match.ph.home_zh')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="客队(英)">
|
||||
<el-input v-model="form.awayTeamEn" placeholder="South Africa" />
|
||||
<el-form-item :label="t('match.field.away_en')">
|
||||
<el-input v-model="form.awayTeamEn" :placeholder="t('match.ph.away_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="客队(中)">
|
||||
<el-input v-model="form.awayTeamZh" placeholder="南非" />
|
||||
<el-form-item :label="t('match.field.away_zh')">
|
||||
<el-input v-model="form.awayTeamZh" :placeholder="t('match.ph.away_zh')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="热门">
|
||||
<el-form-item :label="t('match.field.featured')">
|
||||
<el-switch v-model="form.isHot" />
|
||||
</el-form-item>
|
||||
<p class="field-hint">创建后为草稿,请在列表点击「发布」并生成盘口。</p>
|
||||
<p class="field-hint">{{ t('match.hint.create_draft') }}</p>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
|
||||
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="createLoading" @click="submitCreate">{{ t('user.btn.create') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑赛事" width="520px" destroy-on-close>
|
||||
<el-dialog v-model="editVisible" :title="t('match.dialog.edit')" width="520px" destroy-on-close>
|
||||
<el-form label-width="96px">
|
||||
<p v-if="isEditPublished" class="field-hint edit-hint">
|
||||
已发布:可修改开赛时间、热门及显示名称;封盘/已结算后不可编辑。
|
||||
{{ t('match.hint.edit_published') }}
|
||||
</p>
|
||||
<el-form-item label="联赛(英)">
|
||||
<el-form-item :label="t('match.field.league_en')">
|
||||
<el-input v-model="form.leagueEn" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联赛(中)">
|
||||
<el-form-item :label="t('match.field.league_zh')">
|
||||
<el-input v-model="form.leagueZh" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开赛时间" required>
|
||||
<el-form-item :label="t('match.field.kickoff')" required>
|
||||
<el-input v-model="form.startTime" />
|
||||
</el-form-item>
|
||||
<el-form-item label="主队(英)">
|
||||
<el-form-item :label="t('match.field.home_en')">
|
||||
<el-input v-model="form.homeTeamEn" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item label="主队(中)">
|
||||
<el-form-item :label="t('match.field.home_zh')">
|
||||
<el-input v-model="form.homeTeamZh" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item label="客队(英)">
|
||||
<el-form-item :label="t('match.field.away_en')">
|
||||
<el-input v-model="form.awayTeamEn" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item label="客队(中)">
|
||||
<el-form-item :label="t('match.field.away_zh')">
|
||||
<el-input v-model="form.awayTeamZh" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item label="热门">
|
||||
<el-form-item :label="t('match.field.featured')">
|
||||
<el-switch v-model="form.isHot" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editLoading" @click="submitEdit">保存</el-button>
|
||||
<el-button @click="editVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="editLoading" @click="submitEdit">{{ t('common.save') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="importVisible" title="导入赛事" width="640px" destroy-on-close>
|
||||
<p class="dialog-hint">粘贴含 <code>matches</code> 的 JSON,导入后为草稿,需在列表发布。</p>
|
||||
<el-dialog v-model="importVisible" :title="t('match.dialog.import')" width="640px" destroy-on-close>
|
||||
<p class="dialog-hint">{{ t('match.import_hint') }}</p>
|
||||
<el-input
|
||||
v-model="importJson"
|
||||
type="textarea"
|
||||
:rows="14"
|
||||
placeholder='{"matches":[...]}'
|
||||
:placeholder="t('match.import_json_ph')"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="importVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importLoading" @click="submitImport">开始导入</el-button>
|
||||
<el-button @click="importVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="importLoading" @click="submitImport">{{ t('match.import_start') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,16 @@ import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const route = useRoute();
|
||||
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
|
||||
const preview = ref<Record<string, unknown> | null>(null);
|
||||
|
||||
async function recordScore() {
|
||||
await api.post(`/admin/matches/${route.params.id}/settlement/score`, score.value);
|
||||
ElMessage.success('比分已录入');
|
||||
ElMessage.success(t('msg.score_recorded'));
|
||||
}
|
||||
|
||||
async function previewSettlement() {
|
||||
@@ -21,21 +23,21 @@ async function previewSettlement() {
|
||||
async function confirm() {
|
||||
if (!preview.value?.batch) return;
|
||||
await api.post(`/admin/settlement/${(preview.value.batch as { id: string }).id}/confirm`);
|
||||
ElMessage.success('结算已确认');
|
||||
ElMessage.success(t('msg.settlement_confirmed'));
|
||||
preview.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">赛事结算</h2>
|
||||
<h2 class="page-title">{{ t('page.settlement.title') }}</h2>
|
||||
<span class="page-id"># {{ route.params.id }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="score-section">
|
||||
<div class="score-block">
|
||||
<div class="score-title">半场比分</div>
|
||||
<div class="score-title">{{ t('settlement.ht_score') }}</div>
|
||||
<div class="score-inputs">
|
||||
<el-input-number v-model="score.htHome" :min="0" controls-position="right" style="width: 100px" />
|
||||
<span class="score-sep">—</span>
|
||||
@@ -43,7 +45,7 @@ async function confirm() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-block">
|
||||
<div class="score-title">全场比分</div>
|
||||
<div class="score-title">{{ t('settlement.ft_score') }}</div>
|
||||
<div class="score-inputs">
|
||||
<el-input-number v-model="score.ftHome" :min="0" controls-position="right" style="width: 100px" />
|
||||
<span class="score-sep">—</span>
|
||||
@@ -52,34 +54,34 @@ async function confirm() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<el-button @click="recordScore">录入比分</el-button>
|
||||
<el-button type="primary" @click="previewSettlement">生成结算预览</el-button>
|
||||
<el-button @click="recordScore">{{ t('settlement.record_score') }}</el-button>
|
||||
<el-button type="primary" @click="previewSettlement">{{ t('settlement.preview_btn') }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="preview" class="preview-card" shadow="never">
|
||||
<div class="preview-title">结算预览</div>
|
||||
<div class="preview-title">{{ t('settlement.preview_title') }}</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ preview.singleBetCount }}</div>
|
||||
<div class="pstat-label">单关注单数</div>
|
||||
<div class="pstat-label">{{ t('settlement.single_count') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value pstat-green">{{ preview.totalPayout }}</div>
|
||||
<div class="pstat-label">预计派彩</div>
|
||||
<div class="pstat-label">{{ t('settlement.est_payout') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value pstat-orange">{{ preview.totalRefund }}</div>
|
||||
<div class="pstat-label">退款金额</div>
|
||||
<div class="pstat-label">{{ t('settlement.refund_amount') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button type="success" @click="confirm" style="margin-top: 24px">确认结算</el-button>
|
||||
<el-button type="success" @click="confirm" style="margin-top: 24px">{{ t('settlement.confirm_btn') }}</el-button>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../api';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
import {
|
||||
emptyPlayerCreateForm,
|
||||
emptyPlayerEditForm,
|
||||
@@ -84,7 +88,7 @@ function openCreate() {
|
||||
}
|
||||
|
||||
function parentLabel(row: PlayerRow) {
|
||||
return row.parentUsername ?? '平台直属';
|
||||
return row.parentUsername ?? t('common.platform_direct');
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
@@ -102,7 +106,7 @@ async function openEdit(id: string) {
|
||||
}
|
||||
|
||||
function openDeposit(row: PlayerRow) {
|
||||
depositForm.value = { userId: row.id, amount: 100, remark: '管理员上分' };
|
||||
depositForm.value = { userId: row.id, amount: 100, remark: t('user.deposit_remark_default') };
|
||||
depositVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -111,18 +115,18 @@ async function submitCreate() {
|
||||
try {
|
||||
payload = buildCreatePlayerPayload(createForm.value);
|
||||
} catch (e) {
|
||||
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
|
||||
ElMessage.warning(resolveFormError(e, t));
|
||||
return;
|
||||
}
|
||||
createLoading.value = true;
|
||||
try {
|
||||
await api.post('/admin/users', payload);
|
||||
ElMessage.success('玩家已创建');
|
||||
ElMessage.success(t('msg.player_created'));
|
||||
createVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? '创建失败');
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
@@ -130,12 +134,16 @@ async function submitCreate() {
|
||||
|
||||
async function toggleFreeze(row: PlayerRow) {
|
||||
const freeze = row.status === 'ACTIVE';
|
||||
const action = freeze ? '冻结' : '解冻';
|
||||
const action = freeze ? t('common.freeze') : t('common.unfreeze');
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要${action}玩家「${row.username}」吗?${freeze ? '冻结后该账号将无法登录。' : ''}`,
|
||||
`${action}账号`,
|
||||
{ type: 'warning', confirmButtonText: action, cancelButtonText: '取消' },
|
||||
t('msg.freeze_confirm_body', {
|
||||
action,
|
||||
name: row.username,
|
||||
extra: freeze ? t('msg.freeze_extra') : '',
|
||||
}),
|
||||
t('msg.freeze_confirm_title', { action }),
|
||||
{ type: 'warning', confirmButtonText: action, cancelButtonText: t('common.cancel') },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
@@ -144,11 +152,11 @@ async function toggleFreeze(row: PlayerRow) {
|
||||
await api.put(`/admin/users/${row.id}`, {
|
||||
status: freeze ? 'SUSPENDED' : 'ACTIVE',
|
||||
});
|
||||
ElMessage.success(`已${action}`);
|
||||
ElMessage.success(t('msg.freeze_done', { action }));
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? `${action}失败`);
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,12 +168,12 @@ async function submitEdit() {
|
||||
phone: editForm.value.phone.trim() || undefined,
|
||||
email: editForm.value.email.trim() || undefined,
|
||||
});
|
||||
ElMessage.success('已保存');
|
||||
ElMessage.success(t('msg.saved'));
|
||||
editVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? '保存失败');
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
editLoading.value = false;
|
||||
}
|
||||
@@ -173,7 +181,7 @@ async function submitEdit() {
|
||||
|
||||
async function submitDeposit() {
|
||||
if (depositForm.value.amount <= 0) {
|
||||
ElMessage.warning('金额须大于 0');
|
||||
ElMessage.warning(t('msg.amount_gt_zero'));
|
||||
return;
|
||||
}
|
||||
depositLoading.value = true;
|
||||
@@ -184,12 +192,12 @@ async function submitDeposit() {
|
||||
remark: depositForm.value.remark,
|
||||
requestId: `dep-${depositForm.value.userId}-${Date.now()}`,
|
||||
});
|
||||
ElMessage.success('上分成功');
|
||||
ElMessage.success(t('msg.topup_ok'));
|
||||
depositVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? '上分失败');
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.topup_failed'));
|
||||
} finally {
|
||||
depositLoading.value = false;
|
||||
}
|
||||
@@ -197,11 +205,11 @@ async function submitDeposit() {
|
||||
|
||||
function formatTime(v: string) {
|
||||
if (!v) return '—';
|
||||
return new Date(v).toLocaleString('zh-CN');
|
||||
return new Date(v).toLocaleString(localeTag.value);
|
||||
}
|
||||
|
||||
function formatLastLogin(v: string | null) {
|
||||
if (!v) return '从未登录';
|
||||
if (!v) return t('common.never_login');
|
||||
const d = new Date(v);
|
||||
const now = new Date();
|
||||
const sameDay =
|
||||
@@ -209,9 +217,9 @@ function formatLastLogin(v: string | null) {
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
if (sameDay) {
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleTimeString(localeTag.value, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleString('zh-CN', {
|
||||
return d.toLocaleString(localeTag.value, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
@@ -224,7 +232,9 @@ function statusTagType(s: string) {
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
return s === 'ACTIVE' ? '正常' : s === 'SUSPENDED' ? '停用' : s;
|
||||
const key = `user.status.${s}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : s;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -232,27 +242,27 @@ function statusLabel(s: string) {
|
||||
<div class="admin-list-page users-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">玩家管理</h2>
|
||||
<span class="page-desc">创建玩家、查看余额与投注概况,支持上分与状态管理</span>
|
||||
<h2 class="page-title">{{ t('page.users.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.users.desc') }}</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">+ 新建玩家</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item label="关键词">
|
||||
<el-form-item :label="t('common.keyword')">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="用户名"
|
||||
:placeholder="t('user.filter.username_ph')"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
@keyup.enter="load"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属代理">
|
||||
<el-form-item :label="t('user.filter.agent')">
|
||||
<el-select
|
||||
v-model="filterParentId"
|
||||
placeholder="全部"
|
||||
:placeholder="t('user.filter.agent_ph')"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
>
|
||||
@@ -264,14 +274,14 @@ function statusLabel(s: string) {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filterStatus" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="正常" value="ACTIVE" />
|
||||
<el-option label="停用" value="SUSPENDED" />
|
||||
<el-form-item :label="t('common.status')">
|
||||
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||||
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||||
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
@@ -280,18 +290,18 @@ function statusLabel(s: string) {
|
||||
<div class="table-wrap">
|
||||
<el-table :data="users" stripe>
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column label="状态" width="88">
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||
<el-table-column :label="t('common.status')" width="88">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">
|
||||
{{ statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属代理" min-width="120">
|
||||
<el-table-column :label="t('user.col.agent')" min-width="120">
|
||||
<template #default="{ row }">{{ parentLabel(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="可用 / 冻结" min-width="128" align="right">
|
||||
<el-table-column :label="t('user.col.balance')" min-width="128" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
:content="`${formatAmountFull(row.availableBalance)} / ${formatAmountFull(row.frozenBalance)}`"
|
||||
@@ -303,34 +313,34 @@ function statusLabel(s: string) {
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="betCount" label="注单" width="64" align="center" />
|
||||
<el-table-column label="投注 / 派彩" min-width="108" align="right">
|
||||
<el-table-column prop="betCount" :label="t('user.col.bets')" width="64" align="center" />
|
||||
<el-table-column :label="t('user.col.stake_payout')" min-width="108" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="amount-compact">
|
||||
{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后登录" width="108">
|
||||
<el-table-column :label="t('user.col.last_login')" width="108">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="row.lastLoginAt" :content="formatTime(row.lastLoginAt)" placement="top">
|
||||
<span>{{ formatLastLogin(row.lastLoginAt) }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-muted">从未登录</span>
|
||||
<span v-else class="text-muted">{{ t('common.never_login') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="注册时间" width="108">
|
||||
<el-table-column :label="t('user.col.created')" width="108">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatTime(row.createdAt)" placement="top">
|
||||
<span>{{ formatLastLogin(row.createdAt) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right" align="center">
|
||||
<el-table-column :label="t('common.actions')" width="300" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openDetail(row.id)">详情</el-button>
|
||||
<el-button size="small" link type="primary" @click="openEdit(row.id)">编辑</el-button>
|
||||
<el-button size="small" link type="primary" @click="openDeposit(row)">上分</el-button>
|
||||
<el-button size="small" link type="primary" @click="openDetail(row.id)">{{ t('common.detail') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openEdit(row.id)">{{ t('common.edit') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openDeposit(row)">{{ t('common.topup') }}</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'ACTIVE'"
|
||||
size="small"
|
||||
@@ -338,7 +348,7 @@ function statusLabel(s: string) {
|
||||
type="warning"
|
||||
@click="toggleFreeze(row)"
|
||||
>
|
||||
冻结
|
||||
{{ t('common.freeze') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
@@ -347,7 +357,7 @@ function statusLabel(s: string) {
|
||||
type="primary"
|
||||
@click="toggleFreeze(row)"
|
||||
>
|
||||
解冻
|
||||
{{ t('common.unfreeze') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -367,21 +377,21 @@ function statusLabel(s: string) {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" title="新建玩家" width="520px" destroy-on-close>
|
||||
<el-dialog v-model="createVisible" :title="t('user.dialog.create')" width="520px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="createForm.username" placeholder="登录用户名,唯一" />
|
||||
<el-form-item :label="t('user.col.username')" required>
|
||||
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_unique')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="登录密码" required>
|
||||
<el-form-item :label="t('user.field.password')" required>
|
||||
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" required>
|
||||
<el-form-item :label="t('user.field.confirm_password')" required>
|
||||
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属代理">
|
||||
<el-form-item :label="t('user.filter.agent')">
|
||||
<el-select
|
||||
v-model="createForm.parentId"
|
||||
placeholder="不设置(平台直属玩家)"
|
||||
:placeholder="t('user.ph.no_agent')"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
@@ -392,46 +402,46 @@ function statusLabel(s: string) {
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="field-hint">留空表示不挂靠代理,由平台直接管理</div>
|
||||
<div class="field-hint">{{ t('user.hint.no_agent') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="createForm.phone" placeholder="选填" />
|
||||
<el-form-item :label="t('user.field.phone')">
|
||||
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="createForm.email" placeholder="选填" />
|
||||
<el-form-item :label="t('user.field.email')">
|
||||
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="初始余额">
|
||||
<el-form-item :label="t('user.field.initial_balance')">
|
||||
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
|
||||
<div class="field-hint">创建后自动上分,0 表示不开户赠金</div>
|
||||
<div class="field-hint">{{ t('user.hint.initial_balance') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="上分备注">
|
||||
<el-input v-model="createForm.remark" placeholder="有初始余额时写入流水备注" />
|
||||
<el-form-item :label="t('user.field.deposit_remark')">
|
||||
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
|
||||
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="createLoading" @click="submitCreate">{{ t('user.btn.create') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑玩家" width="560px" destroy-on-close>
|
||||
<el-dialog v-model="editVisible" :title="t('user.dialog.edit')" width="560px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="玩家 ID">
|
||||
<el-form-item :label="t('user.field.player_id')">
|
||||
<el-input :model-value="editForm.id" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名">
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input :model-value="editForm.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="账号状态">
|
||||
<el-form-item :label="t('user.field.account_status')">
|
||||
<el-tag :type="statusTagType(editForm.status)" size="small">
|
||||
{{ statusLabel(editForm.status) }}
|
||||
</el-tag>
|
||||
<span class="field-hint inline-hint">冻结/解冻请在列表操作列进行</span>
|
||||
<span class="field-hint inline-hint">{{ t('user.hint.freeze_in_list') }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属代理">
|
||||
<el-form-item :label="t('user.filter.agent')">
|
||||
<el-select
|
||||
v-model="editForm.parentId"
|
||||
placeholder="不设置(平台直属玩家)"
|
||||
:placeholder="t('user.ph.no_agent')"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
@@ -442,102 +452,102 @@ function statusLabel(s: string) {
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="field-hint">留空表示平台直属;变更后会重算相关代理已用授信</div>
|
||||
<div class="field-hint">{{ t('user.hint.agent_change') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="可用余额">
|
||||
<el-form-item :label="t('user.field.available')">
|
||||
<el-input :model-value="formatAmount(editForm.availableBalance)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="冻结余额">
|
||||
<el-form-item :label="t('user.field.frozen_balance')">
|
||||
<el-input :model-value="formatAmount(editForm.frozenBalance)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="注单 / 投注">
|
||||
<el-form-item :label="t('user.field.bets_summary')">
|
||||
<el-input
|
||||
:model-value="`${editForm.betCount} 笔 / ${formatAmount(editForm.totalStake)}`"
|
||||
:model-value="t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) })"
|
||||
disabled
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="累计派彩">
|
||||
<el-form-item :label="t('user.field.total_payout')">
|
||||
<el-input :model-value="formatAmount(editForm.totalReturn)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="最后登录">
|
||||
<el-form-item :label="t('user.col.last_login')">
|
||||
<el-input
|
||||
:model-value="editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : '从未登录'"
|
||||
:model-value="editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login')"
|
||||
disabled
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="登录失败">
|
||||
<el-input :model-value="`${editForm.loginFailCount} 次`" disabled />
|
||||
<el-form-item :label="t('user.field.login_fail')">
|
||||
<el-input :model-value="t('user.login_fail_value', { n: editForm.loginFailCount })" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="注册时间">
|
||||
<el-form-item :label="t('user.col.created')">
|
||||
<el-input :model-value="formatTime(editForm.createdAt)" disabled />
|
||||
</el-form-item>
|
||||
<el-divider />
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="editForm.phone" placeholder="选填" />
|
||||
<el-form-item :label="t('user.field.phone')">
|
||||
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="editForm.email" placeholder="选填" />
|
||||
<el-form-item :label="t('user.field.email')">
|
||||
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editLoading" @click="submitEdit">保存资料</el-button>
|
||||
<el-button @click="editVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="editLoading" @click="submitEdit">{{ t('user.btn.save_profile') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="depositVisible" title="玩家上分" width="400px" destroy-on-close>
|
||||
<el-dialog v-model="depositVisible" :title="t('user.dialog.deposit')" width="400px" destroy-on-close>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="玩家 ID">
|
||||
<el-form-item :label="t('user.field.player_id')">
|
||||
<el-input :model-value="depositForm.userId" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="金额">
|
||||
<el-form-item :label="t('user.field.amount')">
|
||||
<el-input-number v-model="depositForm.amount" :min="0.01" :step="10" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-form-item :label="t('user.field.remark')">
|
||||
<el-input v-model="depositForm.remark" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="depositVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="depositLoading" @click="submitDeposit">确认上分</el-button>
|
||||
<el-button @click="depositVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="depositLoading" @click="submitDeposit">{{ t('user.btn.confirm_deposit') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="玩家详情" width="560px" destroy-on-close>
|
||||
<el-dialog v-model="detailVisible" :title="t('user.dialog.detail')" width="560px" destroy-on-close>
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ detail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-descriptions-item :label="t('common.col_id')">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.username')">{{ detail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('common.status')">
|
||||
<el-tag :type="statusTagType(detail.status)" size="small">
|
||||
{{ statusLabel(detail.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="所属代理">
|
||||
{{ detail.parentUsername ?? '平台直属' }}
|
||||
<el-descriptions-item :label="t('user.col.agent')">
|
||||
{{ detail.parentUsername ?? t('common.platform_direct') }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="可用余额">
|
||||
<el-descriptions-item :label="t('user.field.available')">
|
||||
{{ formatAmount(detail.availableBalance) }}
|
||||
<span v-if="shouldCompact(detail.availableBalance)" class="amount-full-hint">
|
||||
({{ formatAmountFull(detail.availableBalance) }})
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="冻结余额">
|
||||
<el-descriptions-item :label="t('user.field.frozen_balance')">
|
||||
{{ formatAmount(detail.frozenBalance) }}
|
||||
<span v-if="shouldCompact(detail.frozenBalance)" class="amount-full-hint">
|
||||
({{ formatAmountFull(detail.frozenBalance) }})
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">{{ detail.phone ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ detail.email ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注单数">{{ detail.betCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="累计投注">{{ formatAmount(detail.totalStake) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="累计派彩">{{ formatAmount(detail.totalReturn) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后登录">
|
||||
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : '从未登录' }}
|
||||
<el-descriptions-item :label="t('user.field.phone')">{{ detail.phone ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.email')">{{ detail.email ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.bet_count')">{{ detail.betCount }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.total_stake')">{{ formatAmount(detail.totalStake) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(detail.totalReturn) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.last_login')">
|
||||
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : t('common.never_login') }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="登录失败">{{ detail.loginFailCount }} 次</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间" :span="2">
|
||||
<el-descriptions-item :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: detail.loginFailCount }) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.registered_at')" :span="2">
|
||||
{{ formatTime(detail.createdAt) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FormValidationError } from '../i18n/form-validation';
|
||||
|
||||
export interface AgentCreateForm {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -81,10 +83,10 @@ export function editFormFromAgentDetail(d: AgentDetail): AgentEditForm {
|
||||
}
|
||||
|
||||
export function buildCreateAgentPayload(form: AgentCreateForm) {
|
||||
if (!form.username.trim()) throw new Error('请填写用户名');
|
||||
if (form.password.length < 8) throw new Error('密码至少 8 位');
|
||||
if (form.password !== form.confirmPassword) throw new Error('两次密码不一致');
|
||||
if (form.creditLimit < 0) throw new Error('授信额度不能为负');
|
||||
if (!form.username.trim()) throw new FormValidationError('err.username_required');
|
||||
if (form.password.length < 8) throw new FormValidationError('err.password_min');
|
||||
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
|
||||
if (form.creditLimit < 0) throw new FormValidationError('err.credit_negative');
|
||||
return {
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import { formatAmount } from '../../utils/format-amount';
|
||||
import { betStatusLabel, betStatusTagType, betTypeLabel } from '../../utils/bet-labels';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
interface BetRow {
|
||||
id: string;
|
||||
betNo: string;
|
||||
@@ -44,23 +47,23 @@ function onSizeChange(size: number) {
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">注单查询</h2>
|
||||
<span class="page-desc">下级玩家的全部投注记录</span>
|
||||
<h2 class="page-title">{{ t('page.agent_bets.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agent_bets.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="bets" stripe>
|
||||
<el-table-column prop="id" label="单号" width="56" align="center" />
|
||||
<el-table-column prop="betNo" label="流水编号" width="168" show-overflow-tooltip>
|
||||
<el-table-column prop="id" :label="t('bet.col.serial')" width="56" align="center" />
|
||||
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="168" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="bet-no">{{ row.betNo }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="玩家" min-width="100">
|
||||
<el-table-column :label="t('bet.col.player')" min-width="100">
|
||||
<template #default="{ row }">{{ row.user?.username ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="88" align="center">
|
||||
<el-table-column :label="t('common.type')" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.betType" type="info" size="small" effect="plain">
|
||||
{{ betTypeLabel(row.betType) }}
|
||||
@@ -68,10 +71,10 @@ function onSizeChange(size: number) {
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="投注额" width="110" align="right">
|
||||
<el-table-column :label="t('bet.col.stake')" width="110" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.stake) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="96" align="center">
|
||||
<el-table-column :label="t('common.status')" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="betStatusTagType(row.status)" size="small">
|
||||
{{ betStatusLabel(row.status) }}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../../api';
|
||||
import { formatAmount } from '../../utils/format-amount';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const summary = ref<Record<string, unknown>>({});
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -11,55 +13,55 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
function fmtCount(val: unknown) {
|
||||
return (Number(val) || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
return (Number(val) || 0).toLocaleString(localeTag.value, { maximumFractionDigits: 0 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">代理概览</h2>
|
||||
<span class="page-desc">实时数据总览</span>
|
||||
<h2 class="page-title">{{ t('page.agent_dash.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agent_dash.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" class="stat-row">
|
||||
<el-col :span="6">
|
||||
<div class="stat-card c-blue">
|
||||
<div class="stat-top">
|
||||
<span class="stat-label">授信额度</span>
|
||||
<span class="stat-label">{{ t('agent.credit_limit') }}</span>
|
||||
<span class="stat-badge">¥</span>
|
||||
</div>
|
||||
<div class="stat-value">{{ formatAmount((summary.profile as { creditLimit?: string })?.creditLimit) }}</div>
|
||||
<div class="stat-foot">总额度</div>
|
||||
<div class="stat-foot">{{ t('agent.credit_total') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card c-orange">
|
||||
<div class="stat-top">
|
||||
<span class="stat-label">已用额度</span>
|
||||
<span class="stat-label">{{ t('agent.credit_used') }}</span>
|
||||
<span class="stat-badge">¥</span>
|
||||
</div>
|
||||
<div class="stat-value">{{ formatAmount((summary.profile as { usedCredit?: string })?.usedCredit) }}</div>
|
||||
<div class="stat-foot">已占用</div>
|
||||
<div class="stat-foot">{{ t('agent.credit_occupied') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card c-green">
|
||||
<div class="stat-top">
|
||||
<span class="stat-label">直属玩家</span>
|
||||
<span class="stat-badge">人</span>
|
||||
<span class="stat-label">{{ t('agent.direct_players') }}</span>
|
||||
<span class="stat-badge">{{ t('common.people') }}</span>
|
||||
</div>
|
||||
<div class="stat-value">{{ fmtCount(summary.directPlayerCount) }}</div>
|
||||
<div class="stat-foot">玩家数</div>
|
||||
<div class="stat-foot">{{ t('agent.player_count') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card c-purple">
|
||||
<div class="stat-top">
|
||||
<span class="stat-label">今日投注</span>
|
||||
<span class="stat-label">{{ t('agent.today_stake') }}</span>
|
||||
<span class="stat-badge">¥</span>
|
||||
</div>
|
||||
<div class="stat-value">{{ formatAmount(summary.todayStake) }}</div>
|
||||
<div class="stat-foot">人民币</div>
|
||||
<div class="stat-foot">{{ t('agent.currency_cny') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -78,27 +80,13 @@ function fmtCount(val: unknown) {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); border-color: #2a2a2a; }
|
||||
.stat-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
|
||||
.stat-label { font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
.stat-badge { width: 30px; height: 30px; border-radius: 7px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; }
|
||||
.stat-value { font-size: 28px; font-weight: 800; line-height: 1; margin-bottom: 8px; letter-spacing: -1px; }
|
||||
.stat-foot { font-size: 11px; opacity: 0.4; font-weight: 600; letter-spacing: 0.04em; }
|
||||
|
||||
.c-blue { background: rgba(22,78,180,0.1); color: #60a5fa; }
|
||||
.c-blue .stat-badge { background: rgba(96,165,250,0.12); color: #60a5fa; }
|
||||
.c-orange { background: rgba(180,80,0,0.1); color: #fb923c; }
|
||||
.c-orange .stat-badge { background: rgba(251,146,60,0.12); color: #fb923c; }
|
||||
.c-green {
|
||||
background: linear-gradient(145deg, rgba(36, 143, 84, 0.18), rgba(20, 92, 56, 0.08));
|
||||
border: 1px solid var(--green-border);
|
||||
color: var(--green-text);
|
||||
}
|
||||
.c-green .stat-badge {
|
||||
background: var(--primary-grad);
|
||||
border: 1px solid var(--green-border);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.c-purple { background: rgba(100,40,200,0.1); color: #a78bfa; }
|
||||
.c-purple .stat-badge { background: rgba(167,139,250,0.12); color: #a78bfa; }
|
||||
.stat-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.stat-label { font-size: 12px; color: #666; font-weight: 600; }
|
||||
.stat-badge { font-size: 11px; color: #444; }
|
||||
.stat-value { font-size: 26px; font-weight: 800; color: var(--green-text); margin-bottom: 6px; }
|
||||
.stat-foot { font-size: 11px; color: #444; }
|
||||
.c-blue { background: linear-gradient(135deg, rgba(96, 165, 250, 0.08), transparent); }
|
||||
.c-orange { background: linear-gradient(135deg, rgba(251, 146, 60, 0.08), transparent); }
|
||||
.c-green { background: linear-gradient(135deg, rgba(36, 143, 84, 0.1), transparent); }
|
||||
.c-purple { background: linear-gradient(135deg, rgba(167, 139, 250, 0.08), transparent); }
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const players = ref<unknown[]>([]);
|
||||
const form = ref({ username: '', password: 'Player@123' });
|
||||
const depositForm = ref({ playerId: '', amount: 100, requestId: '' });
|
||||
@@ -17,7 +20,7 @@ async function load() {
|
||||
|
||||
async function create() {
|
||||
await api.post('/agent/players', form.value);
|
||||
ElMessage.success('玩家已创建');
|
||||
ElMessage.success(t('msg.player_created'));
|
||||
load();
|
||||
}
|
||||
|
||||
@@ -27,7 +30,7 @@ async function deposit() {
|
||||
amount: depositForm.value.amount,
|
||||
requestId: depositForm.value.requestId,
|
||||
});
|
||||
ElMessage.success('上分成功');
|
||||
ElMessage.success(t('msg.topup_ok'));
|
||||
load();
|
||||
}
|
||||
|
||||
@@ -36,75 +39,77 @@ async function withdraw(playerId: string, amount: number) {
|
||||
amount,
|
||||
requestId: `wd-${Date.now()}`,
|
||||
});
|
||||
ElMessage.success('下分成功');
|
||||
ElMessage.success(t('msg.withdraw_ok'));
|
||||
load();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">直属玩家</h2>
|
||||
<span class="page-desc">管理你名下的直属玩家</span>
|
||||
</div>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="tool-row">
|
||||
<div class="tool-section">
|
||||
<div class="tool-section-title">创建玩家</div>
|
||||
<el-form inline>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" placeholder="输入用户名" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">+ 创建玩家</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="tool-divider" />
|
||||
<div class="tool-section">
|
||||
<div class="tool-section-title">上分操作</div>
|
||||
<el-form inline>
|
||||
<el-form-item label="玩家ID">
|
||||
<el-input v-model="depositForm.playerId" placeholder="玩家ID" style="width: 110px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="金额">
|
||||
<el-input-number v-model="depositForm.amount" :min="1" style="width: 130px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" @click="deposit">上分</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('page.agent_players.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agent_players.desc') }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="players" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column label="可用余额" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="(row as { wallet?: { availableBalance: string } }).wallet?.availableBalance != null">
|
||||
<el-tooltip
|
||||
:content="formatAmountFull((row as { wallet: { availableBalance: string } }).wallet.availableBalance)"
|
||||
placement="top"
|
||||
>
|
||||
<span>{{ formatAmount((row as { wallet: { availableBalance: string } }).wallet.availableBalance) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="warning" plain @click="withdraw((row as { id: string }).id, 50)">下分 50</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="tool-row">
|
||||
<div class="tool-section">
|
||||
<div class="tool-section-title">{{ t('agent_portal.create_player_section') }}</div>
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input v-model="form.username" :placeholder="t('agent_portal.username_ph')" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">{{ t('agent_portal.create_player_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="tool-divider" />
|
||||
<div class="tool-section">
|
||||
<div class="tool-section-title">{{ t('agent_portal.deposit_section') }}</div>
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.field.player_id')">
|
||||
<el-input v-model="depositForm.playerId" :placeholder="t('agent_portal.player_id_ph')" style="width: 110px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.amount')">
|
||||
<el-input-number v-model="depositForm.amount" :min="1" style="width: 130px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" @click="deposit">{{ t('common.topup') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="players" stripe>
|
||||
<el-table-column prop="id" :label="t('common.col_id')" width="80" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="(row as { wallet?: { availableBalance: string } }).wallet?.availableBalance != null">
|
||||
<el-tooltip
|
||||
:content="formatAmountFull((row as { wallet: { availableBalance: string } }).wallet.availableBalance)"
|
||||
placement="top"
|
||||
>
|
||||
<span>{{ formatAmount((row as { wallet: { availableBalance: string } }).wallet.availableBalance) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="warning" plain @click="withdraw((row as { id: string }).id, 50)">
|
||||
{{ t('agent_portal.withdraw_btn', { amount: 50 }) }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const agents = ref<unknown[]>([]);
|
||||
const form = ref({ username: '', password: 'Agent@123', creditLimit: 10000 });
|
||||
|
||||
@@ -16,57 +19,57 @@ async function load() {
|
||||
|
||||
async function create() {
|
||||
await api.post('/agent/agents', form.value);
|
||||
ElMessage.success('下级代理已创建');
|
||||
ElMessage.success(t('msg.agent_sub_created'));
|
||||
load();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">下级代理</h2>
|
||||
<span class="page-desc">仅一级代理可见</span>
|
||||
</div>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" placeholder="代理用户名" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="授信额度">
|
||||
<el-input-number v-model="form.creditLimit" :min="0" :step="1000" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">+ 创建二级代理</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="agents" stripe>
|
||||
<el-table-column label="用户名" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ (row as { user?: { username: string } }).user?.username }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="授信额度" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull((row as { creditLimit: string }).creditLimit)" placement="top">
|
||||
<span>{{ formatAmount((row as { creditLimit: string }).creditLimit) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已用额度" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull((row as { usedCredit: string }).usedCredit)" placement="top">
|
||||
<span>{{ formatAmount((row as { usedCredit: string }).usedCredit) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('page.agent_sub.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agent_sub.desc') }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input v-model="form.username" :placeholder="t('agent_portal.agent_username_ph')" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.field.credit_limit')">
|
||||
<el-input-number v-model="form.creditLimit" :min="0" :step="1000" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">{{ t('agent_portal.create_tier2_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="agents" stripe>
|
||||
<el-table-column :label="t('user.col.username')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ (row as { user?: { username: string } }).user?.username }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.field.credit_limit')" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull((row as { creditLimit: string }).creditLimit)" placement="top">
|
||||
<span>{{ formatAmount((row as { creditLimit: string }).creditLimit) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.field.used_credit')" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull((row as { usedCredit: string }).usedCredit)" placement="top">
|
||||
<span>{{ formatAmount((row as { usedCredit: string }).usedCredit) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** 后台手动新增赛事(投注平台最小字段) */
|
||||
|
||||
import { FormValidationError } from '../i18n/form-validation';
|
||||
|
||||
export interface MatchCreateForm {
|
||||
leagueEn: string;
|
||||
leagueZh: string;
|
||||
@@ -54,15 +56,15 @@ export function formFromDetail(d: AdminMatchDetail): MatchCreateForm {
|
||||
|
||||
export function buildPlatformPayload(form: MatchCreateForm) {
|
||||
if (!form.startTime.trim()) {
|
||||
throw new Error('请填写开赛时间');
|
||||
throw new FormValidationError('err.kickoff_required');
|
||||
}
|
||||
const homeOk = form.homeTeamZh.trim() || form.homeTeamEn.trim();
|
||||
const awayOk = form.awayTeamZh.trim() || form.awayTeamEn.trim();
|
||||
if (!homeOk || !awayOk) {
|
||||
throw new Error('请填写主客队名称(中文或英文至少一项)');
|
||||
throw new FormValidationError('err.teams_required');
|
||||
}
|
||||
if (!form.leagueZh.trim() && !form.leagueEn.trim()) {
|
||||
throw new Error('请填写联赛名称');
|
||||
throw new FormValidationError('err.league_required');
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FormValidationError } from '../i18n/form-validation';
|
||||
|
||||
export interface PlayerCreateForm {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -105,9 +107,9 @@ export function editFormFromDetail(d: PlayerDetail): PlayerEditForm {
|
||||
}
|
||||
|
||||
export function buildCreatePlayerPayload(form: PlayerCreateForm) {
|
||||
if (!form.username.trim()) throw new Error('请填写用户名');
|
||||
if (form.password.length < 8) throw new Error('密码至少 8 位');
|
||||
if (form.password !== form.confirmPassword) throw new Error('两次密码不一致');
|
||||
if (!form.username.trim()) throw new FormValidationError('err.username_required');
|
||||
if (form.password.length < 8) throw new FormValidationError('err.password_min');
|
||||
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
|
||||
return {
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
|
||||
Reference in New Issue
Block a user