feat: 手动充值、邀请码注册与后台管理增强

新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分),
支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏,
并补充前台注册、充值页及多语言错误码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 12:20:11 +08:00
parent 618fb49511
commit 10485ecfaf
98 changed files with 7908 additions and 856 deletions

View File

@@ -28,63 +28,88 @@ a { color: inherit; text-decoration: none; }
button { cursor: pointer; font-family: inherit; }
:root {
/* 质感绿:深底 + 渐变高光,避免扁平荧光绿 */
--green-deep: #145c38;
--green-mid: #1f8a52;
--green-bright: #2fb56a;
--green-glow: #4dd68a;
--green-surface: rgba(20, 72, 46, 0.45);
--green-border: rgba(77, 214, 138, 0.28);
--green-text: #9ae8bc;
--primary: var(--green-mid);
--primary-dark: var(--green-deep);
--primary-light: var(--green-bright);
--primary-link: var(--green-text);
--primary-on: #ffffff;
--primary-grad: linear-gradient(165deg, #3cc474 0%, #248f54 42%, #1a6b40 100%);
--primary-grad-hover: linear-gradient(165deg, #4dd68a 0%, #2ea864 42%, #1f7a48 100%);
--primary-shadow: 0 1px 0 rgba(255, 255, 255, 0.14) inset, 0 2px 10px rgba(0, 0, 0, 0.45), 0 0 20px rgba(36, 143, 84, 0.18);
--bg-body: #000000;
--bg-card: rgba(20, 20, 20, 0.85);
--bg-elevated: rgba(28, 28, 28, 0.9);
--bg-hover: rgba(36, 143, 84, 0.1);
--text: #ffffff;
--text-muted: #8e8e93;
--border: #2a2a2a;
--border-soft: var(--green-border);
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 4px 20px rgba(0, 0, 0, 0.55);
/* Monochrome admin — 克制、无渐变、无品牌色泛滥 */
--accent: #f5f5f5;
--accent-muted: #a3a3a3;
--accent-dim: #737373;
--accent-subtle: rgba(255, 255, 255, 0.06);
--accent-border: rgba(255, 255, 255, 0.1);
--accent-hover: rgba(255, 255, 255, 0.04);
--accent-focus: rgba(255, 255, 255, 0.14);
/* 语义色:仅 success/warning/danger 场景使用 */
--success-text: #7d9b8a;
--success-bg: rgba(255, 255, 255, 0.04);
--success-border: rgba(255, 255, 255, 0.08);
/* 兼容旧变量名 → 中性灰白 */
--gold-deep: var(--accent-dim);
--gold-mid: var(--accent);
--gold-bright: var(--accent);
--gold-glow: var(--accent);
--gold-surface: var(--accent-subtle);
--gold-border: var(--accent-border);
--gold-text: var(--accent-muted);
--green-deep: var(--accent-dim);
--green-mid: var(--accent);
--green-bright: var(--accent);
--green-glow: var(--accent);
--green-surface: var(--accent-subtle);
--green-border: var(--accent-border);
--green-text: var(--accent-muted);
--primary: var(--accent);
--primary-dark: #e5e5e5;
--primary-light: #ffffff;
--primary-link: var(--accent-muted);
--primary-on: #0a0a0a;
--primary-grad: var(--accent);
--primary-grad-hover: #ffffff;
--primary-shadow: none;
--bg-body: #0a0a0a;
--bg-card: #111111;
--bg-elevated: #161616;
--bg-hover: var(--accent-hover);
--text: #f5f5f5;
--text-muted: #737373;
--border: rgba(255, 255, 255, 0.08);
--border-soft: var(--accent-border);
--radius: 8px;
--radius-sm: 6px;
--shadow: none;
/* Element Plus dark overrides */
--el-bg-color: #141414;
--el-bg-color-page: #000000;
--el-bg-color-overlay: #1c1c1c;
--el-text-color-primary: #ffffff;
--el-text-color-regular: #cccccc;
--el-text-color-secondary: #8e8e93;
--el-text-color-placeholder:#4a4a4a;
--el-border-color: #2a2a2a;
--el-border-color-light: #222;
--el-border-color-lighter: #1a1a1a;
--el-fill-color: #1a1a1a;
--el-fill-color-blank: #0d0d0d;
--el-fill-color-light: #141414;
--el-color-primary: #248f54;
--el-color-primary-light-3: rgba(47, 181, 106, 0.35);
--el-color-primary-light-5: rgba(47, 181, 106, 0.2);
--el-color-primary-light-7: rgba(47, 181, 106, 0.12);
--el-color-primary-light-9: rgba(47, 181, 106, 0.06);
--el-color-primary-dark-2: #1a6b40;
--el-bg-color: #111111;
--el-bg-color-page: #0a0a0a;
--el-bg-color-overlay: #161616;
--el-text-color-primary: #f5f5f5;
--el-text-color-regular: #d4d4d4;
--el-text-color-secondary: #737373;
--el-text-color-placeholder:#525252;
--el-border-color: rgba(255, 255, 255, 0.08);
--el-border-color-light: rgba(255, 255, 255, 0.06);
--el-border-color-lighter: rgba(255, 255, 255, 0.04);
--el-fill-color: #141414;
--el-fill-color-blank: #0a0a0a;
--el-fill-color-light: #111111;
--el-color-primary: #e5e5e5;
--el-color-primary-light-3: rgba(255, 255, 255, 0.22);
--el-color-primary-light-5: rgba(255, 255, 255, 0.12);
--el-color-primary-light-7: rgba(255, 255, 255, 0.07);
--el-color-primary-light-9: rgba(255, 255, 255, 0.04);
--el-color-primary-dark-2: #a3a3a3;
--el-color-success: #7d9b8a;
--el-color-success-light-3: rgba(125, 155, 138, 0.22);
--el-color-success-light-5: rgba(125, 155, 138, 0.12);
--el-color-success-light-7: rgba(125, 155, 138, 0.07);
--el-color-success-light-9: rgba(125, 155, 138, 0.04);
--el-color-success-dark-2: #5c7568;
--el-table-bg-color: transparent;
--el-table-tr-bg-color: transparent;
--el-table-header-bg-color: rgba(255,255,255,0.03);
--el-table-row-hover-bg-color: rgba(36, 143, 84, 0.08);
--el-table-border-color: #222;
--el-table-text-color: #ccc;
--el-table-header-text-color: #666;
--el-card-bg-color: rgba(20,20,20,0.85);
--el-card-border-color: #2a2a2a;
--el-table-header-bg-color: transparent;
--el-table-row-hover-bg-color: rgba(255, 255, 255, 0.03);
--el-table-border-color: rgba(255, 255, 255, 0.06);
--el-table-text-color: #d4d4d4;
--el-table-header-text-color: #737373;
--el-card-bg-color: #111111;
--el-card-border-color: rgba(255, 255, 255, 0.08);
}
html, body, #app {
@@ -313,8 +338,8 @@ body::-webkit-scrollbar {
border-radius: 10px;
}
.admin-list-page > .list-panel {
border: 1px solid rgba(255, 255, 255, 0.06);
background: #121212;
border: 1px solid var(--border);
background: var(--bg-card);
padding: 0 10px 10px;
}
.admin-list-page > .data-card .el-card__body {
@@ -361,16 +386,11 @@ body::-webkit-scrollbar {
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
background:
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.12'/%3E%3C/svg%3E"),
linear-gradient(rgba(0,0,0,0.97), rgba(0,0,0,0.97)),
radial-gradient(ellipse 90% 45% at 50% -8%, rgba(31, 138, 82, 0.14), transparent 55%),
radial-gradient(ellipse 60% 30% at 80% 100%, rgba(20, 92, 56, 0.08), transparent 50%);
background-size: 150px, cover, cover;
background-color: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg-body);
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Element Plus 全局暗色覆盖 ── */
@@ -378,38 +398,38 @@ body {
background: var(--bg-card) !important;
border-color: var(--border) !important;
border-radius: var(--radius) !important;
box-shadow: var(--shadow) !important;
box-shadow: none !important;
}
.el-card__header {
border-bottom-color: #1e1e1e !important;
border-bottom-color: var(--border) !important;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.el-table { background: transparent !important; color: #ccc !important; }
.el-table::before { background-color: #222 !important; }
.el-table th.el-table__cell {
background: rgba(255,255,255,0.02) !important;
color: #888 !important;
font-size: 11px; font-weight: 700;
letter-spacing: 0.06em; text-transform: uppercase;
border-bottom-color: #1e1e1e !important;
background: transparent !important;
color: var(--text-muted) !important;
font-size: 12px; font-weight: 500;
letter-spacing: 0; text-transform: none;
border-bottom-color: var(--border) !important;
}
.el-table td.el-table__cell { border-bottom-color: #161616 !important; color: #bbb !important; }
.el-table td.el-table__cell { border-bottom-color: rgba(255, 255, 255, 0.04) !important; color: #d4d4d4 !important; }
.el-table--striped .el-table__body tr.el-table__row--striped td { background: rgba(255,255,255,0.015) !important; }
.el-table__body tr:hover > td { background: rgba(36, 143, 84, 0.07) !important; }
.el-table__body tr:hover > td { background: rgba(255, 255, 255, 0.03) !important; }
.el-input__wrapper {
background: #0d0d0d !important;
box-shadow: 0 0 0 1px #2a2a2a inset !important;
background: var(--bg-body) !important;
box-shadow: 0 0 0 1px var(--border) inset !important;
border-radius: var(--radius-sm) !important;
}
.el-input__wrapper:hover { box-shadow: 0 0 0 1px #3a3a3a inset !important; }
.el-input__wrapper:hover { box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14) inset !important; }
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--green-mid) inset, 0 0 0 3px rgba(47, 181, 106, 0.15) !important;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.28) inset !important;
}
.el-input__inner { color: #fff !important; background: transparent !important; }
.el-input__inner:-webkit-autofill,
@@ -418,51 +438,48 @@ body {
-webkit-text-fill-color: #fff !important;
}
.el-button { background: #141414 !important; border-color: #2a2a2a !important; color: #aaa !important; transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s !important; }
.el-button:hover { background: #1e1e1e !important; border-color: #3a3a3a !important; color: #fff !important; }
.el-button { background: transparent !important; border-color: var(--border) !important; color: #a3a3a3 !important; font-weight: 500 !important; transition: background 0.15s, border-color 0.15s, color 0.15s !important; }
.el-button:hover { background: var(--accent-hover) !important; border-color: rgba(255, 255, 255, 0.14) !important; color: #f5f5f5 !important; }
.el-button--primary {
background: var(--primary-grad) !important;
border: 1px solid var(--green-border) !important;
background: var(--accent) !important;
border: 1px solid var(--accent) !important;
color: var(--primary-on) !important;
font-weight: 700 !important;
box-shadow: var(--primary-shadow) !important;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.25);
font-weight: 500 !important;
box-shadow: none !important;
}
.el-button--primary:hover {
background: var(--primary-grad-hover) !important;
border-color: rgba(120, 230, 170, 0.4) !important;
background: #ffffff !important;
border-color: #ffffff !important;
color: var(--primary-on) !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.18) inset, 0 4px 14px rgba(0, 0, 0, 0.5), 0 0 24px rgba(47, 181, 106, 0.28) !important;
box-shadow: none !important;
}
.el-button--success {
background: linear-gradient(165deg, #42b86e 0%, #248f54 52%, #1a6b40 100%) !important;
border: 1px solid rgba(77, 214, 138, 0.35) !important;
color: #ffffff !important;
font-weight: 700 !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset, 0 2px 8px rgba(0, 0, 0, 0.35) !important;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
background: transparent !important;
border: 1px solid var(--success-border) !important;
color: var(--success-text) !important;
font-weight: 500 !important;
box-shadow: none !important;
}
.el-button--success:hover {
background: linear-gradient(165deg, #52cc7e 0%, #2ea864 52%, #1f7a48 100%) !important;
border-color: rgba(120, 230, 170, 0.45) !important;
color: #ffffff !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 4px 12px rgba(0, 0, 0, 0.4), 0 0 18px rgba(47, 181, 106, 0.22) !important;
background: var(--success-bg) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
color: #a8c4b4 !important;
box-shadow: none !important;
}
.el-button--warning {
background: linear-gradient(165deg, #e8a820 0%, #c48412 52%, #9a6508 100%) !important;
border: 1px solid rgba(251, 191, 36, 0.4) !important;
color: #ffffff !important;
font-weight: 700 !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.1) inset, 0 2px 8px rgba(0, 0, 0, 0.35) !important;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.25);
background: transparent !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: #d4a574 !important;
font-weight: 500 !important;
box-shadow: none !important;
}
.el-button--warning:hover {
background: linear-gradient(165deg, #f0b830 0%, #d49218 52%, #aa720a 100%) !important;
border-color: rgba(251, 191, 36, 0.55) !important;
color: #ffffff !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.14) inset, 0 4px 12px rgba(0, 0, 0, 0.4), 0 0 16px rgba(251, 191, 36, 0.18) !important;
background: rgba(255, 255, 255, 0.04) !important;
border-color: rgba(255, 255, 255, 0.14) !important;
color: #e0b888 !important;
box-shadow: none !important;
}
.el-button--danger { background: rgba(255,69,58,0.1) !important; border-color: rgba(255,69,58,0.35) !important; color: #ff453a !important; }
.el-button--danger { background: transparent !important; border-color: rgba(255,69,58,0.25) !important; color: #ef4444 !important; }
/* ── Disabled: muted ghost, clearly non-interactive ── */
.el-button.is-disabled,
@@ -489,25 +506,25 @@ body {
.el-button--primary.is-disabled,
.el-button--primary.is-disabled:hover,
.el-button--primary:disabled {
background: rgba(36, 143, 84, 0.1) !important;
border-color: rgba(36, 143, 84, 0.14) !important;
color: rgba(154, 232, 188, 0.32) !important;
background: rgba(255, 255, 255, 0.06) !important;
border-color: rgba(255, 255, 255, 0.06) !important;
color: rgba(255, 255, 255, 0.28) !important;
}
.el-button--primary.is-plain.is-disabled,
.el-button--primary.is-plain.is-disabled:hover,
.el-button--primary.is-plain:disabled {
background: rgba(36, 143, 84, 0.06) !important;
border-color: rgba(36, 143, 84, 0.12) !important;
color: rgba(154, 232, 188, 0.28) !important;
background: transparent !important;
border-color: rgba(255, 255, 255, 0.06) !important;
color: rgba(255, 255, 255, 0.24) !important;
}
.el-button--success.is-disabled,
.el-button--success.is-disabled:hover,
.el-button--success:disabled {
background: rgba(36, 143, 84, 0.08) !important;
border-color: rgba(36, 143, 84, 0.12) !important;
color: rgba(154, 232, 188, 0.28) !important;
background: transparent !important;
border-color: rgba(255, 255, 255, 0.06) !important;
color: rgba(255, 255, 255, 0.24) !important;
}
.el-button--warning.is-disabled,
@@ -534,15 +551,15 @@ body {
color: rgba(255, 107, 98, 0.28) !important;
}
.el-button--primary.is-plain {
background: rgba(36, 143, 84, 0.12) !important;
border-color: var(--green-border) !important;
color: var(--green-text) !important;
background: transparent !important;
border-color: var(--border) !important;
color: #d4d4d4 !important;
box-shadow: none !important;
}
.el-button--primary.is-plain:hover {
background: rgba(36, 143, 84, 0.22) !important;
border-color: rgba(120, 230, 170, 0.45) !important;
color: #d4fde5 !important;
background: var(--accent-hover) !important;
border-color: rgba(255, 255, 255, 0.14) !important;
color: #f5f5f5 !important;
}
.el-button--danger.is-plain {
background: rgba(255, 69, 58, 0.14) !important;
@@ -558,50 +575,106 @@ body {
}
.el-button.is-text,
.el-button.is-link.el-button--default {
color: var(--green-text) !important;
color: var(--accent-muted) !important;
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}
.el-button.is-text:hover,
.el-button.is-link.el-button--default:hover {
color: #d4fde5 !important;
background: rgba(36, 143, 84, 0.1) !important;
color: #f5f5f5 !important;
background: transparent !important;
}
.el-tag { border-radius: 4px !important; font-size: 11px !important; font-weight: 600 !important; }
.el-tag { border-radius: 4px !important; font-size: 11px !important; font-weight: 500 !important; }
.el-tag--success {
background: linear-gradient(135deg, rgba(36, 143, 84, 0.35), rgba(20, 92, 56, 0.5)) !important;
border: 1px solid var(--green-border) !important;
color: #c8f5d8 !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
background: var(--success-bg) !important;
border: 1px solid var(--success-border) !important;
color: var(--success-text) !important;
}
.el-tag--warning { background: rgba(251,191,36,0.1) !important; border-color: rgba(251,191,36,0.3) !important; color: #fbbf24 !important; }
.el-tag--danger { background: rgba(255,69,58,0.1) !important; border-color: rgba(255,69,58,0.3) !important; color: #ff453a !important; }
.el-tag--info { background: rgba(255,255,255,0.06) !important; border-color: #3a3a3a !important; color: #aaa !important; }
/* 表格操作按钮:渐变绿 + 白字 */
/* 表格操作:纯文字链 */
.el-button.is-link.el-button--primary,
.el-button.is-link.el-button--success {
color: #ffffff !important;
background: var(--primary-grad) !important;
border: 1px solid var(--green-border) !important;
border-radius: 6px !important;
padding: 5px 11px !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset, 0 1px 6px rgba(0, 0, 0, 0.35) !important;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
color: var(--accent-muted) !important;
background: transparent !important;
border: none !important;
border-radius: 0 !important;
padding: 0 4px !important;
box-shadow: none !important;
font-weight: 500 !important;
}
.el-button.is-link.el-button--primary:hover,
.el-button.is-link.el-button--primary:focus,
.el-button.is-link.el-button--success:hover,
.el-button.is-link.el-button--success:focus {
color: #ffffff !important;
background: var(--primary-grad-hover) !important;
border-color: rgba(120, 230, 170, 0.45) !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 2px 10px rgba(0, 0, 0, 0.4), 0 0 16px rgba(47, 181, 106, 0.25) !important;
color: #f5f5f5 !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
text-decoration: underline;
text-underline-offset: 3px;
}
.el-button.is-link.el-button--warning {
color: #a3a3a3 !important;
background: transparent !important;
border: none !important;
border-radius: 0 !important;
padding: 0 4px !important;
box-shadow: none !important;
font-weight: 500 !important;
}
.el-button.is-link.el-button--warning:hover,
.el-button.is-link.el-button--warning:focus {
color: #d4a574 !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
text-decoration: underline;
text-underline-offset: 3px;
}
.el-form-item__label { color: #aaa !important; font-size: 12px !important; font-weight: 600 !important; letter-spacing: 0.02em !important; }
/* 表格 link 操作按钮禁用:保留按钮形态,灰显不可点 */
.el-button.is-link.is-disabled,
.el-button.is-link.is-disabled:hover,
.el-button.is-link.is-disabled:focus,
.el-button.is-link:disabled {
cursor: not-allowed !important;
pointer-events: none;
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: rgba(255, 255, 255, 0.28) !important;
box-shadow: none !important;
text-shadow: none !important;
opacity: 1 !important;
transform: none !important;
filter: none;
}
.el-button.is-link.el-button--primary.is-disabled,
.el-button.is-link.el-button--success.is-disabled {
background: transparent !important;
border: none !important;
color: rgba(255, 255, 255, 0.24) !important;
text-decoration: none !important;
}
.el-button.is-link.el-button--danger.is-disabled {
background: rgba(255, 69, 58, 0.06) !important;
border-color: rgba(255, 69, 58, 0.1) !important;
color: rgba(255, 107, 98, 0.26) !important;
}
.el-button.is-link.el-button--warning.is-disabled {
background: rgba(196, 132, 18, 0.08) !important;
border-color: rgba(196, 132, 18, 0.12) !important;
color: rgba(251, 191, 36, 0.26) !important;
}
.el-form-item__label { color: var(--text-muted) !important; font-size: 13px !important; font-weight: 500 !important; letter-spacing: 0 !important; }
/* ── Dialog / overlay实心背景避免噪点透底发糊 ── */
.el-overlay {
@@ -609,10 +682,10 @@ body {
backdrop-filter: none !important;
}
.el-dialog {
background: #1a1a1a !important;
border: 1px solid #333 !important;
background: #141414 !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.65) !important;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5) !important;
}
.el-dialog__header {
border-bottom: 1px solid #2a2a2a !important;
@@ -664,8 +737,20 @@ body {
overflow-y: auto;
}
.el-statistic__head { color: #555 !important; font-size: 11px !important; font-weight: 700 !important; letter-spacing: 0.06em !important; text-transform: uppercase !important; }
.el-statistic__content .el-statistic__number { font-size: 26px !important; font-weight: 800 !important; color: #fff !important; }
.entity-detail-dialog .el-dialog__body {
padding: 12px 20px 16px !important;
max-height: none !important;
overflow: visible !important;
}
.detail-actions {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.el-statistic__head { color: #737373 !important; font-size: 12px !important; font-weight: 500 !important; letter-spacing: 0 !important; text-transform: none !important; }
.el-statistic__content .el-statistic__number { font-size: 24px !important; font-weight: 500 !important; color: #f5f5f5 !important; }
.el-input-number .el-input__wrapper { background: #0d0d0d !important; }
.el-date-editor .el-input__wrapper { background: #0d0d0d !important; }

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
withDefaults(
defineProps<{
columns?: 2 | 3 | 4;
}>(),
{ columns: 3 },
);
</script>
<template>
<div class="admin-detail-grid" :style="{ '--detail-cols': columns }">
<slot />
</div>
</template>
<style scoped>
.admin-detail-grid {
display: grid;
grid-template-columns: repeat(var(--detail-cols, 3), minmax(0, 1fr));
column-gap: 18px;
row-gap: 8px;
align-items: start;
}
</style>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
defineProps<{
label: string;
/** 占满整行 */
full?: boolean;
/** 跨列数(默认 1 */
span?: 2 | 3;
}>();
</script>
<template>
<div
class="admin-detail-item"
:class="{
'admin-detail-item--full': full,
'admin-detail-item--span-2': span === 2,
'admin-detail-item--span-3': span === 3,
}"
>
<div class="admin-detail-item__label">{{ label }}</div>
<div class="admin-detail-item__value">
<slot />
</div>
</div>
</template>
<style scoped>
.admin-detail-item {
display: grid;
grid-template-columns: minmax(5.5em, max-content) minmax(0, 1fr);
gap: 6px 10px;
align-items: baseline;
min-width: 0;
}
.admin-detail-item--span-2 {
grid-column: span 2;
}
.admin-detail-item--span-3,
.admin-detail-item--full {
grid-column: 1 / -1;
}
.admin-detail-item__label {
font-size: 12px;
color: #737373;
font-weight: 500;
line-height: 1.35;
white-space: nowrap;
}
.admin-detail-item__value {
font-size: 13px;
color: #f5f5f5;
line-height: 1.35;
min-width: 0;
word-break: break-word;
}
.admin-detail-item__value :deep(.field-hint),
.admin-detail-item__value :deep(.admin-detail-hint) {
display: inline;
margin-left: 6px;
font-size: 11px;
color: #737373;
line-height: 1.35;
}
.admin-detail-item--full .admin-detail-item__value :deep(.admin-detail-hint),
.admin-detail-item--span-2 .admin-detail-item__value :deep(.admin-detail-hint),
.admin-detail-item--span-3 .admin-detail-item__value :deep(.admin-detail-hint) {
display: inline;
}
</style>

View File

@@ -133,24 +133,24 @@ onUnmounted(() => document.removeEventListener('click', onDocClick));
/* 管理顶栏 */
.admin-locale.admin .admin-locale-trigger {
background: #0d0d0d;
color: var(--green-text);
border: 1px solid var(--green-border);
background: transparent;
color: #a3a3a3;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.admin-locale.admin .admin-locale-menu {
background: #0f1411;
border: 1px solid var(--green-border);
background: #141414;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.admin-locale.admin .admin-locale-option {
color: #8ab89a;
color: #a3a3a3;
}
.admin-locale.admin .admin-locale-option:hover,
.admin-locale.admin .admin-locale-option.active {
background: var(--green-surface);
color: var(--green-text);
background: rgba(255, 255, 255, 0.06);
color: #f5f5f5;
}
/* 登录页:与玩家端金色主题一致 */

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { useAdminLocale } from '../composables/useAdminLocale';
import AdminResponsiveRowActions from './AdminResponsiveRowActions.vue';
export type PlayerActionRow = {
id: number | string;
username: string;
status: string;
};
withDefaults(
defineProps<{
row: PlayerActionRow;
showDetail?: boolean;
showLedger?: boolean;
}>(),
{
showDetail: true,
showLedger: true,
},
);
const emit = defineEmits<{
detail: [];
ledger: [];
edit: [];
deposit: [];
withdraw: [];
freeze: [];
}>();
const { t } = useAdminLocale();
</script>
<template>
<AdminResponsiveRowActions>
<template #inline>
<el-button v-if="showDetail" link type="primary" size="small" @click="emit('detail')">
{{ t('common.detail') }}
</el-button>
<el-button v-if="showLedger" link type="primary" size="small" @click="emit('ledger')">
{{ t('user.action.ledger_short') }}
</el-button>
<el-button link type="primary" size="small" @click="emit('edit')">{{ t('common.edit') }}</el-button>
<el-button link type="success" size="small" @click="emit('deposit')">{{ t('common.topup') }}</el-button>
<el-button link type="warning" size="small" @click="emit('withdraw')">
{{ t('agent_portal.withdraw_btn_label') }}
</el-button>
<el-button
v-if="row.status === 'ACTIVE'"
link
type="warning"
size="small"
@click="emit('freeze')"
>
{{ t('common.freeze') }}
</el-button>
<el-button v-else link type="primary" size="small" @click="emit('freeze')">
{{ t('common.unfreeze') }}
</el-button>
</template>
<template #menu>
<el-dropdown-item v-if="showDetail" @click="emit('detail')">{{ t('common.detail') }}</el-dropdown-item>
<el-dropdown-item v-if="showLedger" @click="emit('ledger')">{{ t('user.action.view_wallet_ledger') }}</el-dropdown-item>
<el-dropdown-item @click="emit('edit')">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item divided @click="emit('deposit')">{{ t('common.topup') }}</el-dropdown-item>
<el-dropdown-item @click="emit('withdraw')">
<span class="action-warning">{{ t('agent_portal.withdraw_btn_label') }}</span>
</el-dropdown-item>
<el-dropdown-item v-if="row.status === 'ACTIVE'" @click="emit('freeze')">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else @click="emit('freeze')">{{ t('common.unfreeze') }}</el-dropdown-item>
</template>
</AdminResponsiveRowActions>
</template>
<style scoped>
:deep(.action-warning) {
color: var(--el-color-warning);
}
</style>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import AdminRowActionsDropdown from './AdminRowActionsDropdown.vue';
</script>
<template>
<div class="admin-responsive-actions" @click.stop>
<div class="admin-responsive-actions__inline action-btns">
<slot name="inline" />
</div>
<div class="admin-responsive-actions__menu">
<AdminRowActionsDropdown>
<slot name="menu" />
</AdminRowActionsDropdown>
</div>
</div>
</template>
<style scoped>
.admin-responsive-actions {
width: 100%;
}
.admin-responsive-actions__menu {
display: none;
justify-content: center;
width: 100%;
}
.action-btns {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 2px 6px;
}
.action-btns :deep(.el-button) {
margin: 0;
padding: 1px 4px;
height: auto;
font-size: 11px;
line-height: 1.3;
white-space: nowrap;
}
@media (max-width: 1280px) {
.admin-responsive-actions__inline {
display: none;
}
.admin-responsive-actions__menu {
display: inline-flex;
}
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { useAdminLocale } from '../composables/useAdminLocale';
const { t } = useAdminLocale();
</script>
<template>
<div class="admin-row-actions" @click.stop>
<el-dropdown trigger="click" @click.stop>
<el-button link type="primary" size="small" @click.stop>
{{ t('common.actions') }}
<span class="admin-row-actions__caret" aria-hidden="true"></span>
</el-button>
<template #dropdown>
<el-dropdown-menu class="admin-row-actions__menu">
<slot />
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<style scoped>
.admin-row-actions {
display: inline-flex;
justify-content: center;
width: 100%;
}
.admin-row-actions__caret {
margin-left: 3px;
font-size: 11px;
opacity: 0.85;
}
.admin-row-actions__menu :deep(.el-dropdown-menu__item) {
font-size: 13px;
line-height: 1.35;
padding: 6px 16px;
max-width: 300px;
white-space: normal;
}
.admin-row-actions__menu :deep(.action-warning) {
color: var(--el-color-warning);
}
.admin-row-actions__menu :deep(.action-danger) {
color: var(--el-color-danger);
}
</style>

View File

@@ -53,15 +53,15 @@ defineProps<{
line-height: 1.4;
}
.admin-subnav__link {
color: var(--green-text);
font-weight: 600;
color: #737373;
font-weight: 500;
transition: color 0.15s;
}
.admin-subnav__link:hover {
color: #d4fde5;
color: #f5f5f5;
}
.admin-subnav__current {
color: #888;
color: #737373;
}
.admin-subnav__sep {
color: #444;
@@ -83,10 +83,10 @@ defineProps<{
}
.admin-subnav__title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #e8e8e8;
letter-spacing: 0.02em;
font-size: 18px;
font-weight: 500;
color: #f5f5f5;
letter-spacing: 0;
}
.admin-subnav__subtitle {
font-size: 13px;

View File

@@ -122,7 +122,7 @@ function fmtFull(value: string | null | undefined) {
color: #888;
}
.c-green {
color: #67c23a;
color: var(--gold-text);
font-weight: 600;
}
.c-amber {

View File

@@ -47,14 +47,14 @@ function isActive(path: string) {
.dashboard-subnav {
display: flex;
align-items: center;
gap: 4px;
gap: 2px;
margin-bottom: 16px;
padding: 6px;
border-radius: 10px;
padding: 3px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
min-height: 48px;
min-height: 40px;
box-sizing: border-box;
}
.dashboard-subnav--embedded {
@@ -68,24 +68,24 @@ function isActive(path: string) {
box-sizing: border-box;
}
.dashboard-subnav__item {
padding: 0 14px;
height: 36px;
padding: 0 12px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 8px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: #888;
font-weight: 500;
color: #737373;
transition: color 0.15s, background 0.15s;
}
.dashboard-subnav__item:hover {
color: #ccc;
color: #d4d4d4;
background: rgba(255, 255, 255, 0.04);
}
.dashboard-subnav__item--active {
color: var(--green-text);
background: rgba(0, 200, 83, 0.1);
color: #f5f5f5;
background: rgba(255, 255, 255, 0.08);
}
</style>

View File

@@ -0,0 +1,247 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import api from '../api';
import { useAdminLocale } from '../composables/useAdminLocale';
import { buildPlayerRegisterUrl, copyText } from '../utils/invite-link';
import { useAuthStore } from '../stores/auth';
import RatePercentInput from './RatePercentInput.vue';
import { decimalRateToPercent, formatRatePercent, percentToDecimalRate } from '../utils/rate-percent';
const props = defineProps<{
inviteCode?: string | null;
compact?: boolean;
readonly?: boolean;
embedded?: boolean;
hideHistory?: boolean;
/** When true, code/link only appear after clicking generate (not from session/props). */
requireGenerate?: boolean;
}>();
const emit = defineEmits<{
generated: [];
}>();
const { t } = useAdminLocale();
const auth = useAuthStore();
const loadedCode = ref<string | null>(null);
const loadedCashbackRate = ref<string | null>(null);
const generating = ref(false);
const cashbackPercent = ref(0);
const defaultCashbackLoaded = ref(false);
const isAdmin = computed(() => auth.isAdmin.value);
const showCashbackField = computed(() => isAdmin.value && !props.readonly);
const effectiveCode = computed(() => {
if (props.readonly) return props.inviteCode ?? null;
if (props.requireGenerate) return loadedCode.value;
return props.inviteCode ?? loadedCode.value;
});
const registerUrl = computed(() =>
effectiveCode.value ? buildPlayerRegisterUrl(effectiveCode.value) : '',
);
const displayedCashbackRate = computed(() => {
if (loadedCashbackRate.value != null) return loadedCashbackRate.value;
if (showCashbackField.value) return percentToDecimalRate(cashbackPercent.value).toString();
return null;
});
async function loadDefaultCashbackRate() {
if (!showCashbackField.value) return;
try {
const { data } = await api.get('/admin/settings/cashback/platform-direct');
const payload = data.data as { adminInviteRate?: number | string; platformDirectRate?: number | string };
cashbackPercent.value = decimalRateToPercent(
payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0,
);
} catch {
cashbackPercent.value = 0;
} finally {
defaultCashbackLoaded.value = true;
}
}
async function generateInvite() {
generating.value = true;
try {
const body = showCashbackField.value
? { cashbackRate: percentToDecimalRate(cashbackPercent.value) }
: undefined;
const { data } = await api.post('/manage/invite/generate', body);
const payload = data.data as { inviteCode?: string | null; cashbackRate?: string | null };
const code = payload.inviteCode ?? null;
loadedCode.value = code;
loadedCashbackRate.value = payload.cashbackRate ?? null;
if (auth.user.value && code) {
auth.setSession(auth.token.value, { ...auth.user.value, inviteCode: code });
}
ElMessage.success(t('invite.generate_ok'));
emit('generated');
} catch {
ElMessage.error(t('invite.generate_failed'));
} finally {
generating.value = false;
}
}
async function copyCode() {
if (!effectiveCode.value) return;
const ok = await copyText(effectiveCode.value);
ElMessage[ok ? 'success' : 'error'](ok ? t('invite.copy_code_ok') : t('invite.copy_failed'));
}
async function copyLink() {
if (!registerUrl.value) return;
const ok = await copyText(registerUrl.value);
ElMessage[ok ? 'success' : 'error'](ok ? t('invite.copy_link_ok') : t('invite.copy_failed'));
}
onMounted(() => {
void loadDefaultCashbackRate();
});
</script>
<template>
<div class="invite-panel" :class="{ compact, embedded }">
<div v-if="!embedded" class="invite-head">
<span class="invite-title">{{ t('invite.title') }}</span>
<span class="invite-hint">{{ t('invite.hint') }}</span>
</div>
<div v-if="showCashbackField && defaultCashbackLoaded" class="invite-cashback-field">
<label class="invite-label">{{ t('invite.cashback_rate') }}</label>
<RatePercentInput v-model="cashbackPercent" />
<p class="invite-cashback-hint">{{ t('invite.cashback_rate_hint') }}</p>
</div>
<div v-if="!readonly" class="invite-actions">
<el-button type="primary" :loading="generating" @click="generateInvite">
{{ effectiveCode ? t('invite.regenerate_btn') : t('invite.generate_btn') }}
</el-button>
</div>
<div v-if="effectiveCode" class="invite-body">
<div v-if="displayedCashbackRate != null" class="invite-row">
<span class="invite-label">{{ t('invite.cashback_rate') }}</span>
<span class="invite-rate">{{ formatRatePercent(displayedCashbackRate) }}</span>
</div>
<div class="invite-row">
<span class="invite-label">{{ t('invite.code') }}</span>
<code class="invite-code">{{ effectiveCode }}</code>
<el-button size="small" @click="copyCode">{{ t('invite.copy_code') }}</el-button>
</div>
<div class="invite-row">
<span class="invite-label">{{ t('invite.link') }}</span>
<span class="invite-link">{{ registerUrl }}</span>
<el-button type="primary" size="small" @click="copyLink">{{ t('invite.copy_link') }}</el-button>
</div>
</div>
<p v-else-if="readonly" class="invite-empty">{{ t('invite.not_generated') }}</p>
</div>
</template>
<style scoped>
.invite-panel {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
padding: 14px 16px;
background: var(--el-fill-color-blank);
}
.invite-panel.compact {
padding: 10px 12px;
}
.invite-panel.embedded {
border: none;
padding: 0;
background: transparent;
}
.invite-head {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.invite-title {
font-size: 14px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.invite-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.invite-cashback-field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin-bottom: 12px;
width: 100%;
}
.invite-cashback-hint {
margin: 0;
font-size: 11px;
color: var(--el-text-color-secondary);
line-height: 1.45;
}
.invite-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.invite-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.invite-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.invite-label {
min-width: 72px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.invite-rate {
font-size: 14px;
font-weight: 600;
color: var(--el-color-success);
}
.invite-code {
font-size: 16px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--el-color-primary);
}
.invite-link {
flex: 1;
min-width: 180px;
font-size: 12px;
color: var(--el-text-color-regular);
word-break: break-all;
}
.invite-empty {
margin: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,507 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import api from '../api';
import { useAdminLocale } from '../composables/useAdminLocale';
import { useAuthStore } from '../stores/auth';
import { buildPlayerRegisterUrl, copyText } from '../utils/invite-link';
import AdminTableEmpty from './AdminTableEmpty.vue';
import { formatRatePercent } from '../utils/rate-percent';
export interface InviteHistoryRow {
id: string;
code: string;
status: string;
createdAt: string;
revokedAt: string | null;
cashbackRate: string | null;
sponsorId: string;
sponsorUsername: string;
sponsorUserType: string;
sponsorAgentLevel: number | null;
registeredPlayerId: string | null;
registeredPlayerUsername: string | null;
}
interface SponsorOption {
id: string;
username: string;
userType: string;
agentLevel: number | null;
}
const props = withDefaults(
defineProps<{
compact?: boolean;
embedded?: boolean;
refreshKey?: number;
}>(),
{
compact: false,
embedded: false,
refreshKey: 0,
},
);
const { t, locale, localeTag } = useAdminLocale();
const auth = useAuthStore();
const items = ref<InviteHistoryRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(props.compact ? 10 : 20);
const loading = ref(false);
const filterStatus = ref('');
const filterSponsorId = ref('');
const filterKeyword = ref('');
const sponsorOptions = ref<SponsorOption[]>([]);
const showSponsorFilter = computed(() => auth.isAdmin.value || auth.isAgent.value);
const useCompactActions = computed(() => props.embedded || props.compact);
function isUsed(row: InviteHistoryRow) {
return Boolean(row.registeredPlayerId);
}
function displayStatus(row: InviteHistoryRow) {
if (row.status === 'ACTIVE') return 'ACTIVE';
if (isUsed(row)) return 'USED';
return 'REVOKED';
}
function statusLabel(row: InviteHistoryRow) {
const status = displayStatus(row);
if (status === 'ACTIVE') return t('invite.status.ACTIVE');
if (status === 'USED') return t('invite.status.USED');
if (status === 'REVOKED') return t('invite.status.REVOKED');
return row.status;
}
function statusTagType(row: InviteHistoryRow) {
const status = displayStatus(row);
if (status === 'ACTIVE') return 'success';
if (status === 'USED') return 'warning';
return 'info';
}
function sponsorLabel(row: InviteHistoryRow) {
if (row.sponsorUserType === 'ADMIN') return `${row.sponsorUsername} (${t('role.admin')})`;
if (row.sponsorAgentLevel != null && row.sponsorAgentLevel > 0) {
return `${row.sponsorUsername} (${t('role.agent_level', { n: row.sponsorAgentLevel })})`;
}
return row.sponsorUsername;
}
function formatTime(v: string | null) {
if (!v) return '—';
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
async function loadSponsors() {
if (!showSponsorFilter.value) return;
try {
const { data } = await api.get('/manage/invites/sponsors');
sponsorOptions.value = (data.data.items ?? []) as SponsorOption[];
} catch {
sponsorOptions.value = [];
}
}
async function load() {
loading.value = true;
try {
const { data } = await api.get('/manage/invites', {
params: {
page: page.value,
pageSize: pageSize.value,
status: filterStatus.value || undefined,
sponsorId: filterSponsorId.value || undefined,
keyword: filterKeyword.value.trim() || undefined,
},
});
items.value = (data.data.items ?? []) as InviteHistoryRow[];
total.value = data.data.total ?? 0;
} catch {
items.value = [];
total.value = 0;
ElMessage.error(t('invite.history_load_failed'));
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
load();
}
function onPageChange(p: number) {
page.value = p;
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
}
async function copyCode(code: string) {
const ok = await copyText(code);
ElMessage[ok ? 'success' : 'error'](ok ? t('invite.copy_code_ok') : t('invite.copy_failed'));
}
async function copyLink(code: string) {
const ok = await copyText(buildPlayerRegisterUrl(code));
ElMessage[ok ? 'success' : 'error'](ok ? t('invite.copy_link_ok') : t('invite.copy_failed'));
}
function canCopy(row: InviteHistoryRow) {
return !isUsed(row);
}
function canCopyLink(row: InviteHistoryRow) {
return row.status === 'ACTIVE' && !isUsed(row);
}
function canRevoke(row: InviteHistoryRow) {
if (row.status !== 'ACTIVE') return false;
if (auth.isAdmin.value) return true;
return row.sponsorId === auth.user.value?.id;
}
function canDelete(row: InviteHistoryRow) {
if (row.status !== 'REVOKED') return false;
if (row.registeredPlayerId) return false;
if (auth.isAdmin.value) return true;
return row.sponsorId === auth.user.value?.id;
}
function registrantLabel(row: InviteHistoryRow) {
return row.registeredPlayerUsername ?? t('invite.not_registered');
}
async function revokeRow(row: InviteHistoryRow) {
try {
await ElMessageBox.confirm(t('invite.revoke_confirm', { code: row.code }), t('invite.revoke_title'), {
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
try {
await api.post(`/manage/invites/${row.id}/revoke`);
ElMessage.success(t('invite.revoke_ok'));
load();
} catch {
ElMessage.error(t('invite.revoke_failed'));
}
}
async function deleteRow(row: InviteHistoryRow) {
try {
await ElMessageBox.confirm(t('invite.delete_confirm', { code: row.code }), t('invite.delete_title'), {
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
try {
await api.delete(`/manage/invites/${row.id}`);
ElMessage.success(t('invite.delete_ok'));
load();
} catch {
ElMessage.error(t('invite.delete_failed'));
}
}
onMounted(async () => {
await loadSponsors();
await load();
});
watch(
() => props.refreshKey,
() => {
load();
},
);
</script>
<template>
<div class="invite-history" :class="{ compact, embedded }">
<div v-if="!embedded" class="history-head">
<span class="history-title">{{ t('invite.history_title') }}</span>
<span v-if="!compact" class="history-hint">{{ t('invite.history_hint') }}</span>
</div>
<el-form inline class="history-filters" @submit.prevent="onSearch">
<el-form-item :label="t('invite.filter_status')">
<el-select v-model="filterStatus" clearable :placeholder="t('common.all')" style="width: 120px">
<el-option :label="t('invite.status.ACTIVE')" value="ACTIVE" />
<el-option :label="t('invite.status.REVOKED')" value="REVOKED" />
</el-select>
</el-form-item>
<el-form-item v-if="showSponsorFilter" :label="t('invite.filter_sponsor')">
<el-select
v-model="filterSponsorId"
clearable
filterable
:placeholder="t('common.all')"
style="width: 180px"
>
<el-option
v-for="opt in sponsorOptions"
:key="opt.id"
:label="opt.username"
:value="opt.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('invite.filter_code')">
<el-input
v-model="filterKeyword"
clearable
:placeholder="t('invite.filter_code_ph')"
style="width: 140px"
@keyup.enter="onSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div class="table-wrap">
<el-table
v-loading="loading"
:key="locale"
:data="items"
stripe
size="small"
class="history-table"
:class="{ 'history-table--embedded': embedded }"
>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column prop="code" :label="t('invite.code')" min-width="96" show-overflow-tooltip>
<template #default="{ row }">
<code class="code-cell">{{ row.code }}</code>
</template>
</el-table-column>
<el-table-column :label="t('invite.col_status')" width="88" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row)" size="small">{{ statusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('invite.col_sponsor')" min-width="128" show-overflow-tooltip>
<template #default="{ row }">{{ sponsorLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('invite.col_registrant')" min-width="96" show-overflow-tooltip>
<template #default="{ row }">
<span :class="{ 'registrant-empty': !row.registeredPlayerUsername }">
{{ registrantLabel(row) }}
</span>
</template>
</el-table-column>
<el-table-column :label="t('invite.col_cashback_rate')" width="88" align="right">
<template #default="{ row }">
<span v-if="row.cashbackRate != null">{{ formatRatePercent(row.cashbackRate) }}</span>
<span v-else class="registrant-empty"></span>
</template>
</el-table-column>
<el-table-column :label="t('invite.col_created')" min-width="128" show-overflow-tooltip>
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('invite.col_revoked')" min-width="128" show-overflow-tooltip>
<template #default="{ row }">{{ formatTime(row.revokedAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" :min-width="useCompactActions ? 220 : 260" align="center">
<template #default="{ row }">
<div class="action-cell" :class="{ 'action-cell--compact': useCompactActions }">
<el-button
link
type="primary"
size="small"
:disabled="!canCopy(row)"
@click="copyCode(row.code)"
>
{{ useCompactActions ? t('invite.copy_code_short') : t('invite.copy_code') }}
</el-button>
<el-button
link
type="primary"
size="small"
:disabled="!canCopyLink(row)"
@click="copyLink(row.code)"
>
{{ useCompactActions ? t('invite.copy_link_short') : t('invite.copy_link') }}
</el-button>
<el-button
link
type="danger"
size="small"
:disabled="!canRevoke(row)"
@click="revokeRow(row)"
>
{{ t('invite.revoke_btn') }}
</el-button>
<el-button
link
type="danger"
size="small"
:disabled="!canDelete(row)"
@click="deleteRow(row)"
>
{{ t('common.delete') }}
</el-button>
</div>
</template>
</el-table-column>
</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"
background
small
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
</template>
<style scoped>
.invite-history {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
padding: 14px 16px;
background: var(--el-fill-color-blank);
}
.invite-history.compact {
padding: 10px 12px;
}
.invite-history.embedded {
border: none;
padding: 0;
background: transparent;
}
.history-head {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.history-title {
font-size: 14px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.history-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.history-filters {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin-bottom: 12px;
}
.history-filters :deep(.el-form-item) {
margin-right: 0;
margin-bottom: 0;
}
.table-wrap {
width: 100%;
overflow-x: auto;
}
.history-table {
width: 100%;
}
.history-table--embedded {
min-width: 900px;
}
.history-table :deep(.cell) {
overflow: visible;
text-overflow: clip;
}
.action-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 2px 8px;
}
.action-cell :deep(.el-button) {
margin: 0;
padding: 0 2px;
height: auto;
}
.action-cell--compact {
gap: 2px 6px;
}
.action-cell--compact :deep(.el-button) {
font-size: 11px;
line-height: 1.3;
padding: 1px 3px;
}
.action-cell :deep(.el-button.is-link) {
border-radius: 6px;
padding: 4px 8px;
}
.action-cell--compact :deep(.el-button.is-link) {
padding: 3px 6px;
}
.code-cell {
font-weight: 700;
letter-spacing: 0.06em;
color: var(--el-color-primary);
}
.registrant-empty {
color: var(--el-text-color-secondary);
}
.pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import InviteCodePanel from './InviteCodePanel.vue';
import InviteHistoryPanel from './InviteHistoryPanel.vue';
const props = defineProps<{
modelValue: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [boolean];
}>();
const { t } = useAdminLocale();
const activeTab = ref<'generate' | 'history'>('generate');
const historyRefreshKey = ref(0);
const tabsBootKey = ref(0);
watch(
() => props.modelValue,
async (open) => {
if (!open) return;
activeTab.value = 'generate';
tabsBootKey.value += 1;
await nextTick();
historyRefreshKey.value += 1;
},
);
function onGenerated() {
historyRefreshKey.value += 1;
}
function onTabChange(name: string | number) {
if (name === 'history') {
historyRefreshKey.value += 1;
}
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="t('invite.dialog_title')"
width="1040px"
align-center
class="invite-manage-dialog"
modal-class="invite-manage-overlay"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<el-tabs
:key="tabsBootKey"
v-model="activeTab"
class="invite-manage-tabs"
@tab-change="onTabChange"
>
<el-tab-pane :label="t('invite.tab_generate')" name="generate">
<div class="tab-pane-scroll tab-pane-scroll--generate">
<div class="generate-tab-inner">
<p class="tab-hint">{{ t('invite.hint') }}</p>
<InviteCodePanel require-generate embedded hide-history @generated="onGenerated" />
</div>
</div>
</el-tab-pane>
<el-tab-pane :label="t('invite.tab_history')" name="history">
<div class="tab-pane-scroll">
<p class="tab-hint">{{ t('invite.history_hint') }}</p>
<InviteHistoryPanel embedded :refresh-key="historyRefreshKey" />
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
</template>
<style scoped>
.tab-hint {
margin: 0 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
</style>
<style>
.invite-manage-overlay .el-overlay-dialog {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100%;
height: 100%;
}
.invite-manage-overlay .invite-manage-dialog.el-dialog {
width: 1040px !important;
height: min(640px, 90vh);
max-width: min(1040px, 96vw);
margin: 0 !important;
display: flex;
flex-direction: column;
overflow: hidden;
}
.invite-manage-dialog .el-dialog__header {
flex-shrink: 0;
}
.invite-manage-dialog .el-dialog__body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 8px 20px 20px;
box-sizing: border-box;
}
.invite-manage-dialog .invite-manage-tabs {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.invite-manage-dialog .invite-manage-tabs > .el-tabs__header {
flex-shrink: 0;
margin-bottom: 12px;
}
.invite-manage-dialog .invite-manage-tabs > .el-tabs__content {
flex: 1;
min-height: 0;
overflow: hidden;
}
.invite-manage-dialog .invite-manage-tabs .el-tab-pane {
height: 100%;
overflow: hidden;
}
.invite-manage-dialog .tab-pane-scroll {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
box-sizing: border-box;
}
.invite-manage-dialog .tab-pane-scroll--generate {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.invite-manage-dialog .generate-tab-inner {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
max-width: 560px;
padding: 12px 8px 24px;
box-sizing: border-box;
}
.invite-manage-dialog .generate-tab-inner .tab-hint {
margin: 0 0 20px;
}
.invite-manage-dialog .generate-tab-inner :deep(.invite-actions) {
justify-content: center;
margin-bottom: 0;
}
.invite-manage-dialog .generate-tab-inner :deep(.invite-actions .el-button) {
min-width: 168px;
height: auto;
min-height: 36px;
padding: 8px 22px;
white-space: nowrap;
line-height: 1.35;
}
.invite-manage-dialog .generate-tab-inner :deep(.invite-body) {
width: 100%;
margin-top: 20px;
text-align: left;
}
.invite-manage-dialog .generate-tab-inner :deep(.invite-cashback-field) {
width: 100%;
margin-bottom: 16px;
text-align: left;
}
</style>

View File

@@ -318,13 +318,13 @@ const oldUrl = ref(props.modelValue);
}
.drop-zone:hover {
border-color: rgba(47, 181, 106, 0.4);
background: rgba(47, 181, 106, 0.04);
border-color: rgba(212, 175, 55, 0.4);
background: rgba(212, 175, 55, 0.04);
}
.drop-zone.is-dragging {
border-color: #2fb56a;
background: rgba(47, 181, 106, 0.1);
border-color: var(--gold-mid);
background: rgba(212, 175, 55, 0.1);
}
.drop-preview {

View File

@@ -5,7 +5,7 @@ import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import AdminTableEmpty from './AdminTableEmpty.vue';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import { walletTxTypeKey } from '../utils/walletTx';
import { walletDepositMethodLabel, walletTxTypeKey } from '../utils/walletTx';
interface WalletTxRow {
id: string;
@@ -17,6 +17,8 @@ interface WalletTxRow {
frozenBefore: string;
frozenAfter: string;
betNo: string | null;
depositMethodKey: string | null;
depositMethodName: string | null;
operatorUsername: string | null;
remark: string | null;
createdAt: string;
@@ -62,6 +64,10 @@ function walletTypeLabel(type: string) {
return key ? t(key) : type;
}
function depositMethodLabel(row: WalletTxRow) {
return walletDepositMethodLabel(row, t);
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
@@ -133,7 +139,7 @@ watch(
<el-dialog
v-model="visible"
:title="dialogTitle"
width="980px"
width="1080px"
destroy-on-close
class="player-wallet-ledger-dialog"
append-to-body
@@ -172,9 +178,12 @@ watch(
<el-table-column :label="t('finance.col.tx_id')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.transactionId }}</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_type')" min-width="88">
<el-table-column :label="t('finance.col.tx_type')" min-width="80">
<template #default="{ row }">{{ walletTypeLabel(row.transactionType) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.deposit_method')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ depositMethodLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_change')" min-width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
const model = defineModel<number>({ default: 0 });
</script>
<template>
<div class="rate-percent-input">
<el-input-number
v-model="model"
:min="0"
:max="100"
:step="0.01"
:precision="2"
controls-position="right"
style="width: 100%"
/>
<span class="rate-percent-suffix">%</span>
</div>
</template>
<style scoped>
.rate-percent-input {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.rate-percent-suffix {
flex-shrink: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -109,7 +109,7 @@ function limitLabel(value: string | null) {
margin-bottom: 8px;
}
.transfer-context-alert { margin-bottom: 0; }
.c-green { color: #67c23a; font-weight: 600; }
.c-green { color: var(--gold-text); font-weight: 600; }
.c-amber { color: #e6a23c; font-weight: 600; }
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
.field-hint { font-size: 12px; color: #666; }

View File

@@ -33,6 +33,7 @@ const zh: Record<string, string> = {
'nav.dashboard': '概览',
'nav.dashboard.matches': '赛事概览',
'nav.dashboard.players': '玩家概览',
'nav.invites': '邀请管理',
'nav.users': '玩家管理',
'nav.agents': '代理管理',
'nav.agents_players': '代理&玩家',
@@ -46,7 +47,61 @@ const zh: Record<string, string> = {
'nav.audit': '操作日志',
'nav.smoke_tests': '自动化测试',
'nav.media': '媒体库',
'nav.payment_methods': '收款方式',
'nav.deposit_orders': '充值审核',
'nav.deposit_manage': '充值管理',
'nav.players': '直属玩家',
'deposit.payment_methods_title': '收款方式管理',
'deposit.deposit_orders_title': '充值审核',
'deposit.all_status': '所有状态',
'deposit.all_types': '所有类型',
'deposit.status_pending': '待审核',
'deposit.status_approved': '已通过',
'deposit.status_rejected': '已拒绝',
'deposit.search_player_ph': '搜索玩家...',
'deposit.order_no': '订单号',
'deposit.player': '玩家',
'deposit.amount': '金额',
'deposit.screenshot': '截图',
'deposit.approved_amount': '批准金额',
'deposit.reviewer': '审核人',
'deposit.time': '时间',
'deposit.approve': '批准',
'deposit.reject': '拒绝',
'deposit.approve_title': '批准充值',
'deposit.reject_title': '拒绝充值',
'deposit.submitted_amount': '申报金额',
'deposit.approved_amount_label': '批准金额(可调整)',
'deposit.remark_label': '备注(可选)',
'deposit.confirm_approve': '确认批准',
'deposit.confirm_reject': '确认拒绝',
'deposit.reject_reason_label': '拒绝原因 *',
'deposit.reject_reason_ph': '请输入拒绝原因...',
'deposit.reason_required': '请输入拒绝原因',
'deposit.prev': '上一页',
'deposit.next': '下一页',
'deposit.add_method': '+ 添加',
'deposit.display_name': '展示名称',
'deposit.details': '详情',
'deposit.sort': '排序',
'deposit.active': '启用',
'deposit.show_player': '前台展示',
'deposit.edit_method': '编辑收款方式',
'deposit.create_method': '新建收款方式',
'deposit.bank_name': '银行名称',
'deposit.account_holder': '账户持有人',
'deposit.account_number': '银行账号',
'deposit.usdt_address': 'USDT 地址',
'deposit.qr_code': '二维码',
'deposit.sort_order': '排序序号',
'deposit.show_on_player': '展示给玩家',
'deposit.save': '保存',
'deposit.confirm_deactivate': '确认停用此收款方式?',
'deposit.lang_zh': '中文',
'deposit.lang_en': 'English',
'deposit.lang_ms': 'Bahasa',
'deposit.multilingual_hint': '为不同语言设置显示名称,留空则使用默认值',
'nav.subAgents': '二级代理',
'nav.myBets': '注单查询',
'nav.open_menu': '打开菜单',
@@ -90,6 +145,10 @@ const zh: Record<string, string> = {
'common.used': '已用',
'common.platform_direct': '平台直属',
'common.updated_at': '更新于',
'common.enable': '启用',
'common.disable': '停用',
'common.show_player': '前台展示',
'common.hide_player': '前台隐藏',
'dash.title': '概览',
'dash.desc': '平台整体运行概况',
@@ -233,6 +292,7 @@ const en: Record<string, string> = {
'nav.dashboard': 'Overview',
'nav.dashboard.matches': 'Match overview',
'nav.dashboard.players': 'Player overview',
'nav.invites': 'Invitations',
'nav.users': 'Players',
'nav.agents': 'Agents',
'nav.agents_players': 'Agents & Players',
@@ -246,9 +306,63 @@ const en: Record<string, string> = {
'nav.audit': 'Audit Log',
'nav.smoke_tests': 'Smoke tests',
'nav.media': 'Media Library',
'nav.payment_methods': 'Payment Methods',
'nav.deposit_orders': 'Deposit Review',
'nav.deposit_manage': 'Deposit Mgmt',
'nav.players': 'My Players',
'nav.subAgents': 'Tier-2 agents',
'nav.myBets': 'Bet Search',
'deposit.payment_methods_title': 'Payment Methods',
'deposit.deposit_orders_title': 'Deposit Review',
'deposit.all_status': 'All Status',
'deposit.all_types': 'All Types',
'deposit.status_pending': 'Pending',
'deposit.status_approved': 'Approved',
'deposit.status_rejected': 'Rejected',
'deposit.search_player_ph': 'Search player...',
'deposit.order_no': 'Order No',
'deposit.player': 'Player',
'deposit.amount': 'Amount',
'deposit.screenshot': 'Screenshot',
'deposit.approved_amount': 'Approved Amt',
'deposit.reviewer': 'Reviewer',
'deposit.time': 'Time',
'deposit.approve': 'Approve',
'deposit.reject': 'Reject',
'deposit.approve_title': 'Approve Deposit',
'deposit.reject_title': 'Reject Deposit',
'deposit.submitted_amount': 'Submitted Amount',
'deposit.approved_amount_label': 'Approved Amount (adjust if needed)',
'deposit.remark_label': 'Remark (optional)',
'deposit.confirm_approve': 'Confirm Approve',
'deposit.confirm_reject': 'Confirm Reject',
'deposit.reject_reason_label': 'Reject Reason *',
'deposit.reject_reason_ph': 'Enter the reason for rejection...',
'deposit.reason_required': 'Please enter a reject reason',
'deposit.prev': 'Prev',
'deposit.next': 'Next',
'deposit.add_method': '+ Add',
'deposit.display_name': 'Display Name',
'deposit.details': 'Details',
'deposit.sort': 'Sort',
'deposit.active': 'Active',
'deposit.show_player': 'Show Player',
'deposit.edit_method': 'Edit Payment Method',
'deposit.create_method': 'Create Payment Method',
'deposit.bank_name': 'Bank Name',
'deposit.account_holder': 'Account Holder',
'deposit.account_number': 'Account Number',
'deposit.usdt_address': 'USDT Address',
'deposit.qr_code': 'QR Code',
'deposit.sort_order': 'Sort Order',
'deposit.show_on_player': 'Show on Player',
'deposit.save': 'Save',
'deposit.confirm_deactivate': 'Confirm deactivate this payment method?',
'deposit.lang_zh': 'Chinese',
'deposit.lang_en': 'English',
'deposit.lang_ms': 'Malay',
'deposit.multilingual_hint': 'Set display names for each language. Leave blank to use default.',
'nav.open_menu': 'Open menu',
'nav.close_menu': 'Close menu',
'breadcrumb.settlement': 'Settlement',
@@ -290,6 +404,10 @@ const en: Record<string, string> = {
'common.used': 'Used',
'common.platform_direct': 'Platform direct',
'common.updated_at': 'Updated',
'common.enable': 'Enable',
'common.disable': 'Disable',
'common.show_player': 'Show player',
'common.hide_player': 'Hide player',
'dash.title': 'Overview',
'dash.desc': 'Platform overview',
@@ -433,6 +551,7 @@ const ms: Record<string, string> = {
'nav.dashboard': 'Gambaran',
'nav.dashboard.matches': 'Gambaran perlawanan',
'nav.dashboard.players': 'Gambaran pemain',
'nav.invites': 'Jemputan',
'nav.users': 'Pemain',
'nav.agents': 'Ejen',
'nav.agents_players': 'Ejen & Pemain',
@@ -446,9 +565,63 @@ const ms: Record<string, string> = {
'nav.audit': 'Log audit',
'nav.smoke_tests': 'Ujian asap',
'nav.media': 'Perpustakaan Media',
'nav.payment_methods': 'Kaedah Pembayaran',
'nav.deposit_orders': 'Semakan Deposit',
'nav.deposit_manage': 'Urus Deposit',
'nav.players': 'Pemain saya',
'nav.subAgents': 'Ejen peringkat 2',
'nav.myBets': 'Carian pertaruhan',
'deposit.payment_methods_title': 'Kaedah Pembayaran',
'deposit.deposit_orders_title': 'Semakan Deposit',
'deposit.all_status': 'Semua Status',
'deposit.all_types': 'Semua Jenis',
'deposit.status_pending': 'Menunggu',
'deposit.status_approved': 'Diluluskan',
'deposit.status_rejected': 'Ditolak',
'deposit.search_player_ph': 'Cari pemain...',
'deposit.order_no': 'No. Pesanan',
'deposit.player': 'Pemain',
'deposit.amount': 'Jumlah',
'deposit.screenshot': 'Tangkapan skrin',
'deposit.approved_amount': 'Jml Diluluskan',
'deposit.reviewer': 'Pengulas',
'deposit.time': 'Masa',
'deposit.approve': 'Luluskan',
'deposit.reject': 'Tolak',
'deposit.approve_title': 'Luluskan Deposit',
'deposit.reject_title': 'Tolak Deposit',
'deposit.submitted_amount': 'Jumlah Dihantar',
'deposit.approved_amount_label': 'Jumlah Diluluskan (laraskan jika perlu)',
'deposit.remark_label': 'Catatan (pilihan)',
'deposit.confirm_approve': 'Sahkan Luluskan',
'deposit.confirm_reject': 'Sahkan Tolak',
'deposit.reject_reason_label': 'Sebab Penolakan *',
'deposit.reject_reason_ph': 'Masukkan sebab penolakan...',
'deposit.reason_required': 'Sila masukkan sebab penolakan',
'deposit.prev': 'Sebelum',
'deposit.next': 'Seterus',
'deposit.add_method': '+ Tambah',
'deposit.display_name': 'Nama Paparan',
'deposit.details': 'Butiran',
'deposit.sort': 'Isih',
'deposit.active': 'Aktif',
'deposit.show_player': 'Papar Pemain',
'deposit.edit_method': 'Edit Kaedah Pembayaran',
'deposit.create_method': 'Cipta Kaedah Pembayaran',
'deposit.bank_name': 'Nama Bank',
'deposit.account_holder': 'Pemegang Akaun',
'deposit.account_number': 'Nombor Akaun',
'deposit.usdt_address': 'Alamat USDT',
'deposit.qr_code': 'Kod QR',
'deposit.sort_order': 'Susunan Isih',
'deposit.show_on_player': 'Papar kepada Pemain',
'deposit.save': 'Simpan',
'deposit.confirm_deactivate': 'Sahkan nyahaktifkan kaedah pembayaran ini?',
'deposit.lang_zh': 'Cina',
'deposit.lang_en': 'Inggeris',
'deposit.lang_ms': 'Melayu',
'deposit.multilingual_hint': 'Tetapkan nama paparan untuk setiap bahasa. Kosongkan untuk guna nilai lalai.',
'nav.open_menu': 'Buka menu',
'nav.close_menu': 'Tutup menu',
'breadcrumb.settlement': 'Penyelesaian',
@@ -490,6 +663,10 @@ const ms: Record<string, string> = {
'common.used': 'Digunakan',
'common.platform_direct': 'Terus platform',
'common.updated_at': 'Dikemas kini',
'common.enable': 'Aktifkan',
'common.disable': 'Nyahaktif',
'common.show_player': 'Papar pemain',
'common.hide_player': 'Sembunyikan pemain',
'dash.title': 'Gambaran',
'dash.desc': 'Gambaran keseluruhan platform',

View File

@@ -27,6 +27,7 @@ export const adminPagesMs: Record<string, string> = {
'user.filter.agent_ph': 'Semua',
'user.col.username': 'Nama pengguna',
'user.col.agent': 'Ejen',
'user.col.invite_code': 'Kod jemputan',
'user.col.balance': 'Tersedia / Dibekukan',
'user.col.bets': 'Pertaruhan',
'user.col.stake_payout': 'Stake / Bayaran',
@@ -141,7 +142,7 @@ export const adminPagesMs: Record<string, string> = {
'agent.field.player_liability': 'Liabiliti pemain',
'agent.field.sub_agent_exposure': 'Pendedahan sub-ejen',
'agent.hint.credit_limit': 'Had maksimum tambah baki untuk pemain terus',
'agent.hint.cashback_example': 'cth. 0.01 = 1%',
'agent.hint.cashback_example': 'cth. 1 bermaksud 1%',
'agent.hint.credit_adjust': 'Positif menambah, negatif mengurangkan',
'agent.hint.credit_remark': 'Pilihan, ditulis ke lejar kredit',
'agent.section.credit_log': 'Perubahan kredit terkini',
@@ -178,6 +179,7 @@ export const adminPagesMs: Record<string, string> = {
'finance.tx.cashback': 'Rebat',
'finance.tx.resettle': 'Penyelesaian semula',
'user.action.view_wallet_ledger': 'Lihat lejar dompet',
'user.action.ledger_short': 'Lejar',
'user.wallet_ledger_dialog_title': 'Lejar dompet — {name}',
'agent.hierarchy.settings_title': 'Hierarki ejen',
'agent.hierarchy.settings_hint': '0 bermaksud tanpa had. Ejen di had atas tidak boleh cipta sub-ejen.',
@@ -199,6 +201,11 @@ export const adminPagesMs: Record<string, string> = {
'finance.col.balance_change': 'Perubahan baki',
'finance.col.balance_before': 'Baki sebelum',
'finance.col.balance_after': 'Baki selepas',
'finance.col.tx_type': 'Jenis',
'finance.col.deposit_method': 'Kaedah deposit',
'finance.deposit_method.manual_admin': 'Deposit manual admin',
'finance.deposit_method.manual_agent': 'Deposit manual ejen',
'finance.deposit_method.manual': 'Deposit manual',
'finance.tx.deposit': 'Deposit',
'finance.tx.withdraw': 'Pengeluaran',
'finance.tx.request_id': 'ID permintaan',
@@ -372,10 +379,17 @@ export const adminPagesMs: Record<string, string> = {
'cashback.rule_period': 'Pilih julat tarikh. Taruhan dikira mengikut masa penyelesaian dalam tempoh tersebut.',
'cashback.rule_eligible': 'Termasuk: taruhan selesai WON/LOST (tunggal ikut stake; parlay sekali ikut stake parlay). Tidak termasuk: belum selesai, dibatalkan, batal, push, kadar 0, dan taruhan yang sudah dibayar rebat.',
'cashback.rule_formula': 'Setiap taruhan: stake × kadar rebat. Jumlah diagregat mengikut pemain.',
'cashback.rule_rate': 'Keutamaan kadar: pemain > ejen > global > kadar lalai ejen (cth. 0.01 = 1%).',
'cashback.rule_rate': 'Keutamaan kadar: pemain > ejen > global > kadar lalai (pemain bawah ejen guna kadar ejen; pemain terus platform guna kadar dalam Tetapan global; boleh ganti setiap pemain). Isi peratus, cth. 1 = 1%.',
'cashback.rule_flow': 'Aliran: pratonton (satu menunggu setiap tempoh) → semak → sahkan bayaran; batalkan jika tidak perlu. Tempoh dibayar tidak boleh pratonton semula.',
'cashback.rule_platform': 'Bayaran: rebat dikreditkan ke baki tunai pemain oleh platform; tidak ditolak daripada kredit atau baki ejen.',
'cashback.rule_note_zero': 'Jika 0, semak taruhan WON/LOST dalam tempoh dan kadar rebat > 0.',
'cashback.rule_note_zero': 'Jika 0, semak taruhan WON/LOST dalam tempoh dan kadar rebat > 0 (termasuk lalai terus platform dalam Tetapan global dan tetapan pemain/ejen).',
'cashback.use_custom_rate': 'Tetapkan kadar rebat khusus',
'cashback.use_default_rate': 'Guna kadar lalai {rate}',
'cashback.settings_title': 'Tetapan rebat',
'cashback.platform_direct_default_rate': 'Kadar rebat lalai terus platform',
'cashback.platform_direct_default_hint': 'Pemain daftar sendiri tanpa kod jemputan guna kadar ini melainkan ditetapkan secara individu.',
'cashback.admin_invite_default_rate': 'Kadar rebat jemputan admin',
'cashback.admin_invite_default_hint': 'Pemain daftar dengan kod jemputan admin; lalai sama dengan terus platform. Jemputan ejen guna kadar ejen yang ditetapkan admin.',
'user.field.player_id': 'ID pemain',
'user.field.bet_count': 'Bilangan pertaruhan',
@@ -544,6 +558,57 @@ export const adminPagesMs: Record<string, string> = {
'agent_portal.initial_deposit_hint': 'Pilihan. Tambah baki awal dari kredit anda semasa pendaftaran',
'agent_portal.search_player_ph': 'Nama pengguna atau ID',
'agent_portal.no_players': 'Tiada pemain langsung. Klik butang di atas untuk cipta.',
'invite.title': 'Kod jemputan & pautan daftar',
'invite.menu_btn': 'Jemputan',
'invite.dialog_title': 'Pengurusan jemputan',
'invite.tab_generate': 'Jana jemputan',
'invite.tab_history': 'Sejarah',
'invite.hint': 'Klik untuk jana kod jemputan dan pautan daftar. Pemain boleh masukkan kod; kosongkan untuk terus platform.',
'invite.generate_btn': 'Jana kod / pautan',
'invite.regenerate_btn': 'Jana semula',
'invite.generate_ok': 'Kod jemputan dijana',
'invite.generate_failed': 'Gagal jana — sila cuba lagi',
'invite.not_generated': 'Belum jana kod jemputan',
'invite.code': 'Kod jemputan',
'invite.cashback_rate': 'Kadar rebat',
'invite.cashback_rate_hint': 'Pemain yang daftar dengan kod ini guna kadar ini. Lalai dari kadar jemputan admin global.',
'invite.link': 'Pautan daftar',
'invite.copy_code': 'Salin kod',
'invite.copy_code_short': 'Salin',
'invite.copy_link_short': 'Salin pautan',
'invite.copy_link': 'Salin pautan daftar',
'invite.copy_code_ok': 'Kod jemputan disalin',
'invite.copy_link_ok': 'Pautan daftar disalin',
'invite.copy_failed': 'Salinan gagal — sila salin secara manual',
'invite.unavailable': 'Kod jemputan tidak tersedia',
'invite.history_title': 'Sejarah jemputan',
'invite.view_history': 'Lihat sejarah',
'invite.history_hint': 'Admin lihat semua kod; ejen lihat kod sendiri dan ejen bawahan.',
'invite.page_desc': 'Lihat sejarah kod jemputan, status dan bilangan pendaftaran.',
'invite.history_load_failed': 'Gagal memuatkan sejarah jemputan',
'invite.filter_status': 'Status',
'invite.filter_sponsor': 'Penjemput',
'invite.filter_code': 'Kod',
'invite.filter_code_ph': 'Masukkan kod',
'invite.col_status': 'Status',
'invite.col_sponsor': 'Penjemput',
'invite.col_registrant': 'Pendaftar',
'invite.not_registered': 'Belum daftar',
'invite.col_cashback_rate': 'Rebat',
'invite.col_created': 'Dicipta',
'invite.col_revoked': 'Dibatalkan',
'invite.status.ACTIVE': 'Aktif',
'invite.status.USED': 'Digunakan',
'invite.status.REVOKED': 'Dibatalkan',
'invite.revoke_btn': 'Batalkan',
'invite.revoke_title': 'Batalkan kod jemputan',
'invite.revoke_confirm': 'Batalkan kod {code}? Kod tidak boleh digunakan untuk pendaftaran.',
'invite.revoke_ok': 'Kod jemputan dibatalkan',
'invite.revoke_failed': 'Gagal batalkan — sila cuba lagi',
'invite.delete_title': 'Padam rekod jemputan',
'invite.delete_confirm': 'Padam sejarah kod {code}? Tindakan ini tidak boleh dibatalkan.',
'invite.delete_ok': 'Rekod jemputan dipadam',
'invite.delete_failed': 'Gagal padam — sila cuba lagi',
'agent_portal.search_sub_agent_ph': 'Nama pengguna atau ID',
'agent_portal.no_sub_agents': 'Tiada ejen peringkat 2. Klik butang di atas untuk cipta.',
'agent_portal.no_sub_agents_level': 'Tiada ejen peringkat {level}. Klik butang di atas untuk cipta.',

View File

@@ -27,6 +27,7 @@ export const adminPagesZh: Record<string, string> = {
'user.filter.agent_ph': '全部',
'user.col.username': '用户名',
'user.col.agent': '所属代理',
'user.col.invite_code': '邀请码',
'user.col.balance': '可用 / 冻结',
'user.col.bets': '注单',
'user.col.stake_payout': '投注 / 派彩',
@@ -141,7 +142,7 @@ export const adminPagesZh: Record<string, string> = {
'agent.field.player_liability': '玩家负债',
'agent.field.sub_agent_exposure': '下级代理敞口',
'agent.hint.credit_limit': '代理可向直属玩家上分的总额度上限',
'agent.hint.cashback_example': '例如 0.01 表示 1%',
'agent.hint.cashback_example': '例如填写 1 表示 1%',
'agent.field.max_single_deposit': '单笔上分限额',
'agent.field.max_daily_deposit': '日上分限额',
'agent.hint.deposit_limit_empty': '0 表示不限;下级代理不能超过上级设置',
@@ -181,9 +182,10 @@ export const adminPagesZh: Record<string, string> = {
'finance.tx.cashback': '返水',
'finance.tx.resettle': '重结算调整',
'user.action.view_wallet_ledger': '查看资金流水',
'user.action.ledger_short': '流水',
'user.wallet_ledger_dialog_title': '{name} 的资金流水',
'agent.hierarchy.settings_title': '代理层级设置',
'agent.hierarchy.settings_hint': '0 表示不限制代理层级;达到上限的代理将无法创建下级。下级默认授信比例用于创建下级代理时的预填额度。',
'agent.hierarchy.settings_hint': '0 表示不限制代理层级;达到上限的代理将无法创建下级。',
'agent.hierarchy.max_level': '最大代理层级',
'agent.hierarchy.default_sub_credit_ratio': '下级默认授信比例',
'agent.hierarchy.default_sub_credit_ratio_hint': '创建下级代理时,授信额度默认预填为上级可用授信 × 此比例',
@@ -203,7 +205,12 @@ export const adminPagesZh: Record<string, string> = {
'finance.col.balance_change': '余额变动',
'finance.col.balance_before': '变动前余额',
'finance.col.balance_after': '变动后余额',
'finance.tx.deposit': '上分',
'finance.col.tx_type': '类型',
'finance.col.deposit_method': '充值方式',
'finance.deposit_method.manual_admin': '管理员人工充值',
'finance.deposit_method.manual_agent': '代理人工充值',
'finance.deposit_method.manual': '人工充值',
'finance.tx.deposit': '充值',
'finance.tx.withdraw': '下分',
'finance.tx.request_id': '请求 ID',
'finance.remark.agent_deposit': '代理上分',
@@ -387,10 +394,17 @@ export const adminPagesZh: Record<string, string> = {
'cashback.rule_period': '选择开始/结束日期,统计该周期内、按注单结算时间落在区间内的有效投注。',
'cashback.rule_eligible': '计入:已结算且结果为「赢」或「输」的注单(单关按本金,串关按整单本金计一次)。不计入:未结算、已取消、作废、走水,以及返水比例为 0 的注单;已返水过的注单不会重复计入。',
'cashback.rule_formula': '单笔返水 = 投注本金 × 适用返水比例;同一玩家多笔注单汇总后生成一条返水明细。',
'cashback.rule_rate': '返水比例优先级:玩家专属规则 > 代理线规则 > 全局规则 > 所属代理默认返水率(在代理/玩家管理中配置,如 0.01 表示 1%。',
'cashback.rule_rate': '返水比例优先级:玩家专属规则 > 代理线规则 > 全局规则 > 默认返水率(代理邀请用代理返水;管理员邀请用「管理员邀请返水」;无邀请码用「平台直属默认返水」;可在玩家管理中单独覆盖)。以百分比填写,如 1 表示 1%。',
'cashback.rule_flow': '操作流程:生成预览(同周期仅保留一条待发放)→ 核对明细 → 确认发放;不需要的可作废。已发放周期不可重复预览。',
'cashback.rule_platform': '发放方式:返水由平台直接打入玩家现金余额,不从代理信用或余额中扣除。',
'cashback.rule_note_zero': '预览为 0 时,请检查:周期内是否有已结算输赢注单、代理/玩家是否配置了大于 0 的返水率。',
'cashback.rule_note_zero': '预览为 0 时,请检查:周期内是否有已结算输赢注单、返水比例是否大于 0含全局设置中的平台直属默认比例与玩家/代理单独设置)。',
'cashback.use_custom_rate': '单独设置返水比例',
'cashback.use_default_rate': '使用默认比例 {rate}',
'cashback.settings_title': '返水设置',
'cashback.platform_direct_default_rate': '平台直属默认返水比例',
'cashback.platform_direct_default_hint': '无邀请码自助注册的玩家在未单独设置时使用此比例。',
'cashback.admin_invite_default_rate': '管理员邀请返水比例',
'cashback.admin_invite_default_hint': '玩家通过管理员邀请码注册时使用;默认与平台直属相同,可单独调整。代理邀请玩家使用管理员在代理管理中为该代理设置的返水比例。',
'user.field.player_id': '玩家 ID',
'user.field.bet_count': '注单数',
@@ -609,6 +623,57 @@ export const adminPagesZh: Record<string, string> = {
'agent_portal.initial_deposit_hint': '可选。开户时从您的授信中给玩家上分,不能超过可用授信',
'agent_portal.search_player_ph': '用户名或 ID',
'agent_portal.no_players': '暂无直属玩家,点击右上角创建',
'invite.title': '邀请码与注册链接',
'invite.menu_btn': '邀请',
'invite.dialog_title': '邀请管理',
'invite.tab_generate': '生成邀请',
'invite.tab_history': '邀请历史',
'invite.hint': '点击生成邀请码与注册链接;玩家填邀请码注册,不填则为平台直属玩家',
'invite.generate_btn': '生成邀请码/链接',
'invite.regenerate_btn': '重新生成',
'invite.generate_ok': '邀请码已生成',
'invite.generate_failed': '生成失败,请重试',
'invite.not_generated': '尚未生成邀请码',
'invite.code': '邀请码',
'invite.cashback_rate': '返水比例',
'invite.cashback_rate_hint': '该邀请码注册的玩家将使用此返水比例;默认读取全局「管理员邀请返水比例」。',
'invite.link': '注册链接',
'invite.copy_code': '复制邀请码',
'invite.copy_code_short': '复制码',
'invite.copy_link_short': '复制链接',
'invite.copy_link': '复制注册链接',
'invite.copy_code_ok': '邀请码已复制',
'invite.copy_link_ok': '注册链接已复制',
'invite.copy_failed': '复制失败,请手动复制',
'invite.unavailable': '邀请码暂不可用',
'invite.history_title': '邀请历史',
'invite.view_history': '查看历史',
'invite.history_hint': '管理员可查看全部邀请码;代理仅可查看自己及下级代理的邀请码',
'invite.page_desc': '查看全部邀请码历史与状态,生成或作废邀请码',
'invite.history_load_failed': '加载邀请历史失败',
'invite.filter_status': '状态',
'invite.filter_sponsor': '邀请人',
'invite.filter_code': '邀请码',
'invite.filter_code_ph': '输入邀请码',
'invite.col_status': '状态',
'invite.col_sponsor': '邀请人',
'invite.col_registrant': '注册人',
'invite.not_registered': '未注册',
'invite.col_cashback_rate': '返水比例',
'invite.col_created': '创建时间',
'invite.col_revoked': '作废时间',
'invite.status.ACTIVE': '有效',
'invite.status.USED': '已使用',
'invite.status.REVOKED': '已作废',
'invite.revoke_btn': '作废',
'invite.revoke_title': '作废邀请码',
'invite.revoke_confirm': '确定作废邀请码 {code}?作废后该码将无法用于注册。',
'invite.revoke_ok': '邀请码已作废',
'invite.revoke_failed': '作废失败,请重试',
'invite.delete_title': '删除邀请记录',
'invite.delete_confirm': '确定删除邀请码 {code} 的历史记录?删除后不可恢复。',
'invite.delete_ok': '邀请记录已删除',
'invite.delete_failed': '删除失败,请重试',
'agent_portal.search_sub_agent_ph': '用户名或 ID',
'agent_portal.no_sub_agents': '暂无二级代理,点击右上角创建',
'agent_portal.no_sub_agents_level': '暂无{level}级代理,点击右上角创建',
@@ -901,6 +966,7 @@ export const adminPagesEn: Record<string, string> = {
'user.filter.agent_ph': 'All',
'user.col.username': 'Username',
'user.col.agent': 'Agent',
'user.col.invite_code': 'Invite code',
'user.col.balance': 'Available / Frozen',
'user.col.bets': 'Bets',
'user.col.stake_payout': 'Stake / Payout',
@@ -1015,7 +1081,7 @@ export const adminPagesEn: Record<string, string> = {
'agent.field.player_liability': 'Player liability',
'agent.field.sub_agent_exposure': 'Sub-agent exposure',
'agent.hint.credit_limit': 'Max total top-up capacity for direct players',
'agent.hint.cashback_example': 'e.g. 0.01 = 1%',
'agent.hint.cashback_example': 'e.g. enter 1 for 1%',
'agent.field.max_single_deposit': 'Max single top-up',
'agent.field.max_daily_deposit': 'Max daily top-up',
'agent.hint.deposit_limit_empty': '0 = unlimited; sub-agents cannot exceed parent limits',
@@ -1055,9 +1121,10 @@ export const adminPagesEn: Record<string, string> = {
'finance.tx.cashback': 'Cashback',
'finance.tx.resettle': 'Resettlement',
'user.action.view_wallet_ledger': 'View wallet ledger',
'user.action.ledger_short': 'Ledger',
'user.wallet_ledger_dialog_title': 'Wallet ledger — {name}',
'agent.hierarchy.settings_title': 'Agent hierarchy',
'agent.hierarchy.settings_hint': '0 means unlimited levels. Agents at the cap cannot create sub-agents. The default credit ratio pre-fills sub-agent credit limits.',
'agent.hierarchy.settings_hint': '0 means unlimited levels. Agents at the cap cannot create sub-agents.',
'agent.hierarchy.max_level': 'Max agent level',
'agent.hierarchy.default_sub_credit_ratio': 'Default sub-agent credit ratio',
'agent.hierarchy.default_sub_credit_ratio_hint': 'When creating a sub-agent, pre-fill credit as parent available × this ratio',
@@ -1077,6 +1144,11 @@ export const adminPagesEn: Record<string, string> = {
'finance.col.balance_change': 'Balance change',
'finance.col.balance_before': 'Balance before',
'finance.col.balance_after': 'Balance after',
'finance.col.tx_type': 'Type',
'finance.col.deposit_method': 'Deposit method',
'finance.deposit_method.manual_admin': 'Admin manual deposit',
'finance.deposit_method.manual_agent': 'Agent manual deposit',
'finance.deposit_method.manual': 'Manual deposit',
'finance.tx.deposit': 'Deposit',
'finance.tx.withdraw': 'Withdraw',
'finance.tx.request_id': 'Request ID',
@@ -1261,10 +1333,17 @@ export const adminPagesEn: Record<string, string> = {
'cashback.rule_period': 'Pick a date range. Bets are included by settlement time within that period.',
'cashback.rule_eligible': 'Included: settled bets with result WON or LOST (singles by stake; parlays counted once by parlay stake). Excluded: pending, cancelled, void, push, zero-rate bets, and bets already paid cashback.',
'cashback.rule_formula': 'Per bet: stake × applicable cashback rate. Amounts are summed per player into one line item.',
'cashback.rule_rate': 'Rate priority: player rule > agent rule > global rule > agent default rate (set under Agents/Players, e.g. 0.01 = 1%).',
'cashback.rule_rate': 'Rate priority: player rule > agent rule > global rule > default rate (agent-line players use their agent default; platform-direct players use the rate under Global settings; override per player in player management). Enter as percent, e.g. 1 = 1%.',
'cashback.rule_flow': 'Flow: preview (one pending batch per period) → review → confirm payout; void if not needed. Paid periods cannot be previewed again.',
'cashback.rule_platform': 'Payout: cashback is credited to player cash balance by the platform; it is not deducted from agent credit or balance.',
'cashback.rule_note_zero': 'If preview is 0, check for settled WON/LOST bets in the period and a cashback rate above 0.',
'cashback.rule_note_zero': 'If preview is 0, check for settled WON/LOST bets in the period and a cashback rate above 0 (including platform-direct default in Global settings and per-player/agent overrides).',
'cashback.use_custom_rate': 'Custom cashback rate',
'cashback.use_default_rate': 'Use default rate {rate}',
'cashback.settings_title': 'Cashback settings',
'cashback.platform_direct_default_rate': 'Platform-direct default cashback rate',
'cashback.admin_invite_default_rate': 'Admin invite cashback rate',
'cashback.admin_invite_default_hint': 'Used when players register with an admin invite code; defaults to platform-direct rate. Agent invites use the rate set for that agent under Agent management.',
'cashback.platform_direct_default_hint': 'Used for self-registration without an invite code unless overridden per player.',
'user.field.player_id': 'Player ID',
'user.field.bet_count': 'Bet count',
@@ -1484,6 +1563,57 @@ export const adminPagesEn: Record<string, string> = {
'agent_portal.initial_deposit_hint': 'Optional. Initial top-up from your credit at account creation',
'agent_portal.search_player_ph': 'Username or ID',
'agent_portal.no_players': 'No direct players yet. Use the button above to create one.',
'invite.title': 'Invitation code & register link',
'invite.menu_btn': 'Invite',
'invite.dialog_title': 'Invitations',
'invite.tab_generate': 'Generate',
'invite.tab_history': 'History',
'invite.hint': 'Generate an invite code and register link. Players may enter the code to register; blank registers as platform-direct.',
'invite.generate_btn': 'Generate code / link',
'invite.regenerate_btn': 'Regenerate',
'invite.generate_ok': 'Invitation code generated',
'invite.generate_failed': 'Failed to generate — please retry',
'invite.not_generated': 'No invitation code yet',
'invite.code': 'Code',
'invite.cashback_rate': 'Cashback rate',
'invite.cashback_rate_hint': 'Players registering with this code use this rate. Defaults to the global admin-invite cashback rate.',
'invite.link': 'Register link',
'invite.copy_code': 'Copy code',
'invite.copy_code_short': 'Copy',
'invite.copy_link': 'Copy register link',
'invite.copy_link_short': 'Copy link',
'invite.copy_code_ok': 'Invitation code copied',
'invite.copy_link_ok': 'Register link copied',
'invite.copy_failed': 'Copy failed — please copy manually',
'invite.unavailable': 'Invitation code unavailable',
'invite.history_title': 'Invitation history',
'invite.view_history': 'View history',
'invite.history_hint': 'Admins see all codes; agents see their own and sub-agents codes.',
'invite.page_desc': 'View all invitation codes, status, and registration counts.',
'invite.history_load_failed': 'Failed to load invitation history',
'invite.filter_status': 'Status',
'invite.filter_sponsor': 'Sponsor',
'invite.filter_code': 'Code',
'invite.filter_code_ph': 'Enter code',
'invite.col_status': 'Status',
'invite.col_sponsor': 'Sponsor',
'invite.col_registrant': 'Registrant',
'invite.not_registered': 'Not registered',
'invite.col_cashback_rate': 'Cashback',
'invite.col_created': 'Created',
'invite.col_revoked': 'Revoked',
'invite.status.ACTIVE': 'Active',
'invite.status.USED': 'Used',
'invite.status.REVOKED': 'Revoked',
'invite.revoke_btn': 'Revoke',
'invite.revoke_title': 'Revoke invitation code',
'invite.revoke_confirm': 'Revoke code {code}? It will no longer work for registration.',
'invite.revoke_ok': 'Invitation code revoked',
'invite.revoke_failed': 'Failed to revoke — please retry',
'invite.delete_title': 'Delete invitation record',
'invite.delete_confirm': 'Delete history for code {code}? This cannot be undone.',
'invite.delete_ok': 'Invitation record deleted',
'invite.delete_failed': 'Failed to delete — please retry',
'agent_portal.search_sub_agent_ph': 'Username or ID',
'agent_portal.no_sub_agents': 'No tier-2 agents yet. Use the button above to create one.',
'agent_portal.no_sub_agents_level': 'No L{level} agents yet. Use the button above to create one.',

View File

@@ -19,6 +19,7 @@ const adminMenus = computed(() => [
{ path: '/matches', label: t('nav.matches'), matchPrefix: true },
{ path: '/users', label: t('nav.agents_players') },
{ path: '/finance-logs', label: t('nav.finance_logs') },
{ path: '/deposit', label: t('nav.deposit_manage'), matchPrefix: true },
{ path: '/cashback', label: t('nav.cashback') },
{ path: '/bets', label: t('nav.bets') },
{ path: '/contents', label: t('nav.contents') },
@@ -49,10 +50,15 @@ function isDashboardSectionPath(path: string) {
return path === '/' || path === '/dashboard/players';
}
function isDepositSectionPath(path: string) {
return path === '/deposit' || path === '/deposit-orders' || path === '/payment-methods';
}
const currentLabel = computed(() => {
const hit = menus.value.find((m) => {
if ('matchPrefix' in m && m.matchPrefix) {
if (m.path === '/') return isDashboardSectionPath(route.path);
if (m.path === '/deposit') return isDepositSectionPath(route.path);
return isMatchesSectionPath(route.path);
}
return route.path === m.path;
@@ -147,6 +153,8 @@ watch(() => route.path, () => {
m.matchPrefix &&
(m.path === '/'
? isDashboardSectionPath(route.path)
: m.path === '/deposit'
? isDepositSectionPath(route.path)
: isMatchesSectionPath(route.path))),
}"
@click="onNavClick"
@@ -224,8 +232,8 @@ watch(() => route.path, () => {
flex-shrink: 0;
position: fixed;
top: 0; left: 0; bottom: 0;
background: rgba(6, 6, 6, 0.98);
border-right: 1px solid #1c1c1c;
background: #0a0a0a;
border-right: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
z-index: 100;
@@ -264,25 +272,22 @@ watch(() => route.path, () => {
.nav-item {
display: flex;
align-items: center;
padding: 9px 10px;
padding: 8px 10px;
border-radius: 6px;
color: #aaa;
color: #737373;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
border-left: 2px solid transparent;
letter-spacing: 0.02em;
font-weight: 450;
transition: color 0.15s, background 0.15s;
letter-spacing: 0;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
background: rgba(255, 255, 255, 0.04);
color: #d4d4d4;
}
.nav-item.active {
background: linear-gradient(90deg, rgba(36, 143, 84, 0.22), rgba(36, 143, 84, 0.04));
color: var(--green-text);
font-weight: 700;
border-left-color: var(--green-bright);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
background: rgba(255, 255, 255, 0.06);
color: #f5f5f5;
font-weight: 500;
}
.sidebar-foot {
@@ -322,9 +327,8 @@ watch(() => route.path, () => {
box-sizing: border-box;
display: flex; align-items: center; justify-content: space-between;
padding: 0 28px;
background: rgba(6, 6, 6, 0.98);
border-bottom: 1px solid #1a1a1a;
backdrop-filter: blur(12px);
background: #0a0a0a;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.topbar-left {
@@ -361,10 +365,10 @@ watch(() => route.path, () => {
}
.topbar-title {
display: flex; align-items: center; gap: 12px;
font-size: 15px; font-weight: 700;
color: #e8e8e8;
letter-spacing: 0.04em;
display: flex; align-items: center; gap: 10px;
font-size: 14px; font-weight: 500;
color: #f5f5f5;
letter-spacing: 0;
min-width: 0;
}
.topbar-page-label {
@@ -382,26 +386,24 @@ watch(() => route.path, () => {
min-width: 0;
}
.crumb-link {
color: var(--green-text);
color: #737373;
transition: color 0.15s;
}
.crumb-link:hover {
color: #d4fde5;
color: #f5f5f5;
}
.crumb-current {
color: #e8e8e8;
color: #f5f5f5;
}
.crumb-sep {
color: #444;
color: #525252;
font-weight: 400;
user-select: none;
}
.topbar-accent {
width: 3px; height: 16px;
background: linear-gradient(180deg, var(--green-glow), var(--green-deep));
border-radius: 2px;
width: 1px; height: 14px;
background: rgba(255, 255, 255, 0.12);
flex-shrink: 0;
box-shadow: 0 0 8px rgba(47, 181, 106, 0.45);
}
.topbar-page-actions {
@@ -424,12 +426,11 @@ watch(() => route.path, () => {
display: flex; align-items: center; gap: 8px;
}
.avatar {
width: 30px; height: 30px; border-radius: 50%;
background: var(--primary-grad);
border: 1px solid var(--green-border);
box-shadow: var(--primary-shadow);
width: 28px; height: 28px; border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 800; color: #fff;
font-size: 11px; font-weight: 500; color: #d4d4d4;
flex-shrink: 0;
}
.user-info {
@@ -445,14 +446,14 @@ watch(() => route.path, () => {
}
.portal-tag {
padding: 3px 8px;
border: 1px solid var(--green-border);
padding: 2px 7px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
font-size: 11px;
color: var(--green-text);
background: var(--green-surface);
font-weight: 600;
letter-spacing: 0.04em;
color: #737373;
background: transparent;
font-weight: 500;
letter-spacing: 0;
}
.btn-logout {

View File

@@ -25,6 +25,10 @@ const router = createRouter({
},
],
},
{
path: 'invites',
redirect: '/users',
},
{
path: 'users',
component: () => import('../views/AgentManager.vue'),
@@ -110,6 +114,22 @@ const router = createRouter({
component: () => import('../views/MediaLibrary.vue'),
meta: { adminOnly: true },
},
{
path: 'payment-methods',
redirect: (to) => ({
path: '/deposit',
query: { ...to.query, tab: 'methods' },
}),
},
{
path: 'deposit-orders',
redirect: '/deposit',
},
{
path: 'deposit',
component: () => import('../views/DepositManage.vue'),
meta: { adminOnly: true },
},
{
path: 'my-players',
component: () => import('../views/agent/Players.vue'),

View File

@@ -11,6 +11,7 @@ export interface StaffUser {
agentLevel?: number | null;
maxAgentLevel?: number | null;
canManageSubAgents?: boolean;
inviteCode?: string | null;
}
const TOKEN_KEY = 'manage_token';
@@ -94,6 +95,7 @@ export function reconcileStaffSessionFromToken(): boolean {
userType: claims.userType,
locale: user.value?.locale,
role: claims.role ?? user.value?.role,
inviteCode: user.value?.inviteCode,
};
user.value = next;
localStorage.setItem(USER_KEY, JSON.stringify(next));

View File

@@ -0,0 +1,26 @@
export function buildPlayerRegisterUrl(inviteCode: string): string {
const configured = (import.meta.env.VITE_PLAYER_URL as string | undefined)?.trim();
const base = configured?.replace(/\/$/, '') || `${window.location.protocol}//${window.location.hostname}:5173`;
return `${base}/register?code=${encodeURIComponent(inviteCode)}`;
}
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const el = document.createElement('textarea');
el.value = text;
el.style.position = 'fixed';
el.style.opacity = '0';
document.body.appendChild(el);
el.select();
const ok = document.execCommand('copy');
document.body.removeChild(el);
return ok;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,19 @@
/** 后端/API 存小数0.01 = 1%),后台 UI 用百分比1 = 1% */
export function decimalRateToPercent(value: string | number | null | undefined): number {
const n = typeof value === 'number' ? value : parseFloat(String(value ?? '0'));
if (!Number.isFinite(n)) return 0;
return Math.round(n * 10000) / 100;
}
export function percentToDecimalRate(percent: string | number | null | undefined): number {
const n = typeof percent === 'number' ? percent : parseFloat(String(percent ?? '0'));
if (!Number.isFinite(n) || n <= 0) return 0;
return Math.round(n * 100) / 10000;
}
export function formatRatePercent(value: string | number | null | undefined): string {
const n = typeof value === 'number' ? value : parseFloat(String(value ?? ''));
if (!Number.isFinite(n)) return '—';
return `${(n * 100).toFixed(2)}%`;
}

View File

@@ -49,6 +49,7 @@ export async function hydrateStaffSession(): Promise<boolean> {
agentLevel: typeof raw.agentLevel === 'number' ? raw.agentLevel : null,
maxAgentLevel: typeof raw.maxAgentLevel === 'number' ? raw.maxAgentLevel : null,
canManageSubAgents: raw.canManageSubAgents === true,
inviteCode: raw.inviteCode ?? null,
});
return true;
} catch (e: unknown) {

View File

@@ -3,7 +3,7 @@ import { buildBarChartOption, buildPieChartOption, type PieSegment } from './das
const STATUS_COLORS: Record<string, string> = {
PENDING: '#e8a040',
WON: '#2fb56a',
WON: '#d4d4d4',
LOST: '#ff453a',
PUSH: '#8e8e93',
VOID: '#555',
@@ -74,7 +74,7 @@ export function buildBetTypePieOption(input: {
centerLabel: string;
}): EChartsOption {
const segments: PieSegment[] = [
{ label: input.labels.single, value: input.singleBets, color: '#2fb56a' },
{ label: input.labels.single, value: input.singleBets, color: '#d4d4d4' },
{ label: input.labels.parlay, value: input.parlayBets, color: '#fb923c' },
].filter((s) => s.value > 0);
@@ -112,7 +112,7 @@ export function buildSelectionStakeBarOption(input: {
}): EChartsOption {
const base = buildBarChartOption(
input.labels,
[{ name: input.seriesName, color: '#2fb56a', values: input.stakes }],
[{ name: input.seriesName, color: '#d4d4d4', values: input.stakes }],
{ amountAxis: true },
);
return withCompactHeader(

View File

@@ -16,8 +16,38 @@ export const TX_KEY_MAP: Record<string, string> = {
RESETTLE_REVERSE: 'finance.tx.resettle',
DEPOSIT: 'finance.tx.deposit',
WITHDRAW: 'finance.tx.withdraw',
PLAYER_DEPOSIT: 'finance.tx.deposit',
};
export function walletTxTypeKey(type: string): string {
return TX_KEY_MAP[type.toUpperCase()] ?? '';
}
export const DEPOSIT_RECHARGE_TYPES = new Set([
'MANUAL_DEPOSIT',
'DEPOSIT',
'PLAYER_DEPOSIT',
]);
export const DEPOSIT_METHOD_KEY_MAP: Record<string, string> = {
MANUAL_ADMIN: 'finance.deposit_method.manual_admin',
MANUAL_AGENT: 'finance.deposit_method.manual_agent',
MANUAL: 'finance.deposit_method.manual',
};
export function walletDepositMethodLabel(
row: {
transactionType: string;
depositMethodKey?: string | null;
depositMethodName?: string | null;
},
t: (key: string) => string,
): string {
if (!DEPOSIT_RECHARGE_TYPES.has(row.transactionType.toUpperCase())) return '—';
if (row.depositMethodName?.trim()) return row.depositMethodName.trim();
if (row.depositMethodKey) {
const key = DEPOSIT_METHOD_KEY_MAP[row.depositMethodKey];
return key ? t(key) : row.depositMethodKey;
}
return '—';
}

View File

@@ -46,6 +46,14 @@ import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import PlayerWalletLedgerDialog from '../components/PlayerWalletLedgerDialog.vue';
import WalletTransferContext from '../components/WalletTransferContext.vue';
import AgentCreditContext from '../components/AgentCreditContext.vue';
import RatePercentInput from '../components/RatePercentInput.vue';
import { formatRatePercent, percentToDecimalRate, decimalRateToPercent } from '../utils/rate-percent';
import InviteCodePanel from '../components/InviteCodePanel.vue';
import InviteManageDialog from '../components/InviteManageDialog.vue';
import AdminRowActionsDropdown from '../components/AdminRowActionsDropdown.vue';
import AdminPlayerRowActions from '../components/AdminPlayerRowActions.vue';
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
import AdminDetailItem from '../components/AdminDetailItem.vue';
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
import {
fetchAdminAgentCreditContext,
@@ -53,6 +61,8 @@ import {
type AgentCreditAdjustContext,
} from '../utils/agent-credit-context';
const inviteDialogOpen = ref(false);
/* ─── Tier-1 agent list ─── */
const tier1Agents = ref<AgentRow[]>([]);
const tier1Total = ref(0);
@@ -75,6 +85,10 @@ const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
const agentLevelCounts = ref<Record<number, number>>({});
const subAgentTableRefs = ref<Record<number, { toggleRowExpansion: (row: AgentRow) => void } | null>>({});
function setSubAgentTableRef(level: number, el: unknown) {
subAgentTableRefs.value[level] = el as { toggleRowExpansion: (row: AgentRow) => void } | null;
}
function ensureSubAgentState(level: number): SubAgentLevelState {
if (!subAgentLevelState[level]) {
subAgentLevelState[level] = {
@@ -189,7 +203,8 @@ const bettingLimits = ref({
});
const settingsSaving = ref(false);
const limitsSaving = ref(false);
const hierarchySettings = ref({ maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 });
const hierarchySettings = ref({ maxAgentLevel: 0 });
const DEFAULT_SUB_AGENT_CREDIT_RATIO = 50;
const freezeAgentVisible = ref(false);
const freezeAgentLoading = ref(false);
const freezeAgentTarget = ref<AgentRow | null>(null);
@@ -199,6 +214,9 @@ const freezeAgentForm = ref({
unfreezeDirectPlayers: false,
});
const hierarchySaving = ref(false);
const platformDirectRate = ref(0);
const adminInviteRate = ref(0);
const platformDirectSaving = ref(false);
const resetAllowed = ref(false);
const resetLoading = ref(false);
const resetConfirmPhrase = ref('');
@@ -296,7 +314,7 @@ function computeSubAgentCreditByRatio(available: number, ratioPercent: number):
const creditQuickRatios = [10, 15, 20, 30] as const;
function computeDefaultSubAgentCreditLimit(available: number): number {
return computeSubAgentCreditByRatio(available, hierarchySettings.value.defaultSubAgentCreditRatio ?? 50);
return computeSubAgentCreditByRatio(available, DEFAULT_SUB_AGENT_CREDIT_RATIO);
}
function applyCreateSubAgentCreditRatio(ratioPercent: number) {
@@ -370,6 +388,7 @@ onMounted(() => {
loadPlayerSettings();
loadBettingLimits();
loadHierarchySettings();
loadPlatformDirectSettings();
loadResetDatabaseStatus();
loadAgentOptions();
loadAllPlayers();
@@ -453,6 +472,14 @@ function onSubAgentSizeChange(level: number, size: number) {
loadSubAgentsAtLevel(level);
}
function bindSubAgentPageChange(level: number) {
return (p: number) => onSubAgentPageChange(level, p);
}
function bindSubAgentSizeChange(level: number) {
return (size: number) => onSubAgentSizeChange(level, size);
}
function searchSubAgentsAtLevel(level: number) {
ensureSubAgentState(level).page = 1;
loadSubAgentsAtLevel(level);
@@ -537,8 +564,14 @@ function onTier1AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent
}
function onSubAgentRowClick(level: number, row: AgentRow, _column: unknown, event: MouseEvent) {
const tableRef = { value: subAgentTableRefs.value[level] };
onAgentRowClick(row, tableRef, _column, event);
if (!shouldToggleExpandOnRowClick(event)) return;
subAgentTableRefs.value[level]?.toggleRowExpansion(row);
}
function bindSubAgentRowClick(level: number) {
return (row: AgentRow, column: unknown, event: MouseEvent) => {
onSubAgentRowClick(level, row, column, event);
};
}
watch(activeViewTab, (tab) => {
@@ -682,9 +715,9 @@ async function savePlayerSettings() {
async function loadHierarchySettings() {
try {
const { data } = await api.get('/admin/agents/settings/hierarchy');
hierarchySettings.value = data.data;
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? 0 };
} catch {
hierarchySettings.value = { maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 };
hierarchySettings.value = { maxAgentLevel: 0 };
}
}
@@ -692,7 +725,7 @@ async function saveHierarchySettings() {
hierarchySaving.value = true;
try {
const { data } = await api.put('/admin/agents/settings/hierarchy', hierarchySettings.value);
hierarchySettings.value = data.data;
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? hierarchySettings.value.maxAgentLevel };
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -703,6 +736,38 @@ async function saveHierarchySettings() {
}
}
async function loadPlatformDirectSettings() {
try {
const { data } = await api.get('/admin/settings/cashback/platform-direct');
const payload = data.data as { platformDirectRate?: number | string; adminInviteRate?: number | string };
platformDirectRate.value = decimalRateToPercent(payload?.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0);
} catch {
platformDirectRate.value = 0;
adminInviteRate.value = 0;
}
}
async function savePlatformDirectSettings() {
platformDirectSaving.value = true;
try {
const { data } = await api.put('/admin/settings/cashback/platform-direct', {
platformDirectRate: percentToDecimalRate(platformDirectRate.value),
adminInviteRate: percentToDecimalRate(adminInviteRate.value),
});
const payload = data.data as { platformDirectRate?: number | string; adminInviteRate?: number | string };
platformDirectRate.value = decimalRateToPercent(payload?.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0);
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
loadPlatformDirectSettings();
} finally {
platformDirectSaving.value = false;
}
}
const walletLedgerVisible = ref(false);
const walletLedgerPlayerId = ref('');
const walletLedgerPlayerUsername = ref<string | null>(null);
@@ -792,7 +857,7 @@ async function submitCreate() {
phone: createForm.value.phone.trim() || undefined,
email: createForm.value.email.trim() || undefined,
creditLimit: createForm.value.creditLimit,
cashbackRate: createForm.value.cashbackRate,
cashbackRate: percentToDecimalRate(createForm.value.cashbackRate),
maxSingleDeposit: createForm.value.maxSingleDeposit > 0 ? createForm.value.maxSingleDeposit : undefined,
maxDailyDeposit: createForm.value.maxDailyDeposit > 0 ? createForm.value.maxDailyDeposit : undefined,
};
@@ -857,12 +922,16 @@ async function submitEditPlayer() {
editPlayerLoading.value = true;
try {
const newPwd = editPlayerForm.value.newPassword.trim();
const { data } = await api.put(`/admin/users/${editingId.value}`, {
const payload: Record<string, unknown> = {
username: editPlayerForm.value.username.trim(),
phone: editPlayerForm.value.phone.trim() || undefined,
email: editPlayerForm.value.email.trim() || undefined,
password: newPwd || undefined,
});
cashbackRate: editPlayerForm.value.useCustomCashback
? percentToDecimalRate(editPlayerForm.value.customCashbackRate ?? 0)
: null,
};
const { data } = await api.put(`/admin/users/${editingId.value}`, payload);
const updated = data.data as PlayerDetail;
if (newPwd) {
editPlayerForm.value.managedPassword = updated.managedPassword ?? newPwd;
@@ -904,7 +973,7 @@ async function submitEditAgent() {
status: editAgentForm.value.status,
phone: editAgentForm.value.phone.trim() || undefined,
email: editAgentForm.value.email.trim() || undefined,
cashbackRate: editAgentForm.value.cashbackRate,
cashbackRate: percentToDecimalRate(editAgentForm.value.cashbackRate),
maxSingleDeposit: editAgentForm.value.maxSingleDeposit,
maxDailyDeposit: editAgentForm.value.maxDailyDeposit,
password: newPwd || undefined,
@@ -1178,22 +1247,27 @@ function creditTypeLabel(type: string) {
:disabled="hierarchySaving"
/>
</el-form-item>
<el-form-item :label="t('agent.hierarchy.default_sub_credit_ratio')">
<el-input-number
v-model="hierarchySettings.defaultSubAgentCreditRatio"
:min="1"
:max="100"
:step="5"
controls-position="right"
:disabled="hierarchySaving"
/>
<span class="list-settings-unit">%</span>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="hierarchySaving" @click="saveHierarchySettings">{{ t('common.save') }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('cashback.settings_title') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('cashback.platform_direct_default_rate')">
<RatePercentInput v-model="platformDirectRate" />
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('cashback.platform_direct_default_hint') }}</p>
<el-form-item :label="t('cashback.admin_invite_default_rate')">
<RatePercentInput v-model="adminInviteRate" />
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('cashback.admin_invite_default_hint') }}</p>
<el-form-item>
<el-button type="primary" :loading="platformDirectSaving" @click="savePlatformDirectSettings">{{ t('common.save') }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('user.betting_limits') }}</p>
<el-form inline size="small" class="settings-form limits-form">
@@ -1236,7 +1310,13 @@ function creditTypeLabel(type: string) {
</el-collapse-item>
</el-collapse>
<el-tabs v-model="activeViewTab" class="mgr-top-tabs">
<InviteManageDialog v-model="inviteDialogOpen" />
<div class="mgr-tabs-shell">
<el-button type="primary" class="invite-prominent-btn" @click="inviteDialogOpen = true">
{{ t('invite.menu_btn') }}
</el-button>
<el-tabs v-model="activeViewTab" class="mgr-top-tabs mgr-top-tabs--with-invite">
<!-- Tab: 全部玩家默认 -->
<el-tab-pane :label="`${t('user.type.player')} (${playerTotal})`" name="players">
<section class="list-panel player-list-panel">
@@ -1297,6 +1377,12 @@ function creditTypeLabel(type: string) {
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="100" show-overflow-tooltip>
<template #default="{ row }">
<code v-if="row.inviteCode" class="invite-code-cell">{{ row.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<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)}`" placement="top">
@@ -1310,32 +1396,17 @@ function creditTypeLabel(type: string) {
<span class="amount-compact">{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.last_login')" width="108">
<el-table-column :label="t('common.actions')" min-width="360" align="center" fixed="right">
<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">{{ t('common.never_login') }}</span>
</template>
</el-table-column>
<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="t('common.actions')" width="420" fixed="right" align="center">
<template #default="{ row }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailPlayer(row.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openPlayerWalletLedger(row.id, row.username)">{{ t('user.action.view_wallet_ledger') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(row.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="success" @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
<el-button size="small" link type="warning" @click="openTransfer('withdraw', row)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezePlayer(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezePlayer(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminPlayerRowActions
:row="row"
@detail="openDetailPlayer(row.id)"
@ledger="openPlayerWalletLedger(row.id, row.username)"
@edit="openEditPlayer(row.id)"
@deposit="openTransfer('deposit', row)"
@withdraw="openTransfer('withdraw', row)"
@freeze="toggleFreezePlayer(row)"
/>
</template>
</el-table-column>
</el-table>
@@ -1408,6 +1479,12 @@ function creditTypeLabel(type: string) {
<template #empty><AdminTableEmpty /></template>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row: player }">
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80">
<template #default="{ row: player }">
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
@@ -1426,24 +1503,17 @@ function creditTypeLabel(type: string) {
<span class="amount-compact">{{ formatAmount(player.totalStake) }} / {{ formatAmount(player.totalReturn) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.last_login')" width="100">
<el-table-column :label="t('common.actions')" min-width="320" align="center">
<template #default="{ row: player }">
<el-tooltip v-if="player.lastLoginAt" :content="formatTime(player.lastLoginAt)" placement="top">
<span>{{ formatLastLogin(player.lastLoginAt) }}</span>
</el-tooltip>
<span v-else class="text-muted">{{ t('common.never_login') }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<template #default="{ row: player }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailPlayer(player.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(player.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="success" @click="openTransfer('deposit', player)">{{ t('common.topup') }}</el-button>
<el-button size="small" link type="warning" @click="openTransfer('withdraw', player)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
<el-button v-if="player.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezePlayer(player)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezePlayer(player)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminPlayerRowActions
:row="player"
@detail="openDetailPlayer(player.id)"
@ledger="openPlayerWalletLedger(player.id, player.username)"
@edit="openEditPlayer(player.id)"
@deposit="openTransfer('deposit', player)"
@withdraw="openTransfer('withdraw', player)"
@freeze="toggleFreezePlayer(player)"
/>
</template>
</el-table-column>
</el-table>
@@ -1472,18 +1542,22 @@ function creditTypeLabel(type: string) {
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" min-width="72" align="center" />
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" min-width="72" align="center" />
<el-table-column :label="t('agent.col.cashback')" min-width="80" align="right">
<template #default="{ row }">{{ row.cashbackRate }}</template>
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="400" fixed="right" align="center">
<el-table-column :label="t('common.actions')" width="96" align="center" fixed="right">
<template #default="{ row }">
<div class="action-btns" @click.stop>
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
<el-button v-if="canAgentCreateSub(row)" size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ childAgentActionLabel(row.level) }}</el-button>
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminRowActionsDropdown>
<el-dropdown-item @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-dropdown-item>
<el-dropdown-item @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="canAgentCreateSub(row)" @click="openCreateSubAgent(row.userId)">
{{ childAgentActionLabel(row.level) }}
</el-dropdown-item>
<el-dropdown-item v-if="row.status === 'ACTIVE'" divided @click="toggleFreezeAgent(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
</template>
</el-table-column>
</el-table>
@@ -1545,14 +1619,14 @@ function creditTypeLabel(type: string) {
</div>
<div class="table-wrap">
<el-table
:ref="(el) => { subAgentTableRefs[agentLevel] = el as { toggleRowExpansion: (row: AgentRow) => void } | null }"
:ref="(el: unknown) => setSubAgentTableRef(agentLevel, el)"
:data="ensureSubAgentState(agentLevel).agents"
stripe
row-key="userId"
:row-class-name="expandableTableRowClassName"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@row-click="(row, column, event) => onSubAgentRowClick(agentLevel, row, column, event)"
@row-click="bindSubAgentRowClick(agentLevel)"
>
<template #empty>
<AdminTableEmpty />
@@ -1570,6 +1644,12 @@ function creditTypeLabel(type: string) {
<template #empty><AdminTableEmpty /></template>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row: player }">
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80">
<template #default="{ row: player }">
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
@@ -1580,14 +1660,17 @@ function creditTypeLabel(type: string) {
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<el-table-column :label="t('common.actions')" min-width="280" align="center">
<template #default="{ row: player }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailPlayer(player.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(player.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="success" @click="openTransfer('deposit', player)">{{ t('common.topup') }}</el-button>
<el-button size="small" link type="warning" @click="openTransfer('withdraw', player)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
</div>
<AdminPlayerRowActions
:row="player"
@detail="openDetailPlayer(player.id)"
@ledger="openPlayerWalletLedger(player.id, player.username)"
@edit="openEditPlayer(player.id)"
@deposit="openTransfer('deposit', player)"
@withdraw="openTransfer('withdraw', player)"
@freeze="toggleFreezePlayer(player)"
/>
</template>
</el-table-column>
</el-table>
@@ -1613,16 +1696,20 @@ function creditTypeLabel(type: string) {
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" min-width="72" align="center" />
<el-table-column :label="t('common.actions')" width="400" fixed="right" align="center">
<el-table-column :label="t('common.actions')" width="96" align="center" fixed="right">
<template #default="{ row }">
<div class="action-btns" @click.stop>
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
<el-button v-if="canAgentCreateSub(row)" size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ childAgentActionLabel(row.level) }}</el-button>
<el-button v-if="subAgentAccountStatus(row) === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminRowActionsDropdown>
<el-dropdown-item @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-dropdown-item>
<el-dropdown-item @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="canAgentCreateSub(row)" @click="openCreateSubAgent(row.userId)">
{{ childAgentActionLabel(row.level) }}
</el-dropdown-item>
<el-dropdown-item v-if="subAgentAccountStatus(row) === 'ACTIVE'" divided @click="toggleFreezeAgent(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
</template>
</el-table-column>
</el-table>
@@ -1635,13 +1722,14 @@ function creditTypeLabel(type: string) {
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="(p) => onSubAgentPageChange(agentLevel, p)"
@size-change="(size) => onSubAgentSizeChange(agentLevel, size)"
@current-change="bindSubAgentPageChange(agentLevel)"
@size-change="bindSubAgentSizeChange(agentLevel)"
/>
</div>
</section>
</el-tab-pane>
</el-tabs>
</div>
<!-- DIALOGS -->
@@ -1798,14 +1886,7 @@ function creditTypeLabel(type: string) {
<div v-else class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number
v-model="createForm.cashbackRate"
:min="0"
:max="1"
:step="0.001"
:precision="4"
style="width: 100%"
/>
<RatePercentInput v-model="createForm.cashbackRate" />
</el-form-item>
<el-form-item :label="t('agent.field.max_single_deposit')">
<el-input-number v-model="createForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
@@ -1875,6 +1956,20 @@ function creditTypeLabel(type: string) {
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(editPlayerForm) }}</el-tag>
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<div class="cashback-edit-block">
<el-checkbox v-model="editPlayerForm.useCustomCashback">
{{ t('cashback.use_custom_rate') }}
</el-checkbox>
<RatePercentInput
v-if="editPlayerForm.useCustomCashback"
v-model="editPlayerForm.customCashbackRate"
/>
<p v-else class="field-hint block-hint">
{{ t('cashback.use_default_rate', { rate: `${editPlayerForm.defaultCashbackRate.toFixed(2)}%` }) }}
</p>
</div>
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
@@ -1928,7 +2023,7 @@ function creditTypeLabel(type: string) {
</el-radio-group>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number v-model="editAgentForm.cashbackRate" :min="0" :max="1" :step="0.001" :precision="4" style="width: 100%" />
<RatePercentInput v-model="editAgentForm.cashbackRate" />
</el-form-item>
<el-form-item :label="t('agent.field.max_single_deposit')">
<el-input-number v-model="editAgentForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
@@ -2026,38 +2121,46 @@ function creditTypeLabel(type: string) {
</el-dialog>
<!-- Player Detail -->
<el-dialog v-model="detailPlayerVisible" :title="t('user.dialog.detail')" width="560px" destroy-on-close>
<el-dialog v-model="detailPlayerVisible" :title="t('user.dialog.detail')" width="780px" destroy-on-close class="entity-detail-dialog">
<template v-if="playerDetail">
<el-descriptions :column="2" border size="small">
<el-descriptions-item :label="t('common.col_id')">{{ playerDetail.id }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.username')">{{ playerDetail.username }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.current_password')">{{ playerDetail.managedPassword ?? '—' }}</el-descriptions-item>
<el-descriptions-item v-if="!playerDetail.managedPassword" :span="2">
<span class="field-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('common.status')">
<AdminDetailGrid :columns="3">
<AdminDetailItem :label="t('common.col_id')">{{ playerDetail.id }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.username')">{{ playerDetail.username }}</AdminDetailItem>
<AdminDetailItem :label="t('common.status')">
<el-tag :type="statusTagType(playerDetail.status)" size="small">{{ statusLabel(playerDetail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="t('user.col.agent')">{{ affiliationLabel(playerDetail) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.available')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.col.agent')">{{ affiliationLabel(playerDetail) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.invite_code')">{{ playerDetail.inviteCode ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('agent.field.cashback_rate')">
{{
playerDetail.customCashbackRate != null
? formatRatePercent(playerDetail.customCashbackRate)
: t('cashback.use_default_rate', { rate: formatRatePercent(playerDetail.defaultCashbackRate) })
}}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.available')">
{{ formatAmount(playerDetail.availableBalance) }}
<span v-if="shouldCompact(playerDetail.availableBalance)" class="amount-full-hint">{{ formatAmountFull(playerDetail.availableBalance) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.frozen_balance')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.frozen_balance')">
{{ formatAmount(playerDetail.frozenBalance) }}
<span v-if="shouldCompact(playerDetail.frozenBalance)" class="amount-full-hint">{{ formatAmountFull(playerDetail.frozenBalance) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.phone')">{{ playerDetail.phone ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.email')">{{ playerDetail.email ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bet_count')">{{ playerDetail.betCount }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_stake')">{{ formatAmount(playerDetail.totalStake) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(playerDetail.totalReturn) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.bet_count')">{{ playerDetail.betCount }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_stake')">{{ formatAmount(playerDetail.totalStake) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(playerDetail.totalReturn) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.phone')">{{ playerDetail.phone ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.last_login')">
{{ playerDetail.lastLoginAt ? formatTime(playerDetail.lastLoginAt) : t('common.never_login') }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: playerDetail.loginFailCount }) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.registered_at')" :span="2">{{ formatTime(playerDetail.createdAt) }}</el-descriptions-item>
</el-descriptions>
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: playerDetail.loginFailCount }) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.email')">{{ playerDetail.email ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.registered_at')">{{ formatTime(playerDetail.createdAt) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.current_password')" :span="2">
{{ playerDetail.managedPassword ?? '—' }}
<span v-if="!playerDetail.managedPassword" class="admin-detail-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</AdminDetailItem>
</AdminDetailGrid>
<div class="detail-actions">
<el-button type="primary" link @click="openPlayerWalletLedger(playerDetail.id, playerDetail.username)">
{{ t('user.action.view_wallet_ledger') }}
@@ -2092,7 +2195,7 @@ function creditTypeLabel(type: string) {
<el-descriptions-item :label="t('agent.col.sub_agents')">{{ agentDetail.childAgentCount }} {{ t('common.people') }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.player_liability')">{{ formatAmount(agentDetail.directPlayerLiability) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.sub_agent_exposure')">{{ formatAmount(agentDetail.childAgentExposure) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.cashback')">{{ agentDetail.cashbackRate }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.cashback')">{{ formatRatePercent(agentDetail.cashbackRate) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.phone')">{{ agentDetail.phone ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.email')">{{ agentDetail.email ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
@@ -2101,6 +2204,8 @@ function creditTypeLabel(type: string) {
<el-descriptions-item :label="t('agent.col.created')" :span="2">{{ formatTime(agentDetail.createdAt) }}</el-descriptions-item>
</el-descriptions>
<InviteCodePanel :invite-code="agentDetail.inviteCode" compact readonly class="detail-invite" />
<div class="section-title section-title--row">
<span>{{ t('agent.section.credit_log') }}</span>
<el-button
@@ -2147,18 +2252,57 @@ function creditTypeLabel(type: string) {
</template>
<style scoped>
.agent-mgr-page > .agent-list-panel,
.agent-mgr-page > .mgr-top-tabs {
flex: 1;
min-height: 0;
}
.agent-mgr-page > .mgr-top-tabs :deep(.el-tabs__content) {
.mgr-tabs-shell {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.agent-mgr-page > .mgr-top-tabs :deep(.el-tab-pane) {
.mgr-top-tabs--with-invite :deep(.el-tabs__header) {
padding-right: 108px;
}
.invite-prominent-btn {
position: absolute;
right: 0;
top: 0;
z-index: 2;
min-width: 96px;
height: 38px;
padding: 0 22px;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.06em;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(64, 158, 255, 0.28);
}
.agent-mgr-page > .mgr-tabs-shell {
flex: 1;
min-height: 0;
}
.mgr-tabs-shell .mgr-top-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.agent-mgr-page > .agent-list-panel,
.agent-mgr-page > .mgr-tabs-shell .mgr-top-tabs {
flex: 1;
min-height: 0;
}
.agent-mgr-page > .mgr-tabs-shell .mgr-top-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.agent-mgr-page > .mgr-tabs-shell .mgr-top-tabs :deep(.el-tab-pane) {
flex: 1;
min-height: 0;
display: flex;
@@ -2195,14 +2339,21 @@ function creditTypeLabel(type: string) {
.list-panel-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 10px 0 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
--list-chrome-control-h: 32px;
--el-component-size: 32px;
}
.list-panel-toolbar .list-chrome__grow {
flex: 1 1 280px;
min-width: 0;
flex-wrap: wrap;
row-gap: 8px;
}
.list-panel-toolbar .list-chrome__actions {
flex-shrink: 0;
}
@@ -2293,7 +2444,7 @@ function creditTypeLabel(type: string) {
font-weight: 700;
}
.inner-tabs :deep(.el-tabs__active-bar) {
background-color: var(--green-bright);
background-color: var(--gold-bright);
height: 2px;
}
.inner-tabs :deep(.el-tabs__content) {
@@ -2309,13 +2460,29 @@ function creditTypeLabel(type: string) {
font-size: 13px;
font-weight: 600;
}
.action-btns {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
flex-wrap: nowrap;
white-space: nowrap;
.player-list-panel .table-wrap :deep(.el-table),
.agent-list-panel .table-wrap :deep(.el-table) {
min-width: 880px;
}
.expand-panel .inner-table {
width: 100%;
}
.expand-panel :deep(.el-table__body-wrapper) {
overflow-x: auto;
}
@media (max-width: 900px) {
.mgr-top-tabs--with-invite :deep(.el-tabs__header) {
padding-right: 0;
}
.invite-prominent-btn {
position: static;
align-self: flex-end;
margin: 0 0 8px;
}
}
/* ─── Inherited from old pages ─── */
@@ -2388,8 +2555,15 @@ function creditTypeLabel(type: string) {
grid-template-columns: 1fr 1fr;
gap: 0 10px;
}
.c-green { color: #2fb56a; }
.c-green { color: var(--gold-text); }
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
.invite-code-cell {
font-family: ui-monospace, monospace;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--el-color-primary);
}
.affiliation-tag {
max-width: 100%;
overflow: hidden;
@@ -2464,22 +2638,3 @@ function creditTypeLabel(type: string) {
gap: 8px;
}
</style>
<style>
/* 玩家列表「冻结」:橙黄底白字 */
.agent-mgr-page .el-button.is-link.el-button--warning {
color: #ffffff !important;
background: linear-gradient(165deg, #e8a84a 0%, #c47a18 42%, #9a5c10 100%) !important;
border: 1px solid rgba(232, 168, 74, 0.45) !important;
border-radius: 6px !important;
padding: 5px 11px !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset, 0 1px 6px rgba(0, 0, 0, 0.35) !important;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
}
.agent-mgr-page .el-button.is-link.el-button--warning:hover,
.agent-mgr-page .el-button.is-link.el-button--warning:focus {
color: #ffffff !important;
background: linear-gradient(165deg, #f0bc62 0%, #d48a28 42%, #a86814 100%) !important;
border-color: rgba(240, 188, 98, 0.55) !important;
}
</style>

View File

@@ -26,7 +26,10 @@ import {
shouldCompactAmount as shouldCompact,
} from '../utils/format-amount';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import InviteCodePanel from '../components/InviteCodePanel.vue';
import RatePercentInput from '../components/RatePercentInput.vue';
import AgentCreditContext from '../components/AgentCreditContext.vue';
import { formatRatePercent, percentToDecimalRate } from '../utils/rate-percent';
import {
fetchAdminAgentCreditContext,
maxCreditIncreaseAmount,
@@ -186,7 +189,7 @@ async function submitEdit() {
status: editForm.value.status,
phone: editForm.value.phone.trim() || undefined,
email: editForm.value.email.trim() || undefined,
cashbackRate: editForm.value.cashbackRate,
cashbackRate: percentToDecimalRate(editForm.value.cashbackRate),
});
ElMessage.success(t('msg.saved'));
editVisible.value = false;
@@ -298,7 +301,7 @@ function creditTypeLabel(type: string) {
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="96" align="center" />
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" width="96" align="center" />
<el-table-column :label="t('agent.col.cashback')" width="88" align="right">
<template #default="{ row }">{{ row.cashbackRate }}</template>
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
</el-table-column>
<el-table-column prop="phone" :label="t('agent.col.phone')" min-width="110">
<template #default="{ row }">{{ row.phone ?? '—' }}</template>
@@ -361,14 +364,7 @@ function creditTypeLabel(type: string) {
<div class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number
v-model="createForm.cashbackRate"
:min="0"
:max="1"
:step="0.001"
:precision="4"
style="width: 100%"
/>
<RatePercentInput v-model="createForm.cashbackRate" />
<div class="field-hint">{{ t('agent.hint.cashback_example') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.phone')">
@@ -393,14 +389,7 @@ function creditTypeLabel(type: string) {
</el-radio-group>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number
v-model="editForm.cashbackRate"
:min="0"
:max="1"
:step="0.001"
:precision="4"
style="width: 100%"
/>
<RatePercentInput v-model="editForm.cashbackRate" />
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editForm.phone" />
@@ -475,7 +464,7 @@ function creditTypeLabel(type: string) {
<el-descriptions-item :label="t('agent.field.sub_agent_exposure')">
{{ formatAmount(detail.childAgentExposure) }}
</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.cashback')">{{ detail.cashbackRate }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.cashback')">{{ formatRatePercent(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">
@@ -486,6 +475,8 @@ function creditTypeLabel(type: string) {
</el-descriptions-item>
</el-descriptions>
<InviteCodePanel :invite-code="detail.inviteCode" compact readonly class="detail-invite" />
<div class="section-title section-title--row">
<span>{{ t('agent.section.credit_log') }}</span>
<el-button

View File

@@ -4,6 +4,7 @@ import type { TableColumnCtx } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import { formatRatePercent } from '../utils/rate-percent';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import { resolveApiError } from '../i18n/form-validation';
import api from '../api';
@@ -69,9 +70,7 @@ const previewItems = computed(() => preview.value?.items ?? []);
const detailItems = computed(() => detail.value?.items ?? []);
function formatRate(value: string | number | null | undefined) {
const n = Number(value);
if (!Number.isFinite(n)) return '—';
return `${(n * 100).toFixed(2)}%`;
return formatRatePercent(value);
}
function formatPeriodDate(value: string) {
@@ -613,9 +612,9 @@ onMounted(loadHistory);
}
.rules-help-btn:hover {
color: var(--green-glow);
border-color: rgba(47, 181, 106, 0.45);
background: rgba(47, 181, 106, 0.08);
color: #f5f5f5;
border-color: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
}
.rules-list {
@@ -708,7 +707,7 @@ onMounted(loadHistory);
.pstat-green {
color: var(--green-glow);
text-shadow: 0 0 20px rgba(47, 181, 106, 0.35);
text-shadow: none;
}
.pstat-label {

View File

@@ -835,15 +835,15 @@ void load();
font-size: 12px;
font-family: inherit;
cursor: pointer;
border: 1px solid rgba(47, 181, 106, 0.5);
background: rgba(47, 181, 106, 0.1);
color: #2fb56a;
border: 1px solid rgba(212, 175, 55, 0.5);
background: rgba(212, 175, 55, 0.1);
color: var(--gold-text);
font-weight: 600;
transition: all 0.15s;
}
.banner-upload-btn:hover {
background: rgba(47, 181, 106, 0.2);
background: rgba(212, 175, 55, 0.2);
}
.banner-upload-btn.is-uploading {
@@ -905,8 +905,8 @@ void load();
}
.media-picker-card:hover {
border-color: rgba(47, 181, 106, 0.5);
box-shadow: 0 2px 12px rgba(47, 181, 106, 0.15);
border-color: rgba(212, 175, 55, 0.5);
box-shadow: 0 2px 12px rgba(212, 175, 55, 0.15);
}
.media-picker-thumb {

View File

@@ -8,6 +8,7 @@ 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';
import InviteCodePanel from '../components/InviteCodePanel.vue';
const { t, locale, localeTag } = useAdminLocale();
const router = useRouter();
@@ -86,7 +87,7 @@ const mainTrendOption = computed(() =>
[
{
name: t('dash.chart_stake'),
color: '#248f54',
color: '#d4d4d4',
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
},
{
@@ -111,7 +112,7 @@ const distributionOption = computed(() => {
const raw = s.value?.bets.todayByStatus ?? {};
const betColors: Record<string, string> = {
PENDING: '#fb923c',
WON: '#248f54',
WON: '#d4d4d4',
LOST: '#f87171',
VOID: '#6b7280',
REFUNDED: '#60a5fa',
@@ -120,7 +121,7 @@ const distributionOption = computed(() => {
const matchSegs = m
? [
{ label: t('dash.match_draft'), value: m.draft, color: '#6b7280' },
{ label: t('dash.match_published'), value: m.published, color: '#248f54' },
{ label: t('dash.match_published'), value: m.published, color: '#d4d4d4' },
{ 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' },
@@ -137,7 +138,7 @@ const distributionOption = computed(() => {
const userSegs = u
? [
{ label: t('dash.user_active'), value: u.playersActive, color: '#248f54' },
{ label: t('dash.user_active'), value: u.playersActive, color: '#d4d4d4' },
{ 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' },
@@ -231,6 +232,7 @@ const kpiSecondary = computed(() => {
</el-card>
<template v-else-if="s">
<InviteCodePanel auto-load class="invite-board" />
<el-card class="overview-board" shadow="never">
<div v-if="s.generatedAt" class="board-head">
<span class="dash-updated">
@@ -301,10 +303,14 @@ const kpiSecondary = computed(() => {
margin-bottom: 14px;
}
.invite-board {
margin-bottom: 12px;
}
.overview-board {
border-radius: 14px;
border: 1px solid #1e1e1e;
background: linear-gradient(180deg, rgba(36, 143, 84, 0.06) 0%, rgba(0, 0, 0, 0) 120px);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #111111;
margin-bottom: 28px;
}
.overview-board :deep(.el-card__body) {
@@ -348,23 +354,23 @@ const kpiSecondary = computed(() => {
}
.kpi-cell--link:hover,
.kpi-cell--link:focus-visible {
border-color: rgba(77, 214, 138, 0.35);
background: rgba(36, 143, 84, 0.1);
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
outline: none;
}
.kpi-label {
display: block;
font-size: 11px;
color: #666;
font-size: 12px;
color: #737373;
margin-bottom: 6px;
}
.kpi-value {
display: block;
font-size: 22px;
font-weight: 800;
color: var(--green-text);
font-weight: 500;
color: #f5f5f5;
line-height: 1.15;
letter-spacing: -0.5px;
letter-spacing: -0.02em;
}
.kpi-value.sm {
font-size: 17px;

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import DepositOrders from './DepositOrders.vue';
import PaymentMethods from './PaymentMethods.vue';
const { t } = useAdminLocale();
const route = useRoute();
const router = useRouter();
const tabs = [
{ key: 'orders', label: () => t('deposit.deposit_orders_title') },
{ key: 'methods', label: () => t('deposit.payment_methods_title') },
] as const;
function getTabFromQuery(): string {
return route.query.tab === 'methods' ? 'methods' : 'orders';
}
const activeTab = ref(getTabFromQuery());
watch(() => route.query.tab, (val) => {
activeTab.value = val === 'methods' ? 'methods' : 'orders';
});
function switchTab(key: string) {
activeTab.value = key;
router.replace({ query: { ...route.query, tab: key } });
}
</script>
<template>
<div class="page-deposit-manage">
<div class="tab-bar">
<button
v-for="tab in tabs"
:key="tab.key"
class="tab-btn"
:class="{ active: activeTab === tab.key }"
@click="switchTab(tab.key)"
>
{{ tab.label() }}
</button>
</div>
<DepositOrders v-if="activeTab === 'orders'" />
<PaymentMethods v-if="activeTab === 'methods'" />
</div>
</template>
<style scoped>
.page-deposit-manage {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid #2a2a2a;
margin-bottom: 0;
flex-shrink: 0;
}
.tab-btn {
position: relative;
padding: 10px 20px;
background: none;
border: none;
color: #888;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: color 0.15s;
letter-spacing: 0.02em;
}
.tab-btn:hover {
color: #ccc;
}
.tab-btn.active {
color: #fff;
}
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--primary, #409eff);
border-radius: 2px 2px 0 0;
}
.page-deposit-manage :deep(.page-deposit-orders),
.page-deposit-manage :deep(.page-payment-methods) {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.page-deposit-manage :deep(.page-deposit-orders .toolbar h2),
.page-deposit-manage :deep(.page-payment-methods .toolbar h2) {
display: none;
}
</style>

View File

@@ -0,0 +1,331 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import { formatAmount } from '../utils/format-amount';
const { t } = useAdminLocale();
interface DepositOrderRow {
id: string;
orderNo: string;
playerId: string;
playerUsername: string | null;
methodType: string;
amount: string;
screenshotUrl: string;
status: string;
approvedAmount: string | null;
reviewerId: string | null;
reviewerUsername: string | null;
rejectReason: string | null;
remark: string | null;
createdAt: string;
reviewedAt: string | null;
paymentMethodName: string | null;
}
const items = ref<DepositOrderRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const loading = ref(false);
// Filters
const statusFilter = ref('');
const keywordFilter = ref('');
const methodTypeFilter = ref('');
// Approve dialog
const approveDialogVisible = ref(false);
const approveTarget = ref<DepositOrderRow | null>(null);
const approveAmount = ref<number>(0);
const approveRemark = ref('');
// Reject dialog
const rejectDialogVisible = ref(false);
const rejectTarget = ref<DepositOrderRow | null>(null);
const rejectReason = ref('');
// Screenshot preview
const previewUrl = ref('');
const previewVisible = ref(false);
async function fetchList() {
loading.value = true;
try {
const params: any = { page: page.value, pageSize: pageSize.value };
if (statusFilter.value) params.status = statusFilter.value;
if (keywordFilter.value) params.keyword = keywordFilter.value;
if (methodTypeFilter.value) params.methodType = methodTypeFilter.value;
const { data } = await api.get('/admin/deposit-orders', { params });
const result = data.data ?? { items: [], total: 0 };
items.value = result.items ?? [];
total.value = result.total ?? 0;
} catch { /* */ } finally {
loading.value = false;
}
}
function openApprove(row: DepositOrderRow) {
approveTarget.value = row;
approveAmount.value = parseFloat(row.amount);
approveRemark.value = '';
approveDialogVisible.value = true;
}
function openReject(row: DepositOrderRow) {
rejectTarget.value = row;
rejectReason.value = '';
rejectDialogVisible.value = true;
}
function openScreenshot(url: string) {
previewUrl.value = url;
previewVisible.value = true;
}
async function handleApprove() {
if (!approveTarget.value) return;
try {
await api.post(`/admin/deposit-orders/${approveTarget.value.id}/approve`, {
approvedAmount: approveAmount.value,
remark: approveRemark.value || undefined,
});
approveDialogVisible.value = false;
await fetchList();
} catch (e: any) {
alert(e.response?.data?.message || 'Error');
}
}
async function handleReject() {
if (!rejectTarget.value) return;
if (!rejectReason.value.trim()) {
alert(t('deposit.reason_required'));
return;
}
try {
await api.post(`/admin/deposit-orders/${rejectTarget.value.id}/reject`, {
reason: rejectReason.value.trim(),
});
rejectDialogVisible.value = false;
await fetchList();
} catch (e: any) {
alert(e.response?.data?.message || 'Error');
}
}
function statusClass(s: string) {
if (s === 'APPROVED') return 'status-approved';
if (s === 'REJECTED') return 'status-rejected';
return 'status-pending';
}
function statusLabel(s: string) {
if (s === 'APPROVED') return '✓ ' + t('deposit.status_approved');
if (s === 'REJECTED') return '✗ ' + t('deposit.status_rejected');
return '● ' + t('deposit.status_pending');
}
function prevPage() { if (page.value > 1) { page.value--; fetchList(); } }
function nextPage() { if (page.value * pageSize.value < total.value) { page.value++; fetchList(); } }
onMounted(fetchList);
</script>
<template>
<div class="page-deposit-orders">
<div class="toolbar">
<h2>{{ t('deposit.deposit_orders_title') }}</h2>
</div>
<div class="filters">
<select v-model="statusFilter" @change="page = 1; fetchList()">
<option value="">{{ t('deposit.all_status') }}</option>
<option value="PENDING">{{ t('deposit.status_pending') }}</option>
<option value="APPROVED">{{ t('deposit.status_approved') }}</option>
<option value="REJECTED">{{ t('deposit.status_rejected') }}</option>
</select>
<select v-model="methodTypeFilter" @change="page = 1; fetchList()">
<option value="">{{ t('deposit.all_types') }}</option>
<option value="BANK">Bank</option>
<option value="USDT">USDT</option>
</select>
<input
v-model="keywordFilter"
:placeholder="t('deposit.search_player_ph')"
@keydown.enter="page = 1; fetchList()"
/>
<button class="btn-search" @click="page = 1; fetchList()">{{ t('common.search') }}</button>
</div>
<AdminTableEmpty v-if="!loading && !items.length" />
<table v-if="items.length" class="data-table">
<thead>
<tr>
<th>{{ t('deposit.order_no') }}</th>
<th>{{ t('deposit.player') }}</th>
<th>{{ t('common.type') }}</th>
<th>{{ t('deposit.amount') }}</th>
<th>{{ t('deposit.screenshot') }}</th>
<th>{{ t('common.status') }}</th>
<th>{{ t('deposit.approved_amount') }}</th>
<th>{{ t('deposit.reviewer') }}</th>
<th>{{ t('deposit.time') }}</th>
<th>{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id">
<td class="mono">{{ row.orderNo }}</td>
<td>{{ row.playerUsername || row.playerId }}</td>
<td><span :class="['badge', row.methodType === 'BANK' ? 'badge-blue' : 'badge-green']">{{ row.methodType }}</span></td>
<td class="amount">{{ formatAmount(row.amount) }}</td>
<td>
<img
v-if="row.screenshotUrl"
:src="row.screenshotUrl"
class="screenshot-thumb"
@click="openScreenshot(row.screenshotUrl)"
/>
</td>
<td><span :class="statusClass(row.status)">{{ statusLabel(row.status) }}</span></td>
<td>{{ row.approvedAmount ? formatAmount(row.approvedAmount) : '-' }}</td>
<td>{{ row.reviewerUsername || '-' }}</td>
<td class="time-cell">{{ new Date(row.createdAt).toLocaleString() }}</td>
<td>
<template v-if="row.status === 'PENDING'">
<button class="btn-sm btn-approve" @click="openApprove(row)">{{ t('deposit.approve') }}</button>
<button class="btn-sm btn-reject" @click="openReject(row)">{{ t('deposit.reject') }}</button>
</template>
<template v-else-if="row.status === 'REJECTED'">
<span class="reject-reason" :title="row.rejectReason || ''">{{ row.rejectReason }}</span>
</template>
</td>
</tr>
</tbody>
</table>
<div v-if="total > pageSize" class="pagination">
<button :disabled="page <= 1" @click="prevPage">{{ t('deposit.prev') }}</button>
<span>{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
<button :disabled="page * pageSize >= total" @click="nextPage">{{ t('deposit.next') }}</button>
</div>
<!-- Approve Dialog -->
<div v-if="approveDialogVisible" class="dialog-overlay" @click.self="approveDialogVisible = false">
<div class="dialog-box">
<h3>{{ t('deposit.approve_title') }}</h3>
<div v-if="approveTarget" class="approve-content">
<div class="info-row">
<span>{{ t('deposit.player') }}:</span> <strong>{{ approveTarget.playerUsername }}</strong>
</div>
<div class="info-row">
<span>{{ t('deposit.submitted_amount') }}:</span> <strong>{{ formatAmount(approveTarget.amount) }}</strong>
</div>
<div class="info-row">
<span>{{ t('deposit.screenshot') }}:</span>
<img :src="approveTarget.screenshotUrl" class="approve-screenshot" @click="openScreenshot(approveTarget.screenshotUrl)" />
</div>
<div class="form-group">
<label>{{ t('deposit.approved_amount_label') }}</label>
<input v-model.number="approveAmount" type="number" step="0.01" min="0.01" />
</div>
<div class="form-group">
<label>{{ t('deposit.remark_label') }}</label>
<input v-model="approveRemark" />
</div>
</div>
<div class="dialog-actions">
<button class="btn-cancel" @click="approveDialogVisible = false">{{ t('common.cancel') }}</button>
<button class="btn-primary" @click="handleApprove">{{ t('deposit.confirm_approve') }}</button>
</div>
</div>
</div>
<!-- Reject Dialog -->
<div v-if="rejectDialogVisible" class="dialog-overlay" @click.self="rejectDialogVisible = false">
<div class="dialog-box">
<h3>{{ t('deposit.reject_title') }}</h3>
<div v-if="rejectTarget" class="reject-content">
<div class="info-row">
<span>{{ t('deposit.player') }}:</span> <strong>{{ rejectTarget.playerUsername }}</strong>
</div>
<div class="info-row">
<span>{{ t('deposit.amount') }}:</span> <strong>{{ formatAmount(rejectTarget.amount) }}</strong>
</div>
<div class="form-group">
<label>{{ t('deposit.reject_reason_label') }}</label>
<textarea v-model="rejectReason" rows="3" :placeholder="t('deposit.reject_reason_ph')"></textarea>
</div>
</div>
<div class="dialog-actions">
<button class="btn-cancel" @click="rejectDialogVisible = false">{{ t('common.cancel') }}</button>
<button class="btn-danger-full" @click="handleReject">{{ t('deposit.confirm_reject') }}</button>
</div>
</div>
</div>
<!-- Screenshot Preview -->
<div v-if="previewVisible" class="dialog-overlay" @click.self="previewVisible = false">
<div class="preview-box">
<img :src="previewUrl" class="preview-image" />
<button class="close-preview" @click="previewVisible = false"></button>
</div>
</div>
</div>
</template>
<style scoped>
.page-deposit-orders { padding: 16px; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.toolbar h2 { margin: 0; font-size: 18px; }
.filters { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.filters select, .filters input { padding: 6px 10px; border-radius: 4px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 13px; }
.filters input { min-width: 160px; }
.btn-search { background: #409eff; color: #fff; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; font-weight: 600; font-size: 13px; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 10px 6px; border-bottom: 1px solid #333; text-align: left; }
.data-table th { font-weight: 700; color: #aaa; font-size: 11px; text-transform: uppercase; }
.mono { font-family: monospace; font-size: 11px; }
.amount { font-weight: 700; }
.time-cell { font-size: 11px; color: #888; }
.screenshot-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 1px solid #444; }
.badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.badge-blue { background: #1e3a5f; color: #66b1ff; }
.badge-green { background: rgba(201, 162, 39, 0.12); color: var(--gold-text); }
.status-pending { color: #e6a23c; font-weight: 700; font-size: 12px; }
.status-approved { color: var(--success-text); font-weight: 700; font-size: 12px; }
.status-rejected { color: #f56c6c; font-weight: 700; font-size: 12px; }
.btn-sm { border: none; border-radius: 4px; padding: 4px 10px; font-size: 11px; cursor: pointer; margin-right: 4px; }
.btn-approve { background: rgba(61, 115, 88, 0.16); color: var(--success-text); }
.btn-approve:hover { background: rgba(61, 115, 88, 0.24); }
.btn-reject { background: #3a1a1a; color: #f56c6c; }
.btn-reject:hover { background: #4a2525; }
.reject-reason { font-size: 11px; color: #f56c6c; max-width: 120px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; }
.pagination button { background: #333; color: #ddd; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; }
.pagination button:disabled { opacity: 0.4; cursor: default; }
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 999; }
.dialog-box { background: #1e1e1e; border-radius: 8px; padding: 24px; min-width: 440px; max-width: 560px; }
.dialog-box h3 { margin: 0 0 16px; font-size: 16px; }
.info-row { margin-bottom: 8px; font-size: 13px; }
.info-row span { color: #888; margin-right: 4px; }
.approve-screenshot { width: 200px; max-height: 200px; object-fit: contain; border-radius: 4px; cursor: pointer; margin-top: 4px; }
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: #aaa; margin-bottom: 4px; font-weight: 600; }
.form-group input, .form-group textarea { width: 100%; padding: 8px; border: 1px solid #444; border-radius: 4px; background: #111; color: #eee; box-sizing: border-box; }
.form-group textarea { resize: vertical; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
.btn-cancel { background: #333; color: #ccc; border: none; border-radius: 4px; padding: 8px 16px; cursor: pointer; }
.btn-primary { background: #409eff; color: #fff; border: none; border-radius: 4px; padding: 8px 16px; cursor: pointer; font-weight: 600; }
.btn-danger-full { background: #f56c6c; color: #fff; border: none; border-radius: 4px; padding: 8px 16px; cursor: pointer; font-weight: 600; }
.preview-box { position: relative; max-width: 90vw; max-height: 90vh; }
.preview-image { max-width: 90vw; max-height: 85vh; object-fit: contain; border-radius: 8px; }
.close-preview { position: absolute; top: -12px; right: -12px; background: #333; color: #fff; border: none; border-radius: 50%; width: 28px; height: 28px; font-size: 14px; cursor: pointer; }
</style>

View File

@@ -238,8 +238,8 @@ label {
}
.field::placeholder { color: #3a3a3a; }
.field:focus {
border-color: var(--green-mid);
box-shadow: 0 0 0 3px rgba(47, 181, 106, 0.15);
border-color: rgba(255, 255, 255, 0.28);
box-shadow: none;
}
.field:-webkit-autofill,
.field:-webkit-autofill:focus {
@@ -249,24 +249,23 @@ label {
}
.btn-login {
margin-top: 6px;
height: 46px;
background: var(--primary-grad);
border: 1px solid var(--green-border);
border-radius: 8px;
color: #fff;
font-size: 15px;
font-weight: 800;
height: 44px;
background: #f5f5f5;
border: 1px solid #f5f5f5;
border-radius: 6px;
color: #0a0a0a;
font-size: 14px;
font-weight: 500;
font-family: inherit;
letter-spacing: 0.15em;
box-shadow: var(--primary-shadow);
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.25);
transition: background 0.2s, box-shadow 0.2s;
letter-spacing: 0;
box-shadow: none;
transition: background 0.15s, border-color 0.15s;
}
.btn-login:hover:not(:disabled) {
background: var(--primary-grad-hover);
border-color: rgba(120, 230, 170, 0.45);
color: #fff;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 4px 14px rgba(0, 0, 0, 0.45), 0 0 20px rgba(47, 181, 106, 0.3);
background: #ffffff;
border-color: #ffffff;
color: #0a0a0a;
box-shadow: none;
}
.btn-login:disabled { opacity: 0.4; cursor: not-allowed; }
.quick-label {
@@ -308,7 +307,7 @@ label {
}
.quick-acc {
font-size: 12px;
color: var(--green-text);
color: var(--gold-text);
font-weight: 600;
font-family: monospace;
}

View File

@@ -345,10 +345,10 @@ async function doUpload() {
}
.tab-btn:hover { border-color: #444; color: #ccc; }
.tab-btn.active {
background: rgba(47, 181, 106, 0.14);
border-color: rgba(47, 181, 106, 0.5);
color: #2fb56a;
font-weight: 600;
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: #f5f5f5;
font-weight: 500;
}
.btn {
@@ -369,13 +369,13 @@ async function doUpload() {
}
.btn-ghost:hover:not(:disabled) { border-color: #444; color: #ccc; }
.btn-primary {
background: linear-gradient(135deg, #2fb56a, #248f54);
color: #fff;
font-weight: 600;
border-color: transparent;
box-shadow: 0 2px 8px rgba(47, 181, 106, 0.3);
background: #f5f5f5;
color: #0a0a0a;
font-weight: 500;
border-color: #f5f5f5;
box-shadow: none;
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
.btn-primary:hover:not(:disabled) { background: #ffffff; border-color: #ffffff; }
/* ── Grid ── */
.file-grid {
@@ -435,9 +435,9 @@ async function doUpload() {
letter-spacing: 0.04em;
}
.badge-used {
background: rgba(47, 181, 106, 0.2);
color: #2fb56a;
border: 1px solid rgba(47, 181, 106, 0.35);
background: rgba(212, 175, 55, 0.2);
color: #c9a227;
border: 1px solid rgba(212, 175, 55, 0.35);
}
.badge-unused {
background: rgba(120, 120, 120, 0.18);
@@ -611,8 +611,8 @@ async function doUpload() {
}
.drop-zone:hover,
.drop-zone.drop-active {
border-color: rgba(47, 181, 106, 0.5);
background: rgba(47, 181, 106, 0.04);
border-color: rgba(212, 175, 55, 0.5);
background: rgba(212, 175, 55, 0.04);
}
.drop-hint {
font-size: 13px;

View File

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

View File

@@ -836,7 +836,7 @@ onMounted(() => {
.leg-badge {
margin-left: 4px;
font-size: 10px;
color: var(--green-text, #2fb56a);
color: var(--gold-text, #dcc078);
}
.bet-content-cell {
@@ -922,7 +922,7 @@ onMounted(() => {
.vs {
font-size: 13px;
font-weight: 700;
color: var(--green-text, #2fb56a);
color: var(--gold-text, #dcc078);
flex-shrink: 0;
}
@@ -1062,7 +1062,7 @@ onMounted(() => {
.pstat-green {
color: var(--green-glow);
text-shadow: 0 0 20px rgba(47, 181, 106, 0.35);
text-shadow: none;
}
.pstat-orange {

View File

@@ -27,6 +27,8 @@ import {
shouldCompactAmount as shouldCompact,
} from '../utils/format-amount';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
import AdminDetailItem from '../components/AdminDetailItem.vue';
import WalletTransferContext from '../components/WalletTransferContext.vue';
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
@@ -788,50 +790,46 @@ function statusLabel(s: string) {
</template>
</el-dialog>
<el-dialog v-model="detailVisible" :title="t('user.dialog.detail')" width="560px" destroy-on-close>
<el-dialog v-model="detailVisible" :title="t('user.dialog.detail')" width="780px" destroy-on-close class="entity-detail-dialog">
<template v-if="detail">
<el-descriptions :column="2" border size="small">
<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('user.field.current_password')">
{{ detail.managedPassword ?? '—' }}
</el-descriptions-item>
<el-descriptions-item v-if="!detail.managedPassword" :span="2">
<span class="field-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('common.status')">
<AdminDetailGrid :columns="3">
<AdminDetailItem :label="t('common.col_id')">{{ detail.id }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.username')">{{ detail.username }}</AdminDetailItem>
<AdminDetailItem :label="t('common.status')">
<el-tag :type="statusTagType(detail.status)" size="small">
{{ statusLabel(detail.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="t('user.col.agent')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.col.agent')">
{{ detail.parentUsername ?? t('common.platform_direct') }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.available')">
</AdminDetailItem>
<AdminDetailItem :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="t('user.field.frozen_balance')">
</AdminDetailItem>
<AdminDetailItem :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="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')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.bet_count')">{{ detail.betCount }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_stake')">{{ formatAmount(detail.totalStake) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(detail.totalReturn) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.phone')">{{ detail.phone ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.last_login')">
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : t('common.never_login') }}
</el-descriptions-item>
<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>
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: detail.loginFailCount }) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.email')">{{ detail.email ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.registered_at')">{{ formatTime(detail.createdAt) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.current_password')" :span="2">
{{ detail.managedPassword ?? '—' }}
<span v-if="!detail.managedPassword" class="admin-detail-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</AdminDetailItem>
</AdminDetailGrid>
</template>
</el-dialog>
</div>
@@ -903,22 +901,3 @@ function statusLabel(s: string) {
margin-bottom: 10px;
}
</style>
<style>
/* 玩家列表「冻结」:橙黄底白字 */
.users-page .el-button.is-link.el-button--warning {
color: #ffffff !important;
background: linear-gradient(165deg, #e8a84a 0%, #c47a18 42%, #9a5c10 100%) !important;
border: 1px solid rgba(232, 168, 74, 0.45) !important;
border-radius: 6px !important;
padding: 5px 11px !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset, 0 1px 6px rgba(0, 0, 0, 0.35) !important;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
}
.users-page .el-button.is-link.el-button--warning:hover,
.users-page .el-button.is-link.el-button--warning:focus {
color: #ffffff !important;
background: linear-gradient(165deg, #f0bc62 0%, #d48a28 42%, #a86814 100%) !important;
border-color: rgba(240, 188, 98, 0.55) !important;
}
</style>

View File

@@ -1,4 +1,5 @@
import { FormValidationError } from '../i18n/form-validation';
import { decimalRateToPercent, percentToDecimalRate } from '../utils/rate-percent';
export interface PromotableUserOption {
id: string;
@@ -64,6 +65,7 @@ export interface AgentRow {
phone: string | null;
email: string | null;
locale: string;
inviteCode?: string | null;
createdAt: string;
}
@@ -131,7 +133,7 @@ export function editFormFromAgentDetail(d: AgentDetail): AgentEditForm {
status: d.status,
phone: d.phone ?? '',
email: d.email ?? '',
cashbackRate: Number(d.cashbackRate),
cashbackRate: decimalRateToPercent(d.cashbackRate),
maxSingleDeposit: d.maxSingleDeposit ? Number(d.maxSingleDeposit) : 0,
maxDailyDeposit: d.maxDailyDeposit ? Number(d.maxDailyDeposit) : 0,
managedPassword: d.managedPassword ?? null,
@@ -164,7 +166,7 @@ export function buildCreateAgentPayload(form: AgentCreateForm) {
return {
userId: form.userId,
creditLimit: form.creditLimit,
cashbackRate: form.cashbackRate,
cashbackRate: percentToDecimalRate(form.cashbackRate),
maxSingleDeposit: form.maxSingleDeposit > 0 ? form.maxSingleDeposit : undefined,
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
phone: form.phone.trim() || undefined,

View File

@@ -86,7 +86,7 @@ const mainTrendOption = computed(() =>
[
{
name: t('dash.chart_stake'),
color: '#248f54',
color: '#d4d4d4',
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
},
{
@@ -111,7 +111,7 @@ const distributionOption = computed(() => {
const raw = s.value?.bets.todayByStatus ?? {};
const betColors: Record<string, string> = {
PENDING: '#fb923c',
WON: '#248f54',
WON: '#d4d4d4',
LOST: '#f87171',
VOID: '#6b7280',
REFUNDED: '#60a5fa',
@@ -122,7 +122,7 @@ const distributionOption = computed(() => {
{
label: t('agent_dash.credit_available'),
value: toNum(c.availableCredit),
color: '#248f54',
color: '#d4d4d4',
},
{
label: t('agent_dash.credit_used'),
@@ -142,7 +142,7 @@ const distributionOption = computed(() => {
const playerSegs = p
? [
{ label: t('dash.user_active'), value: p.active, color: '#248f54' },
{ label: t('dash.user_active'), value: p.active, color: '#d4d4d4' },
{ label: t('dash.user_suspended'), value: p.suspended, color: '#f87171' },
].filter((x) => x.value > 0)
: [];
@@ -322,9 +322,10 @@ const kpiSecondary = computed(() => {
}
.overview-board {
border-radius: 14px;
border: 1px solid #1e1e1e;
background: linear-gradient(180deg, rgba(36, 143, 84, 0.06) 0%, rgba(0, 0, 0, 0) 120px);
margin-top: 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #111111;
}
.overview-board :deep(.el-card__body) {
padding: 20px 22px 16px;
@@ -374,23 +375,23 @@ const kpiSecondary = computed(() => {
}
.kpi-cell--link:hover,
.kpi-cell--link:focus-visible {
border-color: rgba(77, 214, 138, 0.35);
background: rgba(36, 143, 84, 0.1);
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
outline: none;
}
.kpi-label {
display: block;
font-size: 11px;
color: #666;
font-size: 12px;
color: #737373;
margin-bottom: 6px;
}
.kpi-value {
display: block;
font-size: 22px;
font-weight: 800;
color: var(--green-text);
font-weight: 500;
color: #f5f5f5;
line-height: 1.15;
letter-spacing: -0.5px;
letter-spacing: -0.02em;
}
.kpi-value.sm {
font-size: 17px;

View File

@@ -33,6 +33,9 @@ import type { TableInstance } from 'element-plus';
import WalletTransferContext from '../../components/WalletTransferContext.vue';
import AgentCreditContext from '../../components/AgentCreditContext.vue';
import PlayerWalletLedgerDialog from '../../components/PlayerWalletLedgerDialog.vue';
import InviteManageDialog from '../../components/InviteManageDialog.vue';
import AdminPlayerRowActions from '../../components/AdminPlayerRowActions.vue';
import AdminRowActionsDropdown from '../../components/AdminRowActionsDropdown.vue';
import {
depositAmountCap,
parsePlayerAvailable,
@@ -46,6 +49,7 @@ import { formatAgentLevelNumeral } from '../../utils/agent-level-label';
const { t, localeTag } = useAdminLocale();
const auth = useAuthStore();
const inviteDialogOpen = ref(false);
const profile = ref<{
level?: number;
@@ -638,8 +642,14 @@ function statusTagType(s: string) {
</div>
</div>
<InviteManageDialog v-model="inviteDialogOpen" />
<div class="portal-tabs-shell">
<el-button type="primary" class="invite-prominent-btn" @click="inviteDialogOpen = true">
{{ t('invite.menu_btn') }}
</el-button>
<!-- Top-level tabs -->
<el-tabs v-model="activeTab" class="portal-top-tabs">
<el-tabs v-model="activeTab" class="portal-top-tabs portal-top-tabs--with-invite">
<!-- Tab: 直属玩家 -->
<el-tab-pane :label="`${t('nav.players')} (${players.length})`" name="players">
<div class="inner-toolbar">
@@ -657,6 +667,12 @@ function statusTagType(s: string) {
<template #empty><div class="empty-hint">{{ t('agent_portal.no_players') }}</div></template>
<el-table-column prop="id" :label="t('common.col_id')" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row }">
<code v-if="row.inviteCode" class="invite-code-cell">{{ row.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
@@ -672,19 +688,17 @@ function statusTagType(s: string) {
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="380" align="center" fixed="right">
<el-table-column :label="t('common.actions')" min-width="300" align="center" fixed="right">
<template #default="{ row }">
<div class="action-btns">
<el-button size="small" type="primary" link @click="openEdit(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" type="primary" link @click="openPlayerWalletLedger(row.id, row.username)">{{ t('user.action.view_wallet_ledger') }}</el-button>
<el-button size="small" type="success" link @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
<el-button size="small" type="warning" link @click="openTransfer('withdraw', row)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreeze(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreeze(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminPlayerRowActions
:row="row"
:show-detail="false"
@ledger="openPlayerWalletLedger(row.id, row.username)"
@edit="openEdit(row)"
@deposit="openTransfer('deposit', row)"
@withdraw="openTransfer('withdraw', row)"
@freeze="toggleFreeze(row)"
/>
</template>
</el-table-column>
</el-table>
@@ -729,6 +743,12 @@ function statusTagType(s: string) {
<template #empty><div class="empty-hint">暂无数据</div></template>
<el-table-column prop="id" :label="t('common.col_id')" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row: p }">
<code v-if="p.inviteCode" class="invite-code-cell">{{ p.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80" align="center">
<template #default="{ row: p }">
<el-tag :type="statusTagType(p.status)" size="small">{{ statusLabel(p.status) }}</el-tag>
@@ -771,25 +791,22 @@ function statusTagType(s: string) {
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="280" align="center" fixed="right">
<el-table-column :label="t('common.actions')" width="96" align="center" fixed="right">
<template #default="{ row }">
<div class="action-btns" @click.stop>
<el-button size="small" type="primary" link @click="openEditSub(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" type="primary" link @click="openCreditSub(row)">{{ t('common.adjust_credit') }}</el-button>
<el-button
v-if="subAgentAccountStatus(row) === 'ACTIVE'"
size="small"
link
type="warning"
@click="toggleFreezeSub(row)"
>{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeSub(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminRowActionsDropdown>
<el-dropdown-item @click="openEditSub(row)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCreditSub(row)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="subAgentAccountStatus(row) === 'ACTIVE'" divided @click="toggleFreezeSub(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeSub(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
<!-- DIALOGS -->
@@ -921,9 +938,6 @@ function statusTagType(s: string) {
<el-input-number v-model="createSubForm.creditLimit" :min="0" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number v-model="createSubForm.cashbackRate" :min="0" :max="1" :step="0.001" :precision="4" style="width: 100%" />
</el-form-item>
<el-form-item :label="t('agent.field.max_single_deposit')">
<el-input-number v-model="createSubForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
@@ -1029,20 +1043,70 @@ function statusTagType(s: string) {
.credit-item { display: flex; flex-direction: column; gap: 4px; }
.credit-label { font-size: 12px; color: #666; }
.credit-value { font-size: 18px; font-weight: 700; color: #e0e0e0; font-variant-numeric: tabular-nums; }
.credit-value.c-green { color: #67c23a; }
.credit-value.c-green { color: var(--gold-text); }
.credit-divider { width: 1px; height: 32px; background: #2a2a2a; }
/* ─── Tabs ─── */
.portal-top-tabs { margin-bottom: 8px; }
.portal-tabs-shell {
position: relative;
display: flex;
flex-direction: column;
margin-bottom: 8px;
}
.portal-top-tabs { margin-bottom: 0; }
.portal-top-tabs--with-invite :deep(.el-tabs__header) {
margin-bottom: 0;
padding-right: 108px;
}
.portal-top-tabs :deep(.el-tabs__item) { font-size: 14px; }
.invite-prominent-btn {
position: absolute;
right: 0;
top: 0;
z-index: 2;
min-width: 96px;
height: 38px;
padding: 0 22px;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.06em;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(201, 162, 39, 0.22);
}
.invite-code-cell {
font-family: ui-monospace, monospace;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--gold-text);
}
/* ─── Inner content ─── */
.inner-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-bottom: 8px; }
.inner-table { border-radius: 6px; }
.expandable-table :deep(.row-expandable) { cursor: pointer; }
.action-btns {
display: flex; align-items: center; justify-content: center;
gap: 4px; flex-wrap: nowrap; white-space: nowrap;
.agent-portal-mgr :deep(.el-table) {
min-width: 720px;
}
.inner-toolbar {
flex-wrap: wrap;
row-gap: 8px;
}
@media (max-width: 900px) {
.portal-top-tabs--with-invite :deep(.el-tabs__header) {
padding-right: 0;
}
.invite-prominent-btn {
position: static;
align-self: flex-end;
margin: 0 0 8px;
}
}
/* ─── Expansion ─── */
@@ -1072,22 +1136,3 @@ function statusTagType(s: string) {
.edit-stats { margin-top: 8px; }
.compact-edit-form :deep(.el-form-item) { margin-bottom: 10px; }
</style>
<style>
/* 代理端冻结按钮:与管理端一致的金黄渐变 */
.agent-portal-mgr .el-button.is-link.el-button--warning {
color: #ffffff !important;
background: linear-gradient(165deg, #e8a84a 0%, #c47a18 42%, #9a5c10 100%) !important;
border: 1px solid rgba(232, 168, 74, 0.45) !important;
border-radius: 6px !important;
padding: 5px 11px !important;
box-shadow: 0 1px 0 rgba(255,255,255,.12) inset, 0 1px 6px rgba(0,0,0,.35) !important;
text-shadow: 0 1px 1px rgba(0,0,0,.2);
}
.agent-portal-mgr .el-button.is-link.el-button--warning:hover,
.agent-portal-mgr .el-button.is-link.el-button--warning:focus {
color: #ffffff !important;
background: linear-gradient(165deg, #f0bc62 0%, #d48a28 42%, #a86814 100%) !important;
border-color: rgba(240, 188, 98, 0.55) !important;
}
</style>

View File

@@ -301,9 +301,6 @@ function statusLabel(status: string) {
/>
<div class="field-hint">{{ t('agent_portal.sub_agent_credit_hint') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number v-model="createForm.cashbackRate" :min="0" :max="1" :step="0.001" :precision="4" style="width: 100%" />
</el-form-item>
<el-form-item :label="t('agent.field.max_single_deposit')">
<el-input-number v-model="createForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
@@ -401,7 +398,7 @@ function statusLabel(status: string) {
font-variant-numeric: tabular-nums;
}
.credit-value.c-green {
color: #67c23a;
color: var(--gold-text);
}
.credit-divider {
width: 1px;

View File

@@ -16,6 +16,7 @@ export interface AgentPlayerRow {
username: string;
status: string;
createdAt: string;
inviteCode?: string | null;
wallet?: { availableBalance: string; frozenBalance?: string };
}

View File

@@ -79,7 +79,6 @@ export function buildAgentSubAgentCreatePayload(form: AgentSubAgentCreateForm) {
username: form.username.trim(),
password: form.password,
creditLimit: form.creditLimit,
cashbackRate: form.cashbackRate,
maxSingleDeposit: form.maxSingleDeposit > 0 ? form.maxSingleDeposit : undefined,
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
};

View File

@@ -38,7 +38,7 @@ const mainTrendOption = computed(() =>
[
{
name: t('dash.chart_stake'),
color: '#248f54',
color: '#d4d4d4',
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
},
{
@@ -62,7 +62,7 @@ const distributionOption = computed(() => {
const raw = s.value?.bets.todayByStatus ?? {};
const betColors: Record<string, string> = {
PENDING: '#fb923c',
WON: '#248f54',
WON: '#d4d4d4',
LOST: '#f87171',
VOID: '#6b7280',
REFUNDED: '#60a5fa',
@@ -71,7 +71,7 @@ const distributionOption = computed(() => {
const matchSegs = m
? [
{ label: t('dash.match_draft'), value: m.draft, color: '#6b7280' },
{ label: t('dash.match_published'), value: m.published, color: '#248f54' },
{ label: t('dash.match_published'), value: m.published, color: '#d4d4d4' },
{ 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' },

View File

@@ -54,7 +54,7 @@ const userDistributionOption = computed(() => {
const u = s.value?.users;
const userSegs = u
? [
{ label: t('dash.user_active'), value: u.playersActive, color: '#248f54' },
{ label: t('dash.user_active'), value: u.playersActive, color: '#d4d4d4' },
{ 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' },

View File

@@ -3,30 +3,30 @@
}
.state-card {
border-radius: 14px;
border: 1px solid #2a2220;
background: rgba(255, 69, 58, 0.06);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 69, 58, 0.04);
text-align: center;
padding: 8px 0 4px;
}
.state-title {
font-size: 15px;
font-weight: 700;
color: #ff8a80;
font-size: 14px;
font-weight: 500;
color: #fca5a5;
margin-bottom: 6px;
}
.state-hint {
font-size: 13px;
color: #888;
color: #737373;
margin-bottom: 14px;
}
.overview-board {
border-radius: 14px;
border: 1px solid #1e1e1e;
background: linear-gradient(180deg, rgba(36, 143, 84, 0.06) 0%, rgba(0, 0, 0, 0) 120px);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #111111;
margin-bottom: 28px;
}
@@ -45,13 +45,12 @@
.board-hint {
font-size: 12px;
color: #666;
color: #737373;
}
.dash-updated {
font-size: 11px;
color: #555;
letter-spacing: 0.02em;
color: #525252;
}
.kpi-grid {
@@ -71,9 +70,9 @@
.kpi-cell {
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #222;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
}
.kpi-cell.compact {
@@ -87,25 +86,25 @@
.kpi-cell--link:hover,
.kpi-cell--link:focus-visible {
border-color: rgba(77, 214, 138, 0.35);
background: rgba(36, 143, 84, 0.1);
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
outline: none;
}
.kpi-label {
display: block;
font-size: 11px;
color: #666;
font-size: 12px;
color: #737373;
margin-bottom: 6px;
}
.kpi-value {
display: block;
font-size: 22px;
font-weight: 800;
color: var(--green-text);
font-weight: 500;
color: #f5f5f5;
line-height: 1.15;
letter-spacing: -0.5px;
letter-spacing: -0.02em;
}
.kpi-value.sm {
@@ -115,7 +114,7 @@
.kpi-sub {
display: block;
font-size: 11px;
color: #555;
color: #525252;
margin-top: 4px;
}
@@ -123,15 +122,15 @@
display: inline-block;
margin-top: 6px;
font-size: 10px;
font-weight: 600;
color: #888;
font-weight: 500;
color: #737373;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.04);
}
.kpi-delta.up {
color: #4ade80;
color: #a3a3a3;
}
.kpi-delta.down {
@@ -139,13 +138,13 @@
}
.charts-stack {
border-top: 1px solid #1a1a1a;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 12px;
}
.chart-main-caption {
font-size: 11px;
color: #555;
color: #525252;
text-align: center;
margin: -8px 0 8px;
}

View File

@@ -169,8 +169,8 @@ watch(
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(47, 181, 106, 0.04);
border: 1px solid rgba(47, 181, 106, 0.14);
background: rgba(212, 175, 55, 0.04);
border: 1px solid rgba(212, 175, 55, 0.14);
}
.panel-head {
display: flex;

View File

@@ -1,4 +1,6 @@
import { FormValidationError } from '../i18n/form-validation';
import { decimalRateToPercent } from '../utils/rate-percent';
import { percentToDecimalRate } from '../utils/rate-percent';
/** 玩家用户名仅英文字母与数字332 位 */
export const PLAYER_USERNAME_PATTERN = /^[a-zA-Z0-9]{3,32}$/;
@@ -46,6 +48,9 @@ export interface PlayerEditForm {
email: string;
managedPassword: string | null;
newPassword: string;
customCashbackRate: number;
defaultCashbackRate: number;
useCustomCashback: boolean;
}
export interface PlayerRow {
@@ -57,6 +62,7 @@ export interface PlayerRow {
parentUsername: string | null;
/** 归属代理链:一级代理、二级代理(如有) */
affiliationAgents?: string[];
inviteCode?: string | null;
phone: string | null;
email: string | null;
managedPassword: string | null;
@@ -73,6 +79,8 @@ export interface PlayerDetail extends PlayerRow {
loginFailCount: number;
lockedUntil: string | null;
updatedAt: string;
customCashbackRate: string | null;
defaultCashbackRate: string;
}
/** 玩家归属标签,格式:玩家-平台 | 玩家-一级代理 | 玩家-一级代理-二级代理 */
@@ -124,6 +132,9 @@ export function emptyPlayerEditForm(): PlayerEditForm {
email: '',
managedPassword: null,
newPassword: '',
customCashbackRate: 0,
defaultCashbackRate: 0,
useCustomCashback: false,
};
}
@@ -146,6 +157,10 @@ export function editFormFromDetail(d: PlayerDetail): PlayerEditForm {
email: d.email ?? '',
managedPassword: d.managedPassword ?? null,
newPassword: '',
customCashbackRate:
d.customCashbackRate != null ? decimalRateToPercent(d.customCashbackRate) : 0,
defaultCashbackRate: decimalRateToPercent(d.defaultCashbackRate),
useCustomCashback: d.customCashbackRate != null,
};
}
@@ -164,7 +179,7 @@ export function buildCreatePlayerPayload(form: PlayerCreateForm) {
email: form.email.trim() || undefined,
asTier1Agent: true,
creditLimit: form.creditLimit,
cashbackRate: form.cashbackRate,
cashbackRate: percentToDecimalRate(form.cashbackRate),
maxSingleDeposit: form.maxSingleDeposit > 0 ? form.maxSingleDeposit : undefined,
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
};

View File

@@ -0,0 +1,61 @@
-- CreateTable
CREATE TABLE "payment_methods" (
"id" BIGSERIAL NOT NULL,
"method_type" VARCHAR(20) NOT NULL,
"bank_name" VARCHAR(128),
"account_holder" VARCHAR(128),
"account_number" VARCHAR(128),
"usdt_address" VARCHAR(256),
"qr_code_url" VARCHAR(500),
"display_name" VARCHAR(128),
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"show_on_player" BOOLEAN NOT NULL DEFAULT true,
"created_by" BIGINT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payment_methods_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "deposit_orders" (
"id" BIGSERIAL NOT NULL,
"order_no" VARCHAR(64) NOT NULL,
"player_id" BIGINT NOT NULL,
"payment_method_id" BIGINT NOT NULL,
"method_type" VARCHAR(20) NOT NULL,
"amount" DECIMAL(18,4) NOT NULL,
"screenshot_url" VARCHAR(500) NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'PENDING',
"approved_amount" DECIMAL(18,4),
"reviewer_id" BIGINT,
"reviewed_at" TIMESTAMP(3),
"reject_reason" VARCHAR(500),
"remark" VARCHAR(500),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "deposit_orders_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "payment_methods_method_type_is_active_idx" ON "payment_methods"("method_type", "is_active");
-- CreateIndex
CREATE UNIQUE INDEX "deposit_orders_order_no_key" ON "deposit_orders"("order_no");
-- CreateIndex
CREATE INDEX "deposit_orders_player_id_idx" ON "deposit_orders"("player_id");
-- CreateIndex
CREATE INDEX "deposit_orders_status_idx" ON "deposit_orders"("status");
-- CreateIndex
CREATE INDEX "deposit_orders_created_at_idx" ON "deposit_orders"("created_at");
-- AddForeignKey
ALTER TABLE "deposit_orders" ADD CONSTRAINT "deposit_orders_player_id_fkey" FOREIGN KEY ("player_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deposit_orders" ADD CONSTRAINT "deposit_orders_payment_method_id_fkey" FOREIGN KEY ("payment_method_id") REFERENCES "payment_methods"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1 @@
-- This is an empty migration.

View File

@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "invite_code" VARCHAR(16);
-- Backfill existing admins and agents with deterministic unique codes
UPDATE "users"
SET "invite_code" = UPPER(SUBSTR(MD5("id"::text || ':invite'), 1, 8))
WHERE "user_type" IN ('ADMIN', 'AGENT')
AND "invite_code" IS NULL;
-- CreateIndex
CREATE UNIQUE INDEX "users_invite_code_key" ON "users"("invite_code");

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "invite_sponsor_id" BIGINT;
-- CreateIndex
CREATE INDEX "users_invite_sponsor_id_idx" ON "users"("invite_sponsor_id");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_invite_sponsor_id_fkey" FOREIGN KEY ("invite_sponsor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,56 @@
-- CreateTable
CREATE TABLE "user_invites" (
"id" BIGSERIAL NOT NULL,
"code" VARCHAR(16) NOT NULL,
"sponsor_id" BIGINT NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
"register_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revoked_at" TIMESTAMP(3),
CONSTRAINT "user_invites_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_invites_code_key" ON "user_invites"("code");
CREATE INDEX "user_invites_sponsor_id_idx" ON "user_invites"("sponsor_id");
CREATE INDEX "user_invites_status_idx" ON "user_invites"("status");
CREATE INDEX "user_invites_created_at_idx" ON "user_invites"("created_at");
-- AddForeignKey
ALTER TABLE "user_invites" ADD CONSTRAINT "user_invites_sponsor_id_fkey" FOREIGN KEY ("sponsor_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "used_invite_id" BIGINT;
CREATE INDEX "users_used_invite_id_idx" ON "users"("used_invite_id");
ALTER TABLE "users" ADD CONSTRAINT "users_used_invite_id_fkey" FOREIGN KEY ("used_invite_id") REFERENCES "user_invites"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Backfill existing invite codes into history
INSERT INTO "user_invites" ("code", "sponsor_id", "status", "register_count", "created_at")
SELECT u."invite_code", u."id", 'ACTIVE', 0, COALESCE(u."updated_at", u."created_at")
FROM "users" u
WHERE u."invite_code" IS NOT NULL
AND u."user_type" IN ('ADMIN', 'AGENT')
ON CONFLICT ("code") DO NOTHING;
-- Link players to invite records when possible
UPDATE "users" p
SET "used_invite_id" = ui."id"
FROM "user_invites" ui
WHERE p."invite_sponsor_id" = ui."sponsor_id"
AND p."user_type" = 'PLAYER'
AND p."used_invite_id" IS NULL
AND ui."status" = 'ACTIVE'
AND ui."code" = (
SELECT s."invite_code" FROM "users" s WHERE s."id" = p."invite_sponsor_id"
);
UPDATE "user_invites" ui
SET "register_count" = sub.cnt
FROM (
SELECT "used_invite_id" AS id, COUNT(*)::int AS cnt
FROM "users"
WHERE "used_invite_id" IS NOT NULL
GROUP BY "used_invite_id"
) sub
WHERE ui."id" = sub.id;

View File

@@ -0,0 +1,3 @@
-- Per-invite cashback rate (admin can set when generating)
ALTER TABLE "user_invites"
ADD COLUMN "cashback_rate" DECIMAL(8, 4);

View File

@@ -17,6 +17,9 @@ model User {
parentId BigInt? @map("parent_id")
agentLevel Int? @map("agent_level")
locale String @default("en-US") @db.VarChar(10)
inviteCode String? @unique @map("invite_code") @db.VarChar(16)
inviteSponsorId BigInt? @map("invite_sponsor_id")
usedInviteId BigInt? @map("used_invite_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@ -27,15 +30,41 @@ model User {
adminRole AdminUserRole?
bets Bet[]
preferences UserPreference?
depositOrders DepositOrder[] @relation("PlayerDepositOrders")
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
children User[] @relation("UserHierarchy")
inviteSponsor User? @relation("InviteSponsor", fields: [inviteSponsorId], references: [id])
invitedPlayers User[] @relation("InviteSponsor")
usedInvite UserInvite? @relation("UsedInvite", fields: [usedInviteId], references: [id])
invites UserInvite[] @relation("UserInvites")
@@index([userType])
@@index([parentId])
@@index([inviteSponsorId])
@@index([usedInviteId])
@@map("users")
}
model UserInvite {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(16)
sponsorId BigInt @map("sponsor_id")
status String @default("ACTIVE") @db.VarChar(20)
registerCount Int @default(0) @map("register_count")
createdAt DateTime @default(now()) @map("created_at")
revokedAt DateTime? @map("revoked_at")
cashbackRate Decimal? @map("cashback_rate") @db.Decimal(8, 4)
sponsor User @relation("UserInvites", fields: [sponsorId], references: [id])
registrations User[] @relation("UsedInvite")
@@index([sponsorId])
@@index([status])
@@index([createdAt])
@@map("user_invites")
}
model UserAuth {
id BigInt @id @default(autoincrement())
userId BigInt @unique @map("user_id")
@@ -613,6 +642,56 @@ model UploadedFile {
@@map("uploaded_files")
}
// ============ Manual Deposit / Recharge ============
model PaymentMethod {
id BigInt @id @default(autoincrement())
methodType String @map("method_type") @db.VarChar(20)
bankName String? @map("bank_name") @db.VarChar(128)
accountHolder String? @map("account_holder") @db.VarChar(128)
accountNumber String? @map("account_number") @db.VarChar(128)
usdtAddress String? @map("usdt_address") @db.VarChar(256)
qrCodeUrl String? @map("qr_code_url") @db.VarChar(500)
displayName String? @map("display_name") @db.VarChar(128)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
showOnPlayer Boolean @default(true) @map("show_on_player")
createdBy BigInt? @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
depositOrders DepositOrder[]
@@index([methodType, isActive])
@@map("payment_methods")
}
model DepositOrder {
id BigInt @id @default(autoincrement())
orderNo String @unique @map("order_no") @db.VarChar(64)
playerId BigInt @map("player_id")
paymentMethodId BigInt @map("payment_method_id")
methodType String @map("method_type") @db.VarChar(20)
amount Decimal @db.Decimal(18, 4)
screenshotUrl String @map("screenshot_url") @db.VarChar(500)
status String @default("PENDING") @db.VarChar(20)
approvedAmount Decimal? @map("approved_amount") @db.Decimal(18, 4)
reviewerId BigInt? @map("reviewer_id")
reviewedAt DateTime? @map("reviewed_at")
rejectReason String? @map("reject_reason") @db.VarChar(500)
remark String? @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
player User @relation("PlayerDepositOrders", fields: [playerId], references: [id])
paymentMethod PaymentMethod @relation(fields: [paymentMethodId], references: [id])
@@index([playerId])
@@index([status])
@@index([createdAt])
@@map("deposit_orders")
}
// ============ System Config & Audit ============
model SystemConfig {

View File

@@ -17,4 +17,6 @@ export const P = {
content: 'content.manage',
audit: 'audit.view',
resetDatabase: 'settings.reset_database',
depositManage: 'deposit.manage',
depositReview: 'deposit.review',
} as const;

View File

@@ -41,6 +41,7 @@ import { SystemConfigService } from '../../shared/config/system-config.service';
import { P } from './admin-permissions';
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
import { DepositService } from '../../domains/deposit/deposit.service';
import {
IsString,
IsNumber,
@@ -56,7 +57,7 @@ import {
} from 'class-validator';
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
const UPLOAD_CATEGORIES = ['banners', 'teams', 'contents'] as const;
const UPLOAD_CATEGORIES = ['banners', 'teams', 'contents', 'payments', 'deposits'] as const;
type UploadCategory = (typeof UPLOAD_CATEGORIES)[number];
const IMAGE_MIME_EXT: Record<string, string> = {
@@ -236,6 +237,25 @@ class UpdatePlayerAdminDto {
@IsString()
@MinLength(8)
password?: string;
/** 玩家专属返水比例小数null 或 0 表示清除单独设置、使用默认 */
@IsOptional()
@ValidateIf((_, v) => v != null)
@IsNumber()
@Min(0)
cashbackRate?: number | null;
}
class PlatformDirectCashbackSettingsDto {
@IsOptional()
@IsNumber()
@Min(0)
platformDirectRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
adminInviteRate?: number;
}
class PlayerAccountSettingsDto {
@@ -912,6 +932,7 @@ export class AdminController {
private bettingLimits: BettingLimitsService,
private databaseReset: DatabaseResetService,
private smokeTests: SmokeTestService,
private depositService: DepositService,
) {}
@Get('dashboard')
@@ -1080,7 +1101,15 @@ export class AdminController {
@Param('id') id: string,
@Body() dto: UpdatePlayerAdminDto,
) {
const detail = await this.users.updatePlayerAdmin(BigInt(id), dto);
const detail = await this.users.updatePlayerAdmin(BigInt(id), {
status: dto.status,
locale: dto.locale,
phone: dto.phone,
email: dto.email,
username: dto.username,
password: dto.password,
cashbackRate: dto.cashbackRate,
});
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
@@ -2003,6 +2032,20 @@ export class AdminController {
return jsonResponse(detail);
}
@Get('settings/cashback/platform-direct')
@RequirePermissions(P.cashback, P.reports)
async getPlatformDirectCashbackSettings() {
const settings = await this.systemConfig.getPlatformDirectCashbackSettings();
return jsonResponse(settings);
}
@Put('settings/cashback/platform-direct')
@RequirePermissions(P.cashback)
async updatePlatformDirectCashbackSettings(@Body() dto: PlatformDirectCashbackSettingsDto) {
const settings = await this.systemConfig.updatePlatformDirectCashbackSettings(dto);
return jsonResponse(settings);
}
@Post('cashbacks/preview')
@RequirePermissions(P.cashback, P.reports)
async cashbackPreview(@Body() dto: CashbackPreviewDto) {
@@ -2307,4 +2350,134 @@ export class AdminController {
);
return jsonResponse(result);
}
// ============ Payment Methods ============
@Post('payment-methods')
@RequirePermissions(P.depositManage)
async createPaymentMethod(
@CurrentUser('id') operatorId: bigint,
@Body() body: {
methodType: string;
bankName?: string;
accountHolder?: string;
accountNumber?: string;
usdtAddress?: string;
qrCodeUrl?: string;
displayName?: string;
sortOrder?: number;
isActive?: boolean;
showOnPlayer?: boolean;
translations?: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
};
},
) {
if (!body.methodType || !['BANK', 'USDT'].includes(body.methodType)) {
throw appBadRequest('INVALID_METHOD_TYPE');
}
const method = await this.depositService.createPaymentMethod({
...body,
createdBy: operatorId,
});
return jsonResponse(method);
}
@Get('payment-methods')
@RequirePermissions(P.depositManage)
async listPaymentMethods(@Query('methodType') methodType?: string) {
const items = await this.depositService.listPaymentMethods({
methodType: methodType || undefined,
});
return jsonResponse(items);
}
@Put('payment-methods/:id')
@RequirePermissions(P.depositManage)
async updatePaymentMethod(
@Param('id') id: string,
@Body() body: {
bankName?: string;
accountHolder?: string;
accountNumber?: string;
usdtAddress?: string;
qrCodeUrl?: string;
displayName?: string;
sortOrder?: number;
isActive?: boolean;
showOnPlayer?: boolean;
translations?: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
};
},
) {
const method = await this.depositService.updatePaymentMethod(BigInt(id), body);
return jsonResponse(method);
}
@Delete('payment-methods/:id')
@RequirePermissions(P.depositManage)
async deletePaymentMethod(@Param('id') id: string) {
await this.depositService.deletePaymentMethod(BigInt(id));
return jsonResponse({ success: true });
}
// ============ Deposit Orders (Admin Review) ============
@Get('deposit-orders')
@RequirePermissions(P.depositReview)
async listDepositOrders(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('status') status?: string,
@Query('keyword') keyword?: string,
@Query('methodType') methodType?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
) {
const result = await this.depositService.listDepositOrders({
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
status: status || undefined,
keyword: keyword || undefined,
methodType: methodType || undefined,
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
dateTo: dateTo ? new Date(dateTo) : undefined,
});
return jsonResponse(result);
}
@Post('deposit-orders/:id/approve')
@RequirePermissions(P.depositReview)
async approveDepositOrder(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() body: { approvedAmount?: number; remark?: string },
) {
const result = await this.depositService.approveDepositOrder(
BigInt(id),
operatorId,
body.approvedAmount,
body.remark,
);
return jsonResponse(result);
}
@Post('deposit-orders/:id/reject')
@RequirePermissions(P.depositReview)
async rejectDepositOrder(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() body: { reason: string },
) {
if (!body.reason?.trim()) throw appBadRequest('REASON_REQUIRED');
const result = await this.depositService.rejectDepositOrder(
BigInt(id),
operatorId,
body.reason.trim(),
);
return jsonResponse(result);
}
}

View File

@@ -14,6 +14,7 @@ import { I18nModule } from '../../domains/operations/i18n/i18n.module';
import { BetsModule } from '../../domains/betting/bets.module';
import { DatabaseModule } from '../../infrastructure/database/database.module';
import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test.module';
import { DepositModule } from '../../domains/deposit/deposit.module';
@Module({
imports: [
@@ -29,6 +30,7 @@ import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test
BetsModule,
DatabaseModule,
SmokeTestModule,
DepositModule,
],
controllers: [AdminController],
providers: [AdminDashboardService, PermissionsGuard],

View File

@@ -8,11 +8,19 @@ import {
Query,
Headers,
UseGuards,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { randomUUID } from 'crypto';
import { mkdir, writeFile } from 'fs/promises';
import { extname, join } from 'path';
import { JwtAuthGuard, PlayerGuard } from '../../domains/identity/guards';
import { CurrentUser, Public } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { appBadRequest } from '../../shared/common/app-error';
import { getUploadRoot } from '../../shared/uploads/upload-paths';
import { UsersService } from '../../domains/identity/users.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { WalletService } from '../../domains/ledger/wallet.service';
@@ -21,6 +29,7 @@ import { OutrightService } from '../../domains/catalog/outright.service';
import { BetsService } from '../../domains/betting/bets.service';
import { ContentService } from '../../domains/operations/content/content.service';
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
import { DepositService } from '../../domains/deposit/deposit.service';
import { IsString, IsNumber, IsArray, ValidateNested, Min, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
@@ -98,6 +107,7 @@ export class PlayerController {
private content: ContentService,
private cashback: CashbackService,
private systemConfig: SystemConfigService,
private deposit: DepositService,
) {}
private async formatPlayerProfile(user: NonNullable<Awaited<ReturnType<UsersService['findById']>>>) {
@@ -290,4 +300,68 @@ export class PlayerController {
const items = await this.cashback.getUserCashbacks(userId);
return jsonResponse(items);
}
// ============ Deposit / Recharge ============
@Get('payment-methods')
async paymentMethods(
@Query('methodType') methodType?: string,
@CurrentUser('locale') userLocale?: string,
@Headers('x-locale') headerLocale?: string,
) {
const locale = userLocale || headerLocale || 'zh-CN';
const items = await this.deposit.listPlayerPaymentMethods(methodType || undefined, locale);
return jsonResponse(items);
}
@Post('deposit-orders')
@UseInterceptors(FileInterceptor('screenshot', { limits: { fileSize: 5 * 1024 * 1024 } }))
async createDepositOrder(
@CurrentUser('id') userId: bigint,
@UploadedFile() file: { originalname: string; mimetype: string; buffer: Buffer; size: number } | undefined,
@Body() body: { paymentMethodId: string; amount: string },
) {
if (!file) throw appBadRequest('SCREENSHOT_REQUIRED');
if (!file.mimetype.startsWith('image/')) throw appBadRequest('FILE_MUST_BE_IMAGE');
const amount = parseFloat(body.amount);
if (!amount || amount <= 0) throw appBadRequest('INVALID_AMOUNT');
if (!body.paymentMethodId) throw appBadRequest('PAYMENT_METHOD_REQUIRED');
// Save screenshot
const ext = extname(file.originalname || '.jpg').toLowerCase() || '.jpg';
const filename = `${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
const root = getUploadRoot();
const targetDir = join(root, 'deposits');
await mkdir(targetDir, { recursive: true });
await writeFile(join(targetDir, filename), file.buffer);
const screenshotUrl = `/uploads/deposits/${filename}`;
const order = await this.deposit.createDepositOrder(
userId,
BigInt(body.paymentMethodId),
amount,
screenshotUrl,
);
return jsonResponse({
id: order.id.toString(),
orderNo: order.orderNo,
amount: order.amount.toString(),
status: order.status,
createdAt: order.createdAt,
});
}
@Get('deposit-orders')
async myDepositOrders(
@CurrentUser('id') userId: bigint,
@Query('page') page?: string,
) {
const result = await this.deposit.getPlayerDepositOrders(
userId,
page ? parseInt(page, 10) : 1,
);
return jsonResponse(result);
}
}

View File

@@ -6,9 +6,10 @@ import { MatchesModule } from '../../domains/catalog/matches.module';
import { BetsModule } from '../../domains/betting/bets.module';
import { ContentModule } from '../../domains/operations/content/content.module';
import { CashbackModule } from '../../domains/operations/cashback/cashback.module';
import { DepositModule } from '../../domains/deposit/deposit.module';
@Module({
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule],
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule, DepositModule],
controllers: [PlayerController],
})
export class PlayerModule {}

View File

@@ -9,6 +9,7 @@ import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../../shared/common/decorators';
import { assertPlayerUsername } from '@thebet365/shared';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
import { assignInviteCodeWithHistory } from '../../shared/common/invite-code.util';
function dec(v: Decimal | null | undefined) {
return v?.toString() ?? '0';
@@ -774,6 +775,7 @@ export class AgentsService {
phone: p.user.preferences?.phone ?? null,
email: p.user.preferences?.email ?? null,
locale: p.user.locale,
inviteCode: p.user.inviteCode ?? null,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
};
@@ -849,6 +851,7 @@ export class AgentsService {
email: profile.user.preferences?.email ?? null,
managedPassword: profile.user.preferences?.managedPassword ?? null,
locale: profile.user.locale,
inviteCode: profile.user.inviteCode ?? null,
lastLoginAt: profile.user.auth?.lastLoginAt ?? null,
loginFailCount: profile.user.auth?.loginFailCount ?? 0,
createdAt: profile.createdAt,
@@ -1232,6 +1235,7 @@ export class AgentsService {
parentId: null,
},
});
await assignInviteCodeWithHistory(tx, userId);
if (user.preferences) {
await tx.userPreference.update({
@@ -1295,10 +1299,17 @@ export class AgentsService {
) {
await this.validateAgentLevel(data.level, data.parentAgentId);
let resolvedCashbackRate = data.cashbackRate ?? 0;
if (data.parentAgentId) {
const parentProfile = await this.prisma.agentProfile.findUnique({
where: { userId: data.parentAgentId },
select: { cashbackRate: true },
});
resolvedCashbackRate =
data.cashbackRate ?? (parentProfile ? Number(parentProfile.cashbackRate) : 0);
await this.assertChildAgentWithinParent(data.parentAgentId, {
creditLimit: data.creditLimit ?? 0,
cashbackRate: data.cashbackRate ?? 0,
cashbackRate: resolvedCashbackRate,
maxSingleDeposit: data.maxSingleDeposit,
maxDailyDeposit: data.maxDailyDeposit,
});
@@ -1320,6 +1331,7 @@ export class AgentsService {
locale,
},
});
await assignInviteCodeWithHistory(tx, user.id);
await tx.userAuth.create({
data: { userId: user.id, passwordHash: hash },
@@ -1340,7 +1352,7 @@ export class AgentsService {
level: data.level,
parentAgentId: data.parentAgentId,
creditLimit: data.creditLimit ?? 0,
cashbackRate: data.cashbackRate ?? 0,
cashbackRate: resolvedCashbackRate,
maxSingleDeposit,
maxDailyDeposit,
},
@@ -1522,11 +1534,27 @@ export class AgentsService {
}
async getDirectPlayers(agentId: bigint) {
return this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER' },
include: { wallet: true },
const rows = await this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
include: {
wallet: true,
usedInvite: { select: { code: true } },
},
orderBy: { createdAt: 'desc' },
});
return rows.map((u) => ({
id: u.id.toString(),
username: u.username,
status: u.status,
createdAt: u.createdAt,
inviteCode: u.usedInvite?.code ?? null,
wallet: u.wallet
? {
availableBalance: u.wallet.availableBalance.toString(),
frozenBalance: u.wallet.frozenBalance.toString(),
}
: undefined,
}));
}
async getChildAgents(agentId: bigint) {

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DepositService } from './deposit.service';
import { WalletModule } from '../ledger/wallet.module';
@Module({
imports: [WalletModule],
providers: [DepositService],
exports: [DepositService],
})
export class DepositModule {}

View File

@@ -0,0 +1,440 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import { resolveTranslationFallback } from '@thebet365/shared';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { appBadRequest } from '../../shared/common/app-error';
function generateOrderNo(): string {
const ts = Date.now().toString(36).toUpperCase();
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
return `DEP${ts}${rand}`;
}
@Injectable()
export class DepositService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
) {}
// ============ Payment Methods (Admin CRUD) ============
async createPaymentMethod(data: {
methodType: string;
bankName?: string;
accountHolder?: string;
accountNumber?: string;
usdtAddress?: string;
qrCodeUrl?: string;
displayName?: string;
sortOrder?: number;
isActive?: boolean;
showOnPlayer?: boolean;
createdBy?: bigint;
translations?: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
};
}) {
const method = await this.prisma.paymentMethod.create({
data: {
methodType: data.methodType,
bankName: data.bankName,
accountHolder: data.accountHolder,
accountNumber: data.accountNumber,
usdtAddress: data.usdtAddress,
qrCodeUrl: data.qrCodeUrl,
displayName: data.displayName,
sortOrder: data.sortOrder ?? 0,
isActive: data.isActive ?? true,
showOnPlayer: data.showOnPlayer ?? true,
createdBy: data.createdBy,
},
});
if (data.translations) {
await this.upsertPaymentMethodTranslations(method.id, data.translations);
}
return method;
}
async updatePaymentMethod(
id: bigint,
data: {
bankName?: string;
accountHolder?: string;
accountNumber?: string;
usdtAddress?: string;
qrCodeUrl?: string;
displayName?: string;
sortOrder?: number;
isActive?: boolean;
showOnPlayer?: boolean;
translations?: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
};
},
) {
const { translations, ...rest } = data;
const method = await this.prisma.paymentMethod.update({
where: { id },
data: rest,
});
if (translations) {
await this.upsertPaymentMethodTranslations(id, translations);
}
return method;
}
async deletePaymentMethod(id: bigint) {
return this.prisma.paymentMethod.update({
where: { id },
data: { isActive: false, showOnPlayer: false },
});
}
async listPaymentMethods(filters?: { methodType?: string }) {
const where: Prisma.PaymentMethodWhereInput = {};
if (filters?.methodType) {
where.methodType = filters.methodType;
}
const items = await this.prisma.paymentMethod.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
// Attach translations for admin editing
const ids = items.map((m) => m.id);
const translations = ids.length
? await this.prisma.entityTranslation.findMany({
where: { entityType: 'PAYMENT_METHOD', entityId: { in: ids } },
})
: [];
const tMap = new Map<string, Record<string, Record<string, string>>>();
for (const t of translations) {
const key = t.entityId.toString();
if (!tMap.has(key)) tMap.set(key, {});
const entityMap = tMap.get(key)!;
if (!entityMap[t.fieldName]) entityMap[t.fieldName] = {};
entityMap[t.fieldName][t.locale] = t.value;
}
return items.map((m) => ({
...m,
translations: tMap.get(m.id.toString()) ?? {},
}));
}
async listPlayerPaymentMethods(methodType?: string, locale?: string) {
const where: Prisma.PaymentMethodWhereInput = {
isActive: true,
showOnPlayer: true,
};
if (methodType) {
where.methodType = methodType;
}
const items = await this.prisma.paymentMethod.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
select: {
id: true,
methodType: true,
bankName: true,
accountHolder: true,
accountNumber: true,
usdtAddress: true,
qrCodeUrl: true,
displayName: true,
sortOrder: true,
},
});
// Resolve translations for player locale
if (locale && items.length) {
const ids = items.map((m) => m.id);
const translations = await this.prisma.entityTranslation.findMany({
where: { entityType: 'PAYMENT_METHOD', entityId: { in: ids } },
});
const tMap = new Map<string, Record<string, Record<string, string>>>();
for (const t of translations) {
const key = t.entityId.toString();
if (!tMap.has(key)) tMap.set(key, {});
const entityMap = tMap.get(key)!;
if (!entityMap[t.fieldName]) entityMap[t.fieldName] = {};
entityMap[t.fieldName][t.locale] = t.value;
}
return items.map((m) => {
const t = tMap.get(m.id.toString());
const resolvedDisplayName = t?.displayName
? resolveTranslationFallback(t.displayName, locale) || m.displayName
: m.displayName;
const resolvedBankName = t?.bankName
? resolveTranslationFallback(t.bankName, locale) || m.bankName
: m.bankName;
return { ...m, displayName: resolvedDisplayName, bankName: resolvedBankName };
});
}
return items;
}
// ============ Translation helpers ============
private async upsertPaymentMethodTranslations(
entityId: bigint,
translations: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
},
) {
for (const fieldName of ['displayName', 'bankName'] as const) {
const fieldTranslations = translations[fieldName];
if (!fieldTranslations) continue;
for (const [locale, value] of Object.entries(fieldTranslations)) {
await this.prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType: 'PAYMENT_METHOD',
entityId,
locale,
fieldName,
},
},
create: { entityType: 'PAYMENT_METHOD', entityId, locale, fieldName, value },
update: { value },
});
}
}
}
// ============ Deposit Orders ============
async createDepositOrder(
playerId: bigint,
paymentMethodId: bigint,
amount: number,
screenshotUrl: string,
) {
const method = await this.prisma.paymentMethod.findUnique({
where: { id: paymentMethodId },
});
if (!method || !method.isActive) {
throw appBadRequest('PAYMENT_METHOD_NOT_FOUND');
}
const order = await this.prisma.depositOrder.create({
data: {
orderNo: generateOrderNo(),
playerId,
paymentMethodId,
methodType: method.methodType,
amount: new Decimal(amount),
screenshotUrl,
status: 'PENDING',
},
});
return order;
}
async getPlayerDepositOrders(playerId: bigint, page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const where = { playerId };
const [items, total] = await Promise.all([
this.prisma.depositOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: {
paymentMethod: {
select: { bankName: true, usdtAddress: true, displayName: true, methodType: true },
},
},
}),
this.prisma.depositOrder.count({ where }),
]);
return {
items: items.map((o) => ({
id: o.id.toString(),
orderNo: o.orderNo,
methodType: o.methodType,
amount: o.amount.toString(),
screenshotUrl: o.screenshotUrl,
status: o.status,
approvedAmount: o.approvedAmount?.toString() ?? null,
rejectReason: o.rejectReason,
remark: o.remark,
createdAt: o.createdAt,
reviewedAt: o.reviewedAt,
paymentMethodName: o.paymentMethod?.displayName ?? o.paymentMethod?.bankName ?? o.paymentMethod?.usdtAddress ?? null,
})),
total,
page,
pageSize,
};
}
async listDepositOrders(params: {
page?: number;
pageSize?: number;
status?: string;
keyword?: string;
methodType?: string;
dateFrom?: Date;
dateTo?: Date;
}) {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
const skip = (page - 1) * pageSize;
const where: Prisma.DepositOrderWhereInput = {};
if (params.status) {
where.status = params.status;
}
if (params.methodType) {
where.methodType = params.methodType;
}
if (params.dateFrom || params.dateTo) {
where.createdAt = {};
if (params.dateFrom) where.createdAt.gte = params.dateFrom;
if (params.dateTo) where.createdAt.lte = params.dateTo;
}
if (params.keyword?.trim()) {
const players = await this.prisma.user.findMany({
where: {
userType: 'PLAYER',
deletedAt: null,
username: { contains: params.keyword.trim(), mode: 'insensitive' },
},
select: { id: true },
take: 100,
});
if (!players.length) {
return { items: [], total: 0, page, pageSize };
}
where.playerId = { in: players.map((p) => p.id) };
}
const [rows, total] = await Promise.all([
this.prisma.depositOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: {
paymentMethod: {
select: { bankName: true, usdtAddress: true, displayName: true, methodType: true },
},
},
}),
this.prisma.depositOrder.count({ where }),
]);
// Enrich with player usernames and reviewer info
const playerIds = [...new Set(rows.map((r) => r.playerId))];
const reviewerIds = [...new Set(rows.map((r) => r.reviewerId).filter((id): id is bigint => id != null))];
const [players, reviewers] = await Promise.all([
playerIds.length
? this.prisma.user.findMany({
where: { id: { in: playerIds } },
select: { id: true, username: true },
})
: [],
reviewerIds.length
? this.prisma.user.findMany({
where: { id: { in: reviewerIds } },
select: { id: true, username: true },
})
: [],
]);
const playerMap = new Map(players.map((p) => [p.id.toString(), p.username]));
const reviewerMap = new Map(reviewers.map((r) => [r.id.toString(), r.username]));
return {
items: rows.map((o) => ({
id: o.id.toString(),
orderNo: o.orderNo,
playerId: o.playerId.toString(),
playerUsername: playerMap.get(o.playerId.toString()) ?? null,
methodType: o.methodType,
amount: o.amount.toString(),
screenshotUrl: o.screenshotUrl,
status: o.status,
approvedAmount: o.approvedAmount?.toString() ?? null,
reviewerId: o.reviewerId?.toString() ?? null,
reviewerUsername: o.reviewerId ? (reviewerMap.get(o.reviewerId.toString()) ?? null) : null,
rejectReason: o.rejectReason,
remark: o.remark,
createdAt: o.createdAt,
reviewedAt: o.reviewedAt,
paymentMethodName: o.paymentMethod?.displayName ?? o.paymentMethod?.bankName ?? o.paymentMethod?.usdtAddress ?? null,
})),
total,
page,
pageSize,
};
}
async approveDepositOrder(
orderId: bigint,
operatorId: bigint,
approvedAmount?: number,
remark?: string,
) {
return this.prisma.$transaction(async (tx) => {
const order = await tx.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
if (order.status !== 'PENDING') throw appBadRequest('ORDER_NOT_PENDING');
const creditAmount = approvedAmount != null ? new Decimal(approvedAmount) : order.amount;
await tx.depositOrder.update({
where: { id: orderId },
data: {
status: 'APPROVED',
approvedAmount: creditAmount,
reviewerId: operatorId,
reviewedAt: new Date(),
remark: remark ?? null,
},
});
// Credit player wallet
await this.wallet.deposit(
order.playerId,
creditAmount,
operatorId,
remark ?? `Deposit order ${order.orderNo}`,
order.orderNo,
'PLAYER_DEPOSIT',
);
return { success: true };
});
}
async rejectDepositOrder(orderId: bigint, operatorId: bigint, reason: string) {
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
if (order.status !== 'PENDING') throw appBadRequest('ORDER_NOT_PENDING');
await this.prisma.depositOrder.update({
where: { id: orderId },
data: {
status: 'REJECTED',
reviewerId: operatorId,
reviewedAt: new Date(),
rejectReason: reason,
},
});
return { success: true };
}
}

View File

@@ -1,8 +1,9 @@
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Delete, Body, UseGuards, Query, Param } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { InvitesService } from './invites.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { LoginDto, ChangePasswordDto } from './auth.dto';
import { LoginDto, ChangePasswordDto, RegisterDto, GenerateInviteDto } from './auth.dto';
import { Public, CurrentUser } from '../../shared/common/decorators';
import { JwtAuthGuard } from './guards';
import { jsonResponse } from '../../shared/common/filters';
@@ -12,6 +13,7 @@ import { jsonResponse } from '../../shared/common/filters';
export class AuthController {
constructor(
private auth: AuthService,
private invites: InvitesService,
private systemConfig: SystemConfigService,
) {}
@@ -22,6 +24,98 @@ export class AuthController {
return jsonResponse(result);
}
@Public()
@Post('player/auth/register')
async playerRegister(@Body() dto: RegisterDto) {
const result = await this.auth.registerPlayer({
username: dto.username,
password: dto.password,
inviteCode: dto.inviteCode,
locale: dto.locale,
});
return jsonResponse(result);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('manage/invite')
async manageInvite(@CurrentUser('id') userId: bigint) {
const info = await this.auth.getInviteInfo(userId);
return jsonResponse(info);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Post('manage/invite/generate')
async manageInviteGenerate(
@CurrentUser('id') userId: bigint,
@CurrentUser('userType') userType: string,
@Body() dto: GenerateInviteDto,
) {
const info = await this.auth.generateInviteCode(userId, userType, dto.cashbackRate);
return jsonResponse(info);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('manage/invites')
async manageInvites(
@CurrentUser('id') userId: bigint,
@CurrentUser('userType') userType: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('status') status?: string,
@Query('sponsorId') sponsorId?: string,
@Query('keyword') keyword?: string,
) {
const result = await this.invites.listInvites(
{ id: userId, userType },
{
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
status,
sponsorId,
keyword,
},
);
return jsonResponse(result);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('manage/invites/sponsors')
async manageInviteSponsors(
@CurrentUser('id') userId: bigint,
@CurrentUser('userType') userType: string,
) {
const items = await this.invites.listSponsorOptions({ id: userId, userType });
return jsonResponse({ items });
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Post('manage/invites/:id/revoke')
async manageInviteRevoke(
@CurrentUser('id') userId: bigint,
@CurrentUser('userType') userType: string,
@Param('id') id: string,
) {
const item = await this.invites.revokeInvite({ id: userId, userType }, BigInt(id));
return jsonResponse(item);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Delete('manage/invites/:id')
async manageInviteDelete(
@CurrentUser('id') userId: bigint,
@CurrentUser('userType') userType: string,
@Param('id') id: string,
) {
const result = await this.invites.deleteInvite({ id: userId, userType }, BigInt(id));
return jsonResponse(result);
}
@Public()
@Post('admin/auth/login')
async adminLogin(@Body() dto: LoginDto) {
@@ -64,6 +158,11 @@ export class AuthController {
maxAgentLevel === 0 || level < maxAgentLevel;
}
let inviteCode: string | null = null;
if (userType === 'ADMIN' || userType === 'AGENT') {
inviteCode = (await this.auth.getInviteInfo(userId)).inviteCode;
}
return jsonResponse({
id: userId.toString(),
username,
@@ -73,6 +172,7 @@ export class AuthController {
agentLevel: level,
maxAgentLevel,
canManageSubAgents,
inviteCode,
});
}

View File

@@ -1,4 +1,4 @@
import { IsString, MinLength } from 'class-validator';
import { IsString, MinLength, IsOptional, IsNumber } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@@ -12,6 +12,27 @@ export class LoginDto {
password!: string;
}
export class RegisterDto {
@ApiProperty()
@IsString()
username!: string;
@ApiProperty()
@IsString()
@MinLength(8)
password!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
inviteCode?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
locale?: string;
}
export class ChangePasswordDto {
@ApiProperty()
@IsString()
@@ -22,3 +43,10 @@ export class ChangePasswordDto {
@MinLength(8)
newPassword!: string;
}
export class GenerateInviteDto {
@ApiProperty({ required: false, description: 'Decimal cashback rate, e.g. 0.01 = 1%. Admin only.' })
@IsOptional()
@IsNumber()
cashbackRate?: number;
}

View File

@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { InvitesService } from './invites.service';
import { JwtStrategy } from './jwt.strategy';
import { AuthController } from './auth.controller';
import { SystemConfigModule } from '../../shared/config/system-config.module';
@@ -20,8 +21,8 @@ import { SystemConfigModule } from '../../shared/config/system-config.module';
inject: [ConfigService],
}),
],
providers: [AuthService, JwtStrategy],
providers: [AuthService, InvitesService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService, JwtModule],
exports: [AuthService, InvitesService, JwtModule],
})
export class AuthModule {}

View File

@@ -1,10 +1,13 @@
import { Injectable } from '@nestjs/common';
import { appForbidden, appUnauthorized } from '../../shared/common/app-error';
import { Decimal } from '@prisma/client/runtime/library';
import { appForbidden, appUnauthorized, appBadRequest, appNotFound } from '../../shared/common/app-error';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs';
import { assertPlayerUsername } from '@thebet365/shared';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { InvitesService } from './invites.service';
const MAX_LOGIN_FAILS = 5;
const LOCK_DURATION_MS = 15 * 60 * 1000;
@@ -23,6 +26,7 @@ export class AuthService {
private jwt: JwtService,
private config: ConfigService,
private systemConfig: SystemConfigService,
private invites: InvitesService,
) {}
/** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT */
@@ -129,6 +133,125 @@ export class AuthService {
};
}
async resolveInviteSponsor(inviteCodeRaw?: string | null) {
const resolved = await this.invites.resolveActiveInvite(inviteCodeRaw);
return {
sponsorId: resolved.sponsorId,
parentId: resolved.parentId,
inviteId: resolved.inviteId,
};
}
async registerPlayer(data: {
username: string;
password: string;
inviteCode?: string;
locale?: string;
}) {
const username = data.username.trim();
if (!username) {
throw appBadRequest('USERNAME_REQUIRED');
}
try {
assertPlayerUsername(username);
} catch {
throw appBadRequest('USERNAME_FORMAT_INVALID');
}
if (!data.password || data.password.length < 8) {
throw appBadRequest('PASSWORD_MIN_LENGTH');
}
const { parentId, sponsorId, inviteId } = await this.resolveInviteSponsor(data.inviteCode);
const inviteSponsorId = parentId == null && sponsorId != null ? sponsorId : null;
const existing = await this.prisma.user.findUnique({
where: { username },
select: { id: true },
});
if (existing) {
throw appBadRequest('USERNAME_TAKEN');
}
const hash = await this.hashPassword(data.password);
const locale = data.locale?.trim() || 'zh-CN';
const user = await this.prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
username,
userType: 'PLAYER',
parentId,
inviteSponsorId,
locale,
},
});
await tx.userAuth.create({
data: { userId: created.id, passwordHash: hash },
});
await tx.wallet.create({
data: { userId: created.id },
});
await tx.userPreference.create({
data: { userId: created.id, locale },
});
if (inviteId) {
await this.invites.recordRegistration(inviteId, created.id, tx);
const invite = await tx.userInvite.findUnique({
where: { id: inviteId },
select: { cashbackRate: true },
});
if (invite?.cashbackRate != null && new Decimal(invite.cashbackRate).gt(0)) {
await tx.cashbackRule.updateMany({
where: { targetType: 'USER', targetId: created.id },
data: { isActive: false },
});
await tx.cashbackRule.create({
data: {
name: `Player ${created.id.toString()}`,
targetType: 'USER',
targetId: created.id,
rate: invite.cashbackRate,
isActive: true,
},
});
}
}
return created;
});
return this.login(username, data.password, 'player');
}
async getInviteInfo(userId: bigint) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { inviteCode: true, userType: true, deletedAt: true },
});
if (!user || user.deletedAt) {
throw appNotFound('USER_NOT_FOUND');
}
if (user.userType !== 'ADMIN' && user.userType !== 'AGENT') {
throw appForbidden('ACCESS_DENIED_PORTAL');
}
return { inviteCode: user.inviteCode ?? null };
}
async generateInviteCode(
userId: bigint,
userType: string,
cashbackRate?: number,
) {
return this.invites.generateInviteCode(userId, {
userType,
cashbackRate: cashbackRate ?? null,
});
}
async changePassword(userId: bigint, oldPassword: string, newPassword: string) {
const auth = await this.prisma.userAuth.findUnique({ where: { userId } });
if (!auth) throw appUnauthorized('USER_NOT_FOUND');

View File

@@ -0,0 +1,401 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
import { PrismaService } from '../../shared/prisma/prisma.service';
import {
INVITE_STATUS_ACTIVE,
INVITE_STATUS_REVOKED,
normalizeInviteCode,
rotateUserInviteCode,
} from '../../shared/common/invite-code.util';
export interface InviteListQuery {
page?: number;
pageSize?: number;
status?: string;
sponsorId?: string;
keyword?: string;
}
function formatInviteRow(row: {
id: bigint;
code: string;
status: string;
createdAt: Date;
revokedAt: Date | null;
cashbackRate: Prisma.Decimal | null;
sponsor: {
id: bigint;
username: string;
userType: string;
agentLevel: number | null;
};
registrations?: { id: bigint; username: string }[];
}) {
const registrant = row.registrations?.[0] ?? null;
return {
id: row.id.toString(),
code: row.code,
status: row.status,
createdAt: row.createdAt.toISOString(),
revokedAt: row.revokedAt?.toISOString() ?? null,
cashbackRate: row.cashbackRate?.toString() ?? null,
sponsorId: row.sponsor.id.toString(),
sponsorUsername: row.sponsor.username,
sponsorUserType: row.sponsor.userType,
sponsorAgentLevel: row.sponsor.agentLevel,
registeredPlayerId: registrant ? registrant.id.toString() : null,
registeredPlayerUsername: registrant?.username ?? null,
};
}
const inviteListInclude = {
sponsor: {
select: { id: true, username: true, userType: true, agentLevel: true },
},
registrations: {
where: { userType: 'PLAYER', deletedAt: null },
select: { id: true, username: true },
take: 1,
},
} as const;
@Injectable()
export class InvitesService {
constructor(private prisma: PrismaService) {}
async getSelfAndDescendantIds(agentId: bigint): Promise<bigint[]> {
const rows = await this.prisma.agentClosure.findMany({
where: { ancestorId: agentId },
select: { descendantId: true },
});
return rows.map((r) => r.descendantId);
}
private async assertSponsorInScope(
operator: { id: bigint; userType: string },
sponsorId: bigint,
) {
if (operator.userType === 'ADMIN') return;
if (operator.userType !== 'AGENT') {
throw appForbidden('ACCESS_DENIED_PORTAL');
}
const allowed = await this.getSelfAndDescendantIds(operator.id);
if (!allowed.some((id) => id === sponsorId)) {
throw appForbidden('ACCESS_DENIED_PORTAL');
}
}
async resolveActiveInvite(inviteCodeRaw?: string | null) {
const inviteCode = normalizeInviteCode(inviteCodeRaw ?? '');
if (!inviteCode) {
return { inviteId: null as bigint | null, sponsorId: null as bigint | null, parentId: null as bigint | null };
}
const invite = await this.prisma.userInvite.findUnique({
where: { code: inviteCode },
include: {
sponsor: {
select: {
id: true,
userType: true,
status: true,
deletedAt: true,
agentProfile: { select: { blockDirectPlayerLogin: true } },
},
},
registrations: {
where: { deletedAt: null },
select: { id: true },
take: 1,
},
},
});
if (!invite || invite.status !== INVITE_STATUS_ACTIVE) {
throw appBadRequest('INVITE_CODE_INVALID');
}
if (invite.registrations.length > 0) {
throw appBadRequest('INVITE_CODE_ALREADY_USED');
}
const sponsor = invite.sponsor;
if (!sponsor || sponsor.deletedAt) {
throw appBadRequest('INVITE_CODE_INVALID');
}
if (sponsor.userType !== 'ADMIN' && sponsor.userType !== 'AGENT') {
throw appBadRequest('INVITE_CODE_INVALID');
}
if (sponsor.status === 'DISABLED') {
throw appBadRequest('INVITE_CODE_NOT_AVAILABLE');
}
if (sponsor.userType === 'AGENT') {
if (sponsor.status === 'SUSPENDED') {
throw appBadRequest('INVITE_CODE_NOT_AVAILABLE');
}
if (sponsor.agentProfile?.blockDirectPlayerLogin) {
throw appBadRequest('INVITE_CODE_NOT_AVAILABLE');
}
}
return {
inviteId: invite.id,
sponsorId: sponsor.id,
parentId: sponsor.userType === 'AGENT' ? sponsor.id : null,
};
}
async recordRegistration(inviteId: bigint, playerId: bigint, tx: Prisma.TransactionClient) {
const invite = await tx.userInvite.findUnique({
where: { id: inviteId },
include: {
sponsor: { select: { inviteCode: true } },
registrations: {
where: { deletedAt: null },
select: { id: true },
take: 1,
},
},
});
if (!invite) {
throw appBadRequest('INVITE_CODE_INVALID');
}
if (invite.registrations.length > 0) {
throw appBadRequest('INVITE_CODE_ALREADY_USED');
}
await tx.user.update({
where: { id: playerId },
data: { usedInviteId: inviteId },
});
const now = new Date();
await tx.userInvite.update({
where: { id: inviteId },
data: {
registerCount: 1,
status: INVITE_STATUS_REVOKED,
revokedAt: now,
},
});
if (invite.sponsor.inviteCode === invite.code) {
await tx.user.update({
where: { id: invite.sponsorId },
data: { inviteCode: null },
});
}
}
async generateInviteCode(
userId: bigint,
options?: { userType?: string; cashbackRate?: number | null },
) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { userType: true, deletedAt: true },
});
if (!user || user.deletedAt) {
throw appNotFound('USER_NOT_FOUND');
}
if (user.userType !== 'ADMIN' && user.userType !== 'AGENT') {
throw appForbidden('ACCESS_DENIED_PORTAL');
}
let cashbackRate: Prisma.Decimal | null = null;
if (options?.cashbackRate != null) {
if (user.userType !== 'ADMIN') {
throw appForbidden('ACCESS_DENIED_PORTAL');
}
if (!Number.isFinite(options.cashbackRate) || options.cashbackRate < 0) {
throw appBadRequest('INVITE_CASHBACK_RATE_INVALID');
}
cashbackRate = new Prisma.Decimal(options.cashbackRate);
}
const inviteCode = await rotateUserInviteCode(this.prisma, userId, cashbackRate);
return {
inviteCode,
cashbackRate: cashbackRate?.toString() ?? null,
};
}
async listInvites(
operator: { id: bigint; userType: string },
query: InviteListQuery,
) {
const page = Math.max(1, query.page ?? 1);
const pageSize = Math.min(100, Math.max(1, query.pageSize ?? 20));
const where: Prisma.UserInviteWhereInput = {};
if (query.status === INVITE_STATUS_ACTIVE || query.status === INVITE_STATUS_REVOKED) {
where.status = query.status;
}
const keyword = query.keyword?.trim();
if (keyword) {
where.code = { contains: normalizeInviteCode(keyword) };
}
if (operator.userType === 'ADMIN') {
if (query.sponsorId?.trim()) {
where.sponsorId = BigInt(query.sponsorId.trim());
}
} else if (operator.userType === 'AGENT') {
const allowedIds = await this.getSelfAndDescendantIds(operator.id);
if (query.sponsorId?.trim()) {
const sid = BigInt(query.sponsorId.trim());
if (!allowedIds.some((id) => id === sid)) {
throw appForbidden('ACCESS_DENIED_PORTAL');
}
where.sponsorId = sid;
} else {
where.sponsorId = { in: allowedIds };
}
} else {
throw appForbidden('ACCESS_DENIED_PORTAL');
}
const [total, rows] = await Promise.all([
this.prisma.userInvite.count({ where }),
this.prisma.userInvite.findMany({
where,
include: inviteListInclude,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
items: rows.map(formatInviteRow),
total,
page,
pageSize,
};
}
async listSponsorOptions(operator: { id: bigint; userType: string }) {
if (operator.userType === 'ADMIN') {
const rows = await this.prisma.user.findMany({
where: {
userType: { in: ['ADMIN', 'AGENT'] },
deletedAt: null,
},
select: { id: true, username: true, userType: true, agentLevel: true },
orderBy: [{ userType: 'asc' }, { username: 'asc' }],
});
return rows.map((r) => ({
id: r.id.toString(),
username: r.username,
userType: r.userType,
agentLevel: r.agentLevel,
}));
}
if (operator.userType !== 'AGENT') {
throw appForbidden('ACCESS_DENIED_PORTAL');
}
const ids = await this.getSelfAndDescendantIds(operator.id);
const rows = await this.prisma.user.findMany({
where: { id: { in: ids }, deletedAt: null },
select: { id: true, username: true, userType: true, agentLevel: true },
orderBy: [{ agentLevel: 'asc' }, { username: 'asc' }],
});
return rows.map((r) => ({
id: r.id.toString(),
username: r.username,
userType: r.userType,
agentLevel: r.agentLevel,
}));
}
async revokeInvite(
operator: { id: bigint; userType: string },
inviteId: bigint,
) {
const invite = await this.prisma.userInvite.findUnique({
where: { id: inviteId },
include: { sponsor: { select: { id: true, inviteCode: true } } },
});
if (!invite) {
throw appNotFound('INVITE_NOT_FOUND');
}
if (invite.status === INVITE_STATUS_REVOKED) {
const full = await this.prisma.userInvite.findUnique({
where: { id: inviteId },
include: inviteListInclude,
});
if (!full) throw appNotFound('INVITE_NOT_FOUND');
return formatInviteRow(full);
}
await this.assertSponsorInScope(operator, invite.sponsorId);
if (operator.userType === 'AGENT' && invite.sponsorId !== operator.id) {
throw appForbidden('ACCESS_DENIED_PORTAL');
}
const updated = await this.prisma.$transaction(async (tx) => {
const row = await tx.userInvite.update({
where: { id: inviteId },
data: { status: INVITE_STATUS_REVOKED, revokedAt: new Date() },
include: {
sponsor: {
select: { id: true, username: true, userType: true, agentLevel: true, inviteCode: true },
},
registrations: {
where: { userType: 'PLAYER', deletedAt: null },
select: { id: true, username: true },
take: 1,
},
},
});
if (row.sponsor.inviteCode === row.code) {
await tx.user.update({
where: { id: row.sponsorId },
data: { inviteCode: null },
});
}
return row;
});
return formatInviteRow(updated);
}
async deleteInvite(
operator: { id: bigint; userType: string },
inviteId: bigint,
) {
const invite = await this.prisma.userInvite.findUnique({
where: { id: inviteId },
select: { id: true, status: true, sponsorId: true },
});
if (!invite) {
throw appNotFound('INVITE_NOT_FOUND');
}
if (invite.status !== INVITE_STATUS_REVOKED) {
throw appBadRequest('INVITE_MUST_REVOKE_FIRST');
}
const usedBy = await this.prisma.user.findFirst({
where: { usedInviteId: inviteId, deletedAt: null },
select: { id: true },
});
if (usedBy) {
throw appBadRequest('INVITE_CANNOT_DELETE_USED');
}
await this.assertSponsorInScope(operator, invite.sponsorId);
if (operator.userType === 'AGENT' && invite.sponsorId !== operator.id) {
throw appForbidden('ACCESS_DENIED_PORTAL');
}
await this.prisma.userInvite.delete({ where: { id: inviteId } });
return { success: true };
}
}

View File

@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { AgentsModule } from '../agent/agents.module';
import { CashbackModule } from '../operations/cashback/cashback.module';
@Module({
imports: [AgentsModule],
imports: [AgentsModule, CashbackModule],
providers: [UsersService],
exports: [UsersService],
})

View File

@@ -4,7 +4,9 @@ import { SUPPORTED_LOCALES, isValidAvatarKey, assertPlayerUsername } from '@theb
import { PrismaService } from '../../shared/prisma/prisma.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { AgentsService } from '../agent/agents.service';
import { CashbackService } from '../operations/cashback/cashback.service';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
import { Decimal } from '@prisma/client/runtime/library';
export type PlayerListFilters = {
keyword?: string;
@@ -19,6 +21,7 @@ export class UsersService {
private prisma: PrismaService,
private agents: AgentsService,
private systemConfig: SystemConfigService,
private cashback: CashbackService,
) {}
private buildAffiliationAgents(
@@ -103,6 +106,7 @@ export class UsersService {
parent?: { username: string; agentLevel: number | null } | null;
} | null;
auth?: { lastLoginAt: Date | null } | null;
usedInvite?: { code: string } | null;
},
bet?: { count: number; totalStake: string; totalReturn: string },
affiliationChain?: string[],
@@ -116,6 +120,7 @@ export class UsersService {
parentId: u.parentId?.toString() ?? null,
parentUsername: u.parent?.username ?? null,
affiliationAgents,
inviteCode: u.usedInvite?.code ?? null,
phone: u.preferences?.phone ?? null,
email: u.preferences?.email ?? null,
managedPassword: u.preferences?.managedPassword ?? null,
@@ -281,6 +286,7 @@ export class UsersService {
},
},
auth: { select: { lastLoginAt: true } },
usedInvite: { select: { code: true } },
},
skip,
take: pageSize,
@@ -320,17 +326,23 @@ export class UsersService {
},
},
auth: { select: { lastLoginAt: true, loginFailCount: true, lockedUntil: true } },
usedInvite: { select: { code: true } },
},
});
if (!user) throw appNotFound('PLAYER_NOT_FOUND');
const affiliationMap = await this.buildAffiliationChainMap([user.parentId]);
const [betCount, betStake] = await Promise.all([
const [betCount, betStake, customCashbackRate, defaultCashbackRate] = await Promise.all([
this.prisma.bet.count({ where: { userId: playerId } }),
this.prisma.bet.aggregate({
where: { userId: playerId },
_sum: { stake: true, actualReturn: true },
}),
this.cashback.getPlayerCustomCashbackRate(playerId),
this.cashback.resolvePlayerDefaultCashbackRate({
parentId: user.parentId,
inviteSponsorId: user.inviteSponsorId,
}),
]);
return {
@@ -345,6 +357,8 @@ export class UsersService {
betCount,
totalStake: betStake._sum.stake?.toString() ?? '0',
totalReturn: betStake._sum.actualReturn?.toString() ?? '0',
customCashbackRate: customCashbackRate?.toString() ?? null,
defaultCashbackRate: defaultCashbackRate.toString(),
};
}
@@ -357,6 +371,7 @@ export class UsersService {
email?: string;
username?: string;
password?: string;
cashbackRate?: number | null;
},
) {
const user = await this.prisma.user.findFirst({
@@ -440,6 +455,17 @@ export class UsersService {
});
}
if (data.cashbackRate !== undefined) {
if (data.cashbackRate != null && (!Number.isFinite(data.cashbackRate) || data.cashbackRate < 0)) {
throw appBadRequest('CASHBACK_RATE_NEGATIVE');
}
const rate =
data.cashbackRate != null && data.cashbackRate > 0
? new Decimal(data.cashbackRate)
: null;
await this.cashback.setPlayerCustomCashbackRate(playerId, rate);
}
return this.getPlayerAdminDetail(playerId);
}

View File

@@ -287,7 +287,7 @@ export class WalletService {
let typeWhere: Record<string, unknown> = {};
if (typeFilter === 'deposit') {
typeWhere = { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST'] } };
typeWhere = { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST', 'PLAYER_DEPOSIT'] } };
} else if (typeFilter === 'withdraw') {
typeWhere = { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
} else if (typeFilter === 'bet') {
@@ -312,7 +312,7 @@ export class WalletService {
private walletTypeCategoryWhere(category?: string): Prisma.WalletTransactionWhereInput {
const cat = category?.trim();
if (cat === 'deposit') {
return { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST'] } };
return { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST', 'PLAYER_DEPOSIT'] } };
}
if (cat === 'withdraw') {
return { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
@@ -341,6 +341,99 @@ export class WalletService {
return {};
}
private static readonly DEPOSIT_RECHARGE_TYPES = new Set([
'MANUAL_DEPOSIT',
'DEPOSIT',
'PLAYER_DEPOSIT',
]);
private paymentMethodDisplayName(pm: {
displayName: string | null;
bankName: string | null;
usdtAddress: string | null;
methodType: string;
} | null | undefined): string | null {
if (!pm) return null;
return pm.displayName ?? pm.bankName ?? pm.usdtAddress ?? pm.methodType ?? null;
}
private async resolveDepositMethodsForRows(
rows: Array<{ id: bigint; transactionType: string; referenceId: string | null; operatorId: bigint | null }>,
) {
const rechargeRows = rows.filter((r) => WalletService.DEPOSIT_RECHARGE_TYPES.has(r.transactionType));
const result = new Map<
string,
{ depositMethodKey: string | null; depositMethodName: string | null }
>();
if (!rechargeRows.length) return result;
const orderNos = [
...new Set(
rechargeRows
.filter((r) => r.transactionType === 'PLAYER_DEPOSIT' && r.referenceId)
.map((r) => r.referenceId!),
),
];
const manualOperatorIds = [
...new Set(
rechargeRows
.filter((r) => r.transactionType !== 'PLAYER_DEPOSIT' && r.operatorId)
.map((r) => r.operatorId!),
),
];
const [orders, operators] = await Promise.all([
orderNos.length
? this.prisma.depositOrder.findMany({
where: { orderNo: { in: orderNos } },
include: {
paymentMethod: {
select: {
displayName: true,
bankName: true,
usdtAddress: true,
methodType: true,
},
},
},
})
: [],
manualOperatorIds.length
? this.prisma.user.findMany({
where: { id: { in: manualOperatorIds } },
select: { id: true, userType: true },
})
: [],
]);
const orderByNo = new Map(orders.map((o) => [o.orderNo, o]));
const operatorTypeById = new Map(operators.map((o) => [o.id.toString(), o.userType]));
for (const row of rechargeRows) {
const rowKey = row.id.toString();
if (row.transactionType === 'PLAYER_DEPOSIT' && row.referenceId) {
const order = orderByNo.get(row.referenceId);
result.set(rowKey, {
depositMethodKey: null,
depositMethodName: this.paymentMethodDisplayName(order?.paymentMethod),
});
continue;
}
const operatorType = row.operatorId
? operatorTypeById.get(row.operatorId.toString())
: null;
let depositMethodKey = 'MANUAL';
if (operatorType === 'ADMIN') depositMethodKey = 'MANUAL_ADMIN';
else if (operatorType === 'AGENT') depositMethodKey = 'MANUAL_AGENT';
result.set(rowKey, { depositMethodKey, depositMethodName: null });
}
return result;
}
async listWalletTransactionsAdmin(params: {
page?: number;
pageSize?: number;
@@ -495,11 +588,13 @@ export class WalletService {
const playerById = new Map(players.map((p) => [p.id.toString(), p]));
const operatorById = new Map(operators.map((u) => [u.id.toString(), u.username]));
const parentById = new Map(parentAgents.map((a) => [a.id.toString(), a.username]));
const depositMethodByRowId = await this.resolveDepositMethodsForRows(rows);
return {
items: rows.map((row) => {
const player = playerById.get(row.userId.toString());
const parentId = player?.parentId;
const depositMethod = depositMethodByRowId.get(row.id.toString());
return {
id: row.id.toString(),
transactionId: row.transactionId,
@@ -516,6 +611,8 @@ export class WalletService {
referenceType: row.referenceType,
referenceId: row.referenceId,
betNo: row.referenceType === 'BET' ? row.referenceId : null,
depositMethodKey: depositMethod?.depositMethodKey ?? null,
depositMethodName: depositMethod?.depositMethodName ?? null,
operatorId: row.operatorId?.toString() ?? null,
operatorUsername: row.operatorId
? (operatorById.get(row.operatorId.toString()) ?? null)

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../shared/prisma/prisma.service';
import { WalletService } from '../../ledger/wallet.service';
import { SystemConfigService } from '../../../shared/config/system-config.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../../../shared/common/decorators';
import { appBadRequest } from '../../../shared/common/app-error';
@@ -33,6 +34,7 @@ export class CashbackService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private systemConfig: SystemConfigService,
) {}
/** 已被待发放/已发放返水批次占用的注单 */
@@ -55,14 +57,15 @@ export class CashbackService {
eligibleBetCount: number;
skippedClaimedCount: number;
}> {
const [settledBets, rules, agentProfiles, claimedBetIds] = await Promise.all([
const [settledBets, rules, agentProfiles, claimedBetIds, platformDirectRateRaw] =
await Promise.all([
this.prisma.bet.findMany({
where: {
status: { in: ['WON', 'LOST'] },
settledAt: { gte: periodStart, lte: periodEnd },
},
include: {
user: { select: { id: true, parentId: true } },
user: { select: { id: true, parentId: true, inviteSponsorId: true } },
selections: { select: { marketType: true } },
},
}),
@@ -71,11 +74,46 @@ export class CashbackService {
select: { userId: true, cashbackRate: true },
}),
this.loadClaimedBetIds(),
this.systemConfig.getPlatformDirectCashbackSettings(),
]);
const platformDirectDefaultRate = new Decimal(platformDirectRateRaw.platformDirectRate);
const adminInviteDefaultRate = new Decimal(platformDirectRateRaw.adminInviteRate);
const agentRateById = new Map(
agentProfiles.map((p) => [p.userId.toString(), new Decimal(p.cashbackRate)]),
);
const sponsorTypeById = new Map<string, string>();
const sponsorIds = [
...new Set(
settledBets
.map((b) => b.user.inviteSponsorId)
.filter((id): id is bigint => id != null),
),
];
if (sponsorIds.length > 0) {
const sponsors = await this.prisma.user.findMany({
where: { id: { in: sponsorIds } },
select: { id: true, userType: true },
});
for (const sponsor of sponsors) {
sponsorTypeById.set(sponsor.id.toString(), sponsor.userType);
}
}
const resolveDefaultRate = (user: {
parentId: bigint | null;
inviteSponsorId: bigint | null;
}) => {
if (user.parentId) {
return agentRateById.get(user.parentId.toString()) ?? new Decimal(0);
}
if (user.inviteSponsorId) {
const sponsorType = sponsorTypeById.get(user.inviteSponsorId.toString());
if (sponsorType === 'ADMIN') return adminInviteDefaultRate;
}
return platformDirectDefaultRate;
};
const ruleRows: CashbackRuleRow[] = rules.map((r) => ({
targetType: r.targetType,
targetId: r.targetId,
@@ -93,9 +131,7 @@ export class CashbackService {
for (const bet of settledBets) {
const agentId = bet.user.parentId;
const agentDefaultRate = agentId
? agentRateById.get(agentId.toString()) ?? new Decimal(0)
: new Decimal(0);
const agentDefaultRate = resolveDefaultRate(bet.user);
const marketTypes = bet.selections.map((s) => s.marketType);
const rate = resolveCashbackRateForBet({
userId: bet.userId,
@@ -538,4 +574,64 @@ export class CashbackService {
createdAt: item.createdAt,
}));
}
async getPlayerCustomCashbackRate(userId: bigint): Promise<Decimal | null> {
const rule = await this.prisma.cashbackRule.findFirst({
where: {
targetType: 'USER',
targetId: userId,
isActive: true,
marketType: null,
},
orderBy: { updatedAt: 'desc' },
});
if (!rule) return null;
const rate = new Decimal(rule.rate);
return rate.gt(0) ? rate : null;
}
async setPlayerCustomCashbackRate(userId: bigint, rate: Decimal | null) {
await this.prisma.$transaction(async (tx) => {
await tx.cashbackRule.updateMany({
where: { targetType: 'USER', targetId: userId },
data: { isActive: false },
});
if (rate && rate.gt(0)) {
await tx.cashbackRule.create({
data: {
name: `Player ${userId.toString()}`,
targetType: 'USER',
targetId: userId,
rate,
isActive: true,
},
});
}
});
}
async resolvePlayerDefaultCashbackRate(params: {
parentId: bigint | null;
inviteSponsorId?: bigint | null;
}): Promise<Decimal> {
if (params.parentId) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: params.parentId },
select: { cashbackRate: true },
});
return profile ? new Decimal(profile.cashbackRate) : new Decimal(0);
}
const settings = await this.systemConfig.getPlatformDirectCashbackSettings();
if (params.inviteSponsorId) {
const sponsor = await this.prisma.user.findUnique({
where: { id: params.inviteSponsorId },
select: { userType: true },
});
if (sponsor?.userType === 'ADMIN') {
return new Decimal(settings.adminInviteRate);
}
}
return new Decimal(settings.platformDirectRate);
}
}

View File

@@ -1,6 +1,7 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { syncWc2026OutrightMarket } from '../../domains/catalog/wc2026-outright.sync';
import { ensureUserInviteCode } from '../../shared/common/invite-code.util';
export const DEMO_ACCOUNTS = [
'admin / Admin@123',
@@ -746,5 +747,13 @@ export async function runSeed(client: PrismaClient) {
},
}).catch(() => {});
const staffWithoutInvite = await prisma.user.findMany({
where: { userType: { in: ['ADMIN', 'AGENT'] }, inviteCode: null },
select: { id: true },
});
for (const row of staffWithoutInvite) {
await ensureUserInviteCode(prisma, row.id);
}
console.log(`Seed completed! ${DEMO_ACCOUNTS.join(', ')}`);
}

View File

@@ -0,0 +1,114 @@
import { Prisma } from '@prisma/client';
import type { PrismaService } from '../prisma/prisma.service';
const INVITE_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const INVITE_CODE_LENGTH = 8;
export const INVITE_STATUS_ACTIVE = 'ACTIVE';
export const INVITE_STATUS_REVOKED = 'REVOKED';
export function normalizeInviteCode(input: string): string {
return input.trim().toUpperCase();
}
export function generateInviteCodeCandidate(): string {
let code = '';
for (let i = 0; i < INVITE_CODE_LENGTH; i++) {
code += INVITE_CODE_CHARS[Math.floor(Math.random() * INVITE_CODE_CHARS.length)];
}
return code;
}
type InviteCodeDb = Pick<PrismaService, 'user' | 'userInvite'>;
function isInviteCodeUniqueViolation(err: unknown): boolean {
return err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002';
}
async function isCodeTaken(db: InviteCodeDb, code: string): Promise<boolean> {
const [userHit, inviteHit] = await Promise.all([
db.user.findUnique({ where: { inviteCode: code }, select: { id: true } }),
db.userInvite.findUnique({ where: { code }, select: { id: true } }),
]);
return Boolean(userHit || inviteHit);
}
export async function generateUniqueInviteCode(
db: InviteCodeDb,
maxAttempts = 12,
): Promise<string> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const code = generateInviteCodeCandidate();
if (!(await isCodeTaken(db, code))) return code;
}
throw new Error('Failed to generate unique invite code');
}
/** Create history record and set as the user's current invite code. */
export async function assignInviteCodeWithHistory(
db: InviteCodeDb,
sponsorId: bigint,
cashbackRate?: Prisma.Decimal | null,
): Promise<string> {
for (let attempt = 0; attempt < 12; attempt++) {
const candidate = generateInviteCodeCandidate();
if (await isCodeTaken(db, candidate)) continue;
try {
await db.userInvite.create({
data: {
code: candidate,
sponsorId,
status: INVITE_STATUS_ACTIVE,
...(cashbackRate != null ? { cashbackRate } : {}),
},
});
await db.user.update({
where: { id: sponsorId },
data: { inviteCode: candidate },
});
return candidate;
} catch (err) {
if (!isInviteCodeUniqueViolation(err)) throw err;
}
}
throw new Error('Failed to assign invite code');
}
/** Revoke active codes and assign a new one with history. */
export async function rotateUserInviteCode(
db: InviteCodeDb,
userId: bigint,
cashbackRate?: Prisma.Decimal | null,
): Promise<string> {
await db.userInvite.updateMany({
where: { sponsorId: userId, status: INVITE_STATUS_ACTIVE },
data: { status: INVITE_STATUS_REVOKED, revokedAt: new Date() },
});
return assignInviteCodeWithHistory(db, userId, cashbackRate);
}
/** Assign a stable invite code once per staff user (admin/agent). */
export async function ensureUserInviteCode(db: InviteCodeDb, userId: bigint): Promise<string> {
const existing = await db.user.findUnique({
where: { id: userId },
select: { inviteCode: true },
});
if (existing?.inviteCode) {
const history = await db.userInvite.findUnique({
where: { code: existing.inviteCode },
select: { id: true },
});
if (!history) {
await db.userInvite.create({
data: {
code: existing.inviteCode,
sponsorId: userId,
status: INVITE_STATUS_ACTIVE,
},
});
}
return existing.inviteCode;
}
return assignInviteCodeWithHistory(db, userId);
}

View File

@@ -7,6 +7,15 @@ export const AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS = 'agent.suspend_freeze_direct_
export const AGENT_SUSPEND_BLOCK_PLAYER_LOGIN = 'agent.suspend_block_player_login';
export const AGENT_MAX_LEVEL = 'agent.max_level';
export const AGENT_DEFAULT_SUB_CREDIT_RATIO = 'agent.default_sub_credit_ratio';
export const CASHBACK_PLATFORM_DIRECT_RATE = 'cashback.platform_direct_rate';
export const CASHBACK_ADMIN_INVITE_RATE = 'cashback.admin_invite_rate';
export type PlatformDirectCashbackSettings = {
/** 平台直属玩家默认返水比例小数0.01 = 1% */
platformDirectRate: number;
/** 管理员邀请注册玩家返水比例;未配置时与 platformDirectRate 相同 */
adminInviteRate: number;
};
export type AgentHierarchySettings = {
/** 最大代理层级0 = 不限制 */
@@ -155,4 +164,62 @@ export class SystemConfigService {
}
return this.getAgentHierarchySettings();
}
async getDecimalRate(key: string, defaultValue = 0): Promise<number> {
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
if (!row) return defaultValue;
const parsed = parseFloat(row.configValue);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultValue;
}
async setDecimalRate(key: string, value: number, description?: string) {
const safe = Math.max(0, value);
await this.prisma.systemConfig.upsert({
where: { configKey: key },
create: {
configKey: key,
configValue: String(safe),
description,
},
update: { configValue: String(safe) },
});
}
async getPlatformDirectCashbackSettings(): Promise<PlatformDirectCashbackSettings> {
const platformDirectRate = await this.getDecimalRate(CASHBACK_PLATFORM_DIRECT_RATE, 0);
const adminInviteConfigured = await this.prisma.systemConfig.findUnique({
where: { configKey: CASHBACK_ADMIN_INVITE_RATE },
});
const adminInviteRate = adminInviteConfigured
? await this.getDecimalRate(CASHBACK_ADMIN_INVITE_RATE, platformDirectRate)
: platformDirectRate;
return { platformDirectRate, adminInviteRate };
}
async updatePlatformDirectCashbackSettings(data: {
platformDirectRate?: number;
adminInviteRate?: number;
}) {
if (data.platformDirectRate !== undefined) {
if (!Number.isFinite(data.platformDirectRate) || data.platformDirectRate < 0) {
throw new Error('platformDirectRate must be a non-negative number');
}
await this.setDecimalRate(
CASHBACK_PLATFORM_DIRECT_RATE,
data.platformDirectRate,
'平台直属玩家默认返水比例(小数,如 0.01 = 1%',
);
}
if (data.adminInviteRate !== undefined) {
if (!Number.isFinite(data.adminInviteRate) || data.adminInviteRate < 0) {
throw new Error('adminInviteRate must be a non-negative number');
}
await this.setDecimalRate(
CASHBACK_ADMIN_INVITE_RATE,
data.adminInviteRate,
'管理员邀请注册玩家返水比例(小数,如 0.01 = 1%',
);
}
return this.getPlatformDirectCashbackSettings();
}
}

View File

@@ -11,10 +11,12 @@
"dependencies": {
"@thebet365/shared": "workspace:*",
"axios": "^1.7.9",
"browser-image-compression": "^2.0.2",
"pinia": "^2.3.1",
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-router": "^4.5.0"
"vue-router": "^4.5.0",
"vue3-slide-verify": "^1.1.8"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",

View File

@@ -18,7 +18,7 @@ api.interceptors.response.use(
if (err.response?.status === 401) {
const url: string = err.config?.url ?? '';
// Don't redirect on login/auth failures — let the caller handle the error
if (!url.includes('/auth/login')) {
if (!url.includes('/auth/login') && !url.includes('/auth/register')) {
localStorage.removeItem('token');
// 不再强制跳转登录页,让调用方处理 401
}

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatMoney } from '../utils/localeDisplay';
import { usePlayerProfile } from '../composables/usePlayerProfile';
const { locale, t } = useI18n();
const { profileRaw } = usePlayerProfile();
const router = useRouter();
const open = ref(false);
const root = ref<HTMLElement | null>(null);
@@ -43,6 +45,11 @@ function close() {
open.value = false;
}
function goRecharge() {
close();
router.push('/wallet/recharge');
}
function onOutsideClick(e: Event) {
if (!root.value?.contains(e.target as Node)) open.value = false;
}
@@ -82,6 +89,10 @@ onUnmounted(() => {
<span>{{ t('wallet.available') }}</span>
<span>{{ available }}</span>
</div>
<div class="panel-divider" />
<button type="button" class="panel-recharge-btn" @click="goRecharge">
{{ t('recharge.title') }}
</button>
</div>
<div v-if="open" class="backdrop" @click="close" />
@@ -121,6 +132,9 @@ onUnmounted(() => {
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
line-height: 1.15;
}
@@ -185,6 +199,24 @@ onUnmounted(() => {
margin: 4px 0;
}
.panel-recharge-btn {
width: 100%;
padding: 8px 0;
margin-top: 4px;
border-radius: 6px;
border: 1px solid var(--primary, #c8a84e);
background: rgba(200, 168, 78, 0.12);
color: var(--primary-light, #c8a84e);
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
}
.panel-recharge-btn:active {
background: rgba(200, 168, 78, 0.24);
}
.backdrop {
position: fixed;
inset: 0;

View File

@@ -48,10 +48,7 @@ onUnmounted(() => {
<div ref="root" class="locale-switch" :class="{ compact, open }">
<button type="button" class="locale-trigger" :aria-expanded="open" aria-haspopup="listbox" @click.stop="toggle">
<LocaleFlag :locale="locale" :size="compact ? 16 : 18" />
<span class="locale-label">{{ currentLabel }}</span>
<span class="locale-chevron" aria-hidden="true"></span>
</button>
<ul v-show="open" class="locale-menu" role="listbox" :aria-label="compact ? 'Language' : undefined">
</button> <ul v-show="open" class="locale-menu" role="listbox" :aria-label="compact ? 'Language' : undefined">
<li
v-for="l in locales"
:key="l.code"
@@ -72,14 +69,16 @@ onUnmounted(() => {
.locale-switch {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.locale-trigger {
display: inline-flex;
align-items: center;
gap: 5px;
justify-content: center;
width: 36px;
height: 36px;
padding: 0 8px 0 6px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: #0d0d0d;
@@ -89,29 +88,12 @@ onUnmounted(() => {
font-family: inherit;
cursor: pointer;
box-sizing: border-box;
flex-shrink: 0;
}
.locale-switch.compact .locale-trigger {
height: auto;
min-height: 30px;
padding: 4px 6px 4px 5px;
}
.locale-label {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.locale-chevron {
font-size: 10px;
color: var(--text-muted);
transition: transform 0.15s ease;
}
.locale-switch.open .locale-chevron {
transform: rotate(180deg);
width: 30px;
height: 30px;
}
.locale-menu {

View File

@@ -1,150 +1,255 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import SlideVerify, { type SlideVerifyInstance } from 'vue3-slide-verify';
import 'vue3-slide-verify/dist/style.css';
const { t } = useI18n();
const input = ref('');
const code = ref('');
const canvasRef = ref<HTMLCanvasElement | null>(null);
const honeypot = ref('');
const slideRef = ref<SlideVerifyInstance>();
const validated = ref(false);
const showPopup = ref(false);
const errorMsg = ref('');
function generateCode() {
code.value = String(Math.floor(1000 + Math.random() * 9000));
function openPopup() {
if (validated.value) return;
showPopup.value = true;
errorMsg.value = '';
// Refresh captcha after popup renders
nextTick(() => {
slideRef.value?.refresh();
});
}
function drawCaptcha() {
const canvas = canvasRef.value;
if (!canvas) return;
const w = 108;
const h = 44;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#7c3aed';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 28; i++) {
ctx.fillStyle = `rgba(255,255,255,${0.15 + Math.random() * 0.35})`;
ctx.beginPath();
ctx.arc(Math.random() * w, Math.random() * h, Math.random() * 2.2, 0, Math.PI * 2);
ctx.fill();
function closePopup() {
showPopup.value = false;
}
for (let i = 0; i < 5; i++) {
ctx.strokeStyle = `rgba(255,255,255,${0.2 + Math.random() * 0.3})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(Math.random() * w, Math.random() * h);
ctx.lineTo(Math.random() * w, Math.random() * h);
ctx.stroke();
function onSuccess() {
validated.value = true;
errorMsg.value = '';
showPopup.value = false;
}
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.rotate((Math.random() - 0.5) * 0.12);
ctx.font = 'italic bold 26px Arial, sans-serif';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(code.value, 0, 1);
ctx.restore();
function onFail() {
validated.value = false;
}
function refresh() {
generateCode();
input.value = '';
drawCaptcha();
function onAgain() {
validated.value = false;
slideRef.value?.refresh();
}
function validate(): boolean {
if (honeypot.value) {
refresh();
return false;
if (!validated.value) {
errorMsg.value = t('auth.captcha_wrong');
}
return input.value.trim() === code.value;
return validated.value;
}
onMounted(refresh);
function refresh() {
validated.value = false;
errorMsg.value = '';
}
defineExpose({ validate, refresh });
</script>
<template>
<div class="captcha-row">
<input
v-model="honeypot"
type="text"
name="website"
tabindex="-1"
autocomplete="off"
class="hp-field"
aria-hidden="true"
/>
<input
v-model="input"
type="text"
inputmode="numeric"
maxlength="4"
class="captcha-input"
:placeholder="t('auth.captcha_placeholder')"
autocomplete="off"
/>
<canvas
ref="canvasRef"
class="captcha-canvas"
:title="t('auth.captcha_refresh')"
role="button"
tabindex="0"
@click="refresh"
@keydown.enter="refresh"
<div class="captcha-trigger-wrapper">
<!-- Trigger row -->
<div class="captcha-trigger" :class="{ verified: validated }" @click="openPopup">
<span v-if="validated" class="captcha-success-icon"></span>
<span class="captcha-trigger-text">
{{ validated ? t('auth.verified') : t('auth.click_to_verify') }}
</span>
</div>
<p v-if="errorMsg && !validated" class="slide-error">{{ errorMsg }}</p>
<!-- Popup overlay -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showPopup" class="captcha-overlay" @click.self="closePopup">
<div class="captcha-popup">
<div class="captcha-popup-header">
<span>{{ t('auth.slide_to_verify') }}</span>
<button class="captcha-popup-close" @click="closePopup"></button>
</div>
<div class="captcha-popup-body">
<SlideVerify
ref="slideRef"
:w="300"
:h="150"
:l="42"
:r="9"
:accuracy="3"
:slider-text="t('auth.slide_to_verify')"
@success="onSuccess"
@fail="onFail"
@again="onAgain"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.captcha-row {
.captcha-trigger-wrapper {
width: 100%;
}
.captcha-trigger {
display: flex;
gap: 0;
align-items: stretch;
height: 44px;
}
.hp-field {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.captcha-input {
flex: 1;
min-width: 0;
padding: 0 14px;
border: none;
border-radius: 8px 0 0 8px;
background: #ffffff;
color: #111;
font-size: 15px;
font-weight: 500;
outline: none;
}
.captcha-input::placeholder {
color: #9ca3af;
}
.captcha-canvas {
flex-shrink: 0;
width: 108px;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 16px;
border-radius: 8px;
background: #2a2a2a;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
display: block;
border-radius: 0 8px 8px 0;
transition: border-color 0.2s;
user-select: none;
}
.captcha-trigger:active {
border-color: var(--primary, #c8a84e);
}
.captcha-trigger.verified {
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.08);
cursor: default;
}
.captcha-success-icon {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.captcha-trigger-text {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
}
.captcha-trigger.verified .captcha-trigger-text {
color: #22c55e;
}
/* Popup */
.captcha-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.captcha-popup {
background: #1e1e1e;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
max-width: 340px;
width: calc(100vw - 40px);
}
.captcha-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.captcha-popup-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
font-size: 16px;
cursor: pointer;
padding: 4px;
line-height: 1;
}
.captcha-popup-close:active {
color: rgba(255, 255, 255, 0.8);
}
.captcha-popup-body {
padding: 16px;
display: flex;
justify-content: center;
}
.captcha-popup-body :deep(.slide-verify) {
width: 300px !important;
}
.captcha-popup-body :deep(.slide-verify-slider) {
width: 300px !important;
}
.captcha-popup-body :deep(.slide-verify-info) {
background-color: #2a2a2a;
color: rgba(255, 255, 255, 0.6);
}
.captcha-popup-body :deep(.slide-verify-slider .slide-verify-slider__text) {
background-color: #2a2a2a;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
}
.captcha-popup-body :deep(.slide-verify-slider .slide-verify-slider__handler) {
background-color: var(--primary, #c8a84e);
}
.captcha-popup-body :deep(.slide-verify-slider__bg-fill) {
background-color: rgba(200, 168, 78, 0.25);
}
.captcha-popup-body :deep(.slide-verify-slider__icon--success) {
background-color: rgba(34, 197, 94, 0.2);
}
.captcha-popup-body :deep(.slide-verify-slider__icon--fail) {
background-color: rgba(239, 68, 68, 0.2);
}
/* Transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-error {
color: var(--danger);
font-size: 12px;
font-weight: 600;
margin: 4px 0 0;
}
</style>

View File

@@ -45,6 +45,11 @@ function goEdit() {
router.push('/profile/edit');
}
function goRecharge() {
close();
router.push('/wallet/recharge');
}
function logout() {
close();
auth.logout();
@@ -60,6 +65,7 @@ function logout() {
<div v-if="open" class="avatar-menu">
<div class="menu-user">{{ auth.user?.username }}</div>
<button type="button" class="menu-item recharge" @click="goRecharge">{{ t('recharge.title') }}</button>
<button type="button" class="menu-item" @click="goEdit">{{ t('profile.edit') }}</button>
<button type="button" class="menu-item danger" @click="logout">{{ t('auth.logout') }}</button>
</div>
@@ -139,6 +145,11 @@ function logout() {
background: rgba(255, 255, 255, 0.04);
}
.menu-item.recharge {
color: var(--primary-light);
font-weight: 700;
}
.menu-item.danger {
color: var(--danger);
}

View File

@@ -188,6 +188,7 @@ watch(
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
}
.header-actions :deep(.locale-switch:not(.compact)),
.header-actions :deep(.cash-chip),
@@ -213,6 +214,8 @@ watch(
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 1;
min-width: 0;
}
.support-btn:active {

View File

@@ -70,16 +70,26 @@ const i18n = createI18n({
},
auth: {
login: '登录',
register: '注册账号',
logout: '退出登录',
username: '账号',
password: '密码',
invite_code: '邀请码',
optional: '选填',
captcha_placeholder: 'Captcha',
captcha_refresh: '点击换一张',
captcha_wrong: '验证码错误',
captcha_wrong: '请完成滑块验证',
slide_to_verify: '向右滑动完成验证',
click_to_verify: '点击验证',
verified: '验证成功',
login_required: '请先登录',
login_hint: '登录后可下注及访问更多功能',
go_login: '去登录',
continue_browsing: '暂不登录,继续浏览',
go_register: '没有账号?立即注册',
have_account: '已有账号?去登录',
register_btn: '注册',
register_failed: '注册失败,请重试',
continue_browsing: '暂不登录',
username_placeholder: '请输入账号',
password_placeholder: '请输入密码',
login_btn: '登录',
@@ -99,7 +109,7 @@ const i18n = createI18n({
unsettled: '未结算',
available: '可用',
no_records: '暂无账单记录',
tx_deposit: '人工存款',
tx_deposit: '充值',
tx_withdraw: '人工提款',
tx_adjust: '人工调整',
tx_bet_freeze: '投注冻结',
@@ -116,7 +126,7 @@ const i18n = createI18n({
stats_net: '净额',
stats_cashback: '反水',
filter_all: '全部',
filter_deposit: '存款',
filter_deposit: '充值',
filter_withdraw: '提款',
filter_bet: '投注',
filter_cashback: '反水',
@@ -135,7 +145,7 @@ const i18n = createI18n({
detail_tx_id: '流水号',
detail_not_found: '账单不存在',
ref_bet: '投注',
ref_deposit: '存款',
ref_deposit: '充值',
ref_withdraw: '提款',
view_cashbacks: '返水明细',
view_cashbacks_detail: '查看返水周期明细',
@@ -143,6 +153,42 @@ const i18n = createI18n({
detail_cashback_link: '查看返水明细',
ref_cashback: '返水批次',
},
recharge: {
title: '充值',
history: '记录',
history_title: '充值记录',
bank_transfer: '银行转账',
bank_name: '银行名称',
account_holder: '账户名',
account_number: '账号',
usdt_address: 'USDT 地址',
amount_label: '充值金额',
amount_placeholder: '请输入充值金额',
screenshot_label: '上传转账截图',
upload_hint: '点击上传截图(最大 5MB',
compressing: '压缩中',
submit: '提交充值',
submitting: '提交中',
submitted: '充值已提交',
pending_review: '管理员正在审核,请耐心等待',
new_recharge: '继续充值',
no_methods: '暂无可用充值方式',
select_method: '请选择充值方式',
enter_amount: '请输入充值金额',
upload_screenshot: '请上传转账截图',
submit_failed: '提交失败,请重试',
file_must_be_image: '请上传图片文件',
file_too_large: '文件不能超过 10MB',
status_pending: '审核中',
status_approved: '已通过',
status_rejected: '已拒绝',
no_orders: '暂无充值记录',
credited: '实际到账',
reject_reason: '拒绝原因',
apply_time: '申请时间',
review_time: '审核时间',
remark: '审核备注',
},
cashback: {
title: '返水明细',
list_title: '发放明细',
@@ -398,16 +444,26 @@ const i18n = createI18n({
},
auth:
{ login: 'Login',
register: 'Create Account',
logout: 'Log out',
username: 'Username',
password: 'Password',
invite_code: 'Invitation Code',
optional: 'Optional',
captcha_placeholder: 'Captcha',
captcha_refresh: 'Click to refresh',
captcha_wrong: 'Invalid captcha',
captcha_wrong: 'Please complete the slider verification',
slide_to_verify: 'Slide to verify',
click_to_verify: 'Click to verify',
verified: 'Verified',
login_required: 'Login Required',
login_hint: 'Log in to place bets and access more features',
go_login: 'Go to login',
continue_browsing: 'Continue browsing',
go_register: 'No account? Register now',
have_account: 'Already have an account? Log in',
register_btn: 'Register',
register_failed: 'Registration failed, please try again',
continue_browsing: 'Skip login',
username_placeholder: 'Enter username',
password_placeholder: 'Enter password',
login_btn: 'Log In',
@@ -471,6 +527,42 @@ const i18n = createI18n({
ref_cashback: 'Cashback batch',
detail_cashback_link: 'View cashback details',
},
recharge: {
title: 'Recharge',
history: 'History',
history_title: 'Recharge History',
bank_transfer: 'Bank Transfer',
bank_name: 'Bank Name',
account_holder: 'Account Holder',
account_number: 'Account Number',
usdt_address: 'USDT Address',
amount_label: 'Amount',
amount_placeholder: 'Enter recharge amount',
screenshot_label: 'Upload Screenshot',
upload_hint: 'Click to upload screenshot (max 5MB)',
compressing: 'Compressing',
submit: 'Submit',
submitting: 'Submitting',
submitted: 'Recharge Submitted',
pending_review: 'Admin is reviewing, please wait',
new_recharge: 'New Recharge',
no_methods: 'No payment methods available',
select_method: 'Please select a payment method',
enter_amount: 'Please enter the amount',
upload_screenshot: 'Please upload a screenshot',
submit_failed: 'Submit failed, please retry',
file_must_be_image: 'Please upload an image file',
file_too_large: 'File exceeds 10MB',
status_pending: 'Pending',
status_approved: 'Approved',
status_rejected: 'Rejected',
no_orders: 'No recharge records',
credited: 'Credited',
reject_reason: 'Rejection reason',
apply_time: 'Apply time',
review_time: 'Review time',
remark: 'Remark',
},
cashback: {
title: 'Cashback Details',
list_title: 'Payout details',
@@ -732,16 +824,26 @@ const i18n = createI18n({
},
auth: {
login: 'Log Masuk',
register: 'Daftar Akaun',
logout: 'Log Keluar',
username: 'Nama Pengguna',
password: 'Kata Laluan',
invite_code: 'Kod Jemputan',
optional: 'Pilihan',
captcha_placeholder: 'Captcha',
captcha_refresh: 'Klik untuk muat semula',
captcha_wrong: 'Kod pengesahan salah',
captcha_wrong: 'Sila lengkapkan pengesahan gelongsor',
slide_to_verify: 'Gelongsor untuk mengesahkan',
click_to_verify: 'Klik untuk mengesahkan',
verified: 'Disahkan',
login_required: 'Sila Log Masuk',
login_hint: 'Log masuk untuk bertaruh dan akses lebih banyak ciri',
go_login: 'Pergi log masuk',
continue_browsing: 'Teruskan melayari',
go_register: 'Tiada akaun? Daftar sekarang',
have_account: 'Sudah ada akaun? Log masuk',
register_btn: 'Daftar',
register_failed: 'Pendaftaran gagal, sila cuba lagi',
continue_browsing: 'Langkau log masuk',
username_placeholder: 'Masukkan nama pengguna',
password_placeholder: 'Masukkan kata laluan',
login_btn: 'Log Masuk',
@@ -805,6 +907,42 @@ const i18n = createI18n({
ref_cashback: 'Batch rebat',
detail_cashback_link: 'Lihat butiran rebat',
},
recharge: {
title: 'Topup',
history: 'Sejarah',
history_title: 'Sejarah Topup',
bank_transfer: 'Pindahan Bank',
bank_name: 'Nama Bank',
account_holder: 'Pemegang Akaun',
account_number: 'Nombor Akaun',
usdt_address: 'Alamat USDT',
amount_label: 'Jumlah',
amount_placeholder: 'Masukkan jumlah topup',
screenshot_label: 'Muat Naik Screenshot',
upload_hint: 'Klik untuk muat naik (maks 5MB)',
compressing: 'Memampat',
submit: 'Hantar',
submitting: 'Menghantar',
submitted: 'Topup Dihantar',
pending_review: 'Admin sedang menyemak, sila tunggu',
new_recharge: 'Topup Baru',
no_methods: 'Tiada kaedah pembayaran tersedia',
select_method: 'Sila pilih kaedah pembayaran',
enter_amount: 'Sila masukkan jumlah',
upload_screenshot: 'Sila muat naik screenshot',
submit_failed: 'Gagal, sila cuba lagi',
file_must_be_image: 'Sila muat naik fail imej',
file_too_large: 'Fail melebihi 10MB',
status_pending: 'Menunggu',
status_approved: 'Diluluskan',
status_rejected: 'Ditolak',
no_orders: 'Tiada rekod topup',
credited: 'Dikreditkan',
reject_reason: 'Sebab penolakan',
apply_time: 'Masa permohonan',
review_time: 'Masa semakan',
remark: 'Catatan',
},
cashback: {
title: 'Butiran Rebat',
list_title: 'Butiran pembayaran',

View File

@@ -5,6 +5,7 @@ const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('../views/LoginView.vue') },
{ path: '/register', component: () => import('../views/RegisterView.vue') },
{
path: '/',
component: () => import('../layouts/MainLayout.vue'),
@@ -20,6 +21,8 @@ const router = createRouter({
{ path: 'wallet', component: () => import('../views/WalletView.vue'), meta: { keepAlive: true, requiresAuth: true } },
{ path: 'wallet/detail', component: () => import('../views/WalletDetailView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet/recharge', component: () => import('../views/RechargeView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet/recharge/history', component: () => import('../views/RechargeHistoryView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue'), meta: { requiresAuth: true } },
{ path: 'profile', component: () => import('../views/ProfileView.vue'), meta: { keepAlive: true, requiresAuth: true } },
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
@@ -31,7 +34,7 @@ const router = createRouter({
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.path === '/login' && auth.token) return '/';
if ((to.path === '/login' || to.path === '/register') && auth.token) return '/';
// 需要登录的页面 — 未登录时弹出登录提示,留在当前页
if (to.meta.requiresAuth && !auth.token) {
auth.showLoginPrompt(to.fullPath);

View File

@@ -32,6 +32,23 @@ export const useAuthStore = defineStore('auth', () => {
return returnTo;
}
async function register(username: string, password: string, inviteCode?: string) {
const locale = localStorage.getItem('locale') || 'zh-CN';
const code = inviteCode?.trim();
const { data } = await api.post('/player/auth/register', {
username,
password,
locale,
...(code ? { inviteCode: code } : {}),
});
token.value = data.data.token;
user.value = data.data.user;
localStorage.setItem('token', token.value);
localStorage.setItem('user', JSON.stringify(user.value));
loginReturnTo.value = '';
loginPromptVisible.value = false;
}
function logout() {
token.value = '';
user.value = null;
@@ -40,7 +57,7 @@ export const useAuthStore = defineStore('auth', () => {
}
return {
token, user, login, logout,
token, user, login, register, logout,
loginPromptVisible, loginReturnTo,
showLoginPrompt, hideLoginPrompt,
};

View File

@@ -16,6 +16,7 @@ export const TX_KEY_MAP: Record<string, string> = {
RESETTLE_REVERSE: 'wallet.tx_resettle',
DEPOSIT: 'wallet.tx_deposit',
WITHDRAW: 'wallet.tx_withdraw',
PLAYER_DEPOSIT: 'wallet.tx_deposit',
};
export function txTypeKey(type: string): string {

View File

@@ -51,6 +51,13 @@ function continueBrowsing() {
const target = isGuestBrowsablePath(redirect) ? redirect || '/' : '/';
router.replace(target);
}
function goRegister() {
router.push({
path: '/register',
query: route.query.redirect ? { redirect: route.query.redirect as string } : {},
});
}
</script>
<template>
@@ -68,10 +75,13 @@ function continueBrowsing() {
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
{{ t('auth.login') }}
</button>
<button type="button" class="btn-skip" @click="continueBrowsing">
{{ t('auth.continue_browsing') }}
<button type="button" class="btn-skip" @click="goRegister">
{{ t('auth.go_register') }}
</button>
</form>
<button type="button" class="btn-skip-light" @click="continueBrowsing">
{{ t('auth.continue_browsing') }}
</button>
</div>
</template>
@@ -144,6 +154,25 @@ label {
color: rgba(255, 255, 255, 0.75);
}
.btn-skip-light {
position: absolute;
bottom: calc(20px + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
font-weight: 300;
cursor: pointer;
white-space: nowrap;
}
.btn-skip-light:active {
color: rgba(255, 255, 255, 0.65);
}
.error {
color: var(--danger);
font-size: 13px;

View File

@@ -3,7 +3,7 @@ import { ref, onMounted, computed } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import { formatMoney, formatMoneyCompact } from '../utils/localeDisplay';
import LocaleFlag from '../components/LocaleFlag.vue';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
@@ -53,7 +53,7 @@ function runCountUp(target: number) {
const displayedBalance = computed(() =>
animating.value
? formatMoney(displayAmount.value, locale.value)
: formatMoney(profile.value?.wallet?.availableBalance, locale.value),
: formatMoneyCompact(profile.value?.wallet?.availableBalance, locale.value),
);
async function fetchProfile() {
@@ -93,7 +93,7 @@ function logout() {
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div class="wallet-banner" @click="router.push('/wallet/detail')">
<div class="wallet-banner">
<img class="wallet-banner-img" :src="walletBg" alt="" />
<img src="/logo.png" alt="TheBet365" class="wallet-card-logo" />
<div class="wallet-banner-info">
@@ -102,7 +102,12 @@ function logout() {
<LocaleFlag :locale="locale" :size="14" />
{{ t('wallet.balance') }}
</span>
<div class="card-balance-row">
<p class="card-balance">{{ displayedBalance }}</p>
<button type="button" class="card-recharge-btn" @click.stop="router.push('/wallet/recharge')">
{{ t('recharge.title') }}
</button>
</div>
</div>
<div class="card-foot">
<div class="card-holder">
@@ -197,15 +202,9 @@ function logout() {
margin-left: -5px;
margin-right: -5px;
line-height: 0;
cursor: pointer;
box-sizing: border-box;
}
.wallet-banner:active .wallet-banner-img {
filter: brightness(0.9);
transition: filter 0.1s;
}
.wallet-banner-img {
width: 100%;
height: auto;
@@ -265,8 +264,6 @@ function logout() {
.card-balance {
margin: 0;
width: 100%;
max-width: 100%;
font-size: clamp(22px, 6.8vw, 36px);
font-weight: 900;
letter-spacing: -0.02em;
@@ -283,6 +280,12 @@ function logout() {
drop-shadow(0 0 10px rgba(212, 175, 55, 0.22));
}
.card-balance-row {
display: flex;
align-items: center;
gap: 8px;
}
.card-foot {
display: flex;
align-items: flex-end;
@@ -336,6 +339,30 @@ function logout() {
white-space: nowrap;
}
.card-recharge-btn {
z-index: 3;
background: linear-gradient(135deg, #f0d060, #d4a830);
color: #1a1a1a;
border: none;
border-radius: 16px;
padding: 5px 14px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
cursor: pointer;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.4),
0 0 12px rgba(212, 175, 55, 0.25);
pointer-events: auto;
transition: transform 0.1s;
white-space: nowrap;
flex-shrink: 0;
}
.card-recharge-btn:active {
transform: scale(0.95);
}
.settings-group {
background: var(--bg-card);
border: 1px solid var(--border);

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { formatMoney } from '../utils/localeDisplay';
const router = useRouter();
const { t, locale } = useI18n();
interface DepositOrder {
id: string;
orderNo: string;
methodType: string;
amount: string;
status: string;
approvedAmount: string | null;
rejectReason: string | null;
remark: string | null;
createdAt: string;
reviewedAt: string | null;
paymentMethodName: string | null;
}
const items = ref<DepositOrder[]>([]);
const loading = ref(true);
const page = ref(1);
const total = ref(0);
async function fetchOrders() {
loading.value = true;
try {
const { data } = await api.get('/player/deposit-orders', { params: { page: page.value } });
const result = data.data ?? { items: [], total: 0 };
items.value = result.items ?? [];
total.value = result.total ?? 0;
} catch { /* */ } finally {
loading.value = false;
}
}
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: fetchOrders,
});
function statusClass(s: string) {
if (s === 'APPROVED') return 'status-approved';
if (s === 'REJECTED') return 'status-rejected';
return 'status-pending';
}
function statusLabel(s: string) {
if (s === 'APPROVED') return t('recharge.status_approved');
if (s === 'REJECTED') return t('recharge.status_rejected');
return t('recharge.status_pending');
}
function goBack() {
router.push('/wallet');
}
function goRecharge() {
router.push('/wallet/recharge');
}
onMounted(fetchOrders);
</script>
<template>
<div class="recharge-history-page">
<div class="page-header">
<button class="back-btn" @click="goBack"></button>
<h2>{{ t('recharge.history_title') }}</h2>
<button class="recharge-btn" @click="goRecharge">+ {{ t('recharge.title') }}</button>
</div>
<div
class="pull-indicator"
:style="{ height: `${pullDistance}px`, opacity: Math.min(pullDistance / 48, 1) }"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
</div>
<template v-else>
<div v-if="!items.length" class="empty">{{ t('recharge.no_orders') }}</div>
<div v-else class="order-list">
<div v-for="order in items" :key="order.id" class="order-card" :class="{ rejected: order.status === 'REJECTED' }">
<div class="order-header">
<span class="method-badge" :class="order.methodType === 'BANK' ? 'bank' : 'usdt'">{{ order.methodType }}</span>
<span :class="['status-badge', statusClass(order.status)]">{{ statusLabel(order.status) }}</span>
</div>
<div class="order-body">
<div class="order-amount">{{ formatMoney(order.amount, locale) }}</div>
<div v-if="order.approvedAmount && order.approvedAmount !== order.amount" class="approved-amount">
{{ t('recharge.credited') }}: {{ formatMoney(order.approvedAmount, locale) }}
</div>
<div class="order-info-row">
<span class="info-label">{{ order.paymentMethodName || '-' }}</span>
</div>
<div class="order-times">
<div class="time-row">
<span class="time-label">{{ t('recharge.apply_time') }}</span>
<span class="time-value">{{ new Date(order.createdAt).toLocaleString() }}</span>
</div>
<div v-if="order.reviewedAt" class="time-row">
<span class="time-label">{{ t('recharge.review_time') }}</span>
<span class="time-value">{{ new Date(order.reviewedAt).toLocaleString() }}</span>
</div>
</div>
<div v-if="order.remark" class="order-remark">
{{ t('recharge.remark') }}: {{ order.remark }}
</div>
<div v-if="order.status === 'REJECTED' && order.rejectReason" class="reject-reason">
{{ t('recharge.reject_reason') }}: {{ order.rejectReason }}
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.recharge-history-page { padding: 0 16px 24px; }
.page-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; }
.page-header h2 { margin: 0; font-size: 17px; font-weight: 700; }
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 24px; cursor: pointer; padding: 0 8px; }
.recharge-btn { background: none; border: none; color: var(--primary-light); font-size: 13px; cursor: pointer; font-weight: 600; }
.state { display: flex; justify-content: center; padding: 48px; }
.empty { text-align: center; color: #666; padding: 48px 16px; font-weight: 600; }
.pull-indicator { display: flex; align-items: center; justify-content: center; overflow: hidden; transition: height 0.15s ease; }
.order-list { display: flex; flex-direction: column; gap: 12px; }
.order-card {
background: linear-gradient(135deg, #1a1810 0%, #1f1b0e 40%, #16140c 100%);
border: 1px solid rgba(212, 175, 55, 0.2);
border-radius: 12px;
padding: 16px;
position: relative;
overflow: hidden;
}
.order-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.6), transparent);
}
.order-card.rejected {
background: linear-gradient(135deg, #1a1a1a 0%, #1f1f1f 40%, #161616 100%);
border-color: rgba(100, 100, 100, 0.2);
opacity: 0.7;
}
.order-card.rejected::before {
background: linear-gradient(90deg, transparent, rgba(100, 100, 100, 0.4), transparent);
}
.order-card.rejected .order-amount {
background: linear-gradient(135deg, #888, #666);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.order-card.rejected .info-label {
color: rgba(150, 150, 150, 0.7);
}
.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.method-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.method-badge.bank { background: rgba(30, 58, 95, 0.6); color: #66b1ff; }
.method-badge.usdt { background: rgba(26, 58, 42, 0.6); color: #67c23a; }
.status-badge { font-size: 12px; font-weight: 700; }
.status-pending { color: #e6a23c; }
.status-approved { color: #67c23a; }
.status-rejected { color: #f56c6c; }
.order-body { }
.order-amount {
font-size: 22px;
font-weight: 900;
margin-bottom: 4px;
background: linear-gradient(135deg, #f0d060, #d4a830);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.approved-amount { font-size: 12px; color: #67c23a; margin-bottom: 6px; font-weight: 600; }
.order-info-row { margin-bottom: 8px; }
.info-label { font-size: 13px; color: rgba(212, 175, 55, 0.7); font-weight: 600; }
.order-times { display: flex; flex-direction: column; gap: 4px; margin-bottom: 6px; }
.time-row { display: flex; justify-content: space-between; align-items: center; }
.time-label { font-size: 11px; color: #888; }
.time-value { font-size: 11px; color: #aaa; font-variant-numeric: tabular-nums; }
.order-remark {
font-size: 12px;
color: rgba(212, 175, 55, 0.8);
background: rgba(212, 175, 55, 0.06);
padding: 6px 10px;
border-radius: 6px;
margin-top: 6px;
border-left: 2px solid rgba(212, 175, 55, 0.3);
}
.reject-reason { margin-top: 8px; font-size: 12px; color: #f56c6c; background: #2a1515; padding: 6px 10px; border-radius: 6px; }
</style>

View File

@@ -0,0 +1,399 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import imageCompression from 'browser-image-compression';
import api from '../api';
import GoldSpinner from '../components/GoldSpinner.vue';
const router = useRouter();
const { t } = useI18n();
interface PaymentMethod {
id: string;
methodType: string;
bankName: string | null;
accountHolder: string | null;
accountNumber: string | null;
usdtAddress: string | null;
qrCodeUrl: string | null;
displayName: string | null;
}
const methodType = ref<'BANK' | 'USDT'>('BANK');
const methods = ref<PaymentMethod[]>([]);
const selectedMethod = ref<PaymentMethod | null>(null);
const amount = ref<string>('');
const screenshotFile = ref<File | null>(null);
const screenshotPreview = ref<string>('');
const loading = ref(true);
const submitting = ref(false);
const success = ref(false);
const orderNo = ref('');
const compressing = ref(false);
const bankMethods = computed(() => methods.value.filter((m) => m.methodType === 'BANK'));
const usdtMethods = computed(() => methods.value.filter((m) => m.methodType === 'USDT'));
const currentMethods = computed(() => methodType.value === 'BANK' ? bankMethods.value : usdtMethods.value);
async function fetchMethods() {
loading.value = true;
try {
const { data } = await api.get('/player/payment-methods');
methods.value = (data.data ?? []).map((m: any) => ({ ...m, id: String(m.id) }));
// Auto-select first available
if (currentMethods.value.length) {
selectedMethod.value = currentMethods.value[0];
}
} catch { /* */ } finally {
loading.value = false;
}
}
function switchType(type: 'BANK' | 'USDT') {
methodType.value = type;
selectedMethod.value = currentMethods.value.length ? currentMethods.value[0] : null;
}
function selectMethod(m: PaymentMethod) {
selectedMethod.value = m;
}
async function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert(t('recharge.file_must_be_image'));
input.value = '';
return;
}
// Max 10MB before compression
if (file.size > 10 * 1024 * 1024) {
alert(t('recharge.file_too_large'));
input.value = '';
return;
}
// Compress image
compressing.value = true;
try {
const compressed = await imageCompression(file, {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
});
screenshotFile.value = compressed as File;
screenshotPreview.value = URL.createObjectURL(compressed);
} catch {
// Fallback: use original if compression fails
screenshotFile.value = file;
screenshotPreview.value = URL.createObjectURL(file);
} finally {
compressing.value = false;
}
}
function removeScreenshot() {
screenshotFile.value = null;
screenshotPreview.value = '';
}
async function handleSubmit() {
if (!selectedMethod.value) {
alert(t('recharge.select_method'));
return;
}
const amt = parseFloat(amount.value);
if (!amt || amt <= 0) {
alert(t('recharge.enter_amount'));
return;
}
if (!screenshotFile.value) {
alert(t('recharge.upload_screenshot'));
return;
}
submitting.value = true;
try {
const fd = new FormData();
fd.append('paymentMethodId', selectedMethod.value.id);
fd.append('amount', String(amt));
fd.append('screenshot', screenshotFile.value);
const { data } = await api.post('/player/deposit-orders', fd);
const result = data.data;
orderNo.value = result?.orderNo ?? '';
success.value = true;
} catch (e: any) {
alert(e.response?.data?.message || t('recharge.submit_failed'));
} finally {
submitting.value = false;
}
}
function goHistory() {
router.push('/wallet/recharge/history');
}
function goBack() {
router.back();
}
function resetForm() {
success.value = false;
amount.value = '';
screenshotFile.value = null;
screenshotPreview.value = '';
orderNo.value = '';
}
function copyText(text: string) {
navigator.clipboard?.writeText(text);
}
onMounted(fetchMethods);
</script>
<template>
<div class="recharge-page">
<div class="page-header">
<button class="back-btn" @click="goBack"></button>
<h2>{{ t('recharge.title') }}</h2>
<button class="history-btn" @click="goHistory">{{ t('recharge.history') }}</button>
</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="success" class="success-state">
<div class="success-icon"></div>
<h3>{{ t('recharge.submitted') }}</h3>
<p class="order-no">{{ orderNo }}</p>
<p class="success-hint">{{ t('recharge.pending_review') }}</p>
<button class="btn-primary" @click="resetForm">{{ t('recharge.new_recharge') }}</button>
</div>
<template v-else>
<div class="type-tabs">
<button
:class="['tab', methodType === 'BANK' && 'active']"
@click="switchType('BANK')"
>{{ t('recharge.bank_transfer') }}</button>
<button
:class="['tab', methodType === 'USDT' && 'active']"
@click="switchType('USDT')"
>USDT</button>
</div>
<div v-if="currentMethods.length" class="methods-list">
<button
v-for="m in currentMethods"
:key="m.id"
:class="['method-pill', selectedMethod?.id === m.id && 'selected']"
@click="selectMethod(m)"
>
<span class="pill-name">{{ m.displayName || m.bankName || m.usdtAddress }}</span>
<span v-if="m.methodType === 'BANK'" class="pill-sub">{{ m.accountHolder }}</span>
</button>
</div>
<div v-else class="empty-methods">{{ t('recharge.no_methods') }}</div>
<div v-if="selectedMethod" class="method-info">
<template v-if="selectedMethod.methodType === 'BANK'">
<div class="info-row">
<span class="info-label">{{ t('recharge.bank_name') }}</span>
<span class="info-value">{{ selectedMethod.bankName }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('recharge.account_holder') }}</span>
<span class="info-value">{{ selectedMethod.accountHolder }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('recharge.account_number') }}</span>
<span class="info-value copyable" @click="copyText(selectedMethod!.accountNumber || '')">
{{ selectedMethod.accountNumber }}
<svg class="copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
</div>
</template>
<template v-else>
<div class="info-row">
<span class="info-label">{{ t('recharge.usdt_address') }}</span>
<span class="info-value copyable" @click="copyText(selectedMethod!.usdtAddress || '')">
{{ selectedMethod.usdtAddress }}
<svg class="copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
</div>
<div v-if="selectedMethod.qrCodeUrl" class="qr-container">
<img :src="selectedMethod.qrCodeUrl" class="qr-image" />
</div>
</template>
</div>
<div class="form-section">
<label>{{ t('recharge.amount_label') }}</label>
<input
v-model="amount"
type="number"
inputmode="decimal"
:placeholder="t('recharge.amount_placeholder')"
class="amount-input"
/>
</div>
<div class="form-section">
<label>{{ t('recharge.screenshot_label') }}</label>
<div v-if="screenshotPreview" class="screenshot-preview">
<img :src="screenshotPreview" />
<button class="remove-btn" @click="removeScreenshot"></button>
</div>
<label v-else class="upload-area">
<input type="file" accept="image/*" @change="handleFileChange" :disabled="compressing" />
<div v-if="compressing" class="compress-hint">{{ t('recharge.compressing') }}...</div>
<div v-else class="upload-hint">{{ t('recharge.upload_hint') }}</div>
</label>
</div>
<button
class="btn-submit"
:disabled="submitting || !selectedMethod || !amount || !screenshotFile"
@click="handleSubmit"
>
<span v-if="submitting">{{ t('recharge.submitting') }}...</span>
<span v-else>{{ t('recharge.submit') }}</span>
</button>
</template>
</div>
</template>
<style scoped>
.recharge-page { padding: 0 12px 24px; }
.page-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 0 12px;
}
.page-header h2 { margin: 0; font-size: 16px; font-weight: 700; }
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 22px; cursor: pointer; padding: 0 6px; }
.history-btn { background: none; border: none; color: var(--primary-light); font-size: 12px; cursor: pointer; font-weight: 600; }
.state { display: flex; justify-content: center; padding: 48px; }
.type-tabs {
display: flex; margin-bottom: 12px;
border-radius: 6px; overflow: hidden;
border: 1px solid rgba(212, 175, 55, 0.2);
}
.tab {
flex: 1; padding: 8px; border: none;
background: rgba(20, 20, 20, 0.8);
color: var(--text-muted); font-weight: 700; font-size: 13px;
cursor: pointer; transition: all 0.2s;
}
.tab.active {
background: var(--primary-light); color: #000;
}
.methods-list {
display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px;
}
.method-pill {
display: flex; flex-direction: column; gap: 1px;
background: rgba(20, 20, 20, 0.8);
border: 1px solid var(--border);
border-radius: 6px; padding: 6px 12px;
text-align: left; cursor: pointer;
transition: border-color 0.15s;
}
.method-pill.selected {
border-color: var(--primary-light);
background: rgba(212, 175, 55, 0.06);
}
.pill-name { font-weight: 700; font-size: 12px; color: var(--text); }
.pill-sub { font-size: 10px; color: var(--text-muted); }
.empty-methods { text-align: center; color: var(--text-muted); padding: 20px; font-size: 12px; }
.method-info {
background: rgba(17, 17, 17, 0.9);
border-radius: 8px; padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid var(--border);
}
.info-row {
display: flex; justify-content: space-between; align-items: baseline;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.info-row:last-child { border-bottom: none; }
.info-label { font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
.info-value {
font-size: 13px; font-weight: 600;
word-break: break-all; text-align: right;
max-width: 60%;
}
.copyable {
cursor: pointer; color: var(--primary-light);
display: inline-flex; align-items: center; gap: 4px;
}
.copyable:active { opacity: 0.6; }
.copy-icon { width: 14px; height: 14px; flex-shrink: 0; }
.qr-container { display: flex; justify-content: center; padding: 8px 0 4px; }
.qr-image { width: 140px; height: 140px; object-fit: contain; border-radius: 6px; background: #fff; padding: 6px; }
.form-section { margin-bottom: 12px; }
.form-section label {
display: block; font-size: 11px; font-weight: 700;
color: var(--text-muted); margin-bottom: 4px;
}
.amount-input {
width: 100%; padding: 10px; background: #111;
border: 1px solid var(--border); border-radius: 6px;
color: #fff; font-size: 16px; font-weight: 700;
box-sizing: border-box;
}
.amount-input:focus { border-color: var(--primary-light); outline: none; }
.upload-area {
border: 1px dashed rgba(212, 175, 55, 0.3);
border-radius: 6px; padding: 16px;
text-align: center; position: relative;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
}
.upload-area input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.upload-hint { font-size: 12px; color: var(--text-muted); }
.compress-hint { font-size: 12px; color: var(--primary-light); }
.screenshot-preview { position: relative; display: inline-block; }
.screenshot-preview img { max-width: 100%; max-height: 160px; border-radius: 6px; }
.remove-btn {
position: absolute; top: 4px; right: 4px;
background: rgba(0,0,0,0.7); border: none; color: #fff;
width: 20px; height: 20px; border-radius: 50%;
cursor: pointer; font-size: 11px;
}
.btn-submit {
width: 100%; padding: 12px;
background: linear-gradient(135deg, #f0d060, #d4a830);
color: #000; border: none; border-radius: 6px;
font-size: 14px; font-weight: 800;
cursor: pointer; margin-top: 8px;
box-shadow: 0 2px 8px rgba(212, 175, 55, 0.2);
}
.btn-submit:disabled { opacity: 0.4; cursor: not-allowed; }
.success-state { text-align: center; padding: 40px 16px; }
.success-icon { font-size: 40px; color: #67c23a; margin-bottom: 10px; }
.success-state h3 { margin: 0 0 6px; font-size: 16px; }
.order-no { font-family: monospace; color: var(--primary-light); font-size: 13px; margin: 4px 0; }
.success-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 20px; }
.btn-primary {
background: linear-gradient(135deg, #f0d060, #d4a830);
color: #000; border: none; border-radius: 6px;
padding: 10px 20px; font-weight: 700; font-size: 13px; cursor: pointer;
}
</style>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
import RobotVerify from '../components/RobotVerify.vue';
import loginBg from '../assets/images/h5bg.png';
const { t } = useI18n();
const { initFromUser } = useAppLocale();
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const captchaRef = ref<InstanceType<typeof RobotVerify> | null>(null);
const username = ref('');
const password = ref('');
const inviteCode = ref(typeof route.query.code === 'string' ? route.query.code : '');
const error = ref('');
const loading = ref(false);
async function submit() {
if (!captchaRef.value?.validate()) {
error.value = t('auth.captcha_wrong');
captchaRef.value?.refresh();
return;
}
loading.value = true;
error.value = '';
try {
await auth.register(username.value, password.value, inviteCode.value);
initFromUser(auth.user?.locale);
const redirectTo = (route.query.redirect as string) || '/';
router.push(redirectTo);
} catch (e: unknown) {
error.value = (e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('auth.register_failed');
} finally {
loading.value = false;
}
}
function goLogin() {
router.push({ path: '/login', query: route.query.redirect ? { redirect: route.query.redirect as string } : {} });
}
</script>
<template>
<div class="login-page" :style="{ backgroundImage: `url(${loginBg})` }">
<div class="login-lang">
<LocaleSwitcher compact />
</div>
<form @submit.prevent="submit" class="login-form ps-gold-frame">
<h2 class="form-title">{{ t('auth.register') }}</h2>
<label>{{ t('auth.invite_code') }} <span class="optional-tag">{{ t('auth.optional') }}</span></label>
<input v-model="inviteCode" class="ps-gold-input" autocomplete="off" />
<label>{{ t('auth.username') }}</label>
<input v-model="username" class="ps-gold-input" required autocomplete="username" />
<label>{{ t('auth.password') }}</label>
<input v-model="password" class="ps-gold-input" type="password" required autocomplete="new-password" minlength="8" />
<RobotVerify ref="captchaRef" />
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
{{ t('auth.register_btn') }}
</button>
<button type="button" class="btn-skip" @click="goLogin">
{{ t('auth.have_account') }}
</button>
</form>
</div>
</template>
<style scoped>
.login-lang {
position: absolute;
top: max(12px, env(safe-area-inset-top));
right: 16px;
z-index: 2;
}
.login-page {
position: relative;
height: 100%;
min-height: 100dvh;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: 28vh 20px calc(12vh + max(28px, env(safe-area-inset-bottom)));
background-color: var(--tertiary);
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
}
.login-form {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
}
.form-title {
margin: 0 0 4px;
font-size: 18px;
font-weight: 800;
color: #fff;
text-align: center;
}
.optional-tag {
font-size: 10px;
font-weight: 500;
color: rgba(255, 255, 255, 0.45);
}
label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.04em;
}
.btn-login {
margin-top: 4px;
padding: 10px 14px;
border-radius: 6px;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.06em;
}
.btn-login:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-skip {
margin-top: 2px;
padding: 8px 14px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.55);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-skip:active {
color: rgba(255, 255, 255, 0.75);
}
.error {
color: var(--danger);
font-size: 13px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,695 @@
# 创蓝短信ChuanglanTypeScript 全栈接入指南
> 适用场景:**全新独立项目**TypeScript 全栈Next.js / Remix / Nuxt 等),直连创蓝 API。
> 服务商:创蓝 253 云通讯(国际短信网关)
> API Endpoint`https://sgap.253.com/send/sms`
> 签名算法参考:`babylive-backend` 中 `ChuanglanClient.java` + `SignUtil.java`(已验证可用)
---
## 1. 整体架构
创蓝 `account` / `password` 是服务端密钥,**只能在服务端调用**,浏览器/客户端绝不直接接触创蓝。
```
┌─────────────┐ POST /api/sms/send ┌──────────────────┐ POST + sign ┌─────────────┐
│ 前端页面 │ ──────────────────────────▶│ TS 服务端 │ ──────────────────▶│ 创蓝 API │
│ (React 等) │◀────────────────────────── │ API Route / tRPC │◀────────────────── │ 253.com │
└─────────────┘ { sessionId } └──────────────────┘ messageId └─────────────┘
Redis / KV 缓存
(验证码 + 频控)
```
职责划分:
| 层 | 职责 |
|----|------|
| 前端 | 收集手机号、触发发送、倒计时 60s、提交验证码 + `sessionId` |
| 服务端 API | 频控、生成验证码、调创蓝、存/验缓存 |
| `lib/chuanglan` | 签名 + HTTP 请求,不含业务逻辑 |
| Redis | 验证码存储5 分钟 TTL、手机号/IP 频控60 秒 TTL |
---
## 2. 环境变量
```bash
# .env.local勿提交 Git
CHUANGLAN_ACCOUNT=your_account
CHUANGLAN_PASSWORD=your_password
CHUANGLAN_ENDPOINT=https://sgap.253.com/send/sms
CHUANGLAN_CONNECT_TIMEOUT_MS=10000
CHUANGLAN_READ_TIMEOUT_MS=10000
# 验证码业务
SMS_CODE_TTL_SECONDS=300 # 5 分钟
SMS_RATE_LIMIT_SECONDS=60 # 发送冷却
REDIS_URL=redis://127.0.0.1:6379
```
创蓝账号信息可向运维索取(与 babylive-backend `application.yml``chuanglan.*` 同源)。
---
## 3. 推荐目录结构
以 Next.js App Router 为例,其他 TS 全栈框架可平移 `lib/``types/`
```
src/
├── lib/
│ ├── chuanglan/
│ │ ├── client.ts # 创蓝 HTTP Client
│ │ ├── sign.ts # MD5 签名
│ │ └── config.ts # 读取环境变量
│ └── sms/
│ ├── templates.ts # 多语言短信模板
│ ├── code.ts # 验证码生成
│ └── service.ts # 发送 / 校验业务
├── app/
│ └── api/
│ └── sms/
│ ├── send/route.ts
│ └── verify/route.ts
├── types/
│ └── sms.ts
└── hooks/
└── use-sms-code.ts # 前端发送 + 倒计时
```
---
## 4. 创蓝 API 协议
### 4.1 请求
**Method** `POST`
**URL** `https://sgap.253.com/send/sms`
**Headers**
| Header | 说明 |
|--------|------|
| `Content-Type` | `application/json` |
| `nonce` | 毫秒时间戳字符串,如 `1718000000123` |
| `sign` | MD5 签名,见 4.2 |
**Body**
```json
{
"account": "your_account",
"mobile": "8613800138000",
"msg": "您的验证码是123456。5分钟内有效。",
"uid": "optional-session-id"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| account | 是 | 创蓝账号 |
| mobile | 是 | 目标手机号,建议带国家码 |
| msg | 是 | 短信正文 |
| uid | 否 | 自定义 ID建议传本次验证码会话 ID |
> `nonce` 参与签名,放 Header**不进 Body**。
### 4.2 签名算法
1. 取 Body 全部字段 + `nonce`,组成键值对
2. 按 key **字典序升序**(等价 Java `TreeMap`
3. 依次拼接 `key + value`**跳过空值**`null` / `""` / 纯空白)
4. 末尾追加 `password`
5. 整体做 **MD5**,输出 **32 位小写** hex
```
sign = md5("account" + account + "mobile" + mobile + "msg" + msg + "nonce" + nonce + password)
```
### 4.3 响应
成功(`code === "0"`
```json
{
"code": "0",
"message": "提交成功",
"data": { "messageId": "162575412960104448" }
}
```
失败时 `code` 为非 `"0"` 字符串,`message` 为错误描述。
---
## 5. TypeScript 类型
```typescript
// src/types/sms.ts
export type SmsLang = 'zh' | 'en' | 'vi' | 'ms' | 'kh';
export interface ChuanglanSendBody {
account: string;
mobile: string;
msg: string;
uid?: string;
}
export interface ChuanglanSendResponse {
code: string;
message: string;
data?: { messageId: string };
}
export interface SmsSendResult {
success: boolean;
code: string;
message: string;
messageId?: string;
}
export interface SendSmsCodeRequest {
phone: string;
lang?: SmsLang;
}
export interface SendSmsCodeResponse {
sessionId: string;
}
export interface VerifySmsCodeRequest {
phone: string;
code: string;
sessionId: string;
}
export interface VerifySmsCodeResponse {
ok: true;
}
```
---
## 6. 服务端实现
### 6.1 配置
```typescript
// src/lib/chuanglan/config.ts
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing env: ${name}`);
return v;
}
export const chuanglanConfig = {
account: required('CHUANGLAN_ACCOUNT'),
password: required('CHUANGLAN_PASSWORD'),
endpoint: process.env.CHUANGLAN_ENDPOINT ?? 'https://sgap.253.com/send/sms',
connectTimeoutMs: Number(process.env.CHUANGLAN_CONNECT_TIMEOUT_MS ?? 10_000),
readTimeoutMs: Number(process.env.CHUANGLAN_READ_TIMEOUT_MS ?? 10_000),
} as const;
export const smsConfig = {
codeTtlSeconds: Number(process.env.SMS_CODE_TTL_SECONDS ?? 300),
rateLimitSeconds: Number(process.env.SMS_RATE_LIMIT_SECONDS ?? 60),
} as const;
```
### 6.2 签名
```typescript
// src/lib/chuanglan/sign.ts
import crypto from 'node:crypto';
export function generateChuanglanSign(
password: string,
params: Record<string, string | undefined>,
): string {
const raw = Object.keys(params)
.sort()
.reduce((acc, key) => {
const value = params[key];
if (value != null && value.trim() !== '') {
return acc + key + value;
}
return acc;
}, '');
return crypto.createHash('md5').update(raw + password, 'utf8').digest('hex').toLowerCase();
}
```
### 6.3 创蓝 Client
```typescript
// src/lib/chuanglan/client.ts
import { chuanglanConfig } from './config';
import { generateChuanglanSign } from './sign';
import type { ChuanglanSendResponse, SmsSendResult } from '@/types/sms';
export async function sendChuanglanSms(
mobile: string,
msg: string,
uid?: string,
): Promise<SmsSendResult> {
const nonce = String(Date.now());
const body: Record<string, string> = {
account: chuanglanConfig.account,
mobile,
msg,
};
if (uid) body.uid = uid;
const sign = generateChuanglanSign(chuanglanConfig.password, { ...body, nonce });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), chuanglanConfig.readTimeoutMs);
try {
const res = await fetch(chuanglanConfig.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
nonce,
sign,
},
body: JSON.stringify(body),
signal: controller.signal,
});
const data = (await res.json()) as ChuanglanSendResponse;
if (data.code === '0') {
return {
success: true,
code: data.code,
message: 'OK',
messageId: data.data?.messageId,
};
}
return { success: false, code: data.code, message: data.message };
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return { success: false, code: 'HTTP_ERROR', message };
} finally {
clearTimeout(timer);
}
}
```
### 6.4 短信模板
与 babylive-backend `sms.verify` 配置一致:
```typescript
// src/lib/sms/templates.ts
import type { SmsLang } from '@/types/sms';
const TEMPLATES: Record<string, string> = {
default: '您的验证码是:{code}。5分钟内有效。',
zh: '您的验证码是:{code}。5分钟内有效。',
en: 'Your verification code is {code}. Valid for 5 minutes.',
vi: 'Mã xác minh của bạn là {code}. Có hiệu lực trong 5 phút.',
ms: 'Kod pengesahan anda ialah {code}. Sah selama 5 minit.',
kh: 'កូដផ្ទៀងផ្ទាត់របស់អ្នកគឺ {code} ។ មានសុពលភាពរយៈពេល ៥ នាទី។',
};
export function renderVerifySms(lang: SmsLang | undefined, code: string): string {
const key = lang?.trim() || 'zh';
const tpl = TEMPLATES[key] ?? TEMPLATES.default ?? TEMPLATES.zh;
return tpl.replace('{code}', code);
}
```
```typescript
// src/lib/sms/code.ts
export function generateSixDigitCode(): string {
return String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0');
}
```
### 6.5 业务 ServiceRedis
```typescript
// src/lib/sms/service.ts
import { randomUUID } from 'node:crypto';
import { sendChuanglanSms } from '@/lib/chuanglan/client';
import { smsConfig } from '@/lib/chuanglan/config';
import { generateSixDigitCode } from './code';
import { renderVerifySms } from './templates';
import type { SmsLang } from '@/types/sms';
// 按项目替换为 ioredis / @upstash/redis 等
import { redis } from '@/lib/redis';
const codeKey = (sessionId: string) => `sms:code:${sessionId}`;
const phoneRateKey = (phone: string) => `sms:rate:phone:${phone}`;
const ipRateKey = (ip: string) => `sms:rate:ip:${ip}`;
export class SmsRateLimitError extends Error {
constructor() {
super('发送太频繁请60秒后再试');
this.name = 'SmsRateLimitError';
}
}
export class SmsSendError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.name = 'SmsSendError';
this.code = code;
}
}
export async function sendVerifyCode(params: {
phone: string;
lang?: SmsLang;
clientIp: string;
}): Promise<{ sessionId: string }> {
const { phone, lang, clientIp } = params;
const [phoneLimited, ipLimited] = await Promise.all([
redis.exists(phoneRateKey(phone)),
redis.exists(ipRateKey(clientIp)),
]);
if (phoneLimited || ipLimited) throw new SmsRateLimitError();
const code = generateSixDigitCode();
const sessionId = randomUUID();
const msg = renderVerifySms(lang, code);
const result = await sendChuanglanSms(phone, msg, sessionId);
if (!result.success) {
throw new SmsSendError(result.code, result.message);
}
await Promise.all([
redis.set(codeKey(sessionId), JSON.stringify({ phone, code }), 'EX', smsConfig.codeTtlSeconds),
redis.set(phoneRateKey(phone), '1', 'EX', smsConfig.rateLimitSeconds),
redis.set(ipRateKey(clientIp), '1', 'EX', smsConfig.rateLimitSeconds),
]);
return { sessionId };
}
export async function verifyCode(params: {
phone: string;
code: string;
sessionId: string;
}): Promise<void> {
const raw = await redis.get(codeKey(params.sessionId));
if (!raw) throw new Error('验证码已过期');
const cached = JSON.parse(raw) as { phone: string; code: string };
if (cached.phone !== params.phone || cached.code !== params.code) {
throw new Error('验证码错误');
}
await redis.del(codeKey(params.sessionId));
}
```
---
## 7. API RouteNext.js 示例)
### 7.1 发送验证码
```typescript
// src/app/api/sms/send/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendVerifyCode, SmsRateLimitError, SmsSendError } from '@/lib/sms/service';
import type { SendSmsCodeRequest } from '@/types/sms';
function getClientIp(req: NextRequest): string {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| req.headers.get('x-real-ip')
|| '0.0.0.0'
);
}
export async function POST(req: NextRequest) {
const body = (await req.json()) as SendSmsCodeRequest;
if (!body.phone?.trim()) {
return NextResponse.json({ message: 'phone 必填' }, { status: 400 });
}
try {
const { sessionId } = await sendVerifyCode({
phone: body.phone.trim(),
lang: body.lang,
clientIp: getClientIp(req),
});
return NextResponse.json({ sessionId });
} catch (err) {
if (err instanceof SmsRateLimitError) {
return NextResponse.json({ message: err.message }, { status: 429 });
}
if (err instanceof SmsSendError) {
return NextResponse.json({ message: err.message, code: err.code }, { status: 502 });
}
return NextResponse.json({ message: '服务器错误' }, { status: 500 });
}
}
```
### 7.2 校验验证码
```typescript
// src/app/api/sms/verify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyCode } from '@/lib/sms/service';
import type { VerifySmsCodeRequest } from '@/types/sms';
export async function POST(req: NextRequest) {
const body = (await req.json()) as VerifySmsCodeRequest;
if (!body.phone || !body.code || !body.sessionId) {
return NextResponse.json({ message: '参数不完整' }, { status: 400 });
}
try {
await verifyCode(body);
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : '校验失败';
return NextResponse.json({ message }, { status: 400 });
}
}
```
### 7.3 对外 API 契约
**发送**
```
POST /api/sms/send
Content-Type: application/json
{ "phone": "8613800138000", "lang": "zh" }
→ 200 { "sessionId": "uuid" }
→ 429 { "message": "发送太频繁请60秒后再试" }
→ 502 { "message": "...", "code": "创蓝错误码" }
```
**校验**
```
POST /api/sms/verify
Content-Type: application/json
{ "phone": "8613800138000", "code": "123456", "sessionId": "uuid" }
→ 200 { "ok": true }
→ 400 { "message": "验证码错误或已过期" }
```
---
## 8. 前端调用
### 8.1 API Client
```typescript
// src/lib/api/sms.ts
import type { SendSmsCodeResponse, SmsLang, VerifySmsCodeResponse } from '@/types/sms';
export async function sendSmsCode(phone: string, lang: SmsLang = 'zh'): Promise<string> {
const res = await fetch('/api/sms/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, lang }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.message ?? '发送失败');
return (json as SendSmsCodeResponse).sessionId;
}
export async function verifySmsCode(
phone: string,
code: string,
sessionId: string,
): Promise<void> {
const res = await fetch('/api/sms/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, code, sessionId }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.message ?? '校验失败');
void json as VerifySmsCodeResponse;
}
```
### 8.2 React Hook 示例
```typescript
// src/hooks/use-sms-code.ts
'use client';
import { useCallback, useRef, useState } from 'react';
import { sendSmsCode } from '@/lib/api/sms';
import type { SmsLang } from '@/types/sms';
const COOLDOWN_SECONDS = 60;
export function useSmsCode(lang: SmsLang = 'zh') {
const [sessionId, setSessionId] = useState<string | null>(null);
const [countdown, setCountdown] = useState(0);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startCountdown = useCallback(() => {
setCountdown(COOLDOWN_SECONDS);
timerRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
if (timerRef.current) clearInterval(timerRef.current);
return 0;
}
return prev - 1;
});
}, 1000);
}, []);
const send = useCallback(async (phone: string) => {
if (countdown > 0 || sending) return;
setSending(true);
setError(null);
try {
const id = await sendSmsCode(phone, lang);
setSessionId(id);
startCountdown();
} catch (err) {
setError(err instanceof Error ? err.message : '发送失败');
} finally {
setSending(false);
}
}, [countdown, sending, lang, startCountdown]);
return { sessionId, countdown, sending, error, send };
}
```
页面中使用:
```tsx
const { sessionId, countdown, sending, error, send } = useSmsCode('zh');
<button disabled={sending || countdown > 0} onClick={() => send(phone)}>
{countdown > 0 ? `${countdown}s 后重试` : '获取验证码'}
</button>
// 提交表单时带上 sessionId + code 调 /api/sms/verify 或合并进登录/注册接口
```
---
## 9. 手机号格式
- 国际短信建议带国家码:`8613800138000``86` + 11 位)
- 前端可在提交前统一格式化,或在 `service.ts` 中做 normalize
- 创蓝账号为国际网关(`sgap.253.com`),非中国大陆号段需确认创蓝侧已开通对应路由
---
## 10. 业务规则(与 babylive 对齐)
| 规则 | 值 |
|------|-----|
| 验证码位数 | 6 位数字 |
| 验证码有效期 | 5 分钟 |
| 同手机号冷却 | 60 秒 |
| 同 IP 冷却 | 60 秒 |
| 校验成功后 | 立即删除缓存(一次性) |
---
## 11. 签名自测
接入后用固定参数验证签名是否与 Java 端一致:
```typescript
import { generateChuanglanSign } from '@/lib/chuanglan/sign';
const sign = generateChuanglanSign('your_password', {
account: 'your_account',
mobile: '8613800138000',
msg: '您的验证码是123456。5分钟内有效。',
uid: 'test-session-001',
nonce: '1718000000123',
});
console.log(sign);
// 应与 Java SignUtil.generateSign 输出完全相同
```
检查清单:
- [ ] key 字典序排序
- [ ]`uid` 不参与签名
- [ ] `nonce` 在 Header + 签名参数,不在 Body
- [ ] MD5 32 位小写
- [ ] UTF-8 编码
---
## 12. 安全与运维
1. `CHUANGLAN_*` 仅服务端环境变量,不进 `NEXT_PUBLIC_*`
2. 日志中手机号脱敏、禁止打印验证码明文
3. 生产环境 Redis 必开;无 Redis 时不可用内存 MapServerless 多实例会失效)
4. `uid` / `sessionId` 建议用 UUID便于与创蓝 `messageId` 对账
5. 监控创蓝 `code` 分布与 `HTTP_ERROR` 比例
---
## 13. 接入步骤速查
```
1. 配置 .env.local创蓝账号 + Redis
2. 复制 lib/chuanglan/*sign + client
3. 复制 lib/sms/*templates + service
4. 添加 /api/sms/send 与 /api/sms/verify
5. 前端 useSmsCode + 表单提交携带 sessionId
6. 跑签名自测,发一条真实短信验证
```
新项目按此文档从零接入即可,**无需依赖 babylive-backend 运行时**;签名算法以该仓库 `ChuanglanClient.java` 为准。

View File

@@ -0,0 +1,269 @@
## 手动充值功能说明文档
---
### 一、功能概述
手动充值功能允许玩家通过银行转账或USDT方式进行充值管理员在后台审核并确认充值后系统自动为玩家上分。
**核心流程**
```
管理员配置收款方式(银行/USDT
玩家选择充值方式,输入金额,上传转账截图
生成充值订单状态PENDING 审核中)
管理员审核充值记录
├─ 批准 → 自动给玩家钱包上分(可调整金额)
└─ 拒绝 → 记录拒绝原因
```
---
### 二、数据库表结构
#### `payment_methods` — 收款方式配置表
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BigInt | 主键 |
| `method_type` | VARCHAR(20) | 类型:`BANK`(银行)/ `USDT` |
| `bank_name` | VARCHAR(128) | 银行名称BANK 类型使用) |
| `account_holder` | VARCHAR(128) | 银行账户名BANK 类型使用) |
| `account_number` | VARCHAR(128) | 银行账号BANK 类型使用) |
| `usdt_address` | VARCHAR(256) | USDT 地址USDT 类型使用) |
| `qr_code_url` | VARCHAR(500) | USDT 二维码图片 URLUSDT 类型使用) |
| `display_name` | VARCHAR(128) | 展示名称 |
| `sort_order` | INT | 排序序号,越小越靠前 |
| `is_active` | BOOLEAN | 是否启用(管理员可见性) |
| `show_on_player` | BOOLEAN | 是否对玩家展示 |
| `created_by` | BigInt | 创建者 ID |
| `created_at` / `updated_at` | DateTime | 创建/更新时间 |
#### `deposit_orders` — 充值订单表
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BigInt | 主键 |
| `order_no` | VARCHAR(64) | 订单号(唯一),格式:`DEP{timestamp}{random}` |
| `player_id` | BigInt | 玩家 ID |
| `payment_method_id` | BigInt | 关联的收款方式 ID |
| `method_type` | VARCHAR(20) | 冗余的类型BANK/USDT便于筛选 |
| `amount` | DECIMAL(18,4) | 玩家申报的充值金额 |
| `screenshot_url` | VARCHAR(500) | 转账截图 URL |
| `status` | VARCHAR(20) | 状态:`PENDING` / `APPROVED` / `REJECTED` |
| `approved_amount` | DECIMAL(18,4) | 实际批准的金额(批准时写入,可能与申报金额不同) |
| `reviewer_id` | BigInt | 审核人 ID |
| `reviewed_at` | DateTime | 审核时间 |
| `reject_reason` | VARCHAR(500) | 拒绝原因 |
| `remark` | VARCHAR(500) | 备注 |
| `created_at` / `updated_at` | DateTime | 创建/更新时间 |
---
### 三、管理后台操作
#### 3.1 配置收款方式
**入口**:管理后台左侧菜单 → 「收款方式」
支持两种类型:
**银行转账**
- 填写银行名称(如:工商银行)
- 填写账户持有人姓名
- 填写银行账号
- 设置展示名称(可选)
- 设置排序、是否启用、是否展示给玩家
**USDT 充值**
- 填写 USDT 收款地址
- 上传 USDT 二维码图片(通过后台媒体库上传,类别为 `payments`
- 设置展示名称(可选)
- 设置排序、是否启用、是否展示给玩家
**操作说明**
- `Active` 开关:控制该收款方式是否启用(管理员可见)
- `Show Player` 开关:控制是否在前台展示给玩家
- 一个收款方式可以同时启用但不展示给玩家(用于暂停充值)
- 支持创建多个同类型的收款方式
#### 3.2 审核充值订单
**入口**:管理后台左侧菜单 → 「充值审核」
**列表功能**
- 显示所有充值订单,包含:订单号、玩家、收款方式类型、金额、截图、状态、批准金额、审核人、时间
- 支持按状态筛选(全部/待审核/已批准/已拒绝)
- 支持按类型筛选(全部/银行/USDT
- 支持按玩家用户名搜索
- 点击截图缩略图可放大查看
**批准操作**
1. 点击订单行的「Approve」按钮
2. 在弹窗中查看截图
3. 系统默认填入玩家申报的金额,管理员可根据截图实际情况调整金额
4. 可选填写备注
5. 点击「Confirm Approve」确认
**批准后自动执行**
- 订单状态变为 `APPROVED`
- 系统自动调用钱包充值服务,将批准金额添加到玩家可用余额
- 生成一条钱包交易记录,类型为 `PLAYER_DEPOSIT`
- 记录审核人和审核时间
**拒绝操作**
1. 点击订单行的「Reject」按钮
2. 在弹窗中必须填写拒绝原因
3. 点击「Confirm Reject」确认
**拒绝后**
- 订单状态变为 `REJECTED`
- 记录拒绝原因和审核人
- 玩家不会收到任何资金
---
### 四、玩家端操作
#### 4.1 充值入口
- 玩家登录后,进入「账单」页面
- 顶部有一个「+ 充值」按钮,点击进入充值页面
- 或直接访问 `/wallet/recharge`
#### 4.2 充值流程
1. **选择充值方式**顶部有「银行转账」和「USDT」两个标签切换
2. **选择收款账户**:系统展示管理员配置的可用收款方式列表,点击选中
3. **查看收款信息**
- 银行转账:显示银行名称、账户持有人、银行账号(支持点击复制)
- USDT显示 USDT 地址(支持点击复制)和二维码图片
4. **输入充值金额**:输入实际转账的金额
5. **上传转账截图**
- 点击上传区域选择图片
- 前端自动压缩图片(目标大小 ≤1MB最大分辨率 1920px
- 原文件限制 10MB压缩后限制 5MB
- 支持预览和删除已选截图
6. **提交充值**:点击提交按钮,系统创建充值订单
7. **成功提示**:显示订单号,提示等待管理员审核
#### 4.3 查看充值记录
- 充值页面右上角有「记录」链接,或访问 `/wallet/recharge/history`
- 显示所有充值订单列表,每条订单显示:
- 收款方式类型标签
- 充值金额
- 状态(审核中/已通过/已拒绝)
- 收款方式名称、提交时间
- 如果批准金额与申报金额不同,显示「实际到账」金额
- 如果被拒绝,显示拒绝原因
- 支持下拉刷新
---
### 五、API 接口列表
#### 管理后台接口
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| `POST` | `/admin/payment-methods` | `deposit.manage` | 创建收款方式 |
| `GET` | `/admin/payment-methods` | `deposit.manage` | 查看所有收款方式 |
| `PUT` | `/admin/payment-methods/:id` | `deposit.manage` | 更新收款方式 |
| `DELETE` | `/admin/payment-methods/:id` | `deposit.manage` | 停用收款方式(软删除) |
| `GET` | `/admin/deposit-orders` | `deposit.review` | 分页查看充值订单 |
| `POST` | `/admin/deposit-orders/:id/approve` | `deposit.review` | 批准充值订单 |
| `POST` | `/admin/deposit-orders/:id/reject` | `deposit.review` | 拒绝充值订单 |
**充值订单列表查询参数**
- `page` — 页码(默认 1
- `pageSize` — 每页数量(默认 20最大 100
- `status` — 状态筛选:`PENDING` / `APPROVED` / `REJECTED`
- `keyword` — 玩家用户名关键词
- `methodType` — 类型筛选:`BANK` / `USDT`
- `dateFrom` / `dateTo` — 日期范围ISO 格式)
**批准充值订单请求体**
```json
{
"approvedAmount": 100.00, // 可选,不传则使用玩家申报金额
"remark": "备注信息" // 可选
}
```
**拒绝充值订单请求体**
```json
{
"reason": "截图金额与申报金额不符" // 必填
}
```
#### 玩家端接口
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| `GET` | `/player/payment-methods` | 需要 | 查看可用的收款方式列表 |
| `POST` | `/player/deposit-orders` | 需要 | 提交充值订单multipart/form-data |
| `GET` | `/player/deposit-orders` | 需要 | 查看自己的充值记录 |
**提交充值订单**multipart/form-data
- `paymentMethodId` — 收款方式 ID
- `amount` — 充值金额
- `screenshot` — 转账截图图片文件(最大 5MB仅接受图片类型
**查看收款方式请求参数**
- `methodType` — 可选,筛选类型:`BANK` / `USDT`
---
### 六、文件存储
充值相关截图存储在 `uploads/deposits/` 目录下,文件命名格式:`{timestamp}-{random8}.{ext}`
USDT 二维码图片通过管理员上传接口上传,存储在 `uploads/payments/` 目录下。
**上传大小限制**
- 管理员上传USDT 二维码5MB
- 玩家上传充值截图5MB前端压缩后原文件 10MB
---
### 七、错误码
| 错误码 | 说明 |
|--------|------|
| `INVALID_METHOD_TYPE` | 无效的收款方式类型 |
| `PAYMENT_METHOD_NOT_FOUND` | 收款方式不存在或已停用 |
| `ORDER_NOT_FOUND` | 充值订单不存在 |
| `ORDER_NOT_PENDING` | 订单不是待审核状态 |
| `REASON_REQUIRED` | 拒绝原因为必填项 |
| `SCREENSHOT_REQUIRED` | 转账截图为必填项 |
| `FILE_MUST_BE_IMAGE` | 必须上传图片文件 |
| `INVALID_AMOUNT` | 金额无效 |
| `PAYMENT_METHOD_REQUIRED` | 未选择收款方式 |
---
### 八、权限配置
新增两个后台权限码,需要在数据库中通过种子数据或手动添加到 `permissions` 表:
| 权限码 | 说明 |
|--------|------|
| `deposit.manage` | 管理收款方式 |
| `deposit.review` | 审核充值订单 |
并将这些权限分配给需要操作充值功能的管理员角色(通过 `role_permissions` 表关联)。
---
### 九、注意事项
1. **金额调整**:批准充值时,管理员可以调整实际充值金额。当玩家输入的金额与截图显示的不一致时,以截图为准进行调整。
2. **并发审核**:批准操作在数据库事务中执行,会检查订单状态是否为 `PENDING`,防止多人同时审核同一笔订单。
3. **停用收款方式**:删除操作为软删除(设置 `isActive=false``showOnPlayer=false`),不会删除历史记录。
4. **截图审核**:管理员应仔细核对截图中的转账金额、时间、收款账户是否与配置的收款方式一致。
5. **前端图片压缩**:使用 `browser-image-compression` 库在浏览器端压缩图片,减少上传时间和服务器存储压力。

View File

@@ -349,6 +349,46 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Username already taken',
'ms-MY': 'Nama pengguna sudah digunakan',
},
INVITE_CODE_REQUIRED: {
'zh-CN': '请填写邀请码',
'en-US': 'Invitation code is required',
'ms-MY': 'Kod jemputan diperlukan',
},
INVITE_CODE_INVALID: {
'zh-CN': '邀请码无效或已失效',
'en-US': 'Invalid or inactive invitation code',
'ms-MY': 'Kod jemputan tidak sah atau tidak aktif',
},
INVITE_CODE_NOT_AVAILABLE: {
'zh-CN': '该邀请码暂不可用于注册',
'en-US': 'This invitation code is not available for registration',
'ms-MY': 'Kod jemputan ini tidak tersedia untuk pendaftaran',
},
INVITE_NOT_FOUND: {
'zh-CN': '邀请码记录不存在',
'en-US': 'Invitation record not found',
'ms-MY': 'Rekod jemputan tidak dijumpai',
},
INVITE_MUST_REVOKE_FIRST: {
'zh-CN': '请先作废该邀请码后再删除',
'en-US': 'Revoke the invitation code before deleting',
'ms-MY': 'Batalkan kod jemputan dahulu sebelum padam',
},
INVITE_CODE_ALREADY_USED: {
'zh-CN': '该邀请码已被使用,每个邀请码仅可注册一名玩家',
'en-US': 'This invitation code has already been used; each code allows one registration only',
'ms-MY': 'Kod jemputan ini telah digunakan; setiap kod hanya untuk satu pendaftaran',
},
INVITE_CANNOT_DELETE_USED: {
'zh-CN': '已使用的邀请码不可删除',
'en-US': 'Used invitation codes cannot be deleted',
'ms-MY': 'Kod jemputan yang telah digunakan tidak boleh dipadam',
},
INVITE_CASHBACK_RATE_INVALID: {
'zh-CN': '返水比例无效,请输入非负数',
'en-US': 'Invalid cashback rate; must be a non-negative number',
'ms-MY': 'Kadar rebat tidak sah; mesti nombor bukan negatif',
},
USERNAME_FORMAT_INVALID: {
'zh-CN': '玩家用户名仅可使用英文字母和数字332 位),不可含中文或特殊符号',
'en-US': 'Username must be 332 letters or digits only',
@@ -699,6 +739,51 @@ export const API_ERROR_MESSAGES = {
'en-US': 'ACTIVE ticker requires body in at least one locale',
'ms-MY': 'Ticker aktif memerlukan kandungan',
},
INVALID_METHOD_TYPE: {
'zh-CN': '无效的收款方式类型',
'en-US': 'Invalid payment method type',
'ms-MY': 'Jenis kaedah pembayaran tidak sah',
},
PAYMENT_METHOD_NOT_FOUND: {
'zh-CN': '收款方式不存在或已停用',
'en-US': 'Payment method not found or inactive',
'ms-MY': 'Kaedah pembayaran tidak dijumpai atau tidak aktif',
},
ORDER_NOT_FOUND: {
'zh-CN': '充值订单不存在',
'en-US': 'Deposit order not found',
'ms-MY': 'Pesanan deposit tidak dijumpai',
},
ORDER_NOT_PENDING: {
'zh-CN': '订单已被审核或不是待审核状态',
'en-US': 'Order is not in pending status',
'ms-MY': 'Pesanan bukan dalam status menunggu',
},
REASON_REQUIRED: {
'zh-CN': '请填写拒绝原因',
'en-US': 'Rejection reason is required',
'ms-MY': 'Sebab penolakan diperlukan',
},
SCREENSHOT_REQUIRED: {
'zh-CN': '请上传转账截图',
'en-US': 'Screenshot is required',
'ms-MY': 'Screenshot diperlukan',
},
FILE_MUST_BE_IMAGE: {
'zh-CN': '请上传图片文件',
'en-US': 'File must be an image',
'ms-MY': 'Fail mesti imej',
},
INVALID_AMOUNT: {
'zh-CN': '金额无效',
'en-US': 'Invalid amount',
'ms-MY': 'Jumlah tidak sah',
},
PAYMENT_METHOD_REQUIRED: {
'zh-CN': '请选择收款方式',
'en-US': 'Payment method is required',
'ms-MY': 'Kaedah pembayaran diperlukan',
},
} as const satisfies Record<string, Record<Locale, string>>;
export type ApiErrorCode = keyof typeof API_ERROR_MESSAGES;

27
pnpm-lock.yaml generated
View File

@@ -159,6 +159,9 @@ importers:
axios:
specifier: ^1.7.9
version: 1.16.1
browser-image-compression:
specifier: ^2.0.2
version: 2.0.2
pinia:
specifier: ^2.3.1
version: 2.3.1(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))
@@ -171,6 +174,9 @@ importers:
vue-router:
specifier: ^4.5.0
version: 4.6.4(vue@3.5.35(typescript@5.7.3))
vue3-slide-verify:
specifier: ^1.1.8
version: 1.1.8(typescript@5.7.3)
devDependencies:
'@vitejs/plugin-vue':
specifier: ^5.2.1
@@ -1727,6 +1733,9 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browser-image-compression@2.0.2:
resolution: {integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -3559,6 +3568,9 @@ packages:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uzip@0.20201231.0:
resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==}
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@@ -3654,6 +3666,9 @@ packages:
peerDependencies:
typescript: '>=5.0.0'
vue3-slide-verify@1.1.8:
resolution: {integrity: sha512-qsGXM0w0pkPnI10p58neHQNAkMiGdX8ijGkvRo6eIYbFn3+6fQE05t8afdQsCY9llgG1chDrRD7H0XxV3Ts9Cw==}
vue@3.5.35:
resolution: {integrity: sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==}
peerDependencies:
@@ -5413,6 +5428,10 @@ snapshots:
dependencies:
fill-range: 7.1.1
browser-image-compression@2.0.2:
dependencies:
uzip: 0.20201231.0
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.33
@@ -7393,6 +7412,8 @@ snapshots:
uuid@11.1.1: {}
uzip@0.20201231.0: {}
v8-compile-cache-lib@3.0.1: {}
v8-to-istanbul@9.3.0:
@@ -7451,6 +7472,12 @@ snapshots:
'@vue/language-core': 2.2.0(typescript@5.7.3)
typescript: 5.7.3
vue3-slide-verify@1.1.8(typescript@5.7.3):
dependencies:
vue: 3.5.35(typescript@5.7.3)
transitivePeerDependencies:
- typescript
vue@3.5.35(typescript@5.7.3):
dependencies:
'@vue/compiler-dom': 3.5.35