feat: add smoke tests, agent credit ledger, and player cashback page
Introduce admin smoke-test suite with API probes, agent credit transaction history, and player cashback records; fix SmokeTestModule DI and polish admin/player UI assets. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,9 +37,11 @@ const zh: Record<string, string> = {
|
||||
'nav.matches': '赛事管理',
|
||||
'nav.outrights': '优胜冠军',
|
||||
'nav.bets': '注单管理',
|
||||
'nav.credit_transactions': '额度流水',
|
||||
'nav.cashback': '返水管理',
|
||||
'nav.contents': '公共管理',
|
||||
'nav.audit': '操作日志',
|
||||
'nav.smoke_tests': '自动化测试',
|
||||
'nav.players': '直属玩家',
|
||||
'nav.subAgents': '下级代理',
|
||||
'nav.myBets': '注单查询',
|
||||
@@ -215,9 +217,11 @@ const en: Record<string, string> = {
|
||||
'nav.matches': 'Matches',
|
||||
'nav.outrights': 'Outrights',
|
||||
'nav.bets': 'Bets',
|
||||
'nav.credit_transactions': 'Credit ledger',
|
||||
'nav.cashback': 'Cashback',
|
||||
'nav.contents': 'Public Content',
|
||||
'nav.audit': 'Audit Log',
|
||||
'nav.smoke_tests': 'Smoke tests',
|
||||
'nav.players': 'My Players',
|
||||
'nav.subAgents': 'Sub-Agents',
|
||||
'nav.myBets': 'Bet Search',
|
||||
@@ -393,9 +397,11 @@ const ms: Record<string, string> = {
|
||||
'nav.matches': 'Perlawanan',
|
||||
'nav.outrights': 'Juara',
|
||||
'nav.bets': 'Pertaruhan',
|
||||
'nav.credit_transactions': 'Lejar kredit',
|
||||
'nav.cashback': 'Rebat',
|
||||
'nav.contents': 'Kandungan awam',
|
||||
'nav.audit': 'Log audit',
|
||||
'nav.smoke_tests': 'Ujian asap',
|
||||
'nav.players': 'Pemain saya',
|
||||
'nav.subAgents': 'Sub-ejen',
|
||||
'nav.myBets': 'Carian pertaruhan',
|
||||
|
||||
@@ -129,7 +129,13 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'agent.credit.decrease': 'Kurang',
|
||||
'agent.col.credit_type': 'Jenis',
|
||||
'agent.col.credit_change': 'Perubahan',
|
||||
'agent.col.credit_before': 'Sebelum',
|
||||
'agent.col.credit_after': 'Selepas',
|
||||
'agent.credit_tx.filter_agent_ph': 'Nama pengguna ejen',
|
||||
'agent.credit_tx.filter_agent_id': 'ID ejen',
|
||||
'agent.credit_tx.filter_agent_id_ph': 'ID pengguna',
|
||||
'agent.credit_tx.col.operator': 'Operator',
|
||||
'agent.credit_tx.view_all': 'Lihat semua lejar kredit',
|
||||
'agent.col.no_records': 'Tiada rekod',
|
||||
'agent.btn.confirm_adjust': 'Sahkan',
|
||||
'agent.field.select_user': 'Pilih pengguna',
|
||||
@@ -597,4 +603,39 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'msg.freeze_extra': ' Mereka tidak akan dapat log masuk.',
|
||||
'msg.freeze_done': '{action} selesai',
|
||||
'msg.freeze_failed': '{action} gagal',
|
||||
|
||||
'smoke.intro': 'Jalankan ujian asap automatik: penyelesaian, peraturan pertaruhan, kredit ejen, rebat, sondakan DB, dan integrasi pertaruhan→penyelesaian→dompet.',
|
||||
'smoke.intro_rule': 'Kes peraturan menggunakan logik sama seperti ujian unit Jest; tiada data perniagaan ditulis.',
|
||||
'smoke.intro_db': 'Kit Database hanya semak sambungan dan konfigurasi (baca sahaja).',
|
||||
'smoke.intro_bet_flow': 'Kit aliran pertaruhan mencipta perlawanan/pemain sementara, mengesahkan bekuan/payout/kredit ejen, kemudian membersihkan.',
|
||||
'smoke.intro_note': 'Merangkumi kebanyakan regresi UAT; semak rebat secara manual jika perlu.',
|
||||
'smoke.field.suites': 'Kit ujian',
|
||||
'smoke.ph.suites': 'Pilih kit untuk dijalankan',
|
||||
'smoke.btn.run': 'Jalankan ujian',
|
||||
'smoke.last_run': 'Jalanan terakhir',
|
||||
'smoke.results_title': 'Keputusan kes',
|
||||
'smoke.empty': 'Belum dijalankan. Klik Jalankan ujian.',
|
||||
'smoke.stat.pass': 'Lulus',
|
||||
'smoke.stat.fail': 'Gagal',
|
||||
'smoke.stat.total': 'Jumlah',
|
||||
'smoke.col.id': 'ID',
|
||||
'smoke.col.suite': 'Kit',
|
||||
'smoke.col.name': 'Kes',
|
||||
'smoke.col.uat': 'UAT',
|
||||
'smoke.col.duration': 'Masa',
|
||||
'smoke.col.steps': 'Langkah',
|
||||
'smoke.col.message': 'Mesej',
|
||||
'smoke.no_steps': 'Tiada butiran langkah',
|
||||
'smoke.status.PASS': 'Lulus',
|
||||
'smoke.status.FAIL': 'Gagal',
|
||||
'smoke.status.SKIP': 'Langkau',
|
||||
'smoke.msg.all_passed': 'Semua lulus ({n})',
|
||||
'smoke.msg.has_failures': '{n} kes gagal',
|
||||
'smoke.msg.run_failed': 'Gagal menjalankan ujian',
|
||||
'smoke.log_title': 'Log terperinci',
|
||||
'smoke.btn.copy_all': 'Salin semua log',
|
||||
'smoke.btn.copy_one': 'Salin',
|
||||
'smoke.msg.copy_ok': 'Disalin ke papan keratan',
|
||||
'smoke.msg.copy_failed': 'Gagal menyalin — pilih log secara manual',
|
||||
'audit.action.RUN_SMOKE_TESTS': 'Jalankan ujian asap',
|
||||
};
|
||||
|
||||
@@ -132,7 +132,13 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'agent.credit.decrease': '减少',
|
||||
'agent.col.credit_type': '类型',
|
||||
'agent.col.credit_change': '变动',
|
||||
'agent.col.credit_before': '变动前',
|
||||
'agent.col.credit_after': '变动后',
|
||||
'agent.credit_tx.filter_agent_ph': '代理用户名',
|
||||
'agent.credit_tx.filter_agent_id': '代理 ID',
|
||||
'agent.credit_tx.filter_agent_id_ph': '用户 ID',
|
||||
'agent.credit_tx.col.operator': '操作人',
|
||||
'agent.credit_tx.view_all': '查看全部额度流水',
|
||||
'agent.col.no_records': '暂无记录',
|
||||
'agent.btn.confirm_adjust': '确认调整',
|
||||
'agent.field.select_user': '选择用户',
|
||||
@@ -692,6 +698,41 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'msg.freeze_extra': '冻结后该账号将无法登录。',
|
||||
'msg.freeze_done': '已{action}',
|
||||
'msg.freeze_failed': '{action}失败',
|
||||
|
||||
'smoke.intro': '在后台一键运行自动化冒烟测试,覆盖结算引擎、下注规则、代理额度逻辑、返水规则、数据库探针,以及下注→结算→钱包全链路集成用例。',
|
||||
'smoke.intro_rule': '规则类用例与 Jest 单元测试同源逻辑,不写入业务数据。',
|
||||
'smoke.intro_db': '「数据库」套件仅做连接与配置探针(只读)。',
|
||||
'smoke.intro_bet_flow': '「下注结算链路」套件会创建临时赛事/玩家并自动清理,验证冻结、派彩与代理额度。',
|
||||
'smoke.intro_note': '可替代大部分 UAT 手工回归;发返水等仍建议抽样人工确认。',
|
||||
'smoke.field.suites': '测试套件',
|
||||
'smoke.ph.suites': '选择要运行的套件',
|
||||
'smoke.btn.run': '运行测试',
|
||||
'smoke.last_run': '最近一次运行',
|
||||
'smoke.results_title': '用例结果',
|
||||
'smoke.empty': '尚未运行测试,请点击「运行测试」。',
|
||||
'smoke.stat.pass': '通过',
|
||||
'smoke.stat.fail': '失败',
|
||||
'smoke.stat.total': '合计',
|
||||
'smoke.col.id': '编号',
|
||||
'smoke.col.suite': '套件',
|
||||
'smoke.col.name': '用例',
|
||||
'smoke.col.uat': 'UAT',
|
||||
'smoke.col.duration': '耗时',
|
||||
'smoke.col.steps': '步骤',
|
||||
'smoke.col.message': '说明',
|
||||
'smoke.no_steps': '无步骤明细',
|
||||
'smoke.status.PASS': '通过',
|
||||
'smoke.status.FAIL': '失败',
|
||||
'smoke.status.SKIP': '跳过',
|
||||
'smoke.msg.all_passed': '全部通过({n} 项)',
|
||||
'smoke.msg.has_failures': '有 {n} 项失败,请查看明细',
|
||||
'smoke.msg.run_failed': '测试运行失败',
|
||||
'smoke.log_title': '详细日志',
|
||||
'smoke.btn.copy_all': '复制全部日志',
|
||||
'smoke.btn.copy_one': '复制',
|
||||
'smoke.msg.copy_ok': '已复制到剪贴板',
|
||||
'smoke.msg.copy_failed': '复制失败,请手动选中日志复制',
|
||||
'audit.action.RUN_SMOKE_TESTS': '运行自动化测试',
|
||||
};
|
||||
|
||||
export const adminPagesEn: Record<string, string> = {
|
||||
@@ -827,7 +868,13 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'agent.credit.decrease': 'Decrease',
|
||||
'agent.col.credit_type': 'Type',
|
||||
'agent.col.credit_change': 'Change',
|
||||
'agent.col.credit_before': 'Before',
|
||||
'agent.col.credit_after': 'After',
|
||||
'agent.credit_tx.filter_agent_ph': 'Agent username',
|
||||
'agent.credit_tx.filter_agent_id': 'Agent ID',
|
||||
'agent.credit_tx.filter_agent_id_ph': 'User ID',
|
||||
'agent.credit_tx.col.operator': 'Operator',
|
||||
'agent.credit_tx.view_all': 'View all credit ledger',
|
||||
'agent.col.no_records': 'No records',
|
||||
'agent.btn.confirm_adjust': 'Confirm',
|
||||
'agent.field.select_user': 'Select user',
|
||||
@@ -1388,4 +1435,39 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'msg.freeze_extra': ' They will not be able to sign in.',
|
||||
'msg.freeze_done': '{action} completed',
|
||||
'msg.freeze_failed': '{action} failed',
|
||||
|
||||
'smoke.intro': 'Run automated smoke tests from the admin console: settlement, betting rules, agent credit logic, cashback rules, read-only DB probes, and bet→settle→wallet integration.',
|
||||
'smoke.intro_rule': 'Rule cases reuse the same logic as Jest unit tests; no business data is written.',
|
||||
'smoke.intro_db': 'The Database suite only checks connectivity and config (read-only).',
|
||||
'smoke.intro_bet_flow': 'The Bet-flow suite creates temporary matches/players, verifies freeze/payout/agent credit, then cleans up.',
|
||||
'smoke.intro_note': 'Covers most UAT regression; spot-check cashback payout manually if needed.',
|
||||
'smoke.field.suites': 'Suites',
|
||||
'smoke.ph.suites': 'Select suites to run',
|
||||
'smoke.btn.run': 'Run tests',
|
||||
'smoke.last_run': 'Last run',
|
||||
'smoke.results_title': 'Case results',
|
||||
'smoke.empty': 'No run yet. Click Run tests.',
|
||||
'smoke.stat.pass': 'Pass',
|
||||
'smoke.stat.fail': 'Fail',
|
||||
'smoke.stat.total': 'Total',
|
||||
'smoke.col.id': 'ID',
|
||||
'smoke.col.suite': 'Suite',
|
||||
'smoke.col.name': 'Case',
|
||||
'smoke.col.uat': 'UAT',
|
||||
'smoke.col.duration': 'Duration',
|
||||
'smoke.col.steps': 'Steps',
|
||||
'smoke.col.message': 'Message',
|
||||
'smoke.no_steps': 'No step details',
|
||||
'smoke.status.PASS': 'Pass',
|
||||
'smoke.status.FAIL': 'Fail',
|
||||
'smoke.status.SKIP': 'Skip',
|
||||
'smoke.msg.all_passed': 'All passed ({n})',
|
||||
'smoke.msg.has_failures': '{n} case(s) failed — see details',
|
||||
'smoke.msg.run_failed': 'Failed to run tests',
|
||||
'smoke.log_title': 'Detailed log',
|
||||
'smoke.btn.copy_all': 'Copy full log',
|
||||
'smoke.btn.copy_one': 'Copy',
|
||||
'smoke.msg.copy_ok': 'Copied to clipboard',
|
||||
'smoke.msg.copy_failed': 'Copy failed — select the log manually',
|
||||
'audit.action.RUN_SMOKE_TESTS': 'Run smoke tests',
|
||||
};
|
||||
|
||||
@@ -18,10 +18,12 @@ const adminMenus = computed(() => [
|
||||
{ path: '/', label: t('nav.dashboard') },
|
||||
{ path: '/matches', label: t('nav.matches'), matchPrefix: true },
|
||||
{ path: '/users', label: t('nav.agents_players') },
|
||||
{ path: '/agent-credit-transactions', label: t('nav.credit_transactions') },
|
||||
{ path: '/cashback', label: t('nav.cashback') },
|
||||
{ path: '/bets', label: t('nav.bets') },
|
||||
{ path: '/contents', label: t('nav.contents') },
|
||||
{ path: '/audit', label: t('nav.audit') },
|
||||
{ path: '/smoke-tests', label: t('nav.smoke_tests') },
|
||||
]);
|
||||
|
||||
const agentMenus = computed(() => [
|
||||
@@ -60,8 +62,11 @@ const roleLabel = computed(() => {
|
||||
return t('role.agent');
|
||||
});
|
||||
|
||||
const currentUser = computed(() => auth.user.value);
|
||||
const isAdminPortal = computed(() => auth.isAdmin.value);
|
||||
|
||||
const userInitial = computed(() =>
|
||||
(auth.user?.username ?? '').charAt(0).toUpperCase()
|
||||
(currentUser.value?.username ?? '').charAt(0).toUpperCase()
|
||||
);
|
||||
|
||||
function syncMobileNav() {
|
||||
@@ -175,12 +180,12 @@ watch(() => route.path, () => {
|
||||
<div class="user-chip">
|
||||
<div class="avatar">{{ userInitial }}</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ auth.user?.username }}</span>
|
||||
<span class="user-name">{{ currentUser?.username }}</span>
|
||||
<span class="user-role">{{ roleLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AdminLocaleSwitcher />
|
||||
<div class="portal-tag">{{ auth.isAdmin ? t('portal.admin') : t('portal.agent') }}</div>
|
||||
<div class="portal-tag">{{ isAdminPortal ? t('portal.admin') : t('portal.agent') }}</div>
|
||||
<button class="btn-logout" @click="logout">{{ t('logout') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -17,6 +17,11 @@ const router = createRouter({
|
||||
component: () => import('../views/AgentManager.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'agent-credit-transactions',
|
||||
component: () => import('../views/AgentCreditTransactions.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'agents',
|
||||
redirect: '/users',
|
||||
@@ -76,6 +81,16 @@ const router = createRouter({
|
||||
component: () => import('../views/Audit.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'smoke-tests',
|
||||
component: () => import('../views/SmokeTests.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'smoke-tests',
|
||||
component: () => import('../views/SmokeTests.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'my-players',
|
||||
component: () => import('../views/agent/Players.vue'),
|
||||
|
||||
@@ -25,8 +25,9 @@ function dec(value: string | number | null | undefined): number {
|
||||
export function snapshotFromAgentRow(
|
||||
row: Pick<
|
||||
AgentRow | AgentDetail | AgentSubAgentRow,
|
||||
'username' | 'creditLimit' | 'usedCredit' | 'availableCredit' | 'level'
|
||||
'username' | 'creditLimit' | 'usedCredit' | 'availableCredit'
|
||||
> & {
|
||||
level?: number;
|
||||
directPlayerLiability?: string;
|
||||
childAgentExposure?: string;
|
||||
},
|
||||
|
||||
223
apps/admin/src/views/AgentCreditTransactions.vue
Normal file
223
apps/admin/src/views/AgentCreditTransactions.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
import { formatAmount, formatAmountFull } from '../utils/format-amount';
|
||||
|
||||
const { t, locale, localeTag } = useAdminLocale();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
interface CreditTxRow {
|
||||
id: string;
|
||||
agentId: string;
|
||||
agentUsername: string | null;
|
||||
transactionType: string;
|
||||
amount: string;
|
||||
creditBefore: string;
|
||||
creditAfter: string;
|
||||
operatorUsername: string | null;
|
||||
remark: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const items = ref<CreditTxRow[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const keyword = ref('');
|
||||
const agentId = ref('');
|
||||
const transactionType = ref('');
|
||||
|
||||
function creditTypeLabel(type: string) {
|
||||
if (type === 'CREDIT_INCREASE') return t('agent.credit.increase');
|
||||
if (type === 'CREDIT_DECREASE') return t('agent.credit.decrease');
|
||||
return type;
|
||||
}
|
||||
|
||||
function formatTime(v: string) {
|
||||
return new Date(v).toLocaleString(localeTag.value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/admin/agents/credit-transactions', {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
agentId: agentId.value.trim() || undefined,
|
||||
transactionType: transactionType.value || undefined,
|
||||
},
|
||||
});
|
||||
items.value = (data.data?.items ?? []) as CreditTxRow[];
|
||||
total.value = data.data?.total ?? 0;
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p;
|
||||
void load();
|
||||
}
|
||||
|
||||
function onSizeChange(size: number) {
|
||||
pageSize.value = size;
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
|
||||
function openAgentFilter(id: string) {
|
||||
agentId.value = id;
|
||||
keyword.value = '';
|
||||
page.value = 1;
|
||||
void router.replace({ query: { agentId: id } });
|
||||
void load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const q = route.query.agentId;
|
||||
if (typeof q === 'string' && q.trim()) {
|
||||
agentId.value = q.trim();
|
||||
}
|
||||
void load();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.agentId,
|
||||
(q) => {
|
||||
const next = typeof q === 'string' ? q.trim() : '';
|
||||
if (next !== agentId.value) {
|
||||
agentId.value = next;
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('common.keyword')">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
:placeholder="t('agent.credit_tx.filter_agent_ph')"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.credit_tx.filter_agent_id')">
|
||||
<el-input
|
||||
v-model="agentId"
|
||||
:placeholder="t('agent.credit_tx.filter_agent_id_ph')"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.col.credit_type')">
|
||||
<el-select v-model="transactionType" clearable :placeholder="t('common.all')" style="width: 120px">
|
||||
<el-option :label="t('agent.credit.increase')" value="CREDIT_INCREASE" />
|
||||
<el-option :label="t('agent.credit.decrease')" value="CREDIT_DECREASE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSearch">{{ t('common.search') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :key="locale" :data="items" stripe>
|
||||
<template #empty>
|
||||
<AdminTableEmpty />
|
||||
</template>
|
||||
<el-table-column :label="t('audit.col.time')" min-width="158">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('user.col.username')" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.agentUsername"
|
||||
link
|
||||
type="primary"
|
||||
@click="openAgentFilter(row.agentId)"
|
||||
>
|
||||
{{ row.agentUsername }}
|
||||
</el-button>
|
||||
<span v-else>{{ row.agentId }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.col.credit_type')" width="88">
|
||||
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.col.credit_change')" width="108" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
|
||||
<span :class="parseFloat(row.amount) >= 0 ? 'amt-pos' : 'amt-neg'">
|
||||
{{ formatAmount(row.amount) }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.col.credit_before')" width="108" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.creditBefore)" placement="top">
|
||||
<span>{{ formatAmount(row.creditBefore) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.col.credit_after')" width="108" 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 :label="t('agent.credit_tx.col.operator')" min-width="100">
|
||||
<template #default="{ row }">{{ row.operatorUsername ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" :label="t('user.field.remark')" min-width="140" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.amt-pos {
|
||||
color: var(--el-color-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amt-neg {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -19,14 +19,14 @@ import {
|
||||
type PlayerDetail,
|
||||
type PlayerCreateForm,
|
||||
type PlayerEditForm,
|
||||
} from './user-form.ts';
|
||||
} from './user-form';
|
||||
import {
|
||||
emptyAgentEditForm,
|
||||
editFormFromAgentDetail,
|
||||
type AgentRow,
|
||||
type AgentDetail,
|
||||
type AgentEditForm,
|
||||
} from './agent-form.ts';
|
||||
} from './agent-form';
|
||||
import { subAgentAccountStatus } from './agent/agent-sub-agent-form';
|
||||
import {
|
||||
formatAmount,
|
||||
@@ -994,8 +994,8 @@ function creditTypeLabel(type: string) {
|
||||
:expand-row-keys="subAgentExpandedKeys"
|
||||
:row-class-name="expandableTableRowClassName"
|
||||
class="inner-table expandable-table"
|
||||
@expand-change="(sub, rows) => onSubAgentExpand(row.userId, sub, rows)"
|
||||
@row-click="(sub, col, e) => onSubAgentRowClick(row.userId, sub, col, e)"
|
||||
@expand-change="(sub: AgentRow, rows: AgentRow[]) => onSubAgentExpand(row.userId, sub, rows)"
|
||||
@row-click="(sub: AgentRow, col: unknown, e: MouseEvent) => onSubAgentRowClick(row.userId, sub, col, e)"
|
||||
>
|
||||
<template #empty><AdminTableEmpty /></template>
|
||||
<el-table-column type="expand">
|
||||
@@ -1469,7 +1469,17 @@ function creditTypeLabel(type: string) {
|
||||
<el-descriptions-item :label="t('agent.col.created')" :span="2">{{ formatTime(agentDetail.createdAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="section-title">{{ t('agent.section.credit_log') }}</div>
|
||||
<div class="section-title section-title--row">
|
||||
<span>{{ t('agent.section.credit_log') }}</span>
|
||||
<el-button
|
||||
v-if="agentDetail"
|
||||
link
|
||||
type="primary"
|
||||
@click="router.push({ path: '/agent-credit-transactions', query: { agentId: agentDetail.userId } })"
|
||||
>
|
||||
{{ t('agent.credit_tx.view_all') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="agentDetail.recentCreditTransactions" size="small" stripe :empty-text="t('agent.col.no_records')">
|
||||
<el-table-column :label="t('agent.col.credit_type')" width="80">
|
||||
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
|
||||
@@ -1627,6 +1637,13 @@ function creditTypeLabel(type: string) {
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-title--row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError, resolveApiError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const router = useRouter();
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
emptyAgentCreateForm,
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
type AgentCreateForm,
|
||||
type AgentEditForm,
|
||||
type PromotableUserOption,
|
||||
} from './agent-form.ts';
|
||||
} from './agent-form';
|
||||
import {
|
||||
formatAmount,
|
||||
formatAmountFull,
|
||||
@@ -484,7 +486,17 @@ function creditTypeLabel(type: string) {
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="section-title">{{ t('agent.section.credit_log') }}</div>
|
||||
<div class="section-title section-title--row">
|
||||
<span>{{ t('agent.section.credit_log') }}</span>
|
||||
<el-button
|
||||
v-if="detail"
|
||||
link
|
||||
type="primary"
|
||||
@click="router.push({ path: '/agent-credit-transactions', query: { agentId: detail.userId } })"
|
||||
>
|
||||
{{ t('agent.credit_tx.view_all') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
:data="detail.recentCreditTransactions"
|
||||
size="small"
|
||||
@@ -527,6 +539,13 @@ function creditTypeLabel(type: string) {
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-title--row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.amount-compact {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
@@ -15,7 +15,7 @@ import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const { statusOptions, typeOptions } = useBetFilterOptions();
|
||||
import type { BetListRow, BetDetail } from './bet-form.ts';
|
||||
import type { BetListRow, BetDetail } from './bet-form';
|
||||
|
||||
const bets = ref<BetListRow[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getBuiltinCountry } from '../data/builtinCountries';
|
||||
import {
|
||||
readMatchesListUiState,
|
||||
writeMatchesListUiState,
|
||||
} from '../utils/matchesListState.ts';
|
||||
} from '../utils/matchesListState';
|
||||
import { formatAmount } from '../utils/format-amount';
|
||||
import {
|
||||
emptyMatchForm,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
fillBuiltinTeam,
|
||||
clearBuiltinTeam,
|
||||
type MatchCreateForm,
|
||||
} from './match-form.ts';
|
||||
} from './match-form';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const route = useRoute();
|
||||
|
||||
@@ -8,7 +8,7 @@ import LeagueOutrightOddsPanel from './matches/LeagueOutrightOddsPanel.vue';
|
||||
import {
|
||||
readMatchesListUiState,
|
||||
writeMatchesListUiState,
|
||||
} from '../utils/matchesListState.ts';
|
||||
} from '../utils/matchesListState';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const route = useRoute();
|
||||
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
betStatusLabel,
|
||||
betStatusTagType,
|
||||
betTypeLabel,
|
||||
betResultLabel,
|
||||
} from '../utils/bet-labels';
|
||||
import { adminSelectionLabel } from '../utils/adminSelectionLabel.ts';
|
||||
import type { AdminMatchDetail } from './match-form.ts';
|
||||
import { adminSelectionLabel } from '../utils/adminSelectionLabel';
|
||||
import type { AdminMatchDetail } from './match-form';
|
||||
import AdminSubNav from '../components/AdminSubNav.vue';
|
||||
|
||||
interface SettlementBetStats {
|
||||
|
||||
403
apps/admin/src/views/SmokeTests.vue
Normal file
403
apps/admin/src/views/SmokeTests.vue
Normal file
@@ -0,0 +1,403 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
|
||||
const { t, locale, localeTag } = useAdminLocale();
|
||||
|
||||
type SuiteInfo = { id: string; name: string; description: string; caseCount: number };
|
||||
type CaseMeta = { id: string; suite: string; name: string; uatRef?: string };
|
||||
type CaseResult = CaseMeta & {
|
||||
status: 'PASS' | 'FAIL' | 'SKIP';
|
||||
durationMs: number;
|
||||
stepCount?: number;
|
||||
message?: string;
|
||||
error?: string;
|
||||
details?: string[];
|
||||
};
|
||||
type RunSummary = {
|
||||
runId: string;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
durationMs: number;
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
suites: string[];
|
||||
results: CaseResult[];
|
||||
};
|
||||
|
||||
const suites = ref<SuiteInfo[]>([]);
|
||||
const selectedSuites = ref<string[]>([]);
|
||||
const running = ref(false);
|
||||
const lastRun = ref<RunSummary | null>(null);
|
||||
const filterStatus = ref('');
|
||||
|
||||
const suiteName = (id: string) => suites.value.find((s) => s.id === id)?.name ?? id;
|
||||
|
||||
const filteredResults = computed(() => {
|
||||
const rows = lastRun.value?.results ?? [];
|
||||
if (!filterStatus.value) return rows;
|
||||
return rows.filter((r) => r.status === filterStatus.value);
|
||||
});
|
||||
|
||||
const passRate = computed(() => {
|
||||
if (!lastRun.value?.total) return 0;
|
||||
return Math.round((lastRun.value.passed / lastRun.value.total) * 100);
|
||||
});
|
||||
|
||||
const runLogText = computed(() => (lastRun.value ? formatRunLog(lastRun.value) : ''));
|
||||
|
||||
function formatCaseHeader(row: CaseResult) {
|
||||
const parts = [
|
||||
`[${row.status}]`,
|
||||
row.id,
|
||||
suiteName(row.suite),
|
||||
row.name,
|
||||
row.uatRef ? `UAT:${row.uatRef}` : null,
|
||||
row.stepCount ? `${row.stepCount} steps` : null,
|
||||
`${row.durationMs}ms`,
|
||||
row.error || row.message || '',
|
||||
].filter(Boolean);
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
function formatCaseBlock(row: CaseResult) {
|
||||
const lines = [formatCaseHeader(row)];
|
||||
if (row.details?.length) {
|
||||
lines.push(...row.details.map((d) => ` ${d.replace(/\n/g, '\n ')}`));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatCaseLine(row: CaseResult) {
|
||||
return formatCaseBlock(row);
|
||||
}
|
||||
|
||||
function formatRunLog(run: RunSummary) {
|
||||
const lines = [
|
||||
'=== Smoke Test Report ===',
|
||||
`Run ID: ${run.runId}`,
|
||||
`Started: ${run.startedAt}`,
|
||||
`Finished: ${run.finishedAt}`,
|
||||
`Duration: ${run.durationMs}ms`,
|
||||
`Summary: pass=${run.passed}, fail=${run.failed}, skip=${run.skipped}, total=${run.total}`,
|
||||
`Suites: ${run.suites.join(', ')}`,
|
||||
'',
|
||||
'--- Cases ---',
|
||||
...run.results.map((row) => formatCaseBlock(row)),
|
||||
];
|
||||
return lines.join('\n\n');
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (!text) return;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
ElMessage.success(t('smoke.msg.copy_ok'));
|
||||
} catch {
|
||||
ElMessage.error(t('smoke.msg.copy_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const { data } = await api.get('/admin/smoke-tests/suites');
|
||||
suites.value = data.data?.suites ?? [];
|
||||
if (data.data?.lastRun) lastRun.value = data.data.lastRun;
|
||||
if (!selectedSuites.value.length) {
|
||||
selectedSuites.value = suites.value.map((s) => s.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
running.value = true;
|
||||
try {
|
||||
const { data } = await api.post('/admin/smoke-tests/run', {
|
||||
suites: selectedSuites.value.length ? selectedSuites.value : undefined,
|
||||
});
|
||||
lastRun.value = data.data as RunSummary;
|
||||
if (lastRun.value.failed > 0) {
|
||||
ElMessage.warning(t('smoke.msg.has_failures', { n: lastRun.value.failed }));
|
||||
} else {
|
||||
ElMessage.success(t('smoke.msg.all_passed', { n: lastRun.value.passed }));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
ElMessage.error(msg || t('smoke.msg.run_failed'));
|
||||
} finally {
|
||||
running.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(v: string) {
|
||||
return new Date(v).toLocaleString(localeTag.value);
|
||||
}
|
||||
|
||||
function statusTagType(status: string) {
|
||||
if (status === 'PASS') return 'success';
|
||||
if (status === 'FAIL') return 'danger';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
onMounted(loadMeta);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page smoke-page">
|
||||
<el-card class="intro-card" shadow="never">
|
||||
<p class="intro-text">{{ t('smoke.intro') }}</p>
|
||||
<ul class="intro-list">
|
||||
<li>{{ t('smoke.intro_rule') }}</li>
|
||||
<li>{{ t('smoke.intro_db') }}</li>
|
||||
<li>{{ t('smoke.intro_bet_flow') }}</li>
|
||||
<li>{{ t('smoke.intro_note') }}</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('smoke.field.suites')">
|
||||
<el-select
|
||||
v-model="selectedSuites"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:placeholder="t('smoke.ph.suites')"
|
||||
style="min-width: 320px"
|
||||
>
|
||||
<el-option
|
||||
v-for="s in suites"
|
||||
:key="s.id"
|
||||
:label="`${s.name} (${s.caseCount})`"
|
||||
:value="s.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="running" @click="runTests">
|
||||
{{ t('smoke.btn.run') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="lastRun" class="data-card summary-card" shadow="never">
|
||||
<div class="summary-head">
|
||||
<div>
|
||||
<div class="summary-title">{{ t('smoke.last_run') }}</div>
|
||||
<div class="summary-meta">
|
||||
{{ formatTime(lastRun.startedAt) }} · {{ lastRun.durationMs }}ms · {{ lastRun.runId }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-actions">
|
||||
<div class="summary-stats">
|
||||
<el-tag type="success">{{ t('smoke.stat.pass') }} {{ lastRun.passed }}</el-tag>
|
||||
<el-tag type="danger">{{ t('smoke.stat.fail') }} {{ lastRun.failed }}</el-tag>
|
||||
<el-tag>{{ t('smoke.stat.total') }} {{ lastRun.total }}</el-tag>
|
||||
<el-tag type="warning">{{ passRate }}%</el-tag>
|
||||
</div>
|
||||
<el-button size="small" @click="copyText(runLogText)">{{ t('smoke.btn.copy_all') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="lastRun" class="data-card" shadow="never">
|
||||
<div class="table-head">
|
||||
<span class="table-title">{{ t('smoke.results_title') }}</span>
|
||||
<el-select v-model="filterStatus" clearable :placeholder="t('common.all')" style="width: 120px">
|
||||
<el-option :label="t('smoke.status.PASS')" value="PASS" />
|
||||
<el-option :label="t('smoke.status.FAIL')" value="FAIL" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<el-table :key="locale" :data="filteredResults" stripe row-key="id">
|
||||
<template #empty>
|
||||
<AdminTableEmpty />
|
||||
</template>
|
||||
<el-table-column type="expand" width="44">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.details?.length" class="case-details">
|
||||
<pre>{{ row.details.join('\n\n') }}</pre>
|
||||
</div>
|
||||
<span v-else class="case-details-empty">{{ t('smoke.no_steps') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" :label="t('smoke.col.id')" width="88" />
|
||||
<el-table-column :label="t('smoke.col.suite')" width="120">
|
||||
<template #default="{ row }">{{ suiteName(row.suite) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" :label="t('smoke.col.name')" min-width="180" />
|
||||
<el-table-column prop="uatRef" :label="t('smoke.col.uat')" width="88">
|
||||
<template #default="{ row }">{{ row.uatRef ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="96">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">
|
||||
{{ t(`smoke.status.${row.status}`) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('smoke.col.steps')" width="72" align="center">
|
||||
<template #default="{ row }">{{ row.stepCount ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('smoke.col.duration')" width="96" align="right">
|
||||
<template #default="{ row }">{{ row.durationMs }}ms</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('smoke.col.message')" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'fail-msg': row.status === 'FAIL' }">{{ row.error || row.message || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="88" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="copyText(formatCaseBlock(row))">
|
||||
{{ t('smoke.btn.copy_one') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="log-panel">
|
||||
<div class="log-head">
|
||||
<span class="table-title">{{ t('smoke.log_title') }}</span>
|
||||
<el-button size="small" @click="copyText(runLogText)">{{ t('smoke.btn.copy_all') }}</el-button>
|
||||
</div>
|
||||
<el-input
|
||||
type="textarea"
|
||||
class="log-textarea"
|
||||
:model-value="runLogText"
|
||||
readonly
|
||||
:rows="18"
|
||||
resize="vertical"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-else class="data-card empty-card" shadow="never">
|
||||
<p>{{ t('smoke.empty') }}</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.intro-text {
|
||||
margin: 0 0 8px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.intro-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.summary-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-meta {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.log-textarea :deep(textarea) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.table-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fail-msg {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.case-details {
|
||||
padding: 8px 12px 12px 48px;
|
||||
}
|
||||
|
||||
.case-details pre {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.case-details-empty {
|
||||
padding: 8px 12px 12px 48px;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
padding: 24px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type PlayerDetail,
|
||||
type PlayerCreateForm,
|
||||
type PlayerEditForm,
|
||||
} from './user-form.ts';
|
||||
} from './user-form';
|
||||
import {
|
||||
formatAmount,
|
||||
formatAmountFull,
|
||||
|
||||
@@ -472,7 +472,7 @@ function openCreditSub(row: AgentSubAgentRow) {
|
||||
creditContext.value = {
|
||||
target: snapshotFromAgentRow(row),
|
||||
parent: snapshotFromAgentRow({
|
||||
username: auth.user?.username ?? t('credit.context.acting_agent'),
|
||||
username: auth.user.value?.username ?? t('credit.context.acting_agent'),
|
||||
creditLimit: String(profile.value.creditLimit ?? 0),
|
||||
usedCredit: String(profile.value.usedCredit ?? 0),
|
||||
availableCredit: String(profile.value.availableCredit ?? 0),
|
||||
|
||||
@@ -114,7 +114,7 @@ function openCredit(row: AgentSubAgentRow) {
|
||||
creditContext.value = {
|
||||
target: snapshotFromAgentRow(row),
|
||||
parent: snapshotFromAgentRow({
|
||||
username: auth.user?.username ?? t('credit.context.acting_agent'),
|
||||
username: auth.user.value?.username ?? t('credit.context.acting_agent'),
|
||||
creditLimit: String(profile.value.creditLimit ?? 0),
|
||||
usedCredit: String(profile.value.usedCredit ?? 0),
|
||||
availableCredit: String(profile.value.availableCredit ?? 0),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import { ensureLeagueExpanded } from '../../utils/matchesListState.ts';
|
||||
import { ensureLeagueExpanded } from '../../utils/matchesListState';
|
||||
import { formatAmount } from '../../utils/format-amount';
|
||||
const props = defineProps<{
|
||||
leagueId: string;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
formFromDetail,
|
||||
type AdminMatchDetail,
|
||||
type MatchCreateForm,
|
||||
} from '../match-form.ts';
|
||||
} from '../match-form';
|
||||
import AdminSubNav from '../../components/AdminSubNav.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import MatchMarketsPanel from './MatchMarketsPanel.vue';
|
||||
import type { AdminMatchDetail } from '../match-form.ts';
|
||||
import type { AdminMatchDetail } from '../match-form';
|
||||
import AdminSubNav from '../../components/AdminSubNav.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -3,9 +3,9 @@ import { ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import type { AdminMatchDetail } from '../match-form.ts';
|
||||
import { defaultSelectionName } from '../../utils/selectionDefaults.ts';
|
||||
import { adminSelectionLabel } from '../../utils/adminSelectionLabel.ts';
|
||||
import type { AdminMatchDetail } from '../match-form';
|
||||
import { defaultSelectionName } from '../../utils/selectionDefaults';
|
||||
import { adminSelectionLabel } from '../../utils/adminSelectionLabel';
|
||||
|
||||
const props = defineProps<{
|
||||
matchId: string;
|
||||
|
||||
2
apps/admin/src/vite-env.d.ts
vendored
2
apps/admin/src/vite-env.d.ts
vendored
@@ -1,3 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<object, object, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user