feat: 手动充值、邀请码注册与后台管理增强
新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分), 支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏, 并补充前台注册、充值页及多语言错误码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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; }
|
||||
|
||||
24
apps/admin/src/components/AdminDetailGrid.vue
Normal file
24
apps/admin/src/components/AdminDetailGrid.vue
Normal 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>
|
||||
75
apps/admin/src/components/AdminDetailItem.vue
Normal file
75
apps/admin/src/components/AdminDetailItem.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 登录页:与玩家端金色主题一致 */
|
||||
|
||||
82
apps/admin/src/components/AdminPlayerRowActions.vue
Normal file
82
apps/admin/src/components/AdminPlayerRowActions.vue
Normal 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>
|
||||
55
apps/admin/src/components/AdminResponsiveRowActions.vue
Normal file
55
apps/admin/src/components/AdminResponsiveRowActions.vue
Normal 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>
|
||||
51
apps/admin/src/components/AdminRowActionsDropdown.vue
Normal file
51
apps/admin/src/components/AdminRowActionsDropdown.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
247
apps/admin/src/components/InviteCodePanel.vue
Normal file
247
apps/admin/src/components/InviteCodePanel.vue
Normal 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>
|
||||
507
apps/admin/src/components/InviteHistoryPanel.vue
Normal file
507
apps/admin/src/components/InviteHistoryPanel.vue
Normal 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>
|
||||
197
apps/admin/src/components/InviteManageDialog.vue
Normal file
197
apps/admin/src/components/InviteManageDialog.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
33
apps/admin/src/components/RatePercentInput.vue
Normal file
33
apps/admin/src/components/RatePercentInput.vue
Normal 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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,7 +153,9 @@ watch(() => route.path, () => {
|
||||
m.matchPrefix &&
|
||||
(m.path === '/'
|
||||
? isDashboardSectionPath(route.path)
|
||||
: isMatchesSectionPath(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 {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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));
|
||||
|
||||
26
apps/admin/src/utils/invite-link.ts
Normal file
26
apps/admin/src/utils/invite-link.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apps/admin/src/utils/rate-percent.ts
Normal file
19
apps/admin/src/utils/rate-percent.ts
Normal 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)}%`;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 '—';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
111
apps/admin/src/views/DepositManage.vue
Normal file
111
apps/admin/src/views/DepositManage.vue
Normal 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>
|
||||
331
apps/admin/src/views/DepositOrders.vue
Normal file
331
apps/admin/src/views/DepositOrders.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
380
apps/admin/src/views/PaymentMethods.vue
Normal file
380
apps/admin/src/views/PaymentMethods.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- ─── Top-level tabs ─── -->
|
||||
<el-tabs v-model="activeTab" class="portal-top-tabs">
|
||||
<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 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AgentPlayerRow {
|
||||
username: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
inviteCode?: string | null;
|
||||
wallet?: { availableBalance: string; frozenBalance?: string };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { FormValidationError } from '../i18n/form-validation';
|
||||
import { decimalRateToPercent } from '../utils/rate-percent';
|
||||
import { percentToDecimalRate } from '../utils/rate-percent';
|
||||
|
||||
/** 玩家用户名:仅英文字母与数字,3–32 位 */
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Per-invite cashback rate (admin can set when generating)
|
||||
ALTER TABLE "user_invites"
|
||||
ADD COLUMN "cashback_rate" DECIMAL(8, 4);
|
||||
@@ -17,9 +17,12 @@ model User {
|
||||
parentId BigInt? @map("parent_id")
|
||||
agentLevel Int? @map("agent_level")
|
||||
locale String @default("en-US") @db.VarChar(10)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
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")
|
||||
|
||||
auth UserAuth?
|
||||
wallet Wallet?
|
||||
@@ -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")
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
10
apps/api/src/domains/deposit/deposit.module.ts
Normal file
10
apps/api/src/domains/deposit/deposit.module.ts
Normal 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 {}
|
||||
440
apps/api/src/domains/deposit/deposit.service.ts
Normal file
440
apps/api/src/domains/deposit/deposit.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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');
|
||||
|
||||
401
apps/api/src/domains/identity/invites.service.ts
Normal file
401
apps/api/src/domains/identity/invites.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
|
||||
114
apps/api/src/shared/common/invite-code.util.ts
Normal file
114
apps/api/src/shared/common/invite-code.util.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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 closePopup() {
|
||||
showPopup.value = false;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
generateCode();
|
||||
input.value = '';
|
||||
drawCaptcha();
|
||||
function onSuccess() {
|
||||
validated.value = true;
|
||||
errorMsg.value = '';
|
||||
showPopup.value = false;
|
||||
}
|
||||
|
||||
function onFail() {
|
||||
validated.value = false;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
<p class="card-balance">{{ displayedBalance }}</p>
|
||||
<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);
|
||||
|
||||
208
apps/player/src/views/RechargeHistoryView.vue
Normal file
208
apps/player/src/views/RechargeHistoryView.vue
Normal 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>
|
||||
399
apps/player/src/views/RechargeView.vue
Normal file
399
apps/player/src/views/RechargeView.vue
Normal 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>
|
||||
161
apps/player/src/views/RegisterView.vue
Normal file
161
apps/player/src/views/RegisterView.vue
Normal 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>
|
||||
695
docs/chuanglan-sms-js-guide.md
Normal file
695
docs/chuanglan-sms-js-guide.md
Normal file
@@ -0,0 +1,695 @@
|
||||
# 创蓝短信(Chuanglan)TypeScript 全栈接入指南
|
||||
|
||||
> 适用场景:**全新独立项目**,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 业务 Service(Redis)
|
||||
|
||||
```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 Route(Next.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 时不可用内存 Map(Serverless 多实例会失效)
|
||||
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` 为准。
|
||||
269
docs/手动充值功能说明.md
Normal file
269
docs/手动充值功能说明.md
Normal 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 二维码图片 URL(USDT 类型使用) |
|
||||
| `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` 库在浏览器端压缩图片,减少上传时间和服务器存储压力。
|
||||
@@ -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': '玩家用户名仅可使用英文字母和数字(3–32 位),不可含中文或特殊符号',
|
||||
'en-US': 'Username must be 3–32 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
27
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user