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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user