重构
This commit is contained in:
@@ -907,4 +907,956 @@ body {
|
||||
.el-time-panel { background: #1c1c1c !important; border-color: #333 !important; }
|
||||
.el-time-spinner__item { color: #aaa !important; }
|
||||
.el-time-spinner__item.is-active:not(.is-disabled) { color: #fff !important; }
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Admin light refresh
|
||||
The legacy admin theme was built as one dark surface. These
|
||||
overrides intentionally keep the current component system while
|
||||
giving every page clearer hierarchy, calmer colors and stronger
|
||||
table/form affordance.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--accent: #1f2320;
|
||||
--accent-muted: #5f5b53;
|
||||
--accent-dim: #8c867c;
|
||||
--accent-subtle: #f0ede6;
|
||||
--accent-border: #ded8cc;
|
||||
--accent-hover: #f5f2eb;
|
||||
--accent-focus: rgba(31, 35, 32, 0.16);
|
||||
|
||||
--success-text: #346538;
|
||||
--success-bg: #edf3ec;
|
||||
--success-border: #d6e3d4;
|
||||
--warning-text: #956400;
|
||||
--warning-bg: #fbf3db;
|
||||
--warning-border: #eadcad;
|
||||
--danger-text: #9f2f2d;
|
||||
--danger-bg: #fdebec;
|
||||
--danger-border: #f3c5c7;
|
||||
--info-text: #1f6c9f;
|
||||
--info-bg: #e1f3fe;
|
||||
--info-border: #c7e4f6;
|
||||
|
||||
--gold-deep: var(--warning-text);
|
||||
--gold-mid: #b88928;
|
||||
--gold-bright: #8f6a18;
|
||||
--gold-glow: #8f6a18;
|
||||
--gold-surface: var(--warning-bg);
|
||||
--gold-border: var(--warning-border);
|
||||
--gold-text: var(--warning-text);
|
||||
--green-deep: var(--success-text);
|
||||
--green-mid: #477a4c;
|
||||
--green-bright: #346538;
|
||||
--green-glow: #346538;
|
||||
--green-surface: var(--success-bg);
|
||||
--green-border: var(--success-border);
|
||||
--green-text: var(--success-text);
|
||||
|
||||
--primary: #1f2320;
|
||||
--primary-dark: #111411;
|
||||
--primary-light: #363a35;
|
||||
--primary-link: #1f6c9f;
|
||||
--primary-on: #ffffff;
|
||||
--primary-grad: #1f2320;
|
||||
--primary-grad-hover: #363a35;
|
||||
--primary-shadow: none;
|
||||
|
||||
--bg-body: #f7f6f3;
|
||||
--bg-card: #ffffff;
|
||||
--bg-elevated: #fbfbfa;
|
||||
--bg-hover: #f1eee8;
|
||||
--text: #1f2320;
|
||||
--text-muted: #78746b;
|
||||
--border: #e7e2d8;
|
||||
--border-soft: #f0ece4;
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--shadow: 0 8px 28px rgba(56, 49, 37, 0.05);
|
||||
|
||||
--el-bg-color: #ffffff;
|
||||
--el-bg-color-page: #f7f6f3;
|
||||
--el-bg-color-overlay: #ffffff;
|
||||
--el-text-color-primary: #1f2320;
|
||||
--el-text-color-regular: #3d413b;
|
||||
--el-text-color-secondary: #78746b;
|
||||
--el-text-color-placeholder:#aaa49a;
|
||||
--el-border-color: #e7e2d8;
|
||||
--el-border-color-light: #eee9df;
|
||||
--el-border-color-lighter: #f4f0e8;
|
||||
--el-fill-color: #f8f6f1;
|
||||
--el-fill-color-blank: #ffffff;
|
||||
--el-fill-color-light: #fbfaf7;
|
||||
--el-color-primary: #1f2320;
|
||||
--el-color-primary-light-3: #5d635c;
|
||||
--el-color-primary-light-5: #8d938b;
|
||||
--el-color-primary-light-7: #d9ddd6;
|
||||
--el-color-primary-light-9: #f4f6f2;
|
||||
--el-color-primary-dark-2: #111411;
|
||||
--el-color-success: var(--success-text);
|
||||
--el-color-success-light-9: var(--success-bg);
|
||||
--el-color-warning: var(--warning-text);
|
||||
--el-color-warning-light-9: var(--warning-bg);
|
||||
--el-color-danger: var(--danger-text);
|
||||
--el-color-danger-light-9: var(--danger-bg);
|
||||
--el-color-info: var(--info-text);
|
||||
--el-color-info-light-9: var(--info-bg);
|
||||
--el-table-bg-color: #ffffff;
|
||||
--el-table-tr-bg-color: #ffffff;
|
||||
--el-table-header-bg-color: #fbfaf7;
|
||||
--el-table-row-hover-bg-color: #f7f4ee;
|
||||
--el-table-border-color: #eee9df;
|
||||
--el-table-text-color: #363a35;
|
||||
--el-table-header-text-color: #6c675f;
|
||||
--el-card-bg-color: #ffffff;
|
||||
--el-card-border-color: #e7e2d8;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
background: var(--bg-body) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'SF Pro Display', 'Geist Sans', 'Helvetica Neue', 'PingFang SC',
|
||||
'Microsoft YaHei', Arial, sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid rgba(31, 35, 32, 0.28);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d5cfc3;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #bdb6a9;
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-color: #d5cfc3 transparent;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-link);
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: var(--radius) !important;
|
||||
background: var(--bg-card) !important;
|
||||
box-shadow: var(--shadow) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid var(--border-soft) !important;
|
||||
padding: 14px 18px !important;
|
||||
}
|
||||
|
||||
.el-card__body {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
border-radius: var(--radius) !important;
|
||||
background: #ffffff !important;
|
||||
color: #363a35 !important;
|
||||
font-size: 13px;
|
||||
--el-table-border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.el-table::before,
|
||||
.el-table__inner-wrapper::before {
|
||||
background: var(--border-soft) !important;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
height: 42px;
|
||||
background: #fbfaf7 !important;
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
color: #6c675f !important;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.el-table td.el-table__cell {
|
||||
border-bottom-color: var(--border-soft) !important;
|
||||
color: #363a35 !important;
|
||||
}
|
||||
|
||||
.el-table--striped .el-table__body tr.el-table__row--striped td {
|
||||
background: #fbfaf7 !important;
|
||||
}
|
||||
|
||||
.el-table__body tr:hover > td {
|
||||
background: #f7f4ee !important;
|
||||
}
|
||||
|
||||
.el-table .cell {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.el-table__empty-block {
|
||||
min-height: 168px;
|
||||
}
|
||||
|
||||
.el-table__empty-text {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.el-table__fixed-right,
|
||||
.el-table__fixed {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.el-table__fixed-right::before,
|
||||
.el-table__fixed::before {
|
||||
background: var(--border-soft) !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-input-number .el-input__wrapper,
|
||||
.el-date-editor .el-input__wrapper {
|
||||
min-height: 34px;
|
||||
border-radius: 7px !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 0 0 1px var(--border) inset !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover,
|
||||
.el-select__wrapper:hover,
|
||||
.el-textarea__inner:hover {
|
||||
box-shadow: 0 0 0 1px #d5cfc3 inset !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus,
|
||||
.el-select__wrapper.is-focused,
|
||||
.el-textarea__inner:focus {
|
||||
box-shadow: 0 0 0 1px #1f2320 inset, 0 0 0 3px rgba(31, 35, 32, 0.08) !important;
|
||||
}
|
||||
|
||||
.el-input-number {
|
||||
--el-input-number-controls-height: 15px;
|
||||
}
|
||||
|
||||
.el-input-number .el-input__wrapper {
|
||||
padding-right: 32px !important;
|
||||
}
|
||||
|
||||
.el-input-number .el-input__inner {
|
||||
padding: 0 4px !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.el-input-number.is-controls-right .el-input__wrapper {
|
||||
padding-left: 8px !important;
|
||||
padding-right: 32px !important;
|
||||
}
|
||||
|
||||
.el-input-number--small.is-controls-right .el-input__wrapper {
|
||||
padding-right: 28px !important;
|
||||
}
|
||||
|
||||
.el-input-number__increase,
|
||||
.el-input-number__decrease {
|
||||
background: #fbfaf7 !important;
|
||||
border-color: var(--border) !important;
|
||||
color: #6c675f !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.el-input-number__increase:hover,
|
||||
.el-input-number__decrease:hover {
|
||||
background: var(--accent-hover) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-input-number.is-controls-right .el-input-number__increase {
|
||||
top: 1px !important;
|
||||
right: 1px !important;
|
||||
width: 30px !important;
|
||||
height: calc((100% - 2px) / 2) !important;
|
||||
border-left: 1px solid var(--border) !important;
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
border-radius: 0 7px 0 0 !important;
|
||||
}
|
||||
|
||||
.el-input-number.is-controls-right .el-input-number__decrease {
|
||||
right: 1px !important;
|
||||
bottom: 1px !important;
|
||||
width: 30px !important;
|
||||
height: calc((100% - 2px) / 2) !important;
|
||||
border-left: 1px solid var(--border) !important;
|
||||
border-radius: 0 0 7px 0 !important;
|
||||
}
|
||||
|
||||
.el-input-number--small.is-controls-right .el-input-number__increase,
|
||||
.el-input-number--small.is-controls-right .el-input-number__decrease {
|
||||
width: 28px !important;
|
||||
}
|
||||
|
||||
.el-input-number__increase,
|
||||
.el-input-number__decrease {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.el-input-number:not(.is-controls-right) .el-input-number__decrease {
|
||||
border-right: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.el-input-number:not(.is-controls-right) .el-input-number__increase {
|
||||
border-left: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.el-input-number__increase.is-disabled,
|
||||
.el-input-number__decrease.is-disabled {
|
||||
background: #f4f0e8 !important;
|
||||
color: #b6afa3 !important;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
input[type='number']::-webkit-outer-spin-button,
|
||||
input[type='number']::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.el-input__inner,
|
||||
.el-select__placeholder,
|
||||
.el-date-editor .el-input__inner,
|
||||
.el-textarea__inner {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-input__inner::placeholder,
|
||||
.el-textarea__inner::placeholder {
|
||||
color: #aaa49a !important;
|
||||
}
|
||||
|
||||
.el-input.is-disabled .el-input__wrapper,
|
||||
.el-select.is-disabled .el-select__wrapper,
|
||||
.el-textarea.is-disabled .el-textarea__inner {
|
||||
background: #f4f0e8 !important;
|
||||
box-shadow: 0 0 0 1px #ebe5da inset !important;
|
||||
}
|
||||
|
||||
.el-input.is-disabled .el-input__inner,
|
||||
.el-select.is-disabled .el-select__placeholder {
|
||||
color: #aaa49a !important;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0 1000px #ffffff inset !important;
|
||||
-webkit-text-fill-color: var(--text) !important;
|
||||
caret-color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
color: #6c675f !important;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.el-form-item__error {
|
||||
color: var(--danger-text) !important;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: 7px !important;
|
||||
border-color: var(--border) !important;
|
||||
background: #ffffff !important;
|
||||
color: var(--text) !important;
|
||||
font-weight: 650 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.el-button:hover,
|
||||
.el-button:focus {
|
||||
background: var(--accent-hover) !important;
|
||||
border-color: #d5cfc3 !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.el-button--primary:hover,
|
||||
.el-button--primary:focus {
|
||||
background: var(--primary-light) !important;
|
||||
border-color: var(--primary-light) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.el-button--primary.is-plain,
|
||||
.el-button--info.is-plain {
|
||||
background: var(--info-bg) !important;
|
||||
border-color: var(--info-border) !important;
|
||||
color: var(--info-text) !important;
|
||||
}
|
||||
|
||||
.el-button--primary.is-plain:hover,
|
||||
.el-button--primary.is-plain:focus,
|
||||
.el-button--info.is-plain:hover,
|
||||
.el-button--info.is-plain:focus {
|
||||
background: #d5ecfb !important;
|
||||
border-color: #9fd0ed !important;
|
||||
color: #155b86 !important;
|
||||
}
|
||||
|
||||
.el-button--success {
|
||||
background: var(--success-text) !important;
|
||||
border-color: var(--success-text) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.el-button--success.is-plain {
|
||||
background: var(--success-bg) !important;
|
||||
border-color: var(--success-border) !important;
|
||||
color: var(--success-text) !important;
|
||||
}
|
||||
|
||||
.el-button--success.is-plain:hover,
|
||||
.el-button--success.is-plain:focus {
|
||||
background: #dcebd9 !important;
|
||||
border-color: #bad0b8 !important;
|
||||
color: #2f5d34 !important;
|
||||
}
|
||||
|
||||
.el-button--warning {
|
||||
background: var(--warning-text) !important;
|
||||
border-color: var(--warning-text) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.el-button--warning.is-plain {
|
||||
background: var(--warning-bg) !important;
|
||||
border-color: var(--warning-border) !important;
|
||||
color: var(--warning-text) !important;
|
||||
}
|
||||
|
||||
.el-button--warning.is-plain:hover,
|
||||
.el-button--warning.is-plain:focus {
|
||||
background: #f3e6bd !important;
|
||||
border-color: #d4bd75 !important;
|
||||
color: #7f5500 !important;
|
||||
}
|
||||
|
||||
.el-button--danger {
|
||||
background: var(--danger-text) !important;
|
||||
border-color: var(--danger-text) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.el-button--danger.is-plain {
|
||||
background: var(--danger-bg) !important;
|
||||
border-color: var(--danger-border) !important;
|
||||
color: var(--danger-text) !important;
|
||||
}
|
||||
|
||||
.el-button--danger.is-plain:hover,
|
||||
.el-button--danger.is-plain:focus {
|
||||
background: #f8d9dc !important;
|
||||
border-color: #e3a8ac !important;
|
||||
color: #842827 !important;
|
||||
}
|
||||
|
||||
.el-button--info {
|
||||
background: #eef1ec !important;
|
||||
border-color: #d9ddd6 !important;
|
||||
color: #4d524b !important;
|
||||
}
|
||||
|
||||
.el-button.is-link,
|
||||
.el-button--primary.is-link,
|
||||
.el-button--success.is-link,
|
||||
.el-button--warning.is-link,
|
||||
.el-button--danger.is-link {
|
||||
min-height: 0;
|
||||
padding: 0 4px !important;
|
||||
border-color: transparent !important;
|
||||
background: transparent !important;
|
||||
color: var(--primary-link) !important;
|
||||
}
|
||||
|
||||
.el-button--danger.is-link {
|
||||
color: var(--danger-text) !important;
|
||||
}
|
||||
|
||||
.el-button--warning.is-link {
|
||||
color: var(--warning-text) !important;
|
||||
}
|
||||
|
||||
.el-button.is-disabled,
|
||||
.el-button.is-disabled:hover {
|
||||
background: #f4f0e8 !important;
|
||||
border-color: #ebe5da !important;
|
||||
color: #aaa49a !important;
|
||||
}
|
||||
|
||||
.el-button.is-plain.is-disabled,
|
||||
.el-button.is-plain.is-disabled:hover,
|
||||
.el-button.is-plain.is-disabled:focus,
|
||||
.el-button.is-plain:disabled,
|
||||
.el-button--primary.is-plain.is-disabled,
|
||||
.el-button--primary.is-plain.is-disabled:hover,
|
||||
.el-button--primary.is-plain:disabled,
|
||||
.el-button--success.is-plain.is-disabled,
|
||||
.el-button--success.is-plain.is-disabled:hover,
|
||||
.el-button--success.is-plain:disabled,
|
||||
.el-button--warning.is-plain.is-disabled,
|
||||
.el-button--warning.is-plain.is-disabled:hover,
|
||||
.el-button--warning.is-plain:disabled,
|
||||
.el-button--danger.is-plain.is-disabled,
|
||||
.el-button--danger.is-plain.is-disabled:hover,
|
||||
.el-button--danger.is-plain:disabled {
|
||||
background: #f4f0e8 !important;
|
||||
border-color: #e3dcca !important;
|
||||
color: #aaa49a !important;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: 999px !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
|
||||
.el-tag--success {
|
||||
background: var(--success-bg) !important;
|
||||
border-color: var(--success-border) !important;
|
||||
color: var(--success-text) !important;
|
||||
}
|
||||
|
||||
.el-tag--warning {
|
||||
background: var(--warning-bg) !important;
|
||||
border-color: var(--warning-border) !important;
|
||||
color: var(--warning-text) !important;
|
||||
}
|
||||
|
||||
.el-tag--danger {
|
||||
background: var(--danger-bg) !important;
|
||||
border-color: var(--danger-border) !important;
|
||||
color: var(--danger-text) !important;
|
||||
}
|
||||
|
||||
.el-tag--info {
|
||||
background: #eef1ec !important;
|
||||
border-color: #d9ddd6 !important;
|
||||
color: #5d635c !important;
|
||||
}
|
||||
|
||||
.el-tag--primary {
|
||||
background: var(--accent-subtle) !important;
|
||||
border-color: var(--accent-border) !important;
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
|
||||
.el-pagination {
|
||||
--el-pagination-bg-color: #ffffff;
|
||||
--el-pagination-button-bg-color: #ffffff;
|
||||
--el-pagination-hover-color: #1f2320;
|
||||
--el-pagination-text-color: #6c675f;
|
||||
--el-pagination-button-disabled-bg-color: #f4f0e8;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.el-pagination .btn-prev,
|
||||
.el-pagination .btn-next,
|
||||
.el-pager li {
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 7px !important;
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.el-pager li.is-active {
|
||||
background: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.el-dropdown__popper,
|
||||
.el-select__popper,
|
||||
.el-popover.el-popper {
|
||||
border: 1px solid var(--border) !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 18px 44px rgba(56, 49, 37, 0.12) !important;
|
||||
}
|
||||
|
||||
.el-dropdown-menu,
|
||||
.el-select-dropdown {
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item,
|
||||
.el-select-dropdown__item {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item:hover,
|
||||
.el-select-dropdown__item.hover,
|
||||
.el-select-dropdown__item:hover {
|
||||
background: var(--accent-hover) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item.is-selected {
|
||||
color: var(--text) !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.el-dialog,
|
||||
.el-message-box {
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 10px !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 24px 64px rgba(56, 49, 37, 0.14) !important;
|
||||
}
|
||||
|
||||
.el-dialog__header,
|
||||
.el-message-box__header {
|
||||
border-bottom: 1px solid var(--border-soft) !important;
|
||||
}
|
||||
|
||||
.el-dialog__title,
|
||||
.el-message-box__title {
|
||||
color: var(--text) !important;
|
||||
font-weight: 750 !important;
|
||||
}
|
||||
|
||||
.el-dialog__body,
|
||||
.el-message-box__content {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-dialog__footer,
|
||||
.el-message-box__btns {
|
||||
border-top: 1px solid var(--border-soft) !important;
|
||||
}
|
||||
|
||||
.el-overlay {
|
||||
background: rgba(31, 35, 32, 0.22) !important;
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.el-loading-mask {
|
||||
background: rgba(247, 246, 243, 0.78) !important;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.el-loading-spinner .path {
|
||||
stroke: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-loading-spinner .el-loading-text {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.el-message {
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 8px !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 12px 30px rgba(56, 49, 37, 0.12) !important;
|
||||
}
|
||||
|
||||
.el-alert {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.el-tabs__item {
|
||||
color: var(--text-muted) !important;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.el-tabs__item.is-active,
|
||||
.el-tabs__item:hover {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-tabs__active-bar {
|
||||
background: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-tabs__nav-wrap::after {
|
||||
background: var(--border-soft) !important;
|
||||
}
|
||||
|
||||
.el-statistic__head {
|
||||
color: var(--text-muted) !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 650 !important;
|
||||
letter-spacing: 0 !important;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.el-statistic__content .el-statistic__number {
|
||||
color: var(--text) !important;
|
||||
font-size: 24px !important;
|
||||
font-weight: 750 !important;
|
||||
}
|
||||
|
||||
.el-picker-panel,
|
||||
.el-time-panel {
|
||||
background: #ffffff !important;
|
||||
border-color: var(--border) !important;
|
||||
color: var(--text) !important;
|
||||
box-shadow: 0 18px 44px rgba(56, 49, 37, 0.12) !important;
|
||||
}
|
||||
|
||||
.el-picker-panel__footer {
|
||||
background: #fbfaf7 !important;
|
||||
border-top-color: var(--border-soft) !important;
|
||||
}
|
||||
|
||||
.el-date-picker__header-label,
|
||||
.el-date-table th,
|
||||
.el-date-table td .el-date-table-cell__text,
|
||||
.el-time-spinner__item {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-date-table td.available:hover {
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-date-table td.current:not(.disabled) .el-date-table-cell__text {
|
||||
background: var(--primary) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.el-time-spinner__item.is-active:not(.is-disabled) {
|
||||
color: var(--primary) !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-list-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.list-chrome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.list-toolbar,
|
||||
.filter-bar,
|
||||
.filters,
|
||||
.list-panel-toolbar,
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.list-toolbar + .table-wrap,
|
||||
.filter-bar + .table-wrap,
|
||||
.filters + .table-wrap,
|
||||
.list-panel-toolbar + .table-wrap {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.table-wrap,
|
||||
.admin-table-wrap,
|
||||
.table-panel,
|
||||
.list-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-chrome > .table-wrap,
|
||||
.list-chrome > .admin-table-wrap,
|
||||
.list-chrome > .table-panel,
|
||||
.list-chrome > .list-panel {
|
||||
border-color: var(--border-soft);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.table-scroll,
|
||||
.admin-table-scroll,
|
||||
.el-table__body-wrapper {
|
||||
scrollbar-color: #d5cfc3 transparent;
|
||||
}
|
||||
|
||||
.pager,
|
||||
.list-pager,
|
||||
.table-pager,
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.muted,
|
||||
.hint,
|
||||
.subtext,
|
||||
.desc,
|
||||
.table-hint,
|
||||
.section-hint {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.danger-text,
|
||||
.c-red {
|
||||
color: var(--danger-text) !important;
|
||||
}
|
||||
|
||||
.success-text,
|
||||
.c-green {
|
||||
color: var(--success-text) !important;
|
||||
}
|
||||
|
||||
.warning-text,
|
||||
.c-gold,
|
||||
.c-yellow {
|
||||
color: var(--warning-text) !important;
|
||||
}
|
||||
|
||||
.admin-page-state,
|
||||
.state-card,
|
||||
.empty-state,
|
||||
.error-state {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.error-state,
|
||||
.state-card.is-error {
|
||||
background: var(--danger-bg);
|
||||
border-color: var(--danger-border);
|
||||
}
|
||||
|
||||
.error-state .state-title,
|
||||
.state-card.is-error .state-title {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.user-edit-dialog .edit-stats-panel,
|
||||
.agent-edit-dialog .edit-stats-panel,
|
||||
.detail-actions {
|
||||
border-top-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.user-edit-dialog .password-field-label,
|
||||
.agent-edit-dialog .password-field-label,
|
||||
.user-edit-dialog .password-empty,
|
||||
.agent-edit-dialog .password-empty {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.user-edit-dialog .password-plain,
|
||||
.agent-edit-dialog .password-plain {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.admin-list-page {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.list-chrome {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.list-toolbar,
|
||||
.filter-bar,
|
||||
.filters,
|
||||
.list-panel-toolbar,
|
||||
.table-toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.list-toolbar > *,
|
||||
.filter-bar > *,
|
||||
.filters > *,
|
||||
.list-panel-toolbar > *,
|
||||
.table-toolbar > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.el-input,
|
||||
.el-select,
|
||||
.el-date-editor,
|
||||
.el-button {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pager,
|
||||
.list-pager,
|
||||
.table-pager,
|
||||
.pagination-bar {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.el-pagination {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ADMIN_LOCALE_STORAGE_KEY } from './i18n';
|
||||
|
||||
const api = axios.create({ baseURL: '/api' });
|
||||
|
||||
let handling401 = false;
|
||||
let handlingAuthInvalid = false;
|
||||
let handling403Portal = false;
|
||||
|
||||
const PORTAL_MISMATCH_CODES = new Set(['ADMIN_ACCESS_ONLY', 'AGENT_ACCESS_ONLY']);
|
||||
@@ -17,6 +17,33 @@ function requestPath(config: { url?: string; baseURL?: string } | undefined): st
|
||||
return `${base}${config.url}`;
|
||||
}
|
||||
|
||||
function isLoginRequest(config: { url?: string; baseURL?: string } | undefined): boolean {
|
||||
return requestPath(config).includes('/auth/login');
|
||||
}
|
||||
|
||||
function isInvalidCredentialsResponse(data: unknown): boolean {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'code' in data &&
|
||||
(data as { code?: unknown }).code === 'INVALID_CREDENTIALS'
|
||||
);
|
||||
}
|
||||
|
||||
async function redirectToLoginForInvalidSession() {
|
||||
if (handlingAuthInvalid) return;
|
||||
handlingAuthInvalid = true;
|
||||
try {
|
||||
clearStaffSession();
|
||||
resetStaffSessionHydration();
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
await router.replace('/login');
|
||||
}
|
||||
} finally {
|
||||
handlingAuthInvalid = false;
|
||||
}
|
||||
}
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const t = localStorage.getItem('manage_token');
|
||||
if (t) config.headers.Authorization = `Bearer ${t}`;
|
||||
@@ -49,7 +76,18 @@ api.interceptors.request.use((config) => {
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (res) => {
|
||||
if (!isLoginRequest(res.config) && isInvalidCredentialsResponse(res.data)) {
|
||||
await redirectToLoginForInvalidSession();
|
||||
return Promise.reject(
|
||||
Object.assign(new Error((res.data as { error?: string }).error || 'Invalid credentials'), {
|
||||
response: res,
|
||||
config: res.config,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
async (err) => {
|
||||
if (err.isPortalMismatch) {
|
||||
await ensureStaffSession();
|
||||
@@ -59,14 +97,11 @@ api.interceptors.response.use(
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
if (err.response?.status === 401 && !handling401) {
|
||||
handling401 = true;
|
||||
clearStaffSession();
|
||||
resetStaffSessionHydration();
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
await router.replace('/login');
|
||||
}
|
||||
handling401 = false;
|
||||
if (
|
||||
!isLoginRequest(err.config) &&
|
||||
(err.response?.status === 401 || isInvalidCredentialsResponse(err.response?.data))
|
||||
) {
|
||||
await redirectToLoginForInvalidSession();
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -44,15 +44,15 @@ defineProps<{
|
||||
|
||||
.admin-detail-item__label {
|
||||
font-size: 12px;
|
||||
color: #737373;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-detail-item__value {
|
||||
font-size: 13px;
|
||||
color: #f5f5f5;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
@@ -63,7 +63,7 @@ defineProps<{
|
||||
display: inline;
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
color: #737373;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,6 @@ const src = computed(() => `/flags/${countryCode.value}.svg`);
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 0 0 1px var(--border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -174,4 +174,47 @@ onUnmounted(() => document.removeEventListener('click', onDocClick));
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
color: #f0d875;
|
||||
}
|
||||
|
||||
/* Light admin refresh */
|
||||
.admin-locale-trigger {
|
||||
min-height: 32px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.admin-locale-menu {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 18px 44px rgba(56, 49, 37, 0.12);
|
||||
}
|
||||
|
||||
.admin-locale.admin .admin-locale-trigger,
|
||||
.admin-locale.gold .admin-locale-trigger {
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.admin-locale.admin .admin-locale-trigger:hover,
|
||||
.admin-locale.gold .admin-locale-trigger:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: #d5cfc3;
|
||||
}
|
||||
|
||||
.admin-locale.admin .admin-locale-menu,
|
||||
.admin-locale.gold .admin-locale-menu {
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.admin-locale.admin .admin-locale-option,
|
||||
.admin-locale.gold .admin-locale-option {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-locale.admin .admin-locale-option:hover,
|
||||
.admin-locale.admin .admin-locale-option.active,
|
||||
.admin-locale.gold .admin-locale-option:hover,
|
||||
.admin-locale.gold .admin-locale-option.active {
|
||||
background: var(--accent-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -101,18 +101,28 @@ onBeforeUnmount(() => {
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px 6px;
|
||||
gap: 4px 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.action-btns :deep(.el-button) {
|
||||
margin: 0;
|
||||
padding: 1px 4px;
|
||||
height: auto;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
padding: 0 6px !important;
|
||||
min-height: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px !important;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btns :deep(.el-button.is-link) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.admin-responsive-actions.is-menu {
|
||||
min-width: 72px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,21 @@ const { t } = useAdminLocale();
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-row-actions :deep(.el-button) {
|
||||
min-height: 26px;
|
||||
padding: 0 9px !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 7px !important;
|
||||
background: #ffffff !important;
|
||||
color: var(--text) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
.admin-row-actions :deep(.el-button:hover) {
|
||||
background: var(--accent-hover) !important;
|
||||
border-color: #d5cfc3 !important;
|
||||
}
|
||||
|
||||
.admin-row-actions__caret {
|
||||
margin-left: 3px;
|
||||
font-size: 11px;
|
||||
@@ -36,7 +51,7 @@ const { t } = useAdminLocale();
|
||||
.admin-row-actions__menu :deep(.el-dropdown-menu__item) {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
padding: 6px 16px;
|
||||
padding: 7px 16px;
|
||||
max-width: 300px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@@ -53,18 +53,18 @@ defineProps<{
|
||||
line-height: 1.4;
|
||||
}
|
||||
.admin-subnav__link {
|
||||
color: #737373;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.admin-subnav__link:hover {
|
||||
color: #f5f5f5;
|
||||
color: var(--text);
|
||||
}
|
||||
.admin-subnav__current {
|
||||
color: #737373;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.admin-subnav__sep {
|
||||
color: #444;
|
||||
color: #b6afa3;
|
||||
user-select: none;
|
||||
}
|
||||
.admin-subnav__main {
|
||||
@@ -83,14 +83,14 @@ defineProps<{
|
||||
}
|
||||
.admin-subnav__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #f5f5f5;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.admin-subnav__subtitle {
|
||||
font-size: 13px;
|
||||
color: #777;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.admin-subnav__extra {
|
||||
display: flex;
|
||||
@@ -98,4 +98,21 @@ defineProps<{
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-subnav {
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-subnav__main,
|
||||
.admin-subnav__extra {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-subnav__extra {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,23 +25,29 @@ defineProps<{
|
||||
|
||||
<style scoped>
|
||||
.admin-table-empty {
|
||||
padding: 28px 16px;
|
||||
margin: 12px;
|
||||
padding: 34px 18px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
background: #fbfaf7;
|
||||
text-align: center;
|
||||
}
|
||||
.admin-table-empty__icon {
|
||||
color: var(--green-text);
|
||||
opacity: 0.45;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.72;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.admin-table-empty__text {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
color: var(--text);
|
||||
}
|
||||
.admin-table-empty__hint {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
margin-top: 4px;
|
||||
color: var(--text-muted);
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,7 +105,7 @@ function fmtFull(value: string | null | undefined) {
|
||||
.credit-context-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #bbb;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.credit-context-alert {
|
||||
@@ -114,24 +114,24 @@ function fmtFull(value: string | null | undefined) {
|
||||
.credit-context-preview {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.level-tag {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.c-green {
|
||||
color: var(--gold-text);
|
||||
color: var(--success-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.c-amber {
|
||||
color: #e6a23c;
|
||||
color: var(--warning-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.amount-full-hint {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,10 +49,10 @@ function isActive(path: string) {
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-bottom: 16px;
|
||||
padding: 3px;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
box-sizing: border-box;
|
||||
@@ -61,7 +61,7 @@ function isActive(path: string) {
|
||||
margin-bottom: 0;
|
||||
padding: 0 14px 0 0;
|
||||
border: none;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
height: 44px;
|
||||
@@ -76,16 +76,27 @@ function isActive(path: string) {
|
||||
box-sizing: border-box;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #737373;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.dashboard-subnav__item:hover {
|
||||
color: #d4d4d4;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.dashboard-subnav__item--active {
|
||||
color: #f5f5f5;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dashboard-subnav {
|
||||
overflow-x: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.dashboard-subnav__item {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -280,14 +280,14 @@ const oldUrl = ref(props.modelValue);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
flex-shrink: 0;
|
||||
border: 2px dashed #2a2a2a;
|
||||
border: 2px dashed #d5cfc3;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: #fbfaf7;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ const oldUrl = ref(props.modelValue);
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #e55;
|
||||
color: var(--danger-text);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
@@ -306,7 +306,7 @@ const oldUrl = ref(props.modelValue);
|
||||
}
|
||||
|
||||
.delete-old-check:hover {
|
||||
background: rgba(238, 85, 85, 0.1);
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.delete-old-check input[type="checkbox"] {
|
||||
@@ -318,13 +318,13 @@ const oldUrl = ref(props.modelValue);
|
||||
}
|
||||
|
||||
.drop-zone:hover {
|
||||
border-color: rgba(212, 175, 55, 0.4);
|
||||
background: rgba(212, 175, 55, 0.04);
|
||||
border-color: var(--primary);
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.drop-zone.is-dragging {
|
||||
border-color: var(--gold-mid);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border-color: var(--primary);
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.drop-preview {
|
||||
@@ -338,7 +338,7 @@ const oldUrl = ref(props.modelValue);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #555;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
|
||||
@@ -15,16 +15,21 @@ withDefaults(
|
||||
const tabs = [
|
||||
{ path: '/matches', labelKey: 'nav.matches.fixtures' },
|
||||
{ path: '/matches/outrights', labelKey: 'nav.matches.outrights' },
|
||||
{ path: '/matches/market-templates', labelKey: 'nav.matches.market_templates' },
|
||||
];
|
||||
|
||||
function isActive(path: string) {
|
||||
if (path === '/matches/outrights') {
|
||||
return route.path === '/matches/outrights';
|
||||
}
|
||||
if (path === '/matches/market-templates') {
|
||||
return route.path === '/matches/market-templates';
|
||||
}
|
||||
return (
|
||||
route.path === '/matches' ||
|
||||
(route.path.startsWith('/matches/') &&
|
||||
!route.path.startsWith('/matches/outrights'))
|
||||
!route.path.startsWith('/matches/outrights') &&
|
||||
!route.path.startsWith('/matches/market-templates'))
|
||||
);
|
||||
}
|
||||
</script>
|
||||
@@ -53,10 +58,10 @@ function isActive(path: string) {
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
min-height: 48px;
|
||||
box-sizing: border-box;
|
||||
@@ -65,7 +70,7 @@ function isActive(path: string) {
|
||||
margin-bottom: 0;
|
||||
padding: 0 14px 0 0;
|
||||
border: none;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
height: 44px;
|
||||
@@ -73,23 +78,34 @@ function isActive(path: string) {
|
||||
}
|
||||
.matches-subnav__item {
|
||||
padding: 0 14px;
|
||||
height: 36px;
|
||||
height: 34px;
|
||||
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: 700;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.matches-subnav__item:hover {
|
||||
color: #ccc;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.matches-subnav__item--active {
|
||||
color: var(--green-text);
|
||||
background: rgba(0, 200, 83, 0.1);
|
||||
color: #ffffff;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.matches-subnav {
|
||||
overflow-x: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.matches-subnav__item {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,18 +22,18 @@ function drawCaptcha() {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.fillStyle = '#7c3aed';
|
||||
ctx.fillStyle = '#3d413b';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < 28; i++) {
|
||||
ctx.fillStyle = `rgba(255,255,255,${0.15 + Math.random() * 0.35})`;
|
||||
ctx.fillStyle = `rgba(255,255,255,${0.2 + Math.random() * 0.32})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(Math.random() * w, Math.random() * h, Math.random() * 2.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ctx.strokeStyle = `rgba(255,255,255,${0.2 + Math.random() * 0.3})`;
|
||||
ctx.strokeStyle = `rgba(255,255,255,${0.22 + Math.random() * 0.26})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.random() * w, Math.random() * h);
|
||||
@@ -98,16 +98,21 @@ defineExpose({ validate, refresh });
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border: 1px solid var(--border);
|
||||
border-right: none;
|
||||
border-radius: 8px 0 0 8px;
|
||||
background: #ffffff;
|
||||
color: #111;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
font-weight: 650;
|
||||
outline: none;
|
||||
}
|
||||
.captcha-input::placeholder {
|
||||
color: #9ca3af;
|
||||
color: #aaa49a;
|
||||
}
|
||||
.captcha-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(31, 35, 32, 0.08);
|
||||
}
|
||||
.captcha-canvas {
|
||||
flex-shrink: 0;
|
||||
@@ -116,5 +121,8 @@ defineExpose({ validate, refresh });
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
border-radius: 0 8px 8px 0;
|
||||
border: 1px solid #3d413b;
|
||||
border-left: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,12 +105,12 @@ function limitLabel(value: string | null) {
|
||||
.transfer-context-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #bbb;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.transfer-context-alert { margin-bottom: 0; }
|
||||
.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; }
|
||||
.c-green { color: var(--success-text); font-weight: 600; }
|
||||
.c-amber { color: var(--warning-text); font-weight: 600; }
|
||||
.amount-full-hint { font-size: 11px; color: var(--text-muted); margin-left: 4px; }
|
||||
.field-hint { font-size: 12px; color: var(--text-muted); }
|
||||
</style>
|
||||
|
||||
@@ -30,16 +30,16 @@ const style = computed(() => ({ height: props.height, width: '100%' }));
|
||||
<style scoped>
|
||||
.chart-panel {
|
||||
padding: 16px 18px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #1e1e1e;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: #ffffff;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chart-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #ccc;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.chart-canvas {
|
||||
|
||||
@@ -111,7 +111,8 @@ function onChange(code: string | undefined) {
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
background: #222;
|
||||
background: #f4f0e8;
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.country-option {
|
||||
@@ -147,7 +148,7 @@ function onChange(code: string | undefined) {
|
||||
|
||||
.country-option-code {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -218,6 +218,7 @@ const zh: Record<string, string> = {
|
||||
'page.agents.desc': '创建一级代理、调整授信额度、查看直属玩家与额度占用',
|
||||
'nav.matches.fixtures': '赛事配置',
|
||||
'nav.matches.outrights': '优胜赛配置(盘口)',
|
||||
'nav.matches.market_templates': '盘口模板',
|
||||
'page.matches.title': '赛事管理',
|
||||
'page.matches.desc': '草稿可编辑、删除;已发布可改开赛时间与热门;未结算可下架',
|
||||
'page.bets.title': '注单管理',
|
||||
@@ -485,6 +486,7 @@ const en: Record<string, string> = {
|
||||
'page.agents.desc': 'Tier-1 agents, credit limits, players, and usage',
|
||||
'nav.matches.fixtures': 'Fixtures',
|
||||
'nav.matches.outrights': 'Outright odds',
|
||||
'nav.matches.market_templates': 'Market templates',
|
||||
'page.matches.title': 'Matches',
|
||||
'page.matches.desc': 'Edit/delete drafts; adjust kickoff and featured when published; unpublish while unsettled',
|
||||
'page.bets.title': 'Bets',
|
||||
@@ -752,6 +754,7 @@ const ms: Record<string, string> = {
|
||||
'page.agents.desc': 'Ejen peringkat 1, had kredit, pemain dan penggunaan',
|
||||
'nav.matches.fixtures': 'Konfigurasi perlawanan',
|
||||
'nav.matches.outrights': 'Odds juara',
|
||||
'nav.matches.market_templates': 'Templat pasaran',
|
||||
'page.matches.title': 'Perlawanan',
|
||||
'page.matches.desc': 'Edit/padam draf; laraskan masa mula bila diterbitkan; nyahterbit jika belum selesai',
|
||||
'page.bets.title': 'Pertaruhan',
|
||||
|
||||
@@ -259,6 +259,7 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'match.field.lang_en': 'EN',
|
||||
'match.field.lang_ms': 'MS',
|
||||
'match.field.kickoff': 'Masa mula',
|
||||
'match.timezone.platform_hint': 'Disimpan sebagai masa Malaysia (UTC+8)',
|
||||
'match.field.home_team': 'Pasukan tuan rumah',
|
||||
'match.field.away_team': 'Pasukan pelawat',
|
||||
'match.field.home_en': 'Tuan rumah (EN)',
|
||||
@@ -472,7 +473,8 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'matchEditor.field.promo_label': 'Label promosi',
|
||||
'matchEditor.field.promo_label_optional': 'Label promosi (pilihan)',
|
||||
'matchEditor.field.line_value': 'Garisan',
|
||||
'matchEditor.ph.kickoff': 'Pilih tarikh & masa mula',
|
||||
'matchEditor.field.player_visible': 'Papar pemain',
|
||||
'matchEditor.ph.kickoff': 'Pilih tarikh & masa mula (masa Malaysia)',
|
||||
'matchEditor.group.league': 'Liga',
|
||||
'matchEditor.hint.league_readonly': 'Nama dan logo liga diselenggara dalam senarai kejohanan; paparan di sini hanya baca.',
|
||||
'matchEditor.group.home': 'Tuan rumah',
|
||||
@@ -492,7 +494,7 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'matchEditor.market.HT_HANDICAP': 'HT handicap',
|
||||
'matchEditor.market.HT_OVER_UNDER': 'HT O/U',
|
||||
'matchEditor.market.FT_CORRECT_SCORE': 'FT skor tepat',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': 'HT skor tepat',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': '1H skor tepat',
|
||||
'matchEditor.market.SH_CORRECT_SCORE': '2H skor tepat',
|
||||
'matchEditor.period.FT': 'Sepenuh masa',
|
||||
'matchEditor.period.HT': 'Separuh masa',
|
||||
@@ -550,6 +552,13 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'settlement.col.parlay_legs': 'Kaki parlay',
|
||||
'settlement.ht_score': 'Skor separuh masa',
|
||||
'settlement.ft_score': 'Skor penuh masa',
|
||||
'settlement.stats_facts': 'Fakta perlawanan',
|
||||
'settlement.corners': 'Sepakan sudut',
|
||||
'settlement.cards': 'Kad',
|
||||
'settlement.yellow_cards': 'Kad kuning',
|
||||
'settlement.red_cards': 'Kad merah',
|
||||
'settlement.home_stat': 'Tuan rumah',
|
||||
'settlement.away_stat': 'Pelawat',
|
||||
'settlement.record_score': 'Simpan skor',
|
||||
'settlement.preview_hint': 'Pratonton menukar perlawanan ke menunggu penyelesaian (skor disimpan selepas pengesahan; boleh buka semula sebelum itu)',
|
||||
'settlement.preview_btn': 'Pratonton penyelesaian',
|
||||
@@ -742,6 +751,7 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'content.upload.pick_media_title': 'Pilih Imej Banner',
|
||||
'content.upload.no_media': 'Tiada imej banner dalam pustaka — muat naik dahulu',
|
||||
'content.upload.url_placeholder': 'Atau tampal URL imej',
|
||||
'content.upload.recommended_size': 'Saiz disyorkan: 860 x 360 px, atau imej nisbah 43:18. Karusel pemain memaparkan imej penuh dan mengisi ruang tambahan.',
|
||||
'content.link.none': 'Tiada pautan',
|
||||
'content.locale.zh-CN': 'Cina Ringkas',
|
||||
'content.locale.en-US': 'English',
|
||||
|
||||
@@ -281,6 +281,7 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'match.field.lang_en': 'EN',
|
||||
'match.field.lang_ms': 'MS',
|
||||
'match.field.kickoff': '开赛时间',
|
||||
'match.timezone.platform_hint': '按马来西亚时间 (UTC+8) 保存',
|
||||
'match.field.home_team': '主队',
|
||||
'match.field.away_team': '客队',
|
||||
'match.field.home_en': '主队(英)',
|
||||
@@ -490,11 +491,11 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'matchEditor.field.stage': '阶段',
|
||||
'matchEditor.field.group': '小组',
|
||||
'matchEditor.field.display_order': '排序',
|
||||
'matchEditor.field.correct_score_enabled': '波胆玩法',
|
||||
'matchEditor.field.promo_label': '促销标签',
|
||||
'matchEditor.field.promo_label_optional': '促销标签(可选)',
|
||||
'matchEditor.field.line_value': '盘口线',
|
||||
'matchEditor.ph.kickoff': '选择开赛日期与时间',
|
||||
'matchEditor.field.player_visible': '玩家端显示',
|
||||
'matchEditor.ph.kickoff': '选择开赛日期与时间(马来西亚时间)',
|
||||
'matchEditor.group.league': '联赛信息',
|
||||
'matchEditor.hint.league_readonly': '联赛名称与 Logo 请在赛事管理列表中点击「编辑」维护,此处仅展示。',
|
||||
'matchEditor.group.home': '主队',
|
||||
@@ -514,7 +515,7 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'matchEditor.market.HT_HANDICAP': '半场让球',
|
||||
'matchEditor.market.HT_OVER_UNDER': '半场大小',
|
||||
'matchEditor.market.FT_CORRECT_SCORE': '全场波胆',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': '半场波胆',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': '上半场波胆',
|
||||
'matchEditor.market.SH_CORRECT_SCORE': '下半场波胆',
|
||||
'matchEditor.period.FT': '全场',
|
||||
'matchEditor.period.HT': '半场',
|
||||
@@ -574,6 +575,13 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'settlement.col.parlay_legs': '串关腿数',
|
||||
'settlement.ht_score': '半场比分',
|
||||
'settlement.ft_score': '全场比分',
|
||||
'settlement.stats_facts': '统计事实',
|
||||
'settlement.corners': '角球',
|
||||
'settlement.cards': '罚牌',
|
||||
'settlement.yellow_cards': '黄牌',
|
||||
'settlement.red_cards': '红牌',
|
||||
'settlement.home_stat': '主队',
|
||||
'settlement.away_stat': '客队',
|
||||
'settlement.record_score': '录入比分',
|
||||
'settlement.preview_hint': '填写比分后点击生成预览,赛事将进入待结算并计算派彩(正式比分在确认结算后保存;未确认前仍可解除封盘)',
|
||||
'settlement.preview_btn': '生成结算预览',
|
||||
@@ -594,9 +602,9 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'settlement.preview_items_scroll_hint': '笔数较多时可滚动查看',
|
||||
'settlement.preview_col.result': '本场结果',
|
||||
'settlement.preview_zero_parlay_hint':
|
||||
'预计派彩为 0:本场虽有 {legs} 条赢腿,但均在跨场串关内({pending} 笔待其他场次),须等其他比赛结算后才派彩。',
|
||||
'预计派彩为 0:{pending} 笔跨场串关注单仍须等其他比赛结算后,才会统一结算。',
|
||||
'settlement.preview_zero_lost_hint':
|
||||
'预计派彩为 0:{count} 笔串关在本场已有输腿,整单作废;其余单关/串关本场亦未中奖。',
|
||||
'预计派彩为 0:{count} 笔串关已满足整单结算条件但结果为输;其余单关/串关本场亦未中奖。',
|
||||
'settlement.preview_zero_default_hint': '预计派彩为 0:本场相关注单均未中奖或暂不满足派彩条件。',
|
||||
'settlement.preview.result.WIN': '赢',
|
||||
'settlement.preview.result.LOSE': '输',
|
||||
@@ -816,6 +824,7 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'content.upload.pick_media_title': '选择 Banner 图片',
|
||||
'content.upload.no_media': '媒体库中暂无 Banner 图片,请先上传',
|
||||
'content.upload.url_placeholder': '或手动粘贴图片 URL',
|
||||
'content.upload.recommended_size': '建议尺寸:860 x 360 px,或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
|
||||
'content.link.none': '无跳转',
|
||||
'content.locale.zh-CN': '简体中文',
|
||||
'content.locale.en-US': 'English',
|
||||
@@ -1297,6 +1306,7 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'match.field.lang_en': 'EN',
|
||||
'match.field.lang_ms': 'MS',
|
||||
'match.field.kickoff': 'Kickoff time',
|
||||
'match.timezone.platform_hint': 'Saved as Malaysia time (UTC+8)',
|
||||
'match.field.home_team': 'Home team',
|
||||
'match.field.away_team': 'Away team',
|
||||
'match.field.home_en': 'Home (EN)',
|
||||
@@ -1506,11 +1516,11 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'matchEditor.field.stage': 'Stage',
|
||||
'matchEditor.field.group': 'Group',
|
||||
'matchEditor.field.display_order': 'Sort order',
|
||||
'matchEditor.field.correct_score_enabled': 'Correct score',
|
||||
'matchEditor.field.promo_label': 'Promo label',
|
||||
'matchEditor.field.promo_label_optional': 'Promo label (optional)',
|
||||
'matchEditor.field.line_value': 'Line',
|
||||
'matchEditor.ph.kickoff': 'Select kickoff date & time',
|
||||
'matchEditor.field.player_visible': 'Show on player',
|
||||
'matchEditor.ph.kickoff': 'Select kickoff date & time (Malaysia time)',
|
||||
'matchEditor.group.league': 'League',
|
||||
'matchEditor.hint.league_readonly': 'Edit league name and logo from the tournament list; shown here read-only.',
|
||||
'matchEditor.group.home': 'Home team',
|
||||
@@ -1530,7 +1540,7 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'matchEditor.market.HT_HANDICAP': 'HT handicap',
|
||||
'matchEditor.market.HT_OVER_UNDER': 'HT O/U',
|
||||
'matchEditor.market.FT_CORRECT_SCORE': 'FT correct score',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': 'HT correct score',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': '1H correct score',
|
||||
'matchEditor.market.SH_CORRECT_SCORE': '2H correct score',
|
||||
'matchEditor.period.FT': 'Full time',
|
||||
'matchEditor.period.HT': 'Half time',
|
||||
@@ -1590,6 +1600,13 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'settlement.col.parlay_legs': 'Parlay legs',
|
||||
'settlement.ht_score': 'Half-time score',
|
||||
'settlement.ft_score': 'Full-time score',
|
||||
'settlement.stats_facts': 'Match facts',
|
||||
'settlement.corners': 'Corners',
|
||||
'settlement.cards': 'Cards',
|
||||
'settlement.yellow_cards': 'Yellow cards',
|
||||
'settlement.red_cards': 'Red cards',
|
||||
'settlement.home_stat': 'Home',
|
||||
'settlement.away_stat': 'Away',
|
||||
'settlement.record_score': 'Save score',
|
||||
'settlement.preview_hint': 'Preview moves the match to pending settlement and calculates payouts (scores are saved on confirm; you can reopen betting before confirming)',
|
||||
'settlement.preview_btn': 'Preview settlement',
|
||||
@@ -1610,9 +1627,9 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'settlement.preview_items_scroll_hint': 'Scroll when the list is long',
|
||||
'settlement.preview_col.result': 'Match result',
|
||||
'settlement.preview_zero_parlay_hint':
|
||||
'Est. payout is 0: {legs} winning leg(s) on this match are in cross-match parlays ({pending} bet(s) awaiting other fixtures).',
|
||||
'Est. payout is 0: {pending} cross-match parlay bet(s) must wait for every fixture before final settlement.',
|
||||
'settlement.preview_zero_lost_hint':
|
||||
'Est. payout is 0: {count} parlay(s) already lost on this match; other bets did not win here either.',
|
||||
'Est. payout is 0: {count} parlay(s) are ready for final settlement and lost; other bets did not win here either.',
|
||||
'settlement.preview_zero_default_hint':
|
||||
'Est. payout is 0: no winning or refundable outcome on this match yet.',
|
||||
'settlement.preview.result.WIN': 'Win',
|
||||
@@ -1833,6 +1850,7 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'content.upload.pick_media_title': 'Select Banner Image',
|
||||
'content.upload.no_media': 'No banner images in library — upload one first',
|
||||
'content.upload.url_placeholder': 'Or paste image URL',
|
||||
'content.upload.recommended_size': 'Recommended size: 860 x 360 px, or any 43:18 image. The player carousel keeps the full image visible and fills extra space.',
|
||||
'content.link.none': 'No link',
|
||||
'content.locale.zh-CN': 'Chinese (Simplified)',
|
||||
'content.locale.en-US': 'English',
|
||||
|
||||
@@ -279,6 +279,7 @@ const adminPages: Record<string, string> = {
|
||||
'match.field.lang_en': 'EN',
|
||||
'match.field.lang_ms': 'MS',
|
||||
'match.field.kickoff': 'Kickoff time',
|
||||
'match.timezone.platform_hint': 'Saved as Malaysia time (UTC+8)',
|
||||
'match.field.home_team': 'Home team',
|
||||
'match.field.away_team': 'Away team',
|
||||
'match.field.home_en': 'Home (EN)',
|
||||
@@ -491,7 +492,8 @@ const adminPages: Record<string, string> = {
|
||||
'matchEditor.field.promo_label': 'Promo label',
|
||||
'matchEditor.field.promo_label_optional': 'Promo label (optional)',
|
||||
'matchEditor.field.line_value': 'Line',
|
||||
'matchEditor.ph.kickoff': 'Select kickoff date & time',
|
||||
'matchEditor.field.player_visible': 'Show on player',
|
||||
'matchEditor.ph.kickoff': 'Select kickoff date & time (Malaysia time)',
|
||||
'matchEditor.group.league': 'League',
|
||||
'matchEditor.hint.league_readonly': 'Edit league name and logo from the tournament list; shown here read-only.',
|
||||
'matchEditor.group.home': 'Home team',
|
||||
@@ -511,7 +513,7 @@ const adminPages: Record<string, string> = {
|
||||
'matchEditor.market.HT_HANDICAP': 'HT handicap',
|
||||
'matchEditor.market.HT_OVER_UNDER': 'HT O/U',
|
||||
'matchEditor.market.FT_CORRECT_SCORE': 'FT correct score',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': 'HT correct score',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': '1H correct score',
|
||||
'matchEditor.market.SH_CORRECT_SCORE': '2H correct score',
|
||||
'matchEditor.period.FT': 'Full time',
|
||||
'matchEditor.period.HT': 'Half time',
|
||||
@@ -571,6 +573,13 @@ const adminPages: Record<string, string> = {
|
||||
'settlement.col.parlay_legs': 'Parlay legs',
|
||||
'settlement.ht_score': 'Half-time score',
|
||||
'settlement.ft_score': 'Full-time score',
|
||||
'settlement.stats_facts': 'Match facts',
|
||||
'settlement.corners': 'Corners',
|
||||
'settlement.cards': 'Cards',
|
||||
'settlement.yellow_cards': 'Yellow cards',
|
||||
'settlement.red_cards': 'Red cards',
|
||||
'settlement.home_stat': 'Home',
|
||||
'settlement.away_stat': 'Away',
|
||||
'settlement.record_score': 'Save score',
|
||||
'settlement.preview_hint': 'Preview moves the match to pending settlement and calculates payouts (scores are saved on confirm; you can reopen betting before confirming)',
|
||||
'settlement.preview_btn': 'Preview settlement',
|
||||
@@ -591,9 +600,9 @@ const adminPages: Record<string, string> = {
|
||||
'settlement.preview_items_scroll_hint': 'Scroll when the list is long',
|
||||
'settlement.preview_col.result': 'Match result',
|
||||
'settlement.preview_zero_parlay_hint':
|
||||
'Est. payout is 0: {legs} winning leg(s) on this match are in cross-match parlays ({pending} bet(s) awaiting other fixtures).',
|
||||
'Est. payout is 0: {pending} cross-match parlay bet(s) must wait for every fixture before final settlement.',
|
||||
'settlement.preview_zero_lost_hint':
|
||||
'Est. payout is 0: {count} parlay(s) already lost on this match; other bets did not win here either.',
|
||||
'Est. payout is 0: {count} parlay(s) are ready for final settlement and lost; other bets did not win here either.',
|
||||
'settlement.preview_zero_default_hint':
|
||||
'Est. payout is 0: no winning or refundable outcome on this match yet.',
|
||||
'settlement.preview.result.WIN': 'Win',
|
||||
@@ -814,6 +823,7 @@ const adminPages: Record<string, string> = {
|
||||
'content.upload.pick_media_title': 'Select Banner Image',
|
||||
'content.upload.no_media': 'No banner images in library — upload one first',
|
||||
'content.upload.url_placeholder': 'Or paste image URL',
|
||||
'content.upload.recommended_size': 'Recommended size: 860 x 360 px, or any 43:18 image. The player carousel keeps the full image visible and fills extra space.',
|
||||
'content.link.none': 'No link',
|
||||
'content.locale.zh-CN': 'Chinese (Simplified)',
|
||||
'content.locale.en-US': 'English',
|
||||
|
||||
@@ -280,6 +280,7 @@ const adminPages: Record<string, string> = {
|
||||
'match.field.lang_en': 'EN',
|
||||
'match.field.lang_ms': 'MS',
|
||||
'match.field.kickoff': '开赛时间',
|
||||
'match.timezone.platform_hint': '按马来西亚时间 (UTC+8) 保存',
|
||||
'match.field.home_team': '主队',
|
||||
'match.field.away_team': '客队',
|
||||
'match.field.home_en': '主队(英)',
|
||||
@@ -489,11 +490,11 @@ const adminPages: Record<string, string> = {
|
||||
'matchEditor.field.stage': '阶段',
|
||||
'matchEditor.field.group': '小组',
|
||||
'matchEditor.field.display_order': '排序',
|
||||
'matchEditor.field.correct_score_enabled': '波胆玩法',
|
||||
'matchEditor.field.promo_label': '促销标签',
|
||||
'matchEditor.field.promo_label_optional': '促销标签(可选)',
|
||||
'matchEditor.field.line_value': '盘口线',
|
||||
'matchEditor.ph.kickoff': '选择开赛日期与时间',
|
||||
'matchEditor.field.player_visible': '玩家端显示',
|
||||
'matchEditor.ph.kickoff': '选择开赛日期与时间(马来西亚时间)',
|
||||
'matchEditor.group.league': '联赛信息',
|
||||
'matchEditor.hint.league_readonly': '联赛名称与 Logo 请在赛事管理列表中点击「编辑」维护,此处仅展示。',
|
||||
'matchEditor.group.home': '主队',
|
||||
@@ -513,7 +514,7 @@ const adminPages: Record<string, string> = {
|
||||
'matchEditor.market.HT_HANDICAP': '半场让球',
|
||||
'matchEditor.market.HT_OVER_UNDER': '半场大小',
|
||||
'matchEditor.market.FT_CORRECT_SCORE': '全场波胆',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': '半场波胆',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': '上半场波胆',
|
||||
'matchEditor.market.SH_CORRECT_SCORE': '下半场波胆',
|
||||
'matchEditor.period.FT': '全场',
|
||||
'matchEditor.period.HT': '半场',
|
||||
@@ -573,6 +574,13 @@ const adminPages: Record<string, string> = {
|
||||
'settlement.col.parlay_legs': '串关腿数',
|
||||
'settlement.ht_score': '半场比分',
|
||||
'settlement.ft_score': '全场比分',
|
||||
'settlement.stats_facts': '统计事实',
|
||||
'settlement.corners': '角球',
|
||||
'settlement.cards': '罚牌',
|
||||
'settlement.yellow_cards': '黄牌',
|
||||
'settlement.red_cards': '红牌',
|
||||
'settlement.home_stat': '主队',
|
||||
'settlement.away_stat': '客队',
|
||||
'settlement.record_score': '录入比分',
|
||||
'settlement.preview_hint': '填写比分后点击生成预览,赛事将进入待结算并计算派彩(正式比分在确认结算后保存;未确认前仍可解除封盘)',
|
||||
'settlement.preview_btn': '生成结算预览',
|
||||
@@ -593,9 +601,9 @@ const adminPages: Record<string, string> = {
|
||||
'settlement.preview_items_scroll_hint': '笔数较多时可滚动查看',
|
||||
'settlement.preview_col.result': '本场结果',
|
||||
'settlement.preview_zero_parlay_hint':
|
||||
'预计派彩为 0:本场虽有 {legs} 条赢腿,但均在跨场串关内({pending} 笔待其他场次),须等其他比赛结算后才派彩。',
|
||||
'预计派彩为 0:{pending} 笔跨场串关注单仍须等其他比赛结算后,才会统一结算。',
|
||||
'settlement.preview_zero_lost_hint':
|
||||
'预计派彩为 0:{count} 笔串关在本场已有输腿,整单作废;其余单关/串关本场亦未中奖。',
|
||||
'预计派彩为 0:{count} 笔串关已满足整单结算条件但结果为输;其余单关/串关本场亦未中奖。',
|
||||
'settlement.preview_zero_default_hint': '预计派彩为 0:本场相关注单均未中奖或暂不满足派彩条件。',
|
||||
'settlement.preview.result.WIN': '赢',
|
||||
'settlement.preview.result.LOSE': '输',
|
||||
@@ -815,6 +823,7 @@ const adminPages: Record<string, string> = {
|
||||
'content.upload.pick_media_title': '选择 Banner 图片',
|
||||
'content.upload.no_media': '媒体库中暂无 Banner 图片,请先上传',
|
||||
'content.upload.url_placeholder': '或手动粘贴图片 URL',
|
||||
'content.upload.recommended_size': '建议尺寸:860 x 360 px,或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
|
||||
'content.link.none': '无跳转',
|
||||
'content.locale.zh-CN': '简体中文',
|
||||
'content.locale.en-US': 'English',
|
||||
|
||||
@@ -541,4 +541,265 @@ watch(() => route.path, () => {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Light management shell */
|
||||
.shell {
|
||||
--sidebar-width: 204px;
|
||||
height: 100dvh;
|
||||
background: var(--bg-body);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #ffffff;
|
||||
border-right: 1px solid var(--border);
|
||||
box-shadow: 10px 0 28px rgba(56, 49, 37, 0.04);
|
||||
}
|
||||
|
||||
.brand {
|
||||
height: 64px;
|
||||
min-height: 64px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
max-width: 132px;
|
||||
max-height: 38px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
padding: 12px 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: #67635c;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nav-item :deep(.admin-nav-icon) {
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
border-color: var(--border-soft);
|
||||
background: var(--accent-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.nav-item.active :deep(.admin-nav-icon),
|
||||
.nav-item:hover :deep(.admin-nav-icon) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
color: #aaa49a;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
background: rgba(31, 35, 32, 0.28);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.main {
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 64px;
|
||||
min-height: 64px;
|
||||
padding: 0 28px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-bottom: 1px solid var(--border);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-menu {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-menu:hover {
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.topbar-crumbs {
|
||||
gap: 2px 7px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.crumb-link {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.crumb-link:hover,
|
||||
.crumb-current {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.crumb-sep {
|
||||
color: #b6afa3;
|
||||
}
|
||||
|
||||
.topbar-accent {
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.topbar-page-actions {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
padding: 4px 8px 4px 4px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 999px;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid #d6ddd4;
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: var(--text);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
color: var(--text-muted);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.portal-tag {
|
||||
padding: 4px 8px;
|
||||
border-color: var(--border);
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: var(--text-muted);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
border-color: var(--border);
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: 22px 28px 30px;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.sidebar.open {
|
||||
box-shadow: 18px 0 46px rgba(56, 49, 37, 0.16);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: auto;
|
||||
min-height: 60px;
|
||||
padding: 10px 14px;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-crumbs,
|
||||
.topbar-page-label {
|
||||
max-width: 42vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,6 +60,11 @@ const router = createRouter({
|
||||
component: () => import('../views/MatchesOutrights.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'matches/market-templates',
|
||||
component: () => import('../views/MarketTemplates.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'matches/:matchId/edit',
|
||||
name: 'admin-match-edit',
|
||||
|
||||
@@ -14,6 +14,12 @@ export function resolveAdminBreadcrumb(
|
||||
{ label: t('breadcrumb.settlement') },
|
||||
];
|
||||
}
|
||||
if (path === '/matches/market-templates') {
|
||||
return [
|
||||
{ label: t('nav.matches'), to: '/matches' },
|
||||
{ label: t('nav.matches.market_templates') },
|
||||
];
|
||||
}
|
||||
if (/^\/matches\/[^/]+\/edit/.test(path)) {
|
||||
return [
|
||||
{ label: t('nav.matches'), to: '/matches' },
|
||||
|
||||
@@ -14,13 +14,13 @@ export type ChartI18n = {
|
||||
};
|
||||
|
||||
const tooltipBase = {
|
||||
backgroundColor: '#141414',
|
||||
borderColor: '#2a2a2a',
|
||||
textStyle: { color: '#e0e0e0', fontSize: 12 },
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: '#e7e2d8',
|
||||
textStyle: { color: '#1f2320', fontSize: 12 },
|
||||
};
|
||||
|
||||
const axisLabel = { color: '#888', fontSize: 11 };
|
||||
const splitLine = { lineStyle: { color: '#252525' } };
|
||||
const axisLabel = { color: '#78746b', fontSize: 11 };
|
||||
const splitLine = { lineStyle: { color: '#eee9df' } };
|
||||
|
||||
export type ChartSeries = { name: string; color: string; values: number[] };
|
||||
export type PieSegment = { label: string; value: number; color: string };
|
||||
@@ -45,14 +45,14 @@ export function buildBarChartOption(
|
||||
bottom: 0,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { color: '#999', fontSize: 11 },
|
||||
textStyle: { color: '#78746b', fontSize: 11 },
|
||||
},
|
||||
grid: { left: 52, right: 12, top: 20, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
axisLabel,
|
||||
axisLine: { lineStyle: { color: '#333' } },
|
||||
axisLine: { lineStyle: { color: '#e7e2d8' } },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
@@ -90,7 +90,7 @@ export function buildMultiLineChartOption(
|
||||
bottom: 0,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { color: '#999', fontSize: 11 },
|
||||
textStyle: { color: '#78746b', fontSize: 11 },
|
||||
},
|
||||
grid: { left: 52, right: 12, top: 20, bottom: 48 },
|
||||
xAxis: {
|
||||
@@ -98,7 +98,7 @@ export function buildMultiLineChartOption(
|
||||
data: labels,
|
||||
boundaryGap: false,
|
||||
axisLabel,
|
||||
axisLine: { lineStyle: { color: '#333' } },
|
||||
axisLine: { lineStyle: { color: '#e7e2d8' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
@@ -145,7 +145,7 @@ export function buildPieChartOption(
|
||||
top: 'middle',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
textStyle: { color: '#999', fontSize: 11 },
|
||||
textStyle: { color: '#78746b', fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -154,19 +154,19 @@ export function buildPieChartOption(
|
||||
radius: ['42%', '68%'],
|
||||
center: ['36%', '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 4, borderColor: '#111', borderWidth: 2 },
|
||||
itemStyle: { borderRadius: 4, borderColor: '#ffffff', borderWidth: 2 },
|
||||
label: {
|
||||
show: segments.length > 0,
|
||||
color: '#bbb',
|
||||
color: '#5f5b53',
|
||||
fontSize: 11,
|
||||
formatter: '{b}\n{d}%',
|
||||
},
|
||||
labelLine: { lineStyle: { color: '#444' } },
|
||||
labelLine: { lineStyle: { color: '#d5cfc3' } },
|
||||
emphasis: {
|
||||
label: { fontSize: 12, fontWeight: 'bold' },
|
||||
scaleSize: 6,
|
||||
},
|
||||
data: data.length ? data : [{ name: noData, value: 1, itemStyle: { color: '#333' } }],
|
||||
data: data.length ? data : [{ name: noData, value: 1, itemStyle: { color: '#e6e1d8' } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -189,7 +189,7 @@ export function buildCombinedTrendOption(
|
||||
const countSuffix = i18n.countSuffix;
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
color: [...amountSeries.map((s) => s.color), '#fb923c'],
|
||||
color: [...amountSeries.map((s) => s.color), '#956400'],
|
||||
tooltip: {
|
||||
...tooltipBase,
|
||||
trigger: 'axis',
|
||||
@@ -210,7 +210,7 @@ export function buildCombinedTrendOption(
|
||||
top: 0,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { color: '#999', fontSize: 11 },
|
||||
textStyle: { color: '#78746b', fontSize: 11 },
|
||||
},
|
||||
grid: { left: 56, right: 48, top: 36, bottom: 28 },
|
||||
xAxis: {
|
||||
@@ -218,20 +218,20 @@ export function buildCombinedTrendOption(
|
||||
data: labels,
|
||||
boundaryGap: true,
|
||||
axisLabel,
|
||||
axisLine: { lineStyle: { color: '#333' } },
|
||||
axisLine: { lineStyle: { color: '#e7e2d8' } },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: i18n.axisAmount,
|
||||
nameTextStyle: { color: '#666', fontSize: 10 },
|
||||
nameTextStyle: { color: '#8c867c', fontSize: 10 },
|
||||
axisLabel: { ...axisLabel, formatter: (v: number) => formatAmount(v, 2, loc) },
|
||||
splitLine,
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: i18n.axisCount,
|
||||
nameTextStyle: { color: '#666', fontSize: 10 },
|
||||
nameTextStyle: { color: '#8c867c', fontSize: 10 },
|
||||
axisLabel: { ...axisLabel, formatter: (v: number) => fmtCount(v, loc) },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
@@ -254,7 +254,7 @@ export function buildCombinedTrendOption(
|
||||
yAxisIndex: 1,
|
||||
data: betCounts,
|
||||
barMaxWidth: 14,
|
||||
itemStyle: { color: 'rgba(251, 146, 60, 0.45)', borderRadius: [3, 3, 0, 0] },
|
||||
itemStyle: { color: 'rgba(149, 100, 0, 0.34)', borderRadius: [3, 3, 0, 0] },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -286,7 +286,7 @@ export function buildTriplePieOption(
|
||||
top: '6%',
|
||||
style: {
|
||||
text: b.title,
|
||||
fill: '#aaa',
|
||||
fill: '#5f5b53',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
@@ -307,7 +307,7 @@ export function buildTriplePieOption(
|
||||
labelLine: { show: false },
|
||||
data: data.length
|
||||
? data
|
||||
: [{ name: pieEmpty, value: 1, itemStyle: { color: '#333' } }],
|
||||
: [{ name: pieEmpty, value: 1, itemStyle: { color: '#e6e1d8' } }],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -2,11 +2,11 @@ import type { EChartsOption } from 'echarts';
|
||||
import { buildBarChartOption, buildPieChartOption, type PieSegment } from './dashboard-charts';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PENDING: '#e8a040',
|
||||
WON: '#d4d4d4',
|
||||
LOST: '#ff453a',
|
||||
PUSH: '#8e8e93',
|
||||
VOID: '#555',
|
||||
PENDING: '#956400',
|
||||
WON: '#346538',
|
||||
LOST: '#9f2f2d',
|
||||
PUSH: '#5f5b53',
|
||||
VOID: '#aaa49a',
|
||||
};
|
||||
|
||||
function withCompactHeader(option: EChartsOption, text: string, subtext?: string): EChartsOption {
|
||||
@@ -19,8 +19,8 @@ function withCompactHeader(option: EChartsOption, text: string, subtext?: string
|
||||
subtext,
|
||||
left: 'center',
|
||||
top: 0,
|
||||
textStyle: { color: '#ccc', fontSize: 11, fontWeight: 600 },
|
||||
subtextStyle: { color: '#777', fontSize: 9 },
|
||||
textStyle: { color: '#1f2320', fontSize: 11, fontWeight: 700 },
|
||||
subtextStyle: { color: '#78746b', fontSize: 9 },
|
||||
itemGap: 2,
|
||||
},
|
||||
grid: { ...grid, top: Math.max(top, grid.top ?? 0) },
|
||||
@@ -44,7 +44,7 @@ function compactPie(option: EChartsOption, centerLines?: string[]): EChartsOptio
|
||||
style: {
|
||||
text: line,
|
||||
textAlign: 'center' as const,
|
||||
fill: i === 0 ? '#eee' : '#888',
|
||||
fill: i === 0 ? '#1f2320' : '#78746b',
|
||||
fontSize: i === 0 ? 15 : 9,
|
||||
fontWeight: i === 0 ? 700 : 400,
|
||||
},
|
||||
@@ -57,7 +57,7 @@ function compactPie(option: EChartsOption, centerLines?: string[]): EChartsOptio
|
||||
left: 'center',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
textStyle: { color: '#999', fontSize: 9 },
|
||||
textStyle: { color: '#78746b', fontSize: 9 },
|
||||
},
|
||||
graphic,
|
||||
};
|
||||
@@ -74,8 +74,8 @@ export function buildBetTypePieOption(input: {
|
||||
centerLabel: string;
|
||||
}): EChartsOption {
|
||||
const segments: PieSegment[] = [
|
||||
{ label: input.labels.single, value: input.singleBets, color: '#d4d4d4' },
|
||||
{ label: input.labels.parlay, value: input.parlayBets, color: '#fb923c' },
|
||||
{ label: input.labels.single, value: input.singleBets, color: '#346538' },
|
||||
{ label: input.labels.parlay, value: input.parlayBets, color: '#956400' },
|
||||
].filter((s) => s.value > 0);
|
||||
|
||||
const base = buildPieChartOption(input.title, segments, { pieTooltip: '{b}:{c}({d}%)' });
|
||||
@@ -94,7 +94,7 @@ export function buildStatusPieOption(input: {
|
||||
.map(([status, value]) => ({
|
||||
label: input.labelFor(status),
|
||||
value,
|
||||
color: STATUS_COLORS[status] ?? '#666',
|
||||
color: STATUS_COLORS[status] ?? '#8c867c',
|
||||
}));
|
||||
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||
@@ -112,7 +112,7 @@ export function buildSelectionStakeBarOption(input: {
|
||||
}): EChartsOption {
|
||||
const base = buildBarChartOption(
|
||||
input.labels,
|
||||
[{ name: input.seriesName, color: '#d4d4d4', values: input.stakes }],
|
||||
[{ name: input.seriesName, color: '#346538', values: input.stakes }],
|
||||
{ amountAxis: true },
|
||||
);
|
||||
return withCompactHeader(
|
||||
|
||||
@@ -2759,4 +2759,72 @@ function creditTypeLabel(type: string) {
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mgr-top-tabs :deep(.el-tabs__item) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.list-panel-toolbar {
|
||||
border-bottom-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.expand-panel {
|
||||
background: #fbfaf7;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.expand-loading,
|
||||
.expand-section-title,
|
||||
.inner-tabs :deep(.el-tabs__item),
|
||||
.field-hint,
|
||||
.create-meta-label,
|
||||
.amount-full-hint,
|
||||
.text-muted,
|
||||
.list-settings-hint,
|
||||
.list-settings-unit,
|
||||
.section-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.inner-tabs :deep(.el-tabs__item.is-active) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.inner-tabs :deep(.el-tabs__active-bar) {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
.create-meta-bar {
|
||||
border-color: var(--border);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.create-meta-value {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.c-green,
|
||||
.create-meta-row .c-green {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.invite-code-cell {
|
||||
color: var(--primary-link);
|
||||
}
|
||||
|
||||
.list-settings-block--danger {
|
||||
border-top-color: var(--danger-border);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.list-panel-toolbar,
|
||||
.list-panel-toolbar .list-chrome__grow,
|
||||
.list-panel-toolbar .list-chrome__actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.create-form-pair {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -547,4 +547,21 @@ function creditTypeLabel(type: string) {
|
||||
color: #666;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.field-hint,
|
||||
.section-title,
|
||||
.amount-full-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-block {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.section-title--row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -384,4 +384,30 @@ async function openDetail(row: BetListRow) {
|
||||
color: #aaa;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filter-card,
|
||||
.data-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.bet-content-summary,
|
||||
.bet-no,
|
||||
.selections-title {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.bet-content-empty,
|
||||
.detail-league {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.bets-filter-form {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.bets-filter-form :deep(.el-form-item) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -738,4 +738,62 @@ onMounted(loadHistory);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tool-card,
|
||||
.preview-card,
|
||||
.data-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rules-help-btn {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rules-help-btn:hover {
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.rules-list,
|
||||
.detail-summary,
|
||||
.preview-title,
|
||||
.table-title,
|
||||
.pstat-value {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.rules-note,
|
||||
.preview-meta,
|
||||
.detail-meta,
|
||||
.pstat-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pstat {
|
||||
border-color: var(--border);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.amount-cell,
|
||||
.pstat-green {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.preview-head,
|
||||
.history-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-actions,
|
||||
.detail-actions,
|
||||
.pager {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -670,6 +670,7 @@ void load();
|
||||
:required="form.status === 'ACTIVE'"
|
||||
>
|
||||
<div class="banner-upload-field">
|
||||
<p class="banner-size-hint">{{ t('content.upload.recommended_size') }}</p>
|
||||
<!-- Image preview -->
|
||||
<div v-if="tr.imageUrl" class="banner-preview">
|
||||
<img :src="tr.imageUrl" alt="" class="banner-preview-img" />
|
||||
@@ -822,10 +823,18 @@ void load();
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.banner-size-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.banner-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
aspect-ratio: 43 / 18;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #252525;
|
||||
@@ -834,8 +843,8 @@ void load();
|
||||
|
||||
.banner-preview-img {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -980,4 +989,106 @@ void load();
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contents-page {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.type-hint,
|
||||
.batch-hint,
|
||||
.banner-size-hint,
|
||||
.schedule-line,
|
||||
.schedule-sep,
|
||||
.media-picker-loading,
|
||||
.media-picker-empty,
|
||||
.media-picker-name,
|
||||
.thumb-empty,
|
||||
.locale-head {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hidden-tip {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.thumb,
|
||||
.banner-preview,
|
||||
.media-picker-thumb {
|
||||
background: #f4f0e8;
|
||||
}
|
||||
|
||||
.banner-preview,
|
||||
.locale-block {
|
||||
border-color: var(--border);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.banner-preview-remove {
|
||||
border-color: rgba(255, 255, 255, 0.36);
|
||||
background: rgba(31, 35, 32, 0.78);
|
||||
}
|
||||
|
||||
.banner-preview-remove:hover {
|
||||
background: rgba(159, 47, 45, 0.92);
|
||||
}
|
||||
|
||||
.banner-upload-btn,
|
||||
.banner-pick-btn {
|
||||
border-radius: 7px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.banner-upload-btn {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.banner-upload-btn:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.banner-pick-btn {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.banner-pick-btn:hover {
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.media-picker-card {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.media-picker-card:hover {
|
||||
border-color: #d5cfc3;
|
||||
box-shadow: 0 8px 22px rgba(56, 49, 37, 0.08);
|
||||
}
|
||||
|
||||
.media-picker-svg {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.banner-preview,
|
||||
.banner-url-input {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -87,17 +87,17 @@ const mainTrendOption = computed(() =>
|
||||
[
|
||||
{
|
||||
name: t('dash.chart_stake'),
|
||||
color: '#d4d4d4',
|
||||
color: '#346538',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
|
||||
},
|
||||
{
|
||||
name: t('dash.chart_payout'),
|
||||
color: '#60a5fa',
|
||||
color: '#1f6c9f',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.payout)) ?? [],
|
||||
},
|
||||
{
|
||||
name: t('dash.chart_ggr'),
|
||||
color: '#a78bfa',
|
||||
color: '#956400',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.ggr)) ?? [],
|
||||
},
|
||||
],
|
||||
@@ -111,20 +111,20 @@ const distributionOption = computed(() => {
|
||||
const u = s.value?.users;
|
||||
const raw = s.value?.bets.todayByStatus ?? {};
|
||||
const betColors: Record<string, string> = {
|
||||
PENDING: '#fb923c',
|
||||
WON: '#d4d4d4',
|
||||
LOST: '#f87171',
|
||||
VOID: '#6b7280',
|
||||
REFUNDED: '#60a5fa',
|
||||
PENDING: '#956400',
|
||||
WON: '#346538',
|
||||
LOST: '#9f2f2d',
|
||||
VOID: '#8c867c',
|
||||
REFUNDED: '#1f6c9f',
|
||||
};
|
||||
|
||||
const matchSegs = m
|
||||
? [
|
||||
{ label: t('dash.match_draft'), value: m.draft, color: '#6b7280' },
|
||||
{ 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' },
|
||||
{ label: t('dash.match_draft'), value: m.draft, color: '#8c867c' },
|
||||
{ label: t('dash.match_published'), value: m.published, color: '#346538' },
|
||||
{ label: t('dash.match_closed'), value: m.closed, color: '#1f6c9f' },
|
||||
{ label: t('dash.match_pending_settle'), value: m.pendingSettlement, color: '#956400' },
|
||||
{ label: t('dash.match_settled'), value: m.settled ?? 0, color: '#477a4c' },
|
||||
].filter((x) => x.value > 0)
|
||||
: [];
|
||||
|
||||
@@ -133,15 +133,15 @@ const distributionOption = computed(() => {
|
||||
.map((k) => ({
|
||||
label: betStatusLabel(k),
|
||||
value: raw[k].count,
|
||||
color: betColors[k] ?? '#888',
|
||||
color: betColors[k] ?? '#8c867c',
|
||||
}));
|
||||
|
||||
const userSegs = u
|
||||
? [
|
||||
{ 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' },
|
||||
{ label: t('dash.user_active'), value: u.playersActive, color: '#346538' },
|
||||
{ label: t('dash.user_suspended'), value: u.playersSuspended, color: '#9f2f2d' },
|
||||
{ label: t('dash.user_direct'), value: u.playersDirect, color: '#1f6c9f' },
|
||||
{ label: t('dash.user_agents'), value: u.agentsTotal, color: '#956400' },
|
||||
].filter((x) => x.value > 0)
|
||||
: [];
|
||||
|
||||
@@ -428,4 +428,68 @@ const kpiSecondary = computed(() => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Local light overrides for the legacy dashboard entry */
|
||||
.state-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.overview-board {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.dash-updated {
|
||||
color: #9b9489;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.kpi-cell {
|
||||
border-color: var(--border-soft);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.kpi-cell--link:hover,
|
||||
.kpi-cell--link:focus-visible {
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.kpi-sub {
|
||||
color: #9b9489;
|
||||
}
|
||||
|
||||
.kpi-delta {
|
||||
color: var(--text-muted);
|
||||
background: #f0ede6;
|
||||
}
|
||||
|
||||
.kpi-delta.up {
|
||||
color: var(--success-text);
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.kpi-delta.down {
|
||||
color: var(--danger-text);
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.charts-stack {
|
||||
border-top-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.chart-main-caption {
|
||||
color: #9b9489;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,42 +59,41 @@ function switchTab(key: string) {
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
margin-bottom: 0;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
margin-bottom: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
position: relative;
|
||||
padding: 10px 20px;
|
||||
background: none;
|
||||
padding: 8px 18px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #ccc;
|
||||
background: var(--accent-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-deposit-manage :deep(.page-deposit-orders),
|
||||
@@ -108,4 +107,14 @@ function switchTab(key: string) {
|
||||
.page-deposit-manage :deep(.page-payment-methods .toolbar h2) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.tab-bar {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -421,4 +421,239 @@ onMounted(fetchList);
|
||||
.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; }
|
||||
|
||||
.page-deposit-orders {
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.toolbar h2 {
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.filters {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.filters select,
|
||||
.filters input {
|
||||
min-height: 34px;
|
||||
border-color: var(--border);
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filters select:focus,
|
||||
.filters input:focus {
|
||||
border-color: var(--primary);
|
||||
outline: 3px solid rgba(31, 35, 32, 0.08);
|
||||
}
|
||||
|
||||
.btn-search,
|
||||
.btn-primary {
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 7px;
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
border-bottom-color: var(--border-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #fbfaf7;
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.time-cell,
|
||||
.remark-cell,
|
||||
.info-row span,
|
||||
.form-group label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.screenshot-thumb,
|
||||
.approve-screenshot {
|
||||
border-color: var(--border);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.status-pending,
|
||||
.status-approved,
|
||||
.status-rejected {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
background: var(--info-bg);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.status-rejected {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
border-color: var(--success-border);
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.btn-reopen {
|
||||
border-color: var(--warning-border);
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pagination button:not(:disabled):hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
background: rgba(31, 35, 32, 0.28);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.dialog-box {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
box-shadow: 0 24px 64px rgba(56, 49, 37, 0.14);
|
||||
}
|
||||
|
||||
.dialog-box h3 {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.approve-check-hint {
|
||||
border-color: var(--warning-border);
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
border-color: var(--border);
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-danger-full {
|
||||
border: 1px solid var(--danger-text);
|
||||
border-radius: 7px;
|
||||
background: var(--danger-text);
|
||||
}
|
||||
|
||||
.close-preview {
|
||||
background: var(--text);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.toolbar,
|
||||
.filters {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filters > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dialog-box {
|
||||
min-width: 0;
|
||||
width: calc(100vw - 32px);
|
||||
max-height: calc(100dvh - 32px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
import { formatAmount, formatAmountFull } from '../utils/format-amount';
|
||||
import { walletTxTypeKey } from '../utils/walletTx';
|
||||
const { t, locale, localeTag } = useAdminLocale();
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
@@ -75,9 +76,8 @@ function creditTypeLabel(type: string) {
|
||||
}
|
||||
|
||||
function transferTypeLabel(type: string) {
|
||||
if (type === 'MANUAL_DEPOSIT') return t('finance.tx.deposit');
|
||||
if (type === 'MANUAL_WITHDRAW') return t('finance.tx.withdraw');
|
||||
return type;
|
||||
const key = walletTxTypeKey(type);
|
||||
return key ? t(key) : type;
|
||||
}
|
||||
|
||||
const TRANSFER_REMARK_KEYS: Record<string, string> = {
|
||||
@@ -289,7 +289,12 @@ watch(
|
||||
<el-form-item :label="t('agent.col.credit_type')">
|
||||
<el-select v-model="transactionType" clearable :placeholder="t('common.all')" style="width: 110px">
|
||||
<el-option :label="t('finance.tx.deposit')" value="MANUAL_DEPOSIT" />
|
||||
<el-option :label="t('finance.tx.admin_deposit')" value="ADMIN_DEPOSIT" />
|
||||
<el-option :label="t('finance.tx.agent_deposit')" value="AGENT_DEPOSIT" />
|
||||
<el-option :label="t('finance.tx.player_deposit')" value="PLAYER_DEPOSIT" />
|
||||
<el-option :label="t('finance.tx.withdraw')" value="MANUAL_WITHDRAW" />
|
||||
<el-option :label="t('finance.tx.admin_withdraw')" value="ADMIN_WITHDRAW" />
|
||||
<el-option :label="t('finance.tx.agent_withdraw')" value="AGENT_WITHDRAW" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
@@ -311,4 +311,187 @@ label {
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Light operational login */
|
||||
.login-page {
|
||||
min-height: 100dvh;
|
||||
background-color: #f7f6f3;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.login-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(120deg, rgba(247, 246, 243, 0.94), rgba(247, 246, 243, 0.82)),
|
||||
rgba(247, 246, 243, 0.9);
|
||||
}
|
||||
|
||||
.login-mask {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.54), rgba(240, 237, 230, 0.82));
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 12px 0 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 24px 72px rgba(56, 49, 37, 0.14);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.form-lang {
|
||||
padding: 0 18px 12px;
|
||||
}
|
||||
|
||||
.form-body {
|
||||
grid-template-columns: minmax(210px, 270px) 1fr;
|
||||
}
|
||||
|
||||
.form-brand {
|
||||
background: #f4f0e8;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logo-large {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
gap: 10px;
|
||||
padding: 24px 26px 26px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 8px;
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #6c675f;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.field {
|
||||
height: 44px;
|
||||
background: #ffffff;
|
||||
border-color: var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field::placeholder {
|
||||
color: #aaa49a;
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(31, 35, 32, 0.08);
|
||||
}
|
||||
|
||||
.field:-webkit-autofill,
|
||||
.field:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0 1000px #ffffff inset;
|
||||
-webkit-text-fill-color: var(--text);
|
||||
caret-color: var(--text);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
height: 44px;
|
||||
border-color: var(--primary);
|
||||
border-radius: 8px;
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.btn-login:hover:not(:disabled) {
|
||||
border-color: var(--primary-light);
|
||||
background: var(--primary-light);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.quick-label {
|
||||
margin-top: 8px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.quick-btns {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.quick-btn {
|
||||
min-height: 54px;
|
||||
border-color: var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.quick-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: #d5cfc3;
|
||||
}
|
||||
|
||||
.quick-role {
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.quick-acc {
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login-page {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 420px;
|
||||
min-height: 100dvh;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.form-brand {
|
||||
padding: 22px 24px 14px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quick-btns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
938
apps/admin/src/views/MarketTemplates.vue
Normal file
938
apps/admin/src/views/MarketTemplates.vue
Normal file
@@ -0,0 +1,938 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import api from '../api';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import MatchesSubNav from '../components/MatchesSubNav.vue';
|
||||
|
||||
const { t, locale } = useAdminLocale();
|
||||
|
||||
const LOCALES = [
|
||||
{ code: 'zh-CN', label: '中文' },
|
||||
{ code: 'en-US', label: 'English' },
|
||||
{ code: 'ms-MY', label: 'Bahasa Melayu' },
|
||||
] as const;
|
||||
|
||||
const UI_TEXT: Record<string, Record<string, string>> = {
|
||||
add: { 'zh-CN': '添加', 'en-US': 'Add', 'ms-MY': 'Tambah' },
|
||||
addMarket: { 'zh-CN': '增加盘口', 'en-US': 'Add market', 'ms-MY': 'Tambah pasaran' },
|
||||
close: { 'zh-CN': '关闭', 'en-US': 'Close', 'ms-MY': 'Tutup' },
|
||||
copyCreated: { 'zh-CN': '已复制盘口线', 'en-US': 'Market line duplicated', 'ms-MY': 'Garisan pasaran disalin' },
|
||||
copyMarket: { 'zh-CN': '复制盘口', 'en-US': 'Duplicate market', 'ms-MY': 'Salin pasaran' },
|
||||
copyMarketCreated: { 'zh-CN': '已复制盘口', 'en-US': 'Market duplicated', 'ms-MY': 'Pasaran disalin' },
|
||||
copyMarketShort: { 'zh-CN': '复制盘口', 'en-US': 'Duplicate', 'ms-MY': 'Salin pasaran' },
|
||||
copyLine: { 'zh-CN': '复制盘口线', 'en-US': 'Duplicate line', 'ms-MY': 'Salin garisan' },
|
||||
copyLineShort: { 'zh-CN': '复制线', 'en-US': 'Copy', 'ms-MY': 'Salin' },
|
||||
createTemplate: { 'zh-CN': '新建模板', 'en-US': 'New template', 'ms-MY': 'Templat baharu' },
|
||||
defaultTemplate: { 'zh-CN': '默认', 'en-US': 'Default', 'ms-MY': 'Lalai' },
|
||||
duplicateTemplate: { 'zh-CN': '复制模板', 'en-US': 'Duplicate template', 'ms-MY': 'Salin templat' },
|
||||
hidden: { 'zh-CN': '玩家端隐藏', 'en-US': 'Hidden on player', 'ms-MY': 'Disembunyikan pemain' },
|
||||
lineValue: { 'zh-CN': '盘口线', 'en-US': 'Line', 'ms-MY': 'Garisan' },
|
||||
marketDetails: { 'zh-CN': '模板详情', 'en-US': 'Template details', 'ms-MY': 'Butiran templat' },
|
||||
marketName: { 'zh-CN': '盘口名称', 'en-US': 'Market name', 'ms-MY': 'Nama pasaran' },
|
||||
marketToAdd: { 'zh-CN': '选择要添加的盘口', 'en-US': 'Choose a market to add', 'ms-MY': 'Pilih pasaran untuk ditambah' },
|
||||
marketUnavailable: { 'zh-CN': '暂不开放玩家端', 'en-US': 'Not available on player', 'ms-MY': 'Belum tersedia untuk pemain' },
|
||||
moveDown: { 'zh-CN': '下移', 'en-US': 'Move down', 'ms-MY': 'Turun' },
|
||||
moveUp: { 'zh-CN': '上移', 'en-US': 'Move up', 'ms-MY': 'Naik' },
|
||||
odds: { 'zh-CN': '赔率', 'en-US': 'Odds', 'ms-MY': 'Odds' },
|
||||
parlay: { 'zh-CN': '串关', 'en-US': 'Parlay', 'ms-MY': 'Parlay' },
|
||||
playerVisible: { 'zh-CN': '玩家端显示', 'en-US': 'Show on player', 'ms-MY': 'Papar pemain' },
|
||||
promoLabel: { 'zh-CN': '促销标签', 'en-US': 'Promo label', 'ms-MY': 'Label promosi' },
|
||||
remove: { 'zh-CN': '移除', 'en-US': 'Remove', 'ms-MY': 'Buang' },
|
||||
selectMarket: { 'zh-CN': '从盘口池选择', 'en-US': 'Select from market pool', 'ms-MY': 'Pilih daripada kumpulan pasaran' },
|
||||
selectTemplate: { 'zh-CN': '选择模板', 'en-US': 'Select template', 'ms-MY': 'Pilih templat' },
|
||||
setDefault: { 'zh-CN': '设为默认', 'en-US': 'Set default', 'ms-MY': 'Jadikan lalai' },
|
||||
single: { 'zh-CN': '单关', 'en-US': 'Single', 'ms-MY': 'Tunggal' },
|
||||
status: { 'zh-CN': '状态', 'en-US': 'Status', 'ms-MY': 'Status' },
|
||||
statusClosed: { 'zh-CN': '已关闭', 'en-US': 'Closed', 'ms-MY': 'Ditutup' },
|
||||
statusOpen: { 'zh-CN': '可售', 'en-US': 'Open', 'ms-MY': 'Dibuka' },
|
||||
statusSuspended: { 'zh-CN': '暂停', 'en-US': 'Suspended', 'ms-MY': 'Digantung' },
|
||||
templateDefaultSet: { 'zh-CN': '已设为默认模板', 'en-US': 'Default template updated', 'ms-MY': 'Templat lalai dikemas kini' },
|
||||
templateMarkets: { 'zh-CN': '模板盘口', 'en-US': 'Template markets', 'ms-MY': 'Pasaran templat' },
|
||||
templateName: { 'zh-CN': '模板名称', 'en-US': 'Template name', 'ms-MY': 'Nama templat' },
|
||||
};
|
||||
|
||||
type LocalizedText = Record<string, string>;
|
||||
|
||||
interface MarketDefinition {
|
||||
marketType: string;
|
||||
marketKey: string;
|
||||
period: string;
|
||||
sortOrder: number;
|
||||
defaultLineValue: number | null;
|
||||
allowSingle: boolean;
|
||||
allowParlay: boolean;
|
||||
showOnPlayer: boolean;
|
||||
settlementSupported: boolean;
|
||||
usesLineValue: boolean;
|
||||
nameI18n: LocalizedText;
|
||||
selectionTemplate: Array<{ code: string; name: string; nameI18n: LocalizedText; odds: number }>;
|
||||
}
|
||||
|
||||
interface SelectionRow {
|
||||
id?: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
nameI18n: LocalizedText;
|
||||
odds: number;
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface MarketRow {
|
||||
id?: string;
|
||||
marketType: string;
|
||||
marketKey: string;
|
||||
period: string;
|
||||
lineValue: number | null;
|
||||
paramsJson: Record<string, unknown> | null;
|
||||
status: string;
|
||||
allowSingle: boolean;
|
||||
allowParlay: boolean;
|
||||
showOnPlayer: boolean;
|
||||
sortOrder: number;
|
||||
promoLabelI18n: LocalizedText;
|
||||
nameI18n: LocalizedText;
|
||||
selections: SelectionRow[];
|
||||
}
|
||||
|
||||
interface MarketTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
nameI18n: LocalizedText;
|
||||
isDefault: boolean;
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
items: MarketRow[];
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const definitions = ref<MarketDefinition[]>([]);
|
||||
const templates = ref<MarketTemplate[]>([]);
|
||||
const selectedId = ref('');
|
||||
const selectedIndex = ref(0);
|
||||
const addMarketVisible = ref(false);
|
||||
const selectedDefinitionType = ref('');
|
||||
const current = computed(() => templates.value.find((tpl) => tpl.id === selectedId.value) ?? null);
|
||||
const selectedItem = computed(() => current.value?.items[selectedIndex.value] ?? null);
|
||||
const definitionMap = computed(() => new Map(definitions.value.map((d) => [d.marketType, d])));
|
||||
const definitionOptions = computed(() => [...definitions.value].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
const displayLocales = computed(() => {
|
||||
const zhLabels: Record<string, string> = {
|
||||
'zh-CN': '中文',
|
||||
'en-US': '英语',
|
||||
'ms-MY': '马来语',
|
||||
};
|
||||
return LOCALES.map((loc) => ({
|
||||
code: loc.code,
|
||||
label: locale.value === 'zh-CN' ? zhLabels[loc.code] : loc.label,
|
||||
}));
|
||||
});
|
||||
|
||||
function localized(input?: Record<string, string> | null, fallback = ''): LocalizedText {
|
||||
const out: LocalizedText = {};
|
||||
for (const loc of LOCALES) out[loc.code] = input?.[loc.code]?.trim() || '';
|
||||
if (fallback && !out['zh-CN']) out['zh-CN'] = fallback;
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveText(map?: LocalizedText | null, fallback = '') {
|
||||
const order = Array.from(new Set([locale.value, 'en-US', 'zh-CN', 'ms-MY']));
|
||||
for (const loc of order) {
|
||||
const value = map?.[loc]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return Object.values(map ?? {}).find((value) => value?.trim()) || fallback;
|
||||
}
|
||||
|
||||
function ui(key: string) {
|
||||
return resolveText(UI_TEXT[key], key);
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'OPEN') return ui('statusOpen');
|
||||
if (status === 'SUSPENDED') return ui('statusSuspended');
|
||||
if (status === 'CLOSED') return ui('statusClosed');
|
||||
return status;
|
||||
}
|
||||
|
||||
function selectionTitle(selection: SelectionRow) {
|
||||
const score = /^SCORE_(\d+)_(\d+)$/.exec(selection.selectionCode);
|
||||
if (score) return `${score[1]}:${score[2]}`;
|
||||
return resolveText(selection.nameI18n, selection.selectionName || selection.selectionCode);
|
||||
}
|
||||
|
||||
function isScoreSelection(selection: SelectionRow) {
|
||||
return /^SCORE_\d+_\d+$/.test(selection.selectionCode);
|
||||
}
|
||||
|
||||
function marketTitle(market: MarketRow) {
|
||||
const line = isLineMarket(market) && market.lineValue != null ? ` ${market.lineValue}` : '';
|
||||
return `${resolveText(market.nameI18n, market.marketType)}${line}`;
|
||||
}
|
||||
|
||||
function marketMetaText(market: MarketRow) {
|
||||
const parts = [statusLabel(market.status)];
|
||||
if (isLineMarket(market) && market.lineValue != null) parts.push(`${ui('lineValue')} ${market.lineValue}`);
|
||||
if (!market.showOnPlayer) parts.push(ui('hidden'));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function definitionLabel(def: MarketDefinition) {
|
||||
const label = resolveText(def.nameI18n, def.marketType);
|
||||
return def.settlementSupported ? label : `${label}(${ui('marketUnavailable')})`;
|
||||
}
|
||||
|
||||
function templateLabel(tpl: MarketTemplate) {
|
||||
const label = resolveText(tpl.nameI18n, tpl.name || '');
|
||||
return tpl.isDefault ? `${label}(${ui('defaultTemplate')})` : label;
|
||||
}
|
||||
|
||||
function mapItem(raw: MarketRow, index: number): MarketRow {
|
||||
return {
|
||||
id: raw.id,
|
||||
marketType: raw.marketType,
|
||||
marketKey: raw.marketKey || raw.marketType,
|
||||
period: raw.period,
|
||||
lineValue: raw.lineValue,
|
||||
paramsJson: raw.paramsJson ?? null,
|
||||
status: raw.status || 'OPEN',
|
||||
allowSingle: raw.allowSingle ?? true,
|
||||
allowParlay: raw.allowParlay ?? true,
|
||||
showOnPlayer: raw.showOnPlayer ?? true,
|
||||
sortOrder: raw.sortOrder ?? index,
|
||||
promoLabelI18n: localized(raw.promoLabelI18n),
|
||||
nameI18n: localized(raw.nameI18n, raw.marketType),
|
||||
selections: (raw.selections ?? []).map((s, sIndex) => ({
|
||||
id: s.id,
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: localized(s.nameI18n, s.selectionName),
|
||||
odds: Number(s.odds),
|
||||
status: s.status || 'OPEN',
|
||||
sortOrder: s.sortOrder ?? sIndex,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function usesLineValue(marketType: string) {
|
||||
return definitionMap.value.get(marketType)?.usesLineValue ?? false;
|
||||
}
|
||||
|
||||
function isLineMarket(market: MarketRow) {
|
||||
return usesLineValue(market.marketType);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [{ data: defRes }, { data: tplRes }] = await Promise.all([
|
||||
api.get('/admin/market-definitions'),
|
||||
api.get('/admin/market-templates'),
|
||||
]);
|
||||
definitions.value = defRes.data ?? [];
|
||||
templates.value = (tplRes.data ?? []).map((tpl: MarketTemplate) => ({
|
||||
...tpl,
|
||||
nameI18n: localized(tpl.nameI18n, tpl.name),
|
||||
items: (tpl.items ?? []).map(mapItem),
|
||||
}));
|
||||
selectedId.value =
|
||||
selectedId.value && templates.value.some((tpl) => tpl.id === selectedId.value)
|
||||
? selectedId.value
|
||||
: templates.value.find((tpl) => tpl.isDefault)?.id || templates.value[0]?.id || '';
|
||||
selectedIndex.value = 0;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedId, () => {
|
||||
selectedIndex.value = 0;
|
||||
});
|
||||
|
||||
function openAddMarketDialog() {
|
||||
selectedDefinitionType.value = definitionOptions.value[0]?.marketType ?? '';
|
||||
addMarketVisible.value = true;
|
||||
}
|
||||
|
||||
function addSelectedMarket() {
|
||||
const def = definitionMap.value.get(selectedDefinitionType.value);
|
||||
if (!def) return;
|
||||
addItem(def);
|
||||
addMarketVisible.value = false;
|
||||
selectedDefinitionType.value = '';
|
||||
}
|
||||
|
||||
function addItem(def: MarketDefinition) {
|
||||
if (!current.value) return;
|
||||
current.value.items.push({
|
||||
marketType: def.marketType,
|
||||
marketKey: def.marketKey,
|
||||
period: def.period,
|
||||
lineValue: def.defaultLineValue,
|
||||
paramsJson: null,
|
||||
status: 'OPEN',
|
||||
allowSingle: def.allowSingle,
|
||||
allowParlay: def.allowParlay,
|
||||
showOnPlayer: def.showOnPlayer && def.settlementSupported,
|
||||
sortOrder: current.value.items.length,
|
||||
promoLabelI18n: localized(),
|
||||
nameI18n: localized(def.nameI18n, def.marketType),
|
||||
selections: def.selectionTemplate.map((s, index) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
nameI18n: localized(s.nameI18n, s.name),
|
||||
odds: Number(s.odds),
|
||||
status: 'OPEN',
|
||||
sortOrder: index,
|
||||
})),
|
||||
});
|
||||
selectedIndex.value = current.value.items.length - 1;
|
||||
}
|
||||
|
||||
function copyLocalizedName(map: LocalizedText) {
|
||||
const suffix: Record<string, string> = {
|
||||
'zh-CN': ' 副本',
|
||||
'en-US': ' Copy',
|
||||
'ms-MY': ' Salinan',
|
||||
};
|
||||
const next = localized(map, resolveText(map));
|
||||
for (const loc of LOCALES) {
|
||||
const base = next[loc.code] || resolveText(next);
|
||||
next[loc.code] = `${base}${suffix[loc.code]}`;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function clonePlain<T>(value: T): T {
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function nextCopiedLineValue(source: MarketRow) {
|
||||
const tpl = current.value;
|
||||
const used = new Set(
|
||||
(tpl?.items ?? [])
|
||||
.filter((item) => item.marketType === source.marketType && item.lineValue != null)
|
||||
.map((item) => Number(item.lineValue).toFixed(2)),
|
||||
);
|
||||
let candidate = Number(((source.lineValue ?? 0) + 0.25).toFixed(2));
|
||||
while (used.has(candidate.toFixed(2))) {
|
||||
candidate = Number((candidate + 0.25).toFixed(2));
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function duplicateItem(index: number) {
|
||||
const tpl = current.value;
|
||||
const source = tpl?.items[index];
|
||||
if (!tpl || !source) return;
|
||||
const shouldCopyLine = isLineMarket(source);
|
||||
const sourceCopy = clonePlain(source);
|
||||
const paramsCopy = clonePlain(source.paramsJson);
|
||||
const copy: MarketRow = {
|
||||
...sourceCopy,
|
||||
id: undefined,
|
||||
lineValue: shouldCopyLine ? nextCopiedLineValue(source) : null,
|
||||
paramsJson: shouldCopyLine
|
||||
? paramsCopy
|
||||
: { ...(paramsCopy ?? {}), copyId: `copy-${Date.now()}-${index}` },
|
||||
nameI18n: copyLocalizedName(source.nameI18n),
|
||||
sortOrder: tpl.items.length,
|
||||
selections: source.selections.map((selection) => ({ ...clonePlain(selection), id: undefined })),
|
||||
};
|
||||
tpl.items.splice(index + 1, 0, copy);
|
||||
tpl.items.forEach((item, i) => {
|
||||
item.sortOrder = i;
|
||||
});
|
||||
selectedIndex.value = index + 1;
|
||||
ElMessage.success(ui(shouldCopyLine ? 'copyCreated' : 'copyMarketCreated'));
|
||||
}
|
||||
|
||||
function moveItem(index: number, direction: -1 | 1) {
|
||||
const tpl = current.value;
|
||||
if (!tpl) return;
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= tpl.items.length) return;
|
||||
const [row] = tpl.items.splice(index, 1);
|
||||
tpl.items.splice(target, 0, row);
|
||||
tpl.items.forEach((item, i) => {
|
||||
item.sortOrder = i;
|
||||
});
|
||||
selectedIndex.value = target;
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
const tpl = current.value;
|
||||
if (!tpl) return;
|
||||
tpl.items.splice(index, 1);
|
||||
tpl.items.forEach((item, i) => {
|
||||
item.sortOrder = i;
|
||||
});
|
||||
selectedIndex.value = Math.min(selectedIndex.value, Math.max(0, tpl.items.length - 1));
|
||||
}
|
||||
|
||||
function payload(tpl: MarketTemplate) {
|
||||
return {
|
||||
name: tpl.name,
|
||||
nameI18n: localized(tpl.nameI18n, tpl.name),
|
||||
isDefault: tpl.isDefault,
|
||||
status: tpl.status,
|
||||
sortOrder: tpl.sortOrder,
|
||||
items: tpl.items.map((m, index) => ({
|
||||
id: m.id,
|
||||
marketType: m.marketType,
|
||||
marketKey: m.marketKey,
|
||||
lineKey: null,
|
||||
period: m.period,
|
||||
lineValue: usesLineValue(m.marketType) ? m.lineValue : null,
|
||||
paramsJson: m.paramsJson,
|
||||
status: m.status,
|
||||
allowSingle: m.allowSingle,
|
||||
allowParlay: m.allowParlay,
|
||||
showOnPlayer: m.showOnPlayer,
|
||||
sortOrder: index,
|
||||
promoLabelI18n: localized(m.promoLabelI18n),
|
||||
nameI18n: localized(m.nameI18n, m.marketType),
|
||||
selections: m.selections.map((s, sIndex) => ({
|
||||
id: s.id,
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: resolveText(s.nameI18n, s.selectionName),
|
||||
nameI18n: localized(s.nameI18n, s.selectionName),
|
||||
odds: Number(s.odds),
|
||||
status: s.status,
|
||||
sortOrder: sIndex,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function createTemplate() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data } = await api.post('/admin/market-templates', {
|
||||
name: '新盘口模板',
|
||||
nameI18n: { 'zh-CN': '新盘口模板', 'en-US': 'New Market Template', 'ms-MY': 'Templat Pasaran Baharu' },
|
||||
items: [],
|
||||
});
|
||||
await load();
|
||||
selectedId.value = data.data.id;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateTemplate() {
|
||||
if (!current.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data } = await api.post(`/admin/market-templates/${current.value.id}/duplicate`);
|
||||
await load();
|
||||
selectedId.value = data.data.id;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault() {
|
||||
if (!current.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post(`/admin/market-templates/${current.value.id}/set-default`);
|
||||
ElMessage.success(ui('templateDefaultSet'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
if (!current.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.put(`/admin/market-templates/${current.value.id}`, payload(current.value));
|
||||
ElMessage.success(t('msg.saved'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="template-page">
|
||||
<MatchesSubNav />
|
||||
<div class="template-toolbar">
|
||||
<el-select v-model="selectedId" class="template-select" :placeholder="ui('selectTemplate')">
|
||||
<el-option
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
:label="templateLabel(tpl)"
|
||||
:value="tpl.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button size="small" @click="createTemplate">{{ ui('createTemplate') }}</el-button>
|
||||
<el-button size="small" :disabled="!current" @click="duplicateTemplate">{{ ui('duplicateTemplate') }}</el-button>
|
||||
<el-button size="small" :disabled="!current?.id || current?.isDefault" @click="setDefault">{{ ui('setDefault') }}</el-button>
|
||||
<el-button size="small" :disabled="!current" @click="openAddMarketDialog">{{ ui('addMarket') }}</el-button>
|
||||
<el-button size="small" type="primary" :loading="saving" :disabled="!current" @click="saveTemplate">
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="current" class="template-grid">
|
||||
<section class="items">
|
||||
<div class="section-title">{{ ui('templateMarkets') }}</div>
|
||||
<div class="template-name">
|
||||
<label v-for="loc in displayLocales" :key="loc.code">
|
||||
<span>{{ loc.label }}</span>
|
||||
<el-input v-model="current.nameI18n[loc.code]" size="small" />
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, index) in current.items"
|
||||
:key="item.id || `${item.marketType}-${index}`"
|
||||
class="item-card"
|
||||
:class="{ active: selectedIndex === index }"
|
||||
@click="selectedIndex = index"
|
||||
>
|
||||
<div class="item-main">
|
||||
<strong>{{ marketTitle(item) }}</strong>
|
||||
<span>{{ marketMetaText(item) }}</span>
|
||||
</div>
|
||||
<div class="item-actions" @click.stop>
|
||||
<el-button class="item-action-btn" size="small" text :title="ui('moveUp')" :disabled="index === 0" @click="moveItem(index, -1)">↑</el-button>
|
||||
<el-button class="item-action-btn" size="small" text :title="ui('moveDown')" :disabled="index === current.items.length - 1" @click="moveItem(index, 1)">↓</el-button>
|
||||
<el-button
|
||||
class="item-action-btn copy-btn"
|
||||
size="small"
|
||||
text
|
||||
:title="ui(isLineMarket(item) ? 'copyLine' : 'copyMarket')"
|
||||
@click="duplicateItem(index)"
|
||||
>
|
||||
{{ ui(isLineMarket(item) ? 'copyLineShort' : 'copyMarketShort') }}
|
||||
</el-button>
|
||||
<el-button class="item-action-btn" size="small" text type="danger" :title="ui('remove')" @click="removeItem(index)">{{ ui('remove') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="selectedItem" class="detail">
|
||||
<div class="detail-head">
|
||||
<div>
|
||||
<div class="section-title">{{ ui('marketDetails') }}</div>
|
||||
<strong>{{ marketTitle(selectedItem) }}</strong>
|
||||
</div>
|
||||
<el-switch v-model="selectedItem.showOnPlayer" :active-text="ui('playerVisible')" />
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>{{ ui('status') }}</span>
|
||||
<el-select v-model="selectedItem.status" size="small">
|
||||
<el-option :label="ui('statusOpen')" value="OPEN" />
|
||||
<el-option :label="ui('statusSuspended')" value="SUSPENDED" />
|
||||
<el-option :label="ui('statusClosed')" value="CLOSED" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label v-if="isLineMarket(selectedItem)">
|
||||
<span>{{ ui('lineValue') }}</span>
|
||||
<el-input-number v-model="selectedItem.lineValue" size="small" :step="0.25" controls-position="right" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ ui('single') }}</span>
|
||||
<el-switch v-model="selectedItem.allowSingle" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ ui('parlay') }}</span>
|
||||
<el-switch v-model="selectedItem.allowParlay" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="locale-block">
|
||||
<div class="block-title">{{ ui('marketName') }}</div>
|
||||
<div class="locale-grid">
|
||||
<label v-for="loc in displayLocales" :key="loc.code">
|
||||
<span>{{ loc.label }}</span>
|
||||
<el-input v-model="selectedItem.nameI18n[loc.code]" size="small" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="locale-block">
|
||||
<div class="block-title">{{ ui('promoLabel') }}</div>
|
||||
<div class="locale-grid">
|
||||
<label v-for="loc in displayLocales" :key="`promo-${loc.code}`">
|
||||
<span>{{ loc.label }}</span>
|
||||
<el-input v-model="selectedItem.promoLabelI18n[loc.code]" size="small" clearable />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selection-list">
|
||||
<div
|
||||
v-for="sel in selectedItem.selections"
|
||||
:key="sel.selectionCode"
|
||||
class="selection-row"
|
||||
:class="{ 'selection-row--score': isScoreSelection(sel) }"
|
||||
>
|
||||
<div class="selection-meta">
|
||||
<strong :title="sel.selectionCode">{{ selectionTitle(sel) }}</strong>
|
||||
<label class="selection-field selection-field--status">
|
||||
<span>{{ ui('status') }}</span>
|
||||
<el-select v-model="sel.status" size="small">
|
||||
<el-option :label="ui('statusOpen')" value="OPEN" />
|
||||
<el-option :label="ui('statusSuspended')" value="SUSPENDED" />
|
||||
<el-option :label="ui('statusClosed')" value="CLOSED" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="selection-field selection-field--odds">
|
||||
<span>{{ ui('odds') }}</span>
|
||||
<el-input-number v-model="sel.odds" size="small" :min="1.01" :step="0.01" controls-position="right" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!isScoreSelection(sel)" class="locale-grid compact">
|
||||
<label v-for="loc in displayLocales" :key="loc.code">
|
||||
<span>{{ loc.label }}</span>
|
||||
<el-input v-model="sel.nameI18n[loc.code]" size="small" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="addMarketVisible" class="add-market-dialog" :title="ui('addMarket')" width="460px" destroy-on-close>
|
||||
<label class="add-market-field">
|
||||
<span>{{ ui('marketToAdd') }}</span>
|
||||
<el-select
|
||||
v-model="selectedDefinitionType"
|
||||
class="full-select"
|
||||
filterable
|
||||
:placeholder="ui('selectMarket')"
|
||||
>
|
||||
<el-option
|
||||
v-for="def in definitionOptions"
|
||||
:key="def.marketType"
|
||||
:label="definitionLabel(def)"
|
||||
:value="def.marketType"
|
||||
/>
|
||||
</el-select>
|
||||
</label>
|
||||
<template #footer>
|
||||
<el-button @click="addMarketVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :disabled="!selectedDefinitionType" @click="addSelectedMarket">
|
||||
{{ ui('add') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.template-page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 0 12px;
|
||||
}
|
||||
|
||||
.template-select {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 420px) minmax(480px, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.items,
|
||||
.detail {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 8px;
|
||||
background: #111;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.section-title,
|
||||
.block-title {
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border: 1px solid #282828;
|
||||
border-radius: 6px;
|
||||
background: #151515;
|
||||
color: #ddd;
|
||||
padding: 8px;
|
||||
margin-bottom: 6px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-card span {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #777;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.item-card.active {
|
||||
border-color: #9f853d;
|
||||
background: #1a1710;
|
||||
}
|
||||
|
||||
.item-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-main strong,
|
||||
.detail-head strong {
|
||||
display: block;
|
||||
color: #f0f0f0;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 30px 30px 46px 40px;
|
||||
gap: 2px;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.item-action-btn {
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
margin-left: 0 !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.template-name,
|
||||
.form-grid,
|
||||
.locale-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.locale-grid.compact {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.locale-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.selection-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selection-row {
|
||||
padding: 8px;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 6px;
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
.selection-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 1fr) 120px 132px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.selection-row--score .selection-meta {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.selection-meta strong {
|
||||
color: #ddd;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.selection-field {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.selection-field > span {
|
||||
color: #f3c969;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.selection-field--status > span {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.selection-field--odds :deep(.el-input-number),
|
||||
.selection-field--odds :deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-market-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.full-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.template-toolbar,
|
||||
.template-grid,
|
||||
.template-name,
|
||||
.form-grid,
|
||||
.locale-grid,
|
||||
.selection-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.template-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.items,
|
||||
.detail {
|
||||
min-height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.template-toolbar {
|
||||
padding: 12px 0 14px;
|
||||
}
|
||||
|
||||
.items,
|
||||
.detail,
|
||||
.item-card,
|
||||
.selection-row {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.items,
|
||||
.detail {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.section-title,
|
||||
.block-title,
|
||||
.item-main strong,
|
||||
.detail-head strong,
|
||||
.selection-meta strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.item-card span,
|
||||
label,
|
||||
.selection-field > span {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.item-card.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.selection-row {
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.selection-field > span {
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.selection-field--status > span {
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.item-actions {
|
||||
grid-template-columns: repeat(4, minmax(30px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -408,6 +408,11 @@ function onLeagueArchived() {
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-chrome__actions">
|
||||
<el-button type="primary" @click.stop="openCreateLeague">
|
||||
{{ t('match.create_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="filterStatus" class="list-hint">{{ t('match.filter.status_hint') }}</p>
|
||||
</div>
|
||||
@@ -489,9 +494,6 @@ function onLeagueArchived() {
|
||||
<template #header>
|
||||
<div class="actions-col-header">
|
||||
<span class="actions-col-header__label">{{ t('common.actions') }}</span>
|
||||
<el-button type="primary" size="small" @click.stop="openCreateLeague">
|
||||
{{ t('match.create_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
@@ -596,6 +598,7 @@ function onLeagueArchived() {
|
||||
:placeholder="t('matchEditor.ph.kickoff')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<p class="field-hint schedule-timezone-hint">{{ t('match.timezone.platform_hint') }}</p>
|
||||
</el-form-item>
|
||||
<div class="teams-row">
|
||||
<!-- Home Team Column -->
|
||||
@@ -686,19 +689,24 @@ function onLeagueArchived() {
|
||||
|
||||
.team-col-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #bbb;
|
||||
font-weight: 750;
|
||||
color: var(--text);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.schedule-timezone-hint {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.edit-hint {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -714,7 +722,7 @@ function onLeagueArchived() {
|
||||
|
||||
.matches-page :deep(.el-table__expanded-cell) {
|
||||
padding: 0 !important;
|
||||
background: #0a0a0a;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.list-panel :deep(.row-expandable) {
|
||||
@@ -734,7 +742,7 @@ function onLeagueArchived() {
|
||||
font-weight: 600;
|
||||
}
|
||||
.bet-stat-zero {
|
||||
color: #555;
|
||||
color: #aaa49a;
|
||||
}
|
||||
|
||||
.league-cell {
|
||||
@@ -751,13 +759,13 @@ function onLeagueArchived() {
|
||||
}
|
||||
|
||||
.league-en {
|
||||
color: #aaa;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.league-readonly {
|
||||
color: var(--green-text);
|
||||
font-weight: 500;
|
||||
color: var(--success-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.actions-col-header {
|
||||
@@ -773,8 +781,8 @@ function onLeagueArchived() {
|
||||
|
||||
.actions-col-header__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -811,8 +819,8 @@ function onLeagueArchived() {
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: #fbfaf7;
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.league-row-actions :deep(.el-button) {
|
||||
@@ -836,4 +844,50 @@ function onLeagueArchived() {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-chrome__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.team-col {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.matchup-link,
|
||||
.bet-stat-active {
|
||||
color: var(--primary-link);
|
||||
}
|
||||
|
||||
.league-row-actions :deep(.el-button) {
|
||||
min-height: 26px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.list-chrome__actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.teams-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actions-col-header {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.league-row-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -247,13 +247,13 @@ function isLeagueExpanded(id: string) {
|
||||
}
|
||||
.matches-page :deep(.el-table__expanded-cell) {
|
||||
padding: 0 !important;
|
||||
background: #0a0a0a;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
.list-panel :deep(.row-expandable) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.matchup-link {
|
||||
color: var(--green-text);
|
||||
color: var(--primary-link);
|
||||
}
|
||||
.league-cell {
|
||||
display: flex;
|
||||
@@ -267,7 +267,7 @@ function isLeagueExpanded(id: string) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.league-en {
|
||||
color: #aaa;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -649,4 +649,223 @@ async function doUpload() {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #1e1e1e;
|
||||
}
|
||||
|
||||
.media-page {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.unused-badge {
|
||||
border-color: var(--warning-border);
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.tab-btn,
|
||||
.btn {
|
||||
border-radius: 7px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
border-color: var(--primary-light);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
padding: 2px 2px 8px;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
border-color: var(--border);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
border-color: #d5cfc3;
|
||||
box-shadow: 0 10px 26px rgba(56, 49, 37, 0.08);
|
||||
}
|
||||
|
||||
.card-thumb,
|
||||
.media-picker-thumb {
|
||||
background: #f4f0e8;
|
||||
}
|
||||
|
||||
.svg-badge,
|
||||
.media-picker-svg {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge-used {
|
||||
border-color: var(--warning-border);
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.badge-unused {
|
||||
border-color: var(--border);
|
||||
background: #f0ede6;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-filename {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-meta,
|
||||
.card-date,
|
||||
.page-info,
|
||||
.state-center,
|
||||
.muted,
|
||||
.drop-hint,
|
||||
.sel-size,
|
||||
.upload-hint-text,
|
||||
.form-row label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cat-tag {
|
||||
border-color: var(--border-soft);
|
||||
background: #f0ede6;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
border-top-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.act-btn {
|
||||
border-right-color: var(--border-soft);
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.act-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.act-delete:hover {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.file-grid::-webkit-scrollbar-thumb {
|
||||
background: #d5cfc3;
|
||||
}
|
||||
|
||||
.file-grid::-webkit-scrollbar-thumb:hover {
|
||||
background: #bdb6a9;
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
background: rgba(31, 35, 32, 0.28);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 24px 64px rgba(56, 49, 37, 0.14);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
border-bottom-color: var(--border-soft);
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dialog-close:hover {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border-color: #d5cfc3;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone.drop-active {
|
||||
border-color: var(--primary);
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.sel-name {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
border-top-color: var(--border-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.toolbar,
|
||||
.toolbar-right,
|
||||
.filter-tabs {
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-btn,
|
||||
.toolbar-right .btn {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
}
|
||||
|
||||
.dialog {
|
||||
max-height: calc(100dvh - 32px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -309,7 +309,11 @@ onMounted(fetchList);
|
||||
<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 class="check-option">
|
||||
<input type="checkbox" v-model="form.isActive" />
|
||||
<span class="check-option__box" aria-hidden="true"></span>
|
||||
<span>{{ t('deposit.active') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn-cancel" @click="dialogVisible = false">{{ t('common.cancel') }}</button>
|
||||
@@ -365,4 +369,262 @@ onMounted(fetchList);
|
||||
.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; }
|
||||
|
||||
.page-payment-methods {
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.toolbar h2 {
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select,
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.lang-row input {
|
||||
min-height: 34px;
|
||||
border-color: var(--border);
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 7px;
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
border-bottom-color: var(--border-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #fbfaf7;
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.details-cell .sub,
|
||||
.form-group label,
|
||||
.lang-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.qr-thumb,
|
||||
.qr-preview {
|
||||
border: 1px solid var(--border);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
background: var(--info-bg);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.status-on {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.status-off {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
border-color: var(--info-border);
|
||||
background: var(--info-bg);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
background: rgba(31, 35, 32, 0.28);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.dialog-box {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
box-shadow: 0 24px 64px rgba(56, 49, 37, 0.14);
|
||||
}
|
||||
|
||||
.dialog-box h3 {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.row-checks label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.row-checks .check-option {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fbfaf7;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.row-checks .check-option:hover {
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.row-checks .check-option input[type='checkbox'] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.check-option__box {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #cfc7b9;
|
||||
border-radius: 5px;
|
||||
background: #ffffff;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.check-option__box::after {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid #ffffff;
|
||||
border-width: 0 2px 2px 0;
|
||||
opacity: 0;
|
||||
transform: rotate(45deg) translateY(-1px);
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.row-checks .check-option input[type='checkbox']:checked + .check-option__box {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(31, 35, 32, 0.08);
|
||||
}
|
||||
|
||||
.row-checks .check-option input[type='checkbox']:checked + .check-option__box::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row-checks .check-option input[type='checkbox']:focus-visible + .check-option__box {
|
||||
box-shadow: 0 0 0 3px rgba(31, 35, 32, 0.14);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lang-section {
|
||||
border-color: var(--border-soft);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.lang-tag {
|
||||
background: #f0ede6;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.filter-select,
|
||||
.actions .btn-primary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dialog-box {
|
||||
min-width: 0;
|
||||
width: calc(100vw - 32px);
|
||||
max-height: calc(100dvh - 32px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, defineAsyncComponent } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { formatApiErrorMessage, isApiErrorCode } from '@thebet365/shared';
|
||||
import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
@@ -70,11 +71,82 @@ interface SettlementBetStats {
|
||||
const { t, locale, localeTag } = useAdminLocale();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const STAT_FACT_LABELS = {
|
||||
homeCorners: {
|
||||
'zh-CN': '主队角球',
|
||||
'en-US': 'Home corners',
|
||||
'ms-MY': 'Sepakan sudut tuan rumah',
|
||||
},
|
||||
awayCorners: {
|
||||
'zh-CN': '客队角球',
|
||||
'en-US': 'Away corners',
|
||||
'ms-MY': 'Sepakan sudut pelawat',
|
||||
},
|
||||
homeCards: {
|
||||
'zh-CN': '主队黄牌/红牌',
|
||||
'en-US': 'Home yellow/red cards',
|
||||
'ms-MY': 'Kad kuning/merah tuan rumah',
|
||||
},
|
||||
awayCards: {
|
||||
'zh-CN': '客队黄牌/红牌',
|
||||
'en-US': 'Away yellow/red cards',
|
||||
'ms-MY': 'Kad kuning/merah pelawat',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const loading = ref(false);
|
||||
const previewing = ref(false);
|
||||
const match = ref<AdminMatchDetail | null>(null);
|
||||
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
|
||||
const matchStats = ref<{
|
||||
homeCorners: number | null;
|
||||
awayCorners: number | null;
|
||||
homeYellowCards: number | null;
|
||||
awayYellowCards: number | null;
|
||||
homeRedCards: number | null;
|
||||
awayRedCards: number | null;
|
||||
homeCards: number | null;
|
||||
awayCards: number | null;
|
||||
}>(emptyMatchStats());
|
||||
|
||||
function emptyMatchStats() {
|
||||
return {
|
||||
homeCorners: null,
|
||||
awayCorners: null,
|
||||
homeYellowCards: null,
|
||||
awayYellowCards: null,
|
||||
homeRedCards: null,
|
||||
awayRedCards: null,
|
||||
homeCards: null,
|
||||
awayCards: null,
|
||||
};
|
||||
}
|
||||
|
||||
function cardTotalFromBreakdown(
|
||||
yellow: number | null,
|
||||
red: number | null,
|
||||
fallback: number | null,
|
||||
) {
|
||||
const hasBreakdown = yellow != null || red != null;
|
||||
if (!hasBreakdown) return fallback ?? null;
|
||||
if (yellow == null || red == null) return null;
|
||||
return yellow + red;
|
||||
}
|
||||
|
||||
function settlementStatsPayload() {
|
||||
const s = matchStats.value;
|
||||
return {
|
||||
...s,
|
||||
homeCards: cardTotalFromBreakdown(s.homeYellowCards, s.homeRedCards, s.homeCards),
|
||||
awayCards: cardTotalFromBreakdown(s.awayYellowCards, s.awayRedCards, s.awayCards),
|
||||
};
|
||||
}
|
||||
|
||||
function resetSettlementInputs() {
|
||||
score.value = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
|
||||
matchStats.value = emptyMatchStats();
|
||||
}
|
||||
|
||||
const winnerTeamId = ref('');
|
||||
const outrightSelections = ref<
|
||||
Array<{ teamId: string; teamCode: string; teamZh: string; teamEn: string }>
|
||||
@@ -133,7 +205,7 @@ function buildSettlementPayload(): Record<string, unknown> | null {
|
||||
}
|
||||
return { winnerTeamId: Number(winnerTeamId.value) };
|
||||
}
|
||||
return { ...score.value };
|
||||
return { ...score.value, ...settlementStatsPayload() };
|
||||
}
|
||||
type PreviewItem = {
|
||||
betNo: string;
|
||||
@@ -162,7 +234,7 @@ const previewZeroHint = computed(() => {
|
||||
if (payout > 0 || refund > 0) return '';
|
||||
const pending = Number(p.pendingOtherMatches ?? 0);
|
||||
const wonLegs = Number(p.wonLegsOnMatch ?? 0);
|
||||
if (pending > 0 && wonLegs > 0) {
|
||||
if (pending > 0) {
|
||||
return t('settlement.preview_zero_parlay_hint', { pending: String(pending), legs: String(wonLegs) });
|
||||
}
|
||||
if (Number(p.lostOnThisMatch ?? 0) > 0) {
|
||||
@@ -380,8 +452,19 @@ async function loadMatch() {
|
||||
ftHome: detail.score.ftHome,
|
||||
ftAway: detail.score.ftAway,
|
||||
};
|
||||
matchStats.value = {
|
||||
homeCorners: detail.score.homeCorners ?? null,
|
||||
awayCorners: detail.score.awayCorners ?? null,
|
||||
homeYellowCards: detail.score.homeYellowCards ?? null,
|
||||
awayYellowCards: detail.score.awayYellowCards ?? null,
|
||||
homeRedCards: detail.score.homeRedCards ?? null,
|
||||
awayRedCards: detail.score.awayRedCards ?? null,
|
||||
homeCards: detail.score.homeCards ?? null,
|
||||
awayCards: detail.score.awayCards ?? null,
|
||||
};
|
||||
winnerTeamId.value = detail.score.winnerTeamId ?? '';
|
||||
} else {
|
||||
resetSettlementInputs();
|
||||
winnerTeamId.value = '';
|
||||
}
|
||||
if (detail.isOutright) {
|
||||
@@ -437,7 +520,21 @@ async function confirmResettle() {
|
||||
}
|
||||
|
||||
function settlementApiError(e: unknown, fallback: string) {
|
||||
const err = e as { response?: { data?: { error?: string; message?: string } } };
|
||||
const err = e as { response?: { data?: { code?: unknown; params?: Record<string, string | number>; error?: string; message?: string } } };
|
||||
const code = err.response?.data?.code;
|
||||
if (isApiErrorCode(code)) {
|
||||
const params = { ...(err.response?.data?.params ?? {}) };
|
||||
if (code === 'SETTLEMENT_FACTS_REQUIRED' && typeof params.fields === 'string') {
|
||||
params.fields = params.fields
|
||||
.split(',')
|
||||
.map((field) => {
|
||||
const labels = STAT_FACT_LABELS[field as keyof typeof STAT_FACT_LABELS];
|
||||
return labels?.[locale.value as keyof typeof labels] ?? field;
|
||||
})
|
||||
.join(locale.value === 'zh-CN' ? '、' : ', ');
|
||||
}
|
||||
return formatApiErrorMessage(code, locale.value, params);
|
||||
}
|
||||
const raw = err.response?.data?.error ?? err.response?.data?.message ?? fallback;
|
||||
if (raw === 'Score not recorded' || raw === 'Score not found') {
|
||||
return t('settlement.err_score_not_recorded');
|
||||
@@ -595,6 +692,69 @@ onMounted(() => {
|
||||
<el-input-number v-model="score.ftAway" :min="0" controls-position="right" style="width: 88px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-block compact stat-block">
|
||||
<span class="score-title">{{ t('settlement.corners') }}</span>
|
||||
<div class="stat-inputs">
|
||||
<span class="stat-side">{{ t('settlement.home_stat') }}</span>
|
||||
<el-input-number
|
||||
v-model="matchStats.homeCorners"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
style="width: 80px"
|
||||
/>
|
||||
<span class="stat-side">{{ t('settlement.away_stat') }}</span>
|
||||
<el-input-number
|
||||
v-model="matchStats.awayCorners"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
style="width: 80px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-block compact stat-block">
|
||||
<span class="score-title">{{ t('settlement.yellow_cards') }}</span>
|
||||
<div class="stat-inputs">
|
||||
<span class="stat-side">{{ t('settlement.home_stat') }}</span>
|
||||
<el-input-number
|
||||
v-model="matchStats.homeYellowCards"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
style="width: 80px"
|
||||
/>
|
||||
<span class="stat-side">{{ t('settlement.away_stat') }}</span>
|
||||
<el-input-number
|
||||
v-model="matchStats.awayYellowCards"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
style="width: 80px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-block compact stat-block">
|
||||
<span class="score-title">{{ t('settlement.red_cards') }}</span>
|
||||
<div class="stat-inputs">
|
||||
<span class="stat-side">{{ t('settlement.home_stat') }}</span>
|
||||
<el-input-number
|
||||
v-model="matchStats.homeRedCards"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
style="width: 80px"
|
||||
/>
|
||||
<span class="stat-side">{{ t('settlement.away_stat') }}</span>
|
||||
<el-input-number
|
||||
v-model="matchStats.awayRedCards"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
style="width: 80px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settle-actions">
|
||||
@@ -873,7 +1033,7 @@ onMounted(() => {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.outright-settle-head {
|
||||
@@ -887,7 +1047,7 @@ onMounted(() => {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--green-text);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.outright-winner-row {
|
||||
@@ -938,19 +1098,19 @@ onMounted(() => {
|
||||
|
||||
.team-name-inline {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
font-weight: 750;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kickoff-inline {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kickoff-inline .meta-k {
|
||||
color: #777;
|
||||
color: #aaa49a;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
@@ -971,6 +1131,22 @@ onMounted(() => {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stat-block {
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.stat-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-side {
|
||||
font-size: 11px;
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -997,7 +1173,7 @@ onMounted(() => {
|
||||
.leg-badge {
|
||||
margin-left: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--gold-text, #dcc078);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.bet-content-cell {
|
||||
@@ -1083,7 +1259,7 @@ onMounted(() => {
|
||||
.vs {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--gold-text, #dcc078);
|
||||
color: var(--warning-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1256,7 +1432,70 @@ onMounted(() => {
|
||||
|
||||
}
|
||||
|
||||
/* 智能比分弹窗样式(功能已关闭,保留便于恢复)
|
||||
.smart-hint { ... }
|
||||
*/
|
||||
.section-title,
|
||||
.preview-title,
|
||||
.preview-bar-title,
|
||||
.pstat-value {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.subsection-title,
|
||||
.bet-content-cell,
|
||||
.match-league,
|
||||
.score-title,
|
||||
.preview-metric-label,
|
||||
.preview-items-title,
|
||||
.period-tag,
|
||||
.empty-hint,
|
||||
.subsection-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-side,
|
||||
.leg-badge,
|
||||
.vs,
|
||||
.preview-metric-orange,
|
||||
.pstat-orange {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.preview-metric-green,
|
||||
.pstat-green {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.mini-chart,
|
||||
.pstat {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.preview-zero-hint {
|
||||
border-color: var(--warning-border);
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.score-sep {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stats-table-block--bets .table-wrap {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.score-row,
|
||||
.preview-bar,
|
||||
.preview-metrics {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.preview-metric {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -411,4 +411,37 @@ onMounted(async () => {
|
||||
color: #888;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.intro-text,
|
||||
.intro-list,
|
||||
.summary-meta,
|
||||
.empty-card,
|
||||
.case-details-empty {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.summary-title,
|
||||
.table-title {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.case-details {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.case-details pre {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.summary-actions,
|
||||
.summary-stats,
|
||||
.table-head,
|
||||
.log-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -890,4 +890,22 @@ function statusLabel(s: string) {
|
||||
.reset-db-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.field-hint,
|
||||
.amount-full-hint,
|
||||
.text-muted,
|
||||
.list-settings-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.list-settings-block--danger {
|
||||
border-top-color: var(--danger-border);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.settings-form :deep(.el-form-item) {
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -104,5 +104,5 @@ function onSizeChange(size: number) {
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
|
||||
.page-desc { font-size: 13px; color: #3a3a3a; }
|
||||
.data-card { border-radius: 12px; }
|
||||
.bet-no { font-size: 12px; color: #ccc; font-family: ui-monospace, monospace; word-break: break-all; }
|
||||
.bet-no { font-size: 12px; color: var(--text); font-family: ui-monospace, monospace; word-break: break-all; }
|
||||
</style>
|
||||
|
||||
@@ -86,17 +86,17 @@ const mainTrendOption = computed(() =>
|
||||
[
|
||||
{
|
||||
name: t('dash.chart_stake'),
|
||||
color: '#d4d4d4',
|
||||
color: '#346538',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
|
||||
},
|
||||
{
|
||||
name: t('dash.chart_payout'),
|
||||
color: '#60a5fa',
|
||||
color: '#1f6c9f',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.payout)) ?? [],
|
||||
},
|
||||
{
|
||||
name: t('dash.chart_ggr'),
|
||||
color: '#a78bfa',
|
||||
color: '#956400',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.ggr)) ?? [],
|
||||
},
|
||||
],
|
||||
@@ -110,11 +110,11 @@ const distributionOption = computed(() => {
|
||||
const p = s.value?.players;
|
||||
const raw = s.value?.bets.todayByStatus ?? {};
|
||||
const betColors: Record<string, string> = {
|
||||
PENDING: '#fb923c',
|
||||
WON: '#d4d4d4',
|
||||
LOST: '#f87171',
|
||||
VOID: '#6b7280',
|
||||
REFUNDED: '#60a5fa',
|
||||
PENDING: '#956400',
|
||||
WON: '#346538',
|
||||
LOST: '#9f2f2d',
|
||||
VOID: '#8c867c',
|
||||
REFUNDED: '#1f6c9f',
|
||||
};
|
||||
|
||||
const creditSegs = c
|
||||
@@ -122,12 +122,12 @@ const distributionOption = computed(() => {
|
||||
{
|
||||
label: t('agent_dash.credit_available'),
|
||||
value: toNum(c.availableCredit),
|
||||
color: '#d4d4d4',
|
||||
color: '#346538',
|
||||
},
|
||||
{
|
||||
label: t('agent_dash.credit_used'),
|
||||
value: toNum(c.usedCredit),
|
||||
color: '#fb923c',
|
||||
color: '#956400',
|
||||
},
|
||||
].filter((x) => x.value > 0)
|
||||
: [];
|
||||
@@ -137,13 +137,13 @@ const distributionOption = computed(() => {
|
||||
.map((k) => ({
|
||||
label: betStatusLabel(k),
|
||||
value: raw[k].count,
|
||||
color: betColors[k] ?? '#888',
|
||||
color: betColors[k] ?? '#8c867c',
|
||||
}));
|
||||
|
||||
const playerSegs = p
|
||||
? [
|
||||
{ label: t('dash.user_active'), value: p.active, color: '#d4d4d4' },
|
||||
{ label: t('dash.user_suspended'), value: p.suspended, color: '#f87171' },
|
||||
{ label: t('dash.user_active'), value: p.active, color: '#346538' },
|
||||
{ label: t('dash.user_suspended'), value: p.suspended, color: '#9f2f2d' },
|
||||
].filter((x) => x.value > 0)
|
||||
: [];
|
||||
|
||||
@@ -449,4 +449,78 @@ const kpiSecondary = computed(() => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Agent dashboard light overrides */
|
||||
.state-card {
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.state-title {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.state-hint,
|
||||
.board-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.overview-board {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.dash-updated {
|
||||
color: #9b9489;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.kpi-cell {
|
||||
border-color: var(--border-soft);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.kpi-cell--link:hover,
|
||||
.kpi-cell--link:focus-visible {
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.kpi-sub {
|
||||
color: #9b9489;
|
||||
}
|
||||
|
||||
.kpi-delta {
|
||||
color: var(--text-muted);
|
||||
background: #f0ede6;
|
||||
}
|
||||
|
||||
.kpi-delta.up {
|
||||
color: var(--success-text);
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.kpi-delta.down {
|
||||
color: var(--danger-text);
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.charts-stack {
|
||||
border-top-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.chart-main-caption {
|
||||
color: #9b9489;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1601,4 +1601,62 @@ function statusTagType(s: string) {
|
||||
.empty-hint { padding: 32px 0; color: #666; font-size: 13px; }
|
||||
.create-alert { margin-bottom: 16px; }
|
||||
.create-form { margin-top: 4px; }
|
||||
|
||||
.credit-strip {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.credit-label,
|
||||
.expand-loading,
|
||||
.expand-section-title,
|
||||
.expand-readonly-hint,
|
||||
.field-hint,
|
||||
.empty-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.credit-value {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.credit-value.c-green,
|
||||
.invite-code-cell {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.credit-divider {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.invite-prominent-btn {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.expand-panel {
|
||||
margin-top: 6px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.credit-strip {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.credit-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.inner-toolbar,
|
||||
.expand-pager {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -434,4 +434,48 @@ function statusLabel(status: string) {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page-desc,
|
||||
.credit-label,
|
||||
.field-hint,
|
||||
.empty-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.credit-strip,
|
||||
.data-card {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.credit-value {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.credit-value.c-green {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.credit-divider {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page-header,
|
||||
.credit-strip {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.credit-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,17 +38,17 @@ const mainTrendOption = computed(() =>
|
||||
[
|
||||
{
|
||||
name: t('dash.chart_stake'),
|
||||
color: '#d4d4d4',
|
||||
color: '#346538',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
|
||||
},
|
||||
{
|
||||
name: t('dash.chart_payout'),
|
||||
color: '#60a5fa',
|
||||
color: '#1f6c9f',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.payout)) ?? [],
|
||||
},
|
||||
{
|
||||
name: t('dash.chart_ggr'),
|
||||
color: '#a78bfa',
|
||||
color: '#956400',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.ggr)) ?? [],
|
||||
},
|
||||
],
|
||||
@@ -61,20 +61,20 @@ const distributionOption = computed(() => {
|
||||
const m = s.value?.matches;
|
||||
const raw = s.value?.bets.todayByStatus ?? {};
|
||||
const betColors: Record<string, string> = {
|
||||
PENDING: '#fb923c',
|
||||
WON: '#d4d4d4',
|
||||
LOST: '#f87171',
|
||||
VOID: '#6b7280',
|
||||
REFUNDED: '#60a5fa',
|
||||
PENDING: '#956400',
|
||||
WON: '#346538',
|
||||
LOST: '#9f2f2d',
|
||||
VOID: '#8c867c',
|
||||
REFUNDED: '#1f6c9f',
|
||||
};
|
||||
|
||||
const matchSegs = m
|
||||
? [
|
||||
{ label: t('dash.match_draft'), value: m.draft, color: '#6b7280' },
|
||||
{ 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' },
|
||||
{ label: t('dash.match_draft'), value: m.draft, color: '#8c867c' },
|
||||
{ label: t('dash.match_published'), value: m.published, color: '#346538' },
|
||||
{ label: t('dash.match_closed'), value: m.closed, color: '#1f6c9f' },
|
||||
{ label: t('dash.match_pending_settle'), value: m.pendingSettlement, color: '#956400' },
|
||||
{ label: t('dash.match_settled'), value: m.settled ?? 0, color: '#477a4c' },
|
||||
].filter((x) => x.value > 0)
|
||||
: [];
|
||||
|
||||
@@ -83,7 +83,7 @@ const distributionOption = computed(() => {
|
||||
.map((k) => ({
|
||||
label: betStatusLabel(k),
|
||||
value: raw[k].count,
|
||||
color: betColors[k] ?? '#888',
|
||||
color: betColors[k] ?? '#8c867c',
|
||||
}));
|
||||
|
||||
return buildTriplePieOption(
|
||||
|
||||
@@ -54,10 +54,10 @@ const userDistributionOption = computed(() => {
|
||||
const u = s.value?.users;
|
||||
const userSegs = u
|
||||
? [
|
||||
{ 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' },
|
||||
{ label: t('dash.user_active'), value: u.playersActive, color: '#346538' },
|
||||
{ label: t('dash.user_suspended'), value: u.playersSuspended, color: '#9f2f2d' },
|
||||
{ label: t('dash.user_direct'), value: u.playersDirect, color: '#1f6c9f' },
|
||||
{ label: t('dash.user_agents'), value: u.agentsTotal, color: '#956400' },
|
||||
].filter((x) => x.value > 0)
|
||||
: [];
|
||||
|
||||
@@ -167,9 +167,10 @@ function formatBetTime(v: string) {
|
||||
}
|
||||
|
||||
.recent-card {
|
||||
border-radius: 14px;
|
||||
border: 1px solid #1e1e1e;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.recent-card :deep(.el-card__body) {
|
||||
@@ -180,7 +181,7 @@ function formatBetTime(v: string) {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #ccc;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
|
||||
@@ -4,30 +4,31 @@
|
||||
|
||||
.state-card {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 69, 58, 0.04);
|
||||
border: 1px solid var(--danger-border);
|
||||
background: var(--danger-bg);
|
||||
text-align: center;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
.state-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fca5a5;
|
||||
font-weight: 750;
|
||||
color: var(--danger-text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.state-hint {
|
||||
font-size: 13px;
|
||||
color: #737373;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.overview-board {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #111111;
|
||||
border: 1px solid var(--border);
|
||||
background: #ffffff;
|
||||
margin-bottom: 28px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.overview-board :deep(.el-card__body) {
|
||||
@@ -45,12 +46,12 @@
|
||||
|
||||
.board-hint {
|
||||
font-size: 12px;
|
||||
color: #737373;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dash-updated {
|
||||
font-size: 11px;
|
||||
color: #525252;
|
||||
color: #9b9489;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
@@ -71,8 +72,8 @@
|
||||
.kpi-cell {
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.kpi-cell.compact {
|
||||
@@ -86,25 +87,25 @@
|
||||
|
||||
.kpi-cell--link:hover,
|
||||
.kpi-cell--link:focus-visible {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #737373;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
color: #f5f5f5;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.kpi-value.sm {
|
||||
@@ -114,7 +115,7 @@
|
||||
.kpi-sub {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #525252;
|
||||
color: #9b9489;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -122,29 +123,31 @@
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #737373;
|
||||
font-weight: 750;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: #f0ede6;
|
||||
}
|
||||
|
||||
.kpi-delta.up {
|
||||
color: #a3a3a3;
|
||||
color: var(--success-text);
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.kpi-delta.down {
|
||||
color: #f87171;
|
||||
color: var(--danger-text);
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.charts-stack {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.chart-main-caption {
|
||||
font-size: 11px;
|
||||
color: #525252;
|
||||
color: #9b9489;
|
||||
text-align: center;
|
||||
margin: -8px 0 8px;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
type BuiltinCountry,
|
||||
} from '../data/builtinCountries';
|
||||
import { FormValidationError } from '../i18n/form-validation';
|
||||
import {
|
||||
isoToPlatformPickerDateTime,
|
||||
platformPickerDateTimeToIso,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
export interface MatchCreateForm {
|
||||
leagueId: string;
|
||||
@@ -23,7 +27,6 @@ export interface MatchCreateForm {
|
||||
awayTeamEn: string;
|
||||
awayTeamMs: string;
|
||||
isHot: boolean;
|
||||
correctScoreEnabled: boolean;
|
||||
displayOrder: number;
|
||||
matchName: string;
|
||||
stage: string;
|
||||
@@ -49,7 +52,6 @@ export function emptyMatchForm(): MatchCreateForm {
|
||||
awayTeamEn: '',
|
||||
awayTeamMs: '',
|
||||
isHot: false,
|
||||
correctScoreEnabled: true,
|
||||
displayOrder: 0,
|
||||
matchName: '',
|
||||
stage: '',
|
||||
@@ -64,17 +66,26 @@ export interface AdminMarketSelection {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
nameI18n?: Record<string, string>;
|
||||
odds: number;
|
||||
status: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface AdminMarket {
|
||||
id: string;
|
||||
marketType: string;
|
||||
marketKey?: string;
|
||||
lineKey?: string | null;
|
||||
period: string;
|
||||
lineValue: number | null;
|
||||
paramsJson?: Record<string, unknown> | null;
|
||||
status: string;
|
||||
showOnPlayer: boolean;
|
||||
promoLabel: string;
|
||||
promoLabelI18n?: Record<string, string>;
|
||||
nameI18n?: Record<string, string>;
|
||||
sortOrder?: number;
|
||||
selections: AdminMarketSelection[];
|
||||
}
|
||||
|
||||
@@ -83,7 +94,6 @@ export type AdminMatchDetail = {
|
||||
status: string;
|
||||
isOutright: boolean;
|
||||
isHot: boolean;
|
||||
correctScoreEnabled: boolean;
|
||||
displayOrder: number;
|
||||
startTime: string;
|
||||
leagueId?: string;
|
||||
@@ -110,25 +120,25 @@ export type AdminMatchDetail = {
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
winnerTeamId?: string | null;
|
||||
} | null;
|
||||
markets?: AdminMarket[];
|
||||
};
|
||||
|
||||
export function normalizeStartTimeForPicker(iso?: string): string {
|
||||
if (!iso?.trim()) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso.slice(0, 19);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
return isoToPlatformPickerDateTime(iso);
|
||||
}
|
||||
|
||||
export function normalizeStartTimeForApi(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
const d = new Date(trimmed);
|
||||
if (Number.isNaN(d.getTime())) return trimmed;
|
||||
return d.toISOString();
|
||||
return platformPickerDateTimeToIso(value);
|
||||
}
|
||||
|
||||
export function formFromDetail(d: AdminMatchDetail): MatchCreateForm {
|
||||
@@ -147,7 +157,6 @@ export function formFromDetail(d: AdminMatchDetail): MatchCreateForm {
|
||||
awayTeamEn: d.awayTeamEn,
|
||||
awayTeamMs: d.awayTeamMs ?? '',
|
||||
isHot: d.isHot,
|
||||
correctScoreEnabled: d.correctScoreEnabled ?? true,
|
||||
displayOrder: d.displayOrder ?? 0,
|
||||
matchName: d.matchName ?? '',
|
||||
stage: d.stage ?? '',
|
||||
@@ -244,7 +253,6 @@ export function buildPlatformPayload(form: MatchCreateForm) {
|
||||
awayTeamMs: form.awayTeamMs.trim() || undefined,
|
||||
startTime: normalizeStartTimeForApi(form.startTime),
|
||||
isHot: form.isHot,
|
||||
correctScoreEnabled: form.correctScoreEnabled,
|
||||
displayOrder: form.displayOrder,
|
||||
matchName: form.matchName.trim() || undefined,
|
||||
stage: form.stage.trim() || undefined,
|
||||
@@ -290,7 +298,6 @@ export function buildMatchUpdatePayload(form: MatchCreateForm) {
|
||||
awayTeamMs: form.awayTeamMs.trim() || undefined,
|
||||
startTime: normalizeStartTimeForApi(form.startTime),
|
||||
isHot: form.isHot,
|
||||
correctScoreEnabled: form.correctScoreEnabled,
|
||||
displayOrder: form.displayOrder,
|
||||
matchName: form.matchName.trim() || undefined,
|
||||
stage: form.stage.trim() || undefined,
|
||||
|
||||
@@ -7,6 +7,10 @@ import api from '../../api';
|
||||
import MatchArchiveDialog from '../../components/MatchArchiveDialog.vue';
|
||||
import { ensureLeagueExpanded } from '../../utils/matchesListState';
|
||||
import { formatAmount } from '../../utils/format-amount';
|
||||
import {
|
||||
formatPlatformMatchDateTime,
|
||||
platformPickerDateTimeToIso,
|
||||
} from '@thebet365/shared';
|
||||
const props = defineProps<{
|
||||
leagueId: string;
|
||||
filterStatus: string;
|
||||
@@ -180,7 +184,7 @@ function matchId(row: unknown) {
|
||||
return String(rowOf(row).id ?? '');
|
||||
}
|
||||
function matchTime(row: unknown) {
|
||||
return new Date(String(rowOf(row).startTime)).toLocaleString();
|
||||
return formatPlatformMatchDateTime(String(rowOf(row).startTime), locale.value);
|
||||
}
|
||||
function betCount(row: unknown) {
|
||||
return Number(rowOf(row).betCount ?? 0);
|
||||
@@ -260,6 +264,13 @@ async function promptReopenKickoff(): Promise<string | null> {
|
||||
},
|
||||
t('match.reopen_kickoff_hint'),
|
||||
),
|
||||
h(
|
||||
'p',
|
||||
{
|
||||
style: 'margin: 0 0 12px; font-size: 12px; color: var(--el-text-color-secondary)',
|
||||
},
|
||||
t('match.timezone.platform_hint'),
|
||||
),
|
||||
h(ElDatePicker, {
|
||||
modelValue: kickoff.value,
|
||||
'onUpdate:modelValue': (v: string) => {
|
||||
@@ -275,7 +286,8 @@ async function promptReopenKickoff(): Promise<string | null> {
|
||||
cancelButtonText: t('common.cancel'),
|
||||
beforeClose: (action, _instance, done) => {
|
||||
if (action === 'confirm') {
|
||||
if (!kickoff.value || new Date(kickoff.value) <= new Date()) {
|
||||
const iso = kickoff.value ? platformPickerDateTimeToIso(kickoff.value) : '';
|
||||
if (!iso || Number.isNaN(new Date(iso).getTime()) || new Date(iso) <= new Date()) {
|
||||
ElMessage.warning(t('match.reopen_kickoff_invalid'));
|
||||
return;
|
||||
}
|
||||
@@ -283,7 +295,7 @@ async function promptReopenKickoff(): Promise<string | null> {
|
||||
done();
|
||||
},
|
||||
});
|
||||
return kickoff.value || null;
|
||||
return kickoff.value ? platformPickerDateTimeToIso(kickoff.value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -316,6 +328,11 @@ defineExpose({ reload: load });
|
||||
|
||||
<template>
|
||||
<div class="league-matches-panel">
|
||||
<div class="nested-panel-toolbar">
|
||||
<el-button type="primary" size="small" @click.stop="emit('add-match')">
|
||||
{{ t('match.create_fixture_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="matches" stripe row-key="id" class="nested-match-table">
|
||||
<el-table-column prop="id" label="ID" width="64" />
|
||||
<el-table-column :label="t('match.col.matchup')" min-width="180">
|
||||
@@ -328,7 +345,7 @@ defineExpose({ reload: load });
|
||||
<el-tag :type="matchStatusType(row)" size="small">{{ matchStatusLabel(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.kickoff')" min-width="150">
|
||||
<el-table-column :label="t('match.col.kickoff')" min-width="190">
|
||||
<template #default="{ row }">{{ matchTime(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.bet_count')" width="72" align="center">
|
||||
@@ -347,18 +364,15 @@ defineExpose({ reload: load });
|
||||
<span v-else class="bet-stat-zero">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="460" align="center">
|
||||
<el-table-column width="256" align="center">
|
||||
<template #header>
|
||||
<div class="actions-col-header">
|
||||
<span class="actions-col-header__label">{{ t('common.actions') }}</span>
|
||||
<el-button type="primary" size="small" @click.stop="emit('add-match')">
|
||||
{{ t('match.create_fixture_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<div class="action-btns">
|
||||
<div class="action-group">
|
||||
<div class="action-row action-row--primary">
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
@@ -371,13 +385,12 @@ defineExpose({ reload: load });
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
class="action-btn--markets"
|
||||
:disabled="!canManage(row)"
|
||||
@click="openMarkets(matchId(row))"
|
||||
>
|
||||
{{ t('match.btn.markets') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="action-group">
|
||||
<el-button
|
||||
v-if="canPublishRow(row)"
|
||||
size="small"
|
||||
@@ -394,6 +407,8 @@ defineExpose({ reload: load });
|
||||
>
|
||||
{{ t('match.btn.unpublish') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="action-row action-row--workflow">
|
||||
<el-button
|
||||
size="small"
|
||||
type="warning"
|
||||
@@ -419,15 +434,15 @@ defineExpose({ reload: load });
|
||||
>
|
||||
{{ settleButtonLabel(row) }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="confirmDelete(row)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="confirmDelete(row)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -456,8 +471,17 @@ defineExpose({ reload: load });
|
||||
|
||||
<style scoped>
|
||||
.league-matches-panel {
|
||||
width: min(100%, calc(100vw - 272px));
|
||||
max-width: calc(100vw - 272px);
|
||||
padding: 10px 12px 12px;
|
||||
background: #0a0a0a;
|
||||
overflow: hidden;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
.nested-panel-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.nested-pager {
|
||||
display: flex;
|
||||
@@ -477,8 +501,8 @@ defineExpose({ reload: load });
|
||||
|
||||
.actions-col-header__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -502,43 +526,70 @@ defineExpose({ reload: load });
|
||||
}
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.matchup-link {
|
||||
color: var(--green-text);
|
||||
color: var(--primary-link);
|
||||
}
|
||||
.bet-stat-active {
|
||||
color: var(--green-text);
|
||||
color: var(--success-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.bet-stat-zero {
|
||||
color: #555;
|
||||
color: #aaa49a;
|
||||
}
|
||||
.action-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-rows: repeat(2, 26px);
|
||||
align-items: center;
|
||||
gap: 6px 8px;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.action-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
.action-row {
|
||||
display: inline-grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: max-content;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
.action-row--primary {
|
||||
background: #fbfaf7;
|
||||
}
|
||||
.action-row--workflow {
|
||||
background: #ffffff;
|
||||
}
|
||||
.action-btns :deep(.el-button) {
|
||||
margin: 0 !important;
|
||||
min-width: 52px;
|
||||
padding: 4px 10px !important;
|
||||
min-width: 48px;
|
||||
height: 22px !important;
|
||||
min-height: 22px !important;
|
||||
padding: 0 9px !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 1 !important;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.action-btns :deep(.action-btn--markets:not(.is-disabled):not(:disabled)) {
|
||||
background: var(--info-bg) !important;
|
||||
border-color: var(--info-border) !important;
|
||||
color: var(--info-text) !important;
|
||||
}
|
||||
|
||||
.action-btns :deep(.action-btn--markets:not(.is-disabled):not(:disabled):hover),
|
||||
.action-btns :deep(.action-btn--markets:not(.is-disabled):not(:disabled):focus) {
|
||||
background: #d5ecfb !important;
|
||||
border-color: #9fd0ed !important;
|
||||
color: #155b86 !important;
|
||||
}
|
||||
|
||||
.action-btns :deep(.el-button:not(.is-disabled):not(:disabled)) {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -547,4 +598,21 @@ defineExpose({ reload: load });
|
||||
.action-btns :deep(.el-button:disabled) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.league-matches-panel {
|
||||
width: calc(100vw - 32px);
|
||||
max-width: calc(100vw - 32px);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.nested-panel-toolbar,
|
||||
.nested-pager {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -901,7 +901,8 @@ watch(
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 16px 16px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
.outright-odds-panel__head {
|
||||
display: flex;
|
||||
@@ -1027,8 +1028,8 @@ watch(
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 8px 10px 8px 8px;
|
||||
background: #111;
|
||||
border: 1px solid #262626;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
@@ -1036,7 +1037,8 @@ watch(
|
||||
}
|
||||
|
||||
.team-row-wrap:hover .team-row {
|
||||
border-color: #3a3a3a;
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.team-row-wrap--batch-selected .team-row {
|
||||
@@ -1160,12 +1162,13 @@ watch(
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.team-row__odds {
|
||||
width: 72px;
|
||||
.team-row__odds,
|
||||
.team-row__odds.el-input-number {
|
||||
width: 86px;
|
||||
}
|
||||
|
||||
.team-row__odds :deep(.el-input-number) {
|
||||
width: 72px;
|
||||
.team-row__odds :deep(.el-input__wrapper) {
|
||||
padding-right: 28px !important;
|
||||
}
|
||||
|
||||
.add-teams-dialog__toolbar {
|
||||
@@ -1306,4 +1309,93 @@ watch(
|
||||
letter-spacing: 0.05em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.outright-odds-panel__hint,
|
||||
.outright-odds-panel__batch-count,
|
||||
.outright-odds-panel__sort-label,
|
||||
.outright-odds-panel__empty,
|
||||
.team-row__rank,
|
||||
.team-row__meta,
|
||||
.team-row__odds-label,
|
||||
.add-teams-dialog__count,
|
||||
.add-teams-dialog__odds,
|
||||
.add-teams-dialog__empty,
|
||||
.add-teams-dialog__custom-hint,
|
||||
.add-team-pick__code {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.outright-odds-panel__workflow-hint {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.outright-odds-panel__batch,
|
||||
.team-row,
|
||||
.add-team-pick {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.team-row-wrap:hover .team-row,
|
||||
.add-team-pick:hover {
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.team-row-wrap--batch-selected .team-row,
|
||||
.add-team-pick--selected {
|
||||
border-color: var(--primary);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.team-list-scroll {
|
||||
scrollbar-color: #d5cfc3 transparent;
|
||||
}
|
||||
|
||||
.team-list-scroll::-webkit-scrollbar-thumb {
|
||||
background: #d5cfc3;
|
||||
}
|
||||
|
||||
.team-row__trash {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.team-row__trash:hover {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.team-row__flag {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.team-row__name,
|
||||
.add-team-pick__name {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.add-teams-dialog__actions {
|
||||
border-bottom-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.add-teams-dialog__custom :deep(.el-form-item__label) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.team-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.team-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.outright-odds-panel__batch,
|
||||
.outright-odds-panel__sort {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -203,8 +203,8 @@ function onOutrightArchived() {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(212, 175, 55, 0.04);
|
||||
border: 1px solid rgba(212, 175, 55, 0.14);
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
@@ -222,11 +222,11 @@ function onOutrightArchived() {
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--green-text);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
.panel-hint {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.panel-actions {
|
||||
@@ -241,18 +241,18 @@ function onOutrightArchived() {
|
||||
.meta-line {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
color: var(--text);
|
||||
}
|
||||
.meta-warn {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: #e6a23c;
|
||||
color: var(--warning-text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.meta-empty {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.preview-table {
|
||||
margin-top: 6px;
|
||||
|
||||
@@ -194,9 +194,9 @@ async function saveMeta() {
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-section form-section--schedule">
|
||||
<div class="section-label">{{ t('matchEditor.group.schedule') }}</div>
|
||||
<el-row :gutter="12">
|
||||
<el-row :gutter="12" class="schedule-fields">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="t('match.field.kickoff')" required>
|
||||
<el-date-picker
|
||||
@@ -207,6 +207,7 @@ async function saveMeta() {
|
||||
:placeholder="t('matchEditor.ph.kickoff')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<p class="field-hint schedule-timezone-hint">{{ t('match.timezone.platform_hint') }}</p>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -234,11 +235,6 @@ async function saveMeta() {
|
||||
<el-switch v-model="form.isHot" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('matchEditor.field.correct_score_enabled')">
|
||||
<el-switch v-model="form.correctScoreEnabled" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
@@ -258,13 +254,15 @@ async function saveMeta() {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-bottom: 24px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #111;
|
||||
border: 1px solid #252525;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
@@ -274,14 +272,14 @@ async function saveMeta() {
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #222;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #ccc;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
@@ -289,7 +287,7 @@ async function saveMeta() {
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #222;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
@@ -303,9 +301,9 @@ async function saveMeta() {
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--green-text);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--success-text);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@@ -313,6 +311,44 @@ async function saveMeta() {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-section--schedule {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.schedule-fields {
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.schedule-fields :deep(.el-form-item) {
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.schedule-fields :deep(.el-form-item__label) {
|
||||
flex: 0 0 auto;
|
||||
width: auto !important;
|
||||
min-width: 64px;
|
||||
padding-right: 10px;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.schedule-fields :deep(.el-form-item__content) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.schedule-fields :deep(.el-date-editor),
|
||||
.schedule-fields :deep(.el-input),
|
||||
.schedule-fields :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.schedule-fields :deep(.el-switch) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -339,7 +375,7 @@ async function saveMeta() {
|
||||
|
||||
.meta-form :deep(.el-input__inner),
|
||||
.meta-form :deep(.el-input-number .el-input__inner) {
|
||||
color: #fff !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
@@ -349,6 +385,10 @@ async function saveMeta() {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.schedule-timezone-hint {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.league-readonly-grid {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -360,7 +400,7 @@ async function saveMeta() {
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
background: #1a1a1a;
|
||||
background: #f4f0e8;
|
||||
}
|
||||
|
||||
.league-readonly-names {
|
||||
@@ -374,7 +414,7 @@ async function saveMeta() {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #ddd;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.league-readonly-lang {
|
||||
@@ -382,4 +422,58 @@ async function saveMeta() {
|
||||
color: #8e8e93;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panel-head,
|
||||
.panel-foot {
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.panel-title,
|
||||
.preview-title,
|
||||
.preview-bar-title,
|
||||
.league-readonly-line {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
color: var(--success-text);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.logo-inline-label,
|
||||
.meta-form :deep(.el-form-item__label),
|
||||
.field-hint,
|
||||
.league-readonly-lang {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.meta-form :deep(.el-input__inner),
|
||||
.meta-form :deep(.el-input-number .el-input__inner) {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.league-readonly-logo img {
|
||||
background: #f4f0e8;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.panel-head,
|
||||
.panel-foot,
|
||||
.league-readonly-grid {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.schedule-fields :deep(.el-form-item) {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.schedule-fields :deep(.el-form-item__label) {
|
||||
width: auto !important;
|
||||
min-width: 0;
|
||||
padding-right: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,12 @@ const loading = ref(false);
|
||||
const status = ref('DRAFT');
|
||||
const matchLabel = ref('');
|
||||
|
||||
function matchup(home: string, away: string) {
|
||||
const h = home.trim();
|
||||
const a = away.trim();
|
||||
return h && a ? `${h} vs ${a}` : '';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!matchId.value) return;
|
||||
loading.value = true;
|
||||
@@ -29,11 +35,15 @@ async function load() {
|
||||
return;
|
||||
}
|
||||
status.value = detail.status;
|
||||
const home = detail.homeTeamZh || detail.homeTeamEn || detail.homeTeamCode || '';
|
||||
const away = detail.awayTeamZh || detail.awayTeamEn || detail.awayTeamCode || '';
|
||||
matchLabel.value =
|
||||
detail.matchName?.trim() ||
|
||||
(home && away ? `${home} vs ${away}` : `#${matchId.value}`);
|
||||
const zh = matchup(
|
||||
detail.homeTeamZh || detail.homeTeamCode || detail.homeTeamEn || '',
|
||||
detail.awayTeamZh || detail.awayTeamCode || detail.awayTeamEn || '',
|
||||
);
|
||||
const en = matchup(
|
||||
detail.homeTeamEn || detail.homeTeamCode || detail.homeTeamZh || '',
|
||||
detail.awayTeamEn || detail.awayTeamCode || detail.awayTeamZh || '',
|
||||
);
|
||||
matchLabel.value = zh && en && zh !== en ? `${zh} / ${en}` : zh || en || `#${matchId.value}`;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
@@ -46,7 +56,7 @@ watch(matchId, load, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="match-markets-page page-scroll">
|
||||
<div v-loading="loading" class="match-markets-page">
|
||||
<AdminSubNav
|
||||
:title="t('matchEditor.section_markets')"
|
||||
:subtitle="matchLabel"
|
||||
@@ -64,13 +74,21 @@ watch(matchId, load, { immediate: true });
|
||||
|
||||
<style scoped>
|
||||
.match-markets-page {
|
||||
padding: 0 4px 24px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #111;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,81 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import type { AdminMatchDetail } from '../match-form';
|
||||
import { defaultSelectionName } from '../../utils/selectionDefaults';
|
||||
import { adminSelectionLabel } from '../../utils/adminSelectionLabel';
|
||||
|
||||
const props = defineProps<{
|
||||
matchId: string;
|
||||
}>();
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const { t, locale } = useAdminLocale();
|
||||
|
||||
const LOCALES = [
|
||||
{ code: 'zh-CN', label: '中文' },
|
||||
{ code: 'en-US', label: 'English' },
|
||||
{ code: 'ms-MY', label: 'Bahasa Melayu' },
|
||||
] as const;
|
||||
|
||||
const UI_TEXT: Record<string, Record<string, string>> = {
|
||||
add: { 'zh-CN': '添加', 'en-US': 'Add', 'ms-MY': 'Tambah' },
|
||||
addMarket: { 'zh-CN': '增加盘口', 'en-US': 'Add market', 'ms-MY': 'Tambah pasaran' },
|
||||
addScore: { 'zh-CN': '新增比分', 'en-US': 'Add score', 'ms-MY': 'Tambah skor' },
|
||||
cancel: { 'zh-CN': '取消', 'en-US': 'Cancel', 'ms-MY': 'Batal' },
|
||||
close: { 'zh-CN': '关闭', 'en-US': 'Close', 'ms-MY': 'Tutup' },
|
||||
copyCreated: { 'zh-CN': '已复制盘口线', 'en-US': 'Market line duplicated', 'ms-MY': 'Garisan pasaran disalin' },
|
||||
copyMarket: { 'zh-CN': '复制盘口', 'en-US': 'Duplicate market', 'ms-MY': 'Salin pasaran' },
|
||||
copyMarketCreated: { 'zh-CN': '已复制盘口', 'en-US': 'Market duplicated', 'ms-MY': 'Pasaran disalin' },
|
||||
copyMarketShort: { 'zh-CN': '复制盘口', 'en-US': 'Duplicate', 'ms-MY': 'Salin pasaran' },
|
||||
copyLine: { 'zh-CN': '复制盘口线', 'en-US': 'Duplicate line', 'ms-MY': 'Salin garisan' },
|
||||
copyLineShort: { 'zh-CN': '复制线', 'en-US': 'Copy', 'ms-MY': 'Salin' },
|
||||
defaultTemplate: { 'zh-CN': '默认', 'en-US': 'Default', 'ms-MY': 'Lalai' },
|
||||
fillScoreRange: { 'zh-CN': '补齐 0-4 比分', 'en-US': 'Fill 0-4 scores', 'ms-MY': 'Lengkapkan skor 0-4' },
|
||||
hidden: { 'zh-CN': '玩家端隐藏', 'en-US': 'Hidden on player', 'ms-MY': 'Disembunyikan pemain' },
|
||||
lineValue: { 'zh-CN': '盘口线', 'en-US': 'Line', 'ms-MY': 'Garisan' },
|
||||
loadTemplate: { 'zh-CN': '载入模板', 'en-US': 'Load template', 'ms-MY': 'Muat templat' },
|
||||
marketDetails: { 'zh-CN': '赛事详情', 'en-US': 'Match details', 'ms-MY': 'Butiran perlawanan' },
|
||||
marketName: { 'zh-CN': '盘口名称', 'en-US': 'Market name', 'ms-MY': 'Nama pasaran' },
|
||||
marketToAdd: { 'zh-CN': '选择要添加的盘口', 'en-US': 'Choose a market to add', 'ms-MY': 'Pilih pasaran untuk ditambah' },
|
||||
marketUnavailable: { 'zh-CN': '暂不开放玩家端', 'en-US': 'Not available on player', 'ms-MY': 'Belum tersedia untuk pemain' },
|
||||
matchMarkets: { 'zh-CN': '赛事盘口', 'en-US': 'Match markets', 'ms-MY': 'Pasaran perlawanan' },
|
||||
moveDown: { 'zh-CN': '下移', 'en-US': 'Move down', 'ms-MY': 'Turun' },
|
||||
moveUp: { 'zh-CN': '上移', 'en-US': 'Move up', 'ms-MY': 'Naik' },
|
||||
odds: { 'zh-CN': '赔率', 'en-US': 'Odds', 'ms-MY': 'Odds' },
|
||||
parlay: { 'zh-CN': '串关', 'en-US': 'Parlay', 'ms-MY': 'Parlay' },
|
||||
playerVisible: { 'zh-CN': '玩家端显示', 'en-US': 'Show on player', 'ms-MY': 'Papar pemain' },
|
||||
promoLabel: { 'zh-CN': '促销标签', 'en-US': 'Promo label', 'ms-MY': 'Label promosi' },
|
||||
saveAsTemplate: { 'zh-CN': '保存为模板', 'en-US': 'Save as template', 'ms-MY': 'Simpan templat' },
|
||||
saveTemplateTitle: { 'zh-CN': '保存为盘口模板', 'en-US': 'Save as market template', 'ms-MY': 'Simpan sebagai templat pasaran' },
|
||||
selectMarket: { 'zh-CN': '从盘口池选择', 'en-US': 'Select from market pool', 'ms-MY': 'Pilih daripada kumpulan pasaran' },
|
||||
selectTemplate: { 'zh-CN': '选择模板', 'en-US': 'Select template', 'ms-MY': 'Pilih templat' },
|
||||
single: { 'zh-CN': '单关', 'en-US': 'Single', 'ms-MY': 'Tunggal' },
|
||||
status: { 'zh-CN': '状态', 'en-US': 'Status', 'ms-MY': 'Status' },
|
||||
statusClosed: { 'zh-CN': '已关闭', 'en-US': 'Closed', 'ms-MY': 'Ditutup' },
|
||||
statusOpen: { 'zh-CN': '可售', 'en-US': 'Open', 'ms-MY': 'Dibuka' },
|
||||
statusSuspended: { 'zh-CN': '暂停', 'en-US': 'Suspended', 'ms-MY': 'Digantung' },
|
||||
templateLoaded: { 'zh-CN': '模板已载入', 'en-US': 'Template loaded', 'ms-MY': 'Templat dimuatkan' },
|
||||
templateNameInput: { 'zh-CN': '请输入模板名称', 'en-US': 'Enter template name', 'ms-MY': 'Masukkan nama templat' },
|
||||
templateSaved: { 'zh-CN': '模板已保存', 'en-US': 'Template saved', 'ms-MY': 'Templat disimpan' },
|
||||
unsaved: { 'zh-CN': '未保存', 'en-US': 'Unsaved', 'ms-MY': 'Belum disimpan' },
|
||||
};
|
||||
|
||||
type LocalizedText = Record<string, string>;
|
||||
|
||||
interface MarketDefinition {
|
||||
marketType: string;
|
||||
marketKey: string;
|
||||
period: string;
|
||||
sortOrder: number;
|
||||
defaultLineValue: number | null;
|
||||
allowSingle: boolean;
|
||||
allowParlay: boolean;
|
||||
showOnPlayer: boolean;
|
||||
settlementSupported: boolean;
|
||||
usesLineValue: boolean;
|
||||
renderType: 'standard' | 'correctScore' | 'unsupported';
|
||||
nameI18n: LocalizedText;
|
||||
selectionTemplate: Array<{
|
||||
code: string;
|
||||
name: string;
|
||||
nameI18n: LocalizedText;
|
||||
odds: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SelectionRow {
|
||||
id: string;
|
||||
id?: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
nameI18n: LocalizedText;
|
||||
odds: number;
|
||||
status: string;
|
||||
editOdds: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface MarketRow {
|
||||
id: string;
|
||||
id?: string;
|
||||
marketType: string;
|
||||
marketKey: string;
|
||||
period: string;
|
||||
lineValue: number | null;
|
||||
paramsJson: Record<string, unknown> | null;
|
||||
status: string;
|
||||
allowSingle: boolean;
|
||||
allowParlay: boolean;
|
||||
showOnPlayer: boolean;
|
||||
sortOrder: number;
|
||||
promoLabel: string;
|
||||
editPromoLabel: string;
|
||||
editLineValue: number | null;
|
||||
promoLabelI18n: LocalizedText;
|
||||
nameI18n: LocalizedText;
|
||||
selections: SelectionRow[];
|
||||
}
|
||||
|
||||
const DEFAULT_MARKET_TYPES = [
|
||||
'FT_1X2',
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_1X2',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
];
|
||||
interface MarketTemplateSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
nameI18n: LocalizedText;
|
||||
isDefault: boolean;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const savingMarketId = ref<string | null>(null);
|
||||
const saving = ref(false);
|
||||
const applying = ref(false);
|
||||
const definitions = ref<MarketDefinition[]>([]);
|
||||
const templates = ref<MarketTemplateSummary[]>([]);
|
||||
const selectedTemplateId = ref('');
|
||||
const markets = ref<MarketRow[]>([]);
|
||||
const selectedMarketIndex = ref(0);
|
||||
const scoreHome = ref(0);
|
||||
const scoreAway = ref(0);
|
||||
const addMarketVisible = ref(false);
|
||||
const selectedDefinitionType = ref('');
|
||||
|
||||
const definitionMap = computed(() => new Map(definitions.value.map((d) => [d.marketType, d])));
|
||||
const definitionOptions = computed(() => [...definitions.value].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
const selectedMarket = computed(() => markets.value[selectedMarketIndex.value] ?? markets.value[0] ?? null);
|
||||
const displayLocales = computed(() => {
|
||||
const zhLabels: Record<string, string> = {
|
||||
'zh-CN': '中文',
|
||||
'en-US': '英语',
|
||||
'ms-MY': '马来语',
|
||||
};
|
||||
return LOCALES.map((loc) => ({
|
||||
code: loc.code,
|
||||
label: locale.value === 'zh-CN' ? zhLabels[loc.code] : loc.label,
|
||||
}));
|
||||
});
|
||||
const dirty = ref(false);
|
||||
|
||||
function localized(input?: Record<string, string> | null, fallback = ''): LocalizedText {
|
||||
const out: LocalizedText = {};
|
||||
for (const loc of LOCALES) {
|
||||
const value = input?.[loc.code]?.trim();
|
||||
if (value) out[loc.code] = value;
|
||||
}
|
||||
if (!Object.keys(out).length && fallback) out['zh-CN'] = fallback;
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveText(map?: LocalizedText | null, fallback = '') {
|
||||
const order = Array.from(new Set([locale.value, 'en-US', 'zh-CN', 'ms-MY']));
|
||||
for (const loc of order) {
|
||||
const value = map?.[loc]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return Object.values(map ?? {}).find((value) => value?.trim()) || fallback;
|
||||
}
|
||||
|
||||
function ui(key: string) {
|
||||
return resolveText(UI_TEXT[key], key);
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'OPEN') return ui('statusOpen');
|
||||
if (status === 'SUSPENDED') return ui('statusSuspended');
|
||||
if (status === 'CLOSED') return ui('statusClosed');
|
||||
return status;
|
||||
}
|
||||
|
||||
function ensureLocaleKeys(map: LocalizedText, fallback = '') {
|
||||
for (const loc of LOCALES) {
|
||||
if (map[loc.code] == null) map[loc.code] = loc.code === 'zh-CN' ? fallback : '';
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function mapMarkets(detail: AdminMatchDetail) {
|
||||
markets.value = (detail.markets ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
marketType: m.marketType,
|
||||
period: m.period,
|
||||
lineValue: m.lineValue,
|
||||
status: m.status,
|
||||
promoLabel: m.promoLabel,
|
||||
editPromoLabel: m.promoLabel,
|
||||
editLineValue: m.lineValue,
|
||||
selections: m.selections.map((s) => ({
|
||||
id: s.id,
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
odds: s.odds,
|
||||
status: s.status,
|
||||
editOdds: s.odds,
|
||||
})),
|
||||
}));
|
||||
markets.value = (detail.markets ?? []).map((m, index) => {
|
||||
const def = definitionMap.value.get(m.marketType);
|
||||
const nameI18n = ensureLocaleKeys(localized(m.nameI18n, def ? resolveText(def.nameI18n, m.marketType) : m.marketType), m.marketType);
|
||||
const promoLabelI18n = ensureLocaleKeys(localized(m.promoLabelI18n, m.promoLabel || ''), m.promoLabel || '');
|
||||
return {
|
||||
id: m.id,
|
||||
marketType: m.marketType,
|
||||
marketKey: m.marketKey || m.marketType,
|
||||
period: m.period,
|
||||
lineValue: m.lineValue,
|
||||
paramsJson: m.paramsJson ?? null,
|
||||
status: m.status,
|
||||
allowSingle: true,
|
||||
allowParlay: true,
|
||||
showOnPlayer: m.showOnPlayer ?? true,
|
||||
sortOrder: m.sortOrder ?? index,
|
||||
promoLabel: m.promoLabel ?? '',
|
||||
promoLabelI18n,
|
||||
nameI18n,
|
||||
selections: m.selections.map((s, sIndex) => ({
|
||||
id: s.id,
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: ensureLocaleKeys(localized(s.nameI18n, s.selectionName), s.selectionName),
|
||||
odds: s.odds,
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder ?? sIndex,
|
||||
})),
|
||||
};
|
||||
});
|
||||
selectedMarketIndex.value = Math.min(selectedMarketIndex.value, Math.max(0, markets.value.length - 1));
|
||||
dirty.value = false;
|
||||
}
|
||||
|
||||
function usesLineValue(marketType: string) {
|
||||
return definitionMap.value.get(marketType)?.usesLineValue ?? false;
|
||||
}
|
||||
|
||||
function isLineMarket(market: MarketRow) {
|
||||
return usesLineValue(market.marketType);
|
||||
}
|
||||
|
||||
async function loadDefinitionsAndTemplates() {
|
||||
const [{ data: defRes }, { data: tplRes }] = await Promise.all([
|
||||
api.get('/admin/market-definitions'),
|
||||
api.get('/admin/market-templates'),
|
||||
]);
|
||||
definitions.value = defRes.data ?? [];
|
||||
templates.value = tplRes.data ?? [];
|
||||
selectedTemplateId.value =
|
||||
templates.value.find((tpl) => tpl.isDefault)?.id || templates.value[0]?.id || '';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.matchId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadDefinitionsAndTemplates();
|
||||
const { data } = await api.get(`/admin/matches/${props.matchId}`);
|
||||
mapMarkets(data.data as AdminMatchDetail);
|
||||
} catch (e: unknown) {
|
||||
@@ -94,387 +262,846 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function marketLabel(type: string) {
|
||||
const key = `matchEditor.market.${type}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : type;
|
||||
function markDirty() {
|
||||
dirty.value = true;
|
||||
}
|
||||
|
||||
function selectionCodeLabel(code: string, market?: MarketRow) {
|
||||
return adminSelectionLabel(t, code, {
|
||||
lineValue: market?.editLineValue ?? market?.lineValue,
|
||||
period: market?.period,
|
||||
function isCorrectScoreMarket(market: MarketRow) {
|
||||
return ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'].includes(market.marketType);
|
||||
}
|
||||
|
||||
function marketTitle(market: MarketRow) {
|
||||
const line = isLineMarket(market) && market.lineValue != null ? ` ${market.lineValue}` : '';
|
||||
return `${resolveText(market.nameI18n, market.marketType)}${line}`;
|
||||
}
|
||||
|
||||
function definitionLabel(def: MarketDefinition) {
|
||||
const label = resolveText(def.nameI18n, def.marketType);
|
||||
return def.settlementSupported ? label : `${label}(${ui('marketUnavailable')})`;
|
||||
}
|
||||
|
||||
function templateLabel(tpl: MarketTemplateSummary) {
|
||||
const label = resolveText(tpl.nameI18n, tpl.name || '');
|
||||
return tpl.isDefault ? `${label}(${ui('defaultTemplate')})` : label;
|
||||
}
|
||||
|
||||
function marketMetaText(market: MarketRow) {
|
||||
const parts = [statusLabel(market.status)];
|
||||
if (isLineMarket(market) && market.lineValue != null) parts.push(`${ui('lineValue')} ${market.lineValue}`);
|
||||
if (!market.showOnPlayer) parts.push(ui('hidden'));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function selectionTitle(selection: SelectionRow) {
|
||||
const score = /^SCORE_(\d+)_(\d+)$/.exec(selection.selectionCode);
|
||||
if (score) return `${score[1]}:${score[2]}`;
|
||||
return resolveText(selection.nameI18n, selection.selectionName || selection.selectionCode);
|
||||
}
|
||||
|
||||
function isScoreSelection(selection: SelectionRow) {
|
||||
return /^SCORE_\d+_\d+$/.test(selection.selectionCode);
|
||||
}
|
||||
|
||||
function openAddMarketDialog() {
|
||||
selectedDefinitionType.value = definitionOptions.value[0]?.marketType ?? '';
|
||||
addMarketVisible.value = true;
|
||||
}
|
||||
|
||||
function addSelectedMarket() {
|
||||
const def = definitionMap.value.get(selectedDefinitionType.value);
|
||||
if (!def) return;
|
||||
addMarket(def);
|
||||
addMarketVisible.value = false;
|
||||
selectedDefinitionType.value = '';
|
||||
}
|
||||
|
||||
function addMarket(def: MarketDefinition) {
|
||||
const row: MarketRow = {
|
||||
marketType: def.marketType,
|
||||
marketKey: def.marketKey,
|
||||
period: def.period,
|
||||
lineValue: def.defaultLineValue,
|
||||
paramsJson: null,
|
||||
status: 'OPEN',
|
||||
allowSingle: def.allowSingle,
|
||||
allowParlay: def.allowParlay,
|
||||
showOnPlayer: def.showOnPlayer && def.settlementSupported,
|
||||
sortOrder: markets.value.length,
|
||||
promoLabel: '',
|
||||
promoLabelI18n: ensureLocaleKeys({}),
|
||||
nameI18n: ensureLocaleKeys(localized(def.nameI18n, def.marketType), def.marketType),
|
||||
selections: def.selectionTemplate.map((s, index) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
nameI18n: ensureLocaleKeys(localized(s.nameI18n, s.name), s.name),
|
||||
odds: Number(s.odds),
|
||||
status: 'OPEN',
|
||||
sortOrder: index,
|
||||
})),
|
||||
};
|
||||
markets.value.push(row);
|
||||
selectedMarketIndex.value = markets.value.length - 1;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function copyLocalizedName(map: LocalizedText) {
|
||||
const suffix: Record<string, string> = {
|
||||
'zh-CN': ' 副本',
|
||||
'en-US': ' Copy',
|
||||
'ms-MY': ' Salinan',
|
||||
};
|
||||
const next = ensureLocaleKeys(localized(map, resolveText(map)));
|
||||
for (const loc of LOCALES) {
|
||||
const base = next[loc.code] || resolveText(next);
|
||||
next[loc.code] = `${base}${suffix[loc.code]}`;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function clonePlain<T>(value: T): T {
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function nextCopiedLineValue(source: MarketRow) {
|
||||
const used = new Set(
|
||||
markets.value
|
||||
.filter((market) => market.marketType === source.marketType && market.lineValue != null)
|
||||
.map((market) => Number(market.lineValue).toFixed(2)),
|
||||
);
|
||||
let candidate = Number(((source.lineValue ?? 0) + 0.25).toFixed(2));
|
||||
while (used.has(candidate.toFixed(2))) {
|
||||
candidate = Number((candidate + 0.25).toFixed(2));
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function duplicateMarket(index: number) {
|
||||
const source = markets.value[index];
|
||||
if (!source) return;
|
||||
const shouldCopyLine = isLineMarket(source);
|
||||
const sourceCopy = clonePlain(source);
|
||||
const paramsCopy = clonePlain(source.paramsJson);
|
||||
const copy: MarketRow = {
|
||||
...sourceCopy,
|
||||
id: undefined,
|
||||
lineValue: shouldCopyLine ? nextCopiedLineValue(source) : null,
|
||||
paramsJson: shouldCopyLine
|
||||
? paramsCopy
|
||||
: { ...(paramsCopy ?? {}), copyId: `copy-${Date.now()}-${index}` },
|
||||
nameI18n: copyLocalizedName(source.nameI18n),
|
||||
sortOrder: markets.value.length,
|
||||
selections: source.selections.map((s) => ({ ...clonePlain(s), id: undefined })),
|
||||
};
|
||||
markets.value.splice(index + 1, 0, copy);
|
||||
selectedMarketIndex.value = index + 1;
|
||||
reorder();
|
||||
markDirty();
|
||||
ElMessage.success(ui(shouldCopyLine ? 'copyCreated' : 'copyMarketCreated'));
|
||||
}
|
||||
|
||||
function removeMarket(index: number) {
|
||||
markets.value.splice(index, 1);
|
||||
selectedMarketIndex.value = Math.min(selectedMarketIndex.value, Math.max(0, markets.value.length - 1));
|
||||
reorder();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function moveMarket(index: number, direction: -1 | 1) {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= markets.value.length) return;
|
||||
const [row] = markets.value.splice(index, 1);
|
||||
markets.value.splice(target, 0, row);
|
||||
selectedMarketIndex.value = target;
|
||||
reorder();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function reorder() {
|
||||
markets.value.forEach((m, index) => {
|
||||
m.sortOrder = index;
|
||||
});
|
||||
}
|
||||
|
||||
function hasLine(market: MarketRow) {
|
||||
return market.lineValue != null || market.editLineValue != null;
|
||||
function addScoreRange(market: MarketRow) {
|
||||
const existing = new Set(market.selections.map((s) => s.selectionCode));
|
||||
for (let home = 0; home <= 4; home += 1) {
|
||||
for (let away = 0; away <= 4; away += 1) {
|
||||
const code = `SCORE_${home}_${away}`;
|
||||
if (existing.has(code)) continue;
|
||||
market.selections.push({
|
||||
selectionCode: code,
|
||||
selectionName: `${home}-${away}`,
|
||||
nameI18n: ensureLocaleKeys({ 'zh-CN': `${home}-${away}`, 'en-US': `${home}-${away}`, 'ms-MY': `${home}-${away}` }),
|
||||
odds: 8,
|
||||
status: 'OPEN',
|
||||
sortOrder: market.selections.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
markDirty();
|
||||
}
|
||||
|
||||
const CORRECT_SCORE_TYPES = new Set([
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
]);
|
||||
|
||||
function isMultiRowMarket(market: MarketRow) {
|
||||
return CORRECT_SCORE_TYPES.has(market.marketType) || market.selections.length > 6;
|
||||
function addScore(market: MarketRow) {
|
||||
const home = Math.max(0, Number(scoreHome.value) || 0);
|
||||
const away = Math.max(0, Number(scoreAway.value) || 0);
|
||||
const code = `SCORE_${home}_${away}`;
|
||||
if (market.selections.some((s) => s.selectionCode === code)) return;
|
||||
market.selections.push({
|
||||
selectionCode: code,
|
||||
selectionName: `${home}-${away}`,
|
||||
nameI18n: ensureLocaleKeys({ 'zh-CN': `${home}-${away}`, 'en-US': `${home}-${away}`, 'ms-MY': `${home}-${away}` }),
|
||||
odds: 8,
|
||||
status: 'OPEN',
|
||||
sortOrder: market.selections.length,
|
||||
});
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function normPromo(value: string | null | undefined) {
|
||||
return (value ?? '').trim() || null;
|
||||
function removeSelection(market: MarketRow, index: number) {
|
||||
market.selections.splice(index, 1);
|
||||
market.selections.forEach((s, i) => {
|
||||
s.sortOrder = i;
|
||||
});
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function normLine(value: number | null | undefined) {
|
||||
return value == null ? null : Number(value);
|
||||
function payloadMarkets() {
|
||||
return markets.value.map((m, index) => ({
|
||||
id: m.id,
|
||||
marketType: m.marketType,
|
||||
marketKey: m.marketKey,
|
||||
lineKey: null,
|
||||
period: m.period,
|
||||
lineValue: usesLineValue(m.marketType) ? m.lineValue : null,
|
||||
paramsJson: m.paramsJson,
|
||||
status: m.status,
|
||||
allowSingle: m.allowSingle,
|
||||
allowParlay: m.allowParlay,
|
||||
showOnPlayer: m.showOnPlayer,
|
||||
sortOrder: index,
|
||||
promoLabel: resolveText(m.promoLabelI18n, m.promoLabel),
|
||||
promoLabelI18n: localized(m.promoLabelI18n),
|
||||
nameI18n: localized(m.nameI18n, m.marketType),
|
||||
selections: m.selections.map((s, sIndex) => ({
|
||||
id: s.id,
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: resolveText(s.nameI18n, s.selectionName),
|
||||
nameI18n: localized(s.nameI18n, s.selectionName),
|
||||
odds: Number(s.odds),
|
||||
status: s.status,
|
||||
sortOrder: sIndex,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function isMarketDirty(market: MarketRow) {
|
||||
if (normPromo(market.editPromoLabel) !== normPromo(market.promoLabel)) return true;
|
||||
if (normLine(market.editLineValue) !== normLine(market.lineValue)) return true;
|
||||
return market.selections.some((s) => Number(s.editOdds) !== Number(s.odds));
|
||||
}
|
||||
|
||||
async function generateTemplates() {
|
||||
loading.value = true;
|
||||
async function applyTemplate() {
|
||||
if (!selectedTemplateId.value) return;
|
||||
applying.value = true;
|
||||
try {
|
||||
await api.post(`/admin/matches/${props.matchId}/markets/templates`, {
|
||||
marketTypes: DEFAULT_MARKET_TYPES,
|
||||
await api.post(`/admin/matches/${props.matchId}/markets/apply-template`, {
|
||||
templateId: selectedTemplateId.value,
|
||||
});
|
||||
ElMessage.success(t('matchEditor.templates_generated'));
|
||||
ElMessage.success(ui('templateLoaded'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
applying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMarket(market: MarketRow) {
|
||||
const invalid = market.selections.find((s) => !s.editOdds || s.editOdds <= 1);
|
||||
async function saveAll() {
|
||||
const invalid = markets.value.flatMap((m) => m.selections).find((s) => !s.odds || s.odds <= 1);
|
||||
if (invalid) {
|
||||
ElMessage.warning(t('outright.err_odds_min'));
|
||||
return;
|
||||
}
|
||||
|
||||
savingMarketId.value = market.id;
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.patch(`/admin/markets/${market.id}`, {
|
||||
promoLabel: market.editPromoLabel.trim() || null,
|
||||
lineValue: market.editLineValue,
|
||||
await api.put(`/admin/matches/${props.matchId}/markets/bulk`, {
|
||||
markets: payloadMarkets(),
|
||||
});
|
||||
await api.put(`/admin/matches/${props.matchId}/odds`, {
|
||||
updates: market.selections.map((s) => ({
|
||||
selectionId: s.id,
|
||||
odds: s.editOdds,
|
||||
})),
|
||||
});
|
||||
for (const s of market.selections) {
|
||||
const name = defaultSelectionName(s.selectionCode, {
|
||||
lineValue: market.editLineValue,
|
||||
period: market.period,
|
||||
});
|
||||
if (name !== s.selectionName) {
|
||||
await api.patch(`/admin/selections/${s.id}`, {
|
||||
selectionName: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
ElMessage.success(t('msg.saved'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
savingMarketId.value = null;
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAsTemplate() {
|
||||
let value = '';
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(ui('templateNameInput'), ui('saveTemplateTitle'), {
|
||||
confirmButtonText: t('common.save'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
inputValue: `${resolveText({ 'zh-CN': '自定义足球盘口模板', 'en-US': 'Custom football market template', 'ms-MY': 'Templat pasaran bola sepak tersuai' })}`,
|
||||
});
|
||||
value = result.value?.trim() || '';
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post('/admin/market-templates', {
|
||||
name: value,
|
||||
nameI18n: { 'zh-CN': value },
|
||||
items: payloadMarkets(),
|
||||
});
|
||||
ElMessage.success(ui('templateSaved'));
|
||||
await loadDefinitionsAndTemplates();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="match-markets-panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">{{ t('matchEditor.section_markets') }}</span>
|
||||
<el-button size="small" type="primary" plain @click="generateTemplates">
|
||||
{{ t('matchEditor.generate_templates') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<p v-if="!markets.length" class="empty-hint">{{ t('matchEditor.no_markets') }}</p>
|
||||
|
||||
<div v-else class="market-lines">
|
||||
<div
|
||||
v-for="market in markets"
|
||||
:key="market.id"
|
||||
class="market-line"
|
||||
:class="{ 'market-line--wrap': isMultiRowMarket(market) }"
|
||||
>
|
||||
<label class="field-promo-wrap">
|
||||
<span class="promo-label">{{ t('matchEditor.field.promo_label_optional') }}</span>
|
||||
<el-input
|
||||
v-model="market.editPromoLabel"
|
||||
size="small"
|
||||
class="field-promo"
|
||||
clearable
|
||||
<div v-loading="loading" class="market-editor">
|
||||
<div class="market-toolbar">
|
||||
<div class="template-tools">
|
||||
<el-select v-model="selectedTemplateId" size="small" class="template-select" :placeholder="ui('selectTemplate')">
|
||||
<el-option
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
:label="templateLabel(tpl)"
|
||||
:value="tpl.id"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="market-line-head">
|
||||
<span class="market-label" :title="market.marketType">{{ marketLabel(market.marketType) }}</span>
|
||||
|
||||
<el-input-number
|
||||
v-if="hasLine(market)"
|
||||
v-model="market.editLineValue"
|
||||
size="small"
|
||||
class="field-line"
|
||||
:step="0.25"
|
||||
controls-position="right"
|
||||
/>
|
||||
|
||||
<el-button
|
||||
v-if="isMultiRowMarket(market)"
|
||||
size="small"
|
||||
:type="isMarketDirty(market) ? 'primary' : 'default'"
|
||||
class="btn-save"
|
||||
:disabled="!isMarketDirty(market)"
|
||||
:loading="savingMarketId === market.id"
|
||||
@click="saveMarket(market)"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="selections-wrap"
|
||||
:class="isMultiRowMarket(market) ? 'selections-grid' : 'selections-inline'"
|
||||
>
|
||||
<div v-for="sel in market.selections" :key="sel.id" class="sel-inline">
|
||||
<span class="sel-label" :title="sel.selectionCode">{{
|
||||
selectionCodeLabel(sel.selectionCode, market)
|
||||
}}</span>
|
||||
<el-input-number
|
||||
v-model="sel.editOdds"
|
||||
size="small"
|
||||
class="sel-odds"
|
||||
:min="1.01"
|
||||
:step="0.01"
|
||||
controls-position="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
v-if="!isMultiRowMarket(market)"
|
||||
size="small"
|
||||
:type="isMarketDirty(market) ? 'primary' : 'default'"
|
||||
class="btn-save"
|
||||
:disabled="!isMarketDirty(market)"
|
||||
:loading="savingMarketId === market.id"
|
||||
@click="saveMarket(market)"
|
||||
>
|
||||
</el-select>
|
||||
<el-button size="small" :loading="applying" @click="applyTemplate">{{ ui('loadTemplate') }}</el-button>
|
||||
<el-button size="small" @click="openAddMarketDialog">{{ ui('addMarket') }}</el-button>
|
||||
<el-button size="small" @click="saveAsTemplate">{{ ui('saveAsTemplate') }}</el-button>
|
||||
</div>
|
||||
<div class="save-tools">
|
||||
<el-tag v-if="dirty" size="small" type="warning">{{ ui('unsaved') }}</el-tag>
|
||||
<el-button size="small" type="primary" :loading="saving" @click="saveAll">
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-grid">
|
||||
<section class="market-list">
|
||||
<div class="section-title">{{ ui('matchMarkets') }}</div>
|
||||
<p v-if="!markets.length" class="empty-hint">{{ t('matchEditor.no_markets') }}</p>
|
||||
<div
|
||||
v-for="(market, index) in markets"
|
||||
:key="market.id || `${market.marketType}-${index}`"
|
||||
class="market-card"
|
||||
:class="{ active: selectedMarketIndex === index, hidden: !market.showOnPlayer }"
|
||||
@click="selectedMarketIndex = index"
|
||||
>
|
||||
<div class="card-main">
|
||||
<strong>{{ marketTitle(market) }}</strong>
|
||||
<span>{{ marketMetaText(market) }}</span>
|
||||
</div>
|
||||
<div class="card-actions" @click.stop>
|
||||
<el-button
|
||||
class="card-action-btn"
|
||||
size="small"
|
||||
text
|
||||
:title="ui('moveUp')"
|
||||
:disabled="index === 0"
|
||||
@click="moveMarket(index, -1)"
|
||||
>
|
||||
↑
|
||||
</el-button>
|
||||
<el-button
|
||||
class="card-action-btn"
|
||||
size="small"
|
||||
text
|
||||
:title="ui('moveDown')"
|
||||
:disabled="index === markets.length - 1"
|
||||
@click="moveMarket(index, 1)"
|
||||
>
|
||||
↓
|
||||
</el-button>
|
||||
<el-button
|
||||
class="card-action-btn copy-btn"
|
||||
size="small"
|
||||
text
|
||||
:title="ui(isLineMarket(market) ? 'copyLine' : 'copyMarket')"
|
||||
@click="duplicateMarket(index)"
|
||||
>
|
||||
{{ ui(isLineMarket(market) ? 'copyLineShort' : 'copyMarketShort') }}
|
||||
</el-button>
|
||||
<el-button class="card-action-btn" size="small" text type="danger" :title="ui('close')" @click="removeMarket(index)">
|
||||
{{ ui('close') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="selectedMarket" class="detail-panel">
|
||||
<div class="detail-head">
|
||||
<div>
|
||||
<div class="section-title">{{ ui('marketDetails') }}</div>
|
||||
<strong>{{ marketTitle(selectedMarket) }}</strong>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="selectedMarket.showOnPlayer"
|
||||
:active-text="ui('playerVisible')"
|
||||
@change="markDirty"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>{{ ui('status') }}</span>
|
||||
<el-select v-model="selectedMarket.status" size="small" @change="markDirty">
|
||||
<el-option :label="ui('statusOpen')" value="OPEN" />
|
||||
<el-option :label="ui('statusSuspended')" value="SUSPENDED" />
|
||||
<el-option :label="ui('statusClosed')" value="CLOSED" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label v-if="isLineMarket(selectedMarket)">
|
||||
<span>{{ ui('lineValue') }}</span>
|
||||
<el-input-number
|
||||
v-model="selectedMarket.lineValue"
|
||||
size="small"
|
||||
:step="0.25"
|
||||
controls-position="right"
|
||||
@change="markDirty"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ ui('single') }}</span>
|
||||
<el-switch v-model="selectedMarket.allowSingle" @change="markDirty" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ ui('parlay') }}</span>
|
||||
<el-switch v-model="selectedMarket.allowParlay" @change="markDirty" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="locale-block">
|
||||
<div class="block-title">{{ ui('marketName') }}</div>
|
||||
<div class="locale-grid">
|
||||
<label v-for="loc in displayLocales" :key="loc.code">
|
||||
<span>{{ loc.label }}</span>
|
||||
<el-input v-model="selectedMarket.nameI18n[loc.code]" size="small" @input="markDirty" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="locale-block">
|
||||
<div class="block-title">{{ ui('promoLabel') }}</div>
|
||||
<div class="locale-grid">
|
||||
<label v-for="loc in displayLocales" :key="loc.code">
|
||||
<span>{{ loc.label }}</span>
|
||||
<el-input v-model="selectedMarket.promoLabelI18n[loc.code]" size="small" clearable @input="markDirty" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isCorrectScoreMarket(selectedMarket)" class="score-tools">
|
||||
<el-button size="small" @click="addScoreRange(selectedMarket)">{{ ui('fillScoreRange') }}</el-button>
|
||||
<el-input-number v-model="scoreHome" size="small" :min="0" controls-position="right" />
|
||||
<span>-</span>
|
||||
<el-input-number v-model="scoreAway" size="small" :min="0" controls-position="right" />
|
||||
<el-button size="small" @click="addScore(selectedMarket)">{{ ui('addScore') }}</el-button>
|
||||
</div>
|
||||
|
||||
<div class="selection-list">
|
||||
<div
|
||||
v-for="(sel, index) in selectedMarket.selections"
|
||||
:key="sel.id || `${sel.selectionCode}-${index}`"
|
||||
class="selection-row"
|
||||
:class="{ 'selection-row--score': isScoreSelection(sel) }"
|
||||
>
|
||||
<div class="selection-meta">
|
||||
<strong :title="sel.selectionCode">{{ selectionTitle(sel) }}</strong>
|
||||
<label class="selection-field selection-field--status">
|
||||
<span>{{ ui('status') }}</span>
|
||||
<el-select v-model="sel.status" size="small" @change="markDirty">
|
||||
<el-option :label="ui('statusOpen')" value="OPEN" />
|
||||
<el-option :label="ui('statusSuspended')" value="SUSPENDED" />
|
||||
<el-option :label="ui('statusClosed')" value="CLOSED" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="selection-field selection-field--odds">
|
||||
<span>{{ ui('odds') }}</span>
|
||||
<el-input-number
|
||||
v-model="sel.odds"
|
||||
size="small"
|
||||
:min="1.01"
|
||||
:step="0.01"
|
||||
controls-position="right"
|
||||
@change="markDirty"
|
||||
/>
|
||||
</label>
|
||||
<el-button size="small" text type="danger" @click="removeSelection(selectedMarket, index)">{{ ui('close') }}</el-button>
|
||||
</div>
|
||||
<div v-if="!isScoreSelection(sel)" class="locale-grid compact">
|
||||
<label v-for="loc in displayLocales" :key="loc.code">
|
||||
<span>{{ loc.label }}</span>
|
||||
<el-input v-model="sel.nameI18n[loc.code]" size="small" @input="markDirty" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="addMarketVisible" class="add-market-dialog" :title="ui('addMarket')" width="460px" destroy-on-close>
|
||||
<label class="add-market-field">
|
||||
<span>{{ ui('marketToAdd') }}</span>
|
||||
<el-select
|
||||
v-model="selectedDefinitionType"
|
||||
class="full-select"
|
||||
filterable
|
||||
:placeholder="ui('selectMarket')"
|
||||
>
|
||||
<el-option
|
||||
v-for="def in definitionOptions"
|
||||
:key="def.marketType"
|
||||
:label="definitionLabel(def)"
|
||||
:value="def.marketType"
|
||||
/>
|
||||
</el-select>
|
||||
</label>
|
||||
<template #footer>
|
||||
<el-button @click="addMarketVisible = false">{{ ui('cancel') }}</el-button>
|
||||
<el-button type="primary" :disabled="!selectedDefinitionType" @click="addSelectedMarket">
|
||||
{{ ui('add') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.match-markets-panel {
|
||||
padding: 10px 12px 12px 16px;
|
||||
.market-editor {
|
||||
background: #0a0a0a;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
.market-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #ccc;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.market-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.market-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #252525;
|
||||
border-radius: 6px;
|
||||
background: #111;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.market-line--wrap {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.market-line--wrap .field-promo-wrap {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.field-promo-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.promo-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8e8e93;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-promo {
|
||||
flex: 0 0 88px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.market-line--wrap .field-promo {
|
||||
flex: 1;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.market-line-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.market-line:not(.market-line--wrap) .market-line-head {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.market-line--wrap .market-line-head {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.market-line--wrap .market-line-head .btn-save {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.market-label {
|
||||
flex: 0 0 76px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #e8e8e8;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-line {
|
||||
flex: 0 0 88px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-line :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selections-inline {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selections-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selections-grid .sel-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.selections-grid .sel-label {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.selections-grid .sel-odds {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selections-grid .sel-odds :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sel-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #242424;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
.sel-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
.template-tools,
|
||||
.save-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.template-select {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 420px) minmax(480px, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 10px 12px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.market-list,
|
||||
.detail-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 8px;
|
||||
background: #111;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.section-title,
|
||||
.block-title {
|
||||
color: #b8b8b8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--green-text);
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
margin: 0;
|
||||
color: #777;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.market-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid #282828;
|
||||
border-radius: 6px;
|
||||
background: #151515;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.market-card.active {
|
||||
border-color: #9f853d;
|
||||
background: #1a1710;
|
||||
}
|
||||
|
||||
.market-card.hidden {
|
||||
opacity: 0.66;
|
||||
}
|
||||
|
||||
.card-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-main strong,
|
||||
.detail-head strong {
|
||||
color: #f0f0f0;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sel-odds {
|
||||
width: 88px;
|
||||
.card-main span {
|
||||
color: #777;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sel-odds :deep(.el-input-number) {
|
||||
.card-actions {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 30px 30px 46px 40px;
|
||||
gap: 2px;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.card-action-btn {
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
margin-left: 0 !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-grid,
|
||||
.locale-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.locale-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.locale-grid.compact {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.locale-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.score-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.selection-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selection-row {
|
||||
padding: 8px;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 6px;
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
.selection-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 1fr) 120px 132px 70px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.selection-row--score .selection-meta {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.selection-meta strong {
|
||||
color: #ddd;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.selection-field {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.selection-field > span {
|
||||
color: #f3c969;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.selection-field--status > span {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.selection-field--odds :deep(.el-input-number),
|
||||
.selection-field--odds :deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
flex-shrink: 0;
|
||||
min-width: 52px;
|
||||
.add-market-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-save.is-disabled,
|
||||
.btn-save:disabled {
|
||||
opacity: 0.45;
|
||||
.full-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.market-toolbar,
|
||||
.editor-grid,
|
||||
.form-grid,
|
||||
.locale-grid,
|
||||
.selection-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.template-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.market-list,
|
||||
.detail-panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.market-editor {
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.market-toolbar {
|
||||
border-bottom-color: var(--border);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.market-list,
|
||||
.detail-panel,
|
||||
.market-card,
|
||||
.selection-row {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.market-list,
|
||||
.detail-panel {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.section-title,
|
||||
.block-title,
|
||||
.card-main strong,
|
||||
.detail-head strong,
|
||||
.selection-meta strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.empty-hint,
|
||||
.card-main span,
|
||||
label,
|
||||
.selection-field > span {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.market-card:hover {
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.market-card.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.selection-row {
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.selection-field > span {
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.selection-field--status > span {
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.market-toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.template-tools,
|
||||
.save-tools {
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -603,4 +603,33 @@ async function saveRow(row: SelectionRow) {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.league-meta,
|
||||
.league-meta-k,
|
||||
.readonly-field {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.panel-foot.compact {
|
||||
border-top-color: var(--border-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.settings-top,
|
||||
.panel-head.compact,
|
||||
.panel-foot.compact {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -356,26 +356,26 @@ onMounted(() => {
|
||||
.expand-line {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.expand-k {
|
||||
display: inline-block;
|
||||
min-width: 72px;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.expand-warn {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: #c9a227;
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.expand-empty {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.preview-table {
|
||||
|
||||
@@ -1 +1,14 @@
|
||||
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "skipLibCheck": true }, "include": ["src/**/*.ts", "src/**/*.vue"] }
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@thebet365/shared": ["../../packages/shared/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
'^.+\\.ts$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: {
|
||||
esModuleInterop: true,
|
||||
resolveJsonModule: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
'^@thebet365/shared$': '<rootDir>/../../../packages/shared/src/index.ts',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "wallet_transactions" ADD COLUMN IF NOT EXISTS "business_key" VARCHAR(128);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "wallet_transactions_business_key_key" ON "wallet_transactions"("business_key");
|
||||
|
||||
-- Deduplicate legacy settlement preview items before enforcing idempotency.
|
||||
WITH ranked_settlement_items AS (
|
||||
SELECT
|
||||
"id",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "batch_id", "bet_id"
|
||||
ORDER BY "id"
|
||||
) AS row_num
|
||||
FROM "settlement_items"
|
||||
)
|
||||
DELETE FROM "settlement_items" si
|
||||
USING ranked_settlement_items r
|
||||
WHERE si."id" = r."id"
|
||||
AND r.row_num > 1;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "settlement_items_batch_id_bet_id_key" ON "settlement_items"("batch_id", "bet_id");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "show_on_player" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,81 @@
|
||||
-- Market templates, line identity, and multilingual labels.
|
||||
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "market_key" VARCHAR(64);
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "line_key" VARCHAR(180);
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "params_json" JSONB;
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "promo_label_i18n" JSONB;
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "name_i18n" JSONB;
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "template_item_id" BIGINT;
|
||||
|
||||
UPDATE "markets"
|
||||
SET
|
||||
"market_key" = COALESCE("market_key", "market_type"),
|
||||
"line_key" = COALESCE(
|
||||
"line_key",
|
||||
"market_type" || ':' || COALESCE(to_char("line_value", 'FM999999990.00'), 'none')
|
||||
)
|
||||
WHERE "market_key" IS NULL OR "line_key" IS NULL;
|
||||
|
||||
ALTER TABLE "market_selections" ADD COLUMN IF NOT EXISTS "name_i18n" JSONB;
|
||||
ALTER TABLE "bet_selections" ADD COLUMN IF NOT EXISTS "market_name_snapshot" VARCHAR(255);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "market_templates" (
|
||||
"id" BIGSERIAL PRIMARY KEY,
|
||||
"sport_type" VARCHAR(20) NOT NULL DEFAULT 'FOOTBALL',
|
||||
"name" VARCHAR(128) NOT NULL,
|
||||
"name_i18n" JSONB,
|
||||
"description" VARCHAR(500),
|
||||
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "market_template_items" (
|
||||
"id" BIGSERIAL PRIMARY KEY,
|
||||
"template_id" BIGINT NOT NULL,
|
||||
"market_type" VARCHAR(64) NOT NULL,
|
||||
"market_key" VARCHAR(64),
|
||||
"line_key" VARCHAR(180) NOT NULL,
|
||||
"period" VARCHAR(16) NOT NULL,
|
||||
"line_value" DECIMAL(8,2),
|
||||
"params_json" JSONB,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'OPEN',
|
||||
"allow_single" BOOLEAN NOT NULL DEFAULT true,
|
||||
"allow_parlay" BOOLEAN NOT NULL DEFAULT true,
|
||||
"show_on_player" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"promo_label" VARCHAR(100),
|
||||
"promo_label_i18n" JSONB,
|
||||
"name_i18n" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "market_template_items_template_id_fkey"
|
||||
FOREIGN KEY ("template_id") REFERENCES "market_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "market_template_selections" (
|
||||
"id" BIGSERIAL PRIMARY KEY,
|
||||
"template_item_id" BIGINT NOT NULL,
|
||||
"selection_code" VARCHAR(64) NOT NULL,
|
||||
"selection_name" VARCHAR(255) NOT NULL,
|
||||
"name_i18n" JSONB,
|
||||
"odds" DECIMAL(18,6) NOT NULL,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'OPEN',
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "market_template_selections_template_item_id_fkey"
|
||||
FOREIGN KEY ("template_item_id") REFERENCES "market_template_items"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "markets_match_id_line_key_idx" ON "markets"("match_id", "line_key");
|
||||
CREATE INDEX IF NOT EXISTS "market_templates_sport_type_status_idx" ON "market_templates"("sport_type", "status");
|
||||
CREATE INDEX IF NOT EXISTS "market_templates_is_default_idx" ON "market_templates"("is_default");
|
||||
CREATE INDEX IF NOT EXISTS "market_template_items_template_id_idx" ON "market_template_items"("template_id");
|
||||
CREATE INDEX IF NOT EXISTS "market_template_items_market_type_idx" ON "market_template_items"("market_type");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "market_template_items_template_id_line_key_key" ON "market_template_items"("template_id", "line_key");
|
||||
CREATE INDEX IF NOT EXISTS "market_template_selections_template_item_id_idx" ON "market_template_selections"("template_item_id");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "market_template_selections_template_item_id_selection_code_key"
|
||||
ON "market_template_selections"("template_item_id", "selection_code");
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "match_scores"
|
||||
ADD COLUMN "home_corners" INTEGER,
|
||||
ADD COLUMN "away_corners" INTEGER,
|
||||
ADD COLUMN "home_cards" INTEGER,
|
||||
ADD COLUMN "away_cards" INTEGER;
|
||||
|
||||
ALTER TABLE "settlement_batches"
|
||||
ADD COLUMN "home_corners" INTEGER,
|
||||
ADD COLUMN "away_corners" INTEGER,
|
||||
ADD COLUMN "home_cards" INTEGER,
|
||||
ADD COLUMN "away_cards" INTEGER;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "matches" DROP COLUMN IF EXISTS "correct_score_enabled";
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "match_scores"
|
||||
ADD COLUMN "home_yellow_cards" INTEGER,
|
||||
ADD COLUMN "away_yellow_cards" INTEGER,
|
||||
ADD COLUMN "home_red_cards" INTEGER,
|
||||
ADD COLUMN "away_red_cards" INTEGER;
|
||||
|
||||
ALTER TABLE "settlement_batches"
|
||||
ADD COLUMN "home_yellow_cards" INTEGER,
|
||||
ADD COLUMN "away_yellow_cards" INTEGER,
|
||||
ADD COLUMN "home_red_cards" INTEGER,
|
||||
ADD COLUMN "away_red_cards" INTEGER;
|
||||
@@ -10,19 +10,19 @@ datasource db {
|
||||
// ============ Users & Auth ============
|
||||
|
||||
model User {
|
||||
id BigInt @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(64)
|
||||
userType String @map("user_type") @db.VarChar(20)
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
parentId BigInt? @map("parent_id")
|
||||
agentLevel Int? @map("agent_level")
|
||||
locale String @default("en-US") @db.VarChar(10)
|
||||
inviteCode String? @unique @map("invite_code") @db.VarChar(16)
|
||||
inviteSponsorId BigInt? @map("invite_sponsor_id")
|
||||
usedInviteId BigInt? @map("used_invite_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(64)
|
||||
userType String @map("user_type") @db.VarChar(20)
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
parentId BigInt? @map("parent_id")
|
||||
agentLevel Int? @map("agent_level")
|
||||
locale String @default("en-US") @db.VarChar(10)
|
||||
inviteCode String? @unique @map("invite_code") @db.VarChar(16)
|
||||
inviteSponsorId BigInt? @map("invite_sponsor_id")
|
||||
usedInviteId BigInt? @map("used_invite_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
auth UserAuth?
|
||||
wallet Wallet?
|
||||
@@ -30,14 +30,14 @@ model User {
|
||||
adminRole AdminUserRole?
|
||||
bets Bet[]
|
||||
preferences UserPreference?
|
||||
depositOrders DepositOrder[] @relation("PlayerDepositOrders")
|
||||
depositOrders DepositOrder[] @relation("PlayerDepositOrders")
|
||||
|
||||
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
|
||||
children User[] @relation("UserHierarchy")
|
||||
inviteSponsor User? @relation("InviteSponsor", fields: [inviteSponsorId], references: [id])
|
||||
invitedPlayers User[] @relation("InviteSponsor")
|
||||
usedInvite UserInvite? @relation("UsedInvite", fields: [usedInviteId], references: [id])
|
||||
invites UserInvite[] @relation("UserInvites")
|
||||
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
|
||||
children User[] @relation("UserHierarchy")
|
||||
inviteSponsor User? @relation("InviteSponsor", fields: [inviteSponsorId], references: [id])
|
||||
invitedPlayers User[] @relation("InviteSponsor")
|
||||
usedInvite UserInvite? @relation("UsedInvite", fields: [usedInviteId], references: [id])
|
||||
invites UserInvite[] @relation("UserInvites")
|
||||
|
||||
@@index([userType])
|
||||
@@index([parentId])
|
||||
@@ -56,8 +56,8 @@ model UserInvite {
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
cashbackRate Decimal? @map("cashback_rate") @db.Decimal(8, 4)
|
||||
|
||||
sponsor User @relation("UserInvites", fields: [sponsorId], references: [id])
|
||||
registrations User[] @relation("UsedInvite")
|
||||
sponsor User @relation("UserInvites", fields: [sponsorId], references: [id])
|
||||
registrations User[] @relation("UsedInvite")
|
||||
|
||||
@@index([sponsorId])
|
||||
@@index([status])
|
||||
@@ -66,16 +66,16 @@ model UserInvite {
|
||||
}
|
||||
|
||||
model UserAuth {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
passwordHash String @map("password_hash") @db.VarChar(255)
|
||||
loginFailCount Int @default(0) @map("login_fail_count")
|
||||
lockedUntil DateTime? @map("locked_until")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
passwordHash String @map("password_hash") @db.VarChar(255)
|
||||
loginFailCount Int @default(0) @map("login_fail_count")
|
||||
lockedUntil DateTime? @map("locked_until")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("user_auth")
|
||||
}
|
||||
@@ -95,18 +95,18 @@ model UserPreference {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([phoneCountryDial, phoneLocal])
|
||||
@@map("user_preferences")
|
||||
}
|
||||
|
||||
model Role {
|
||||
id BigInt @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(64)
|
||||
name String @db.VarChar(128)
|
||||
description String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(64)
|
||||
name String @db.VarChar(128)
|
||||
description String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
permissions RolePermission[]
|
||||
adminUsers AdminUserRole[]
|
||||
@@ -115,23 +115,23 @@ model Role {
|
||||
}
|
||||
|
||||
model Permission {
|
||||
id BigInt @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(128)
|
||||
name String @db.VarChar(128)
|
||||
module String @db.VarChar(64)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(128)
|
||||
name String @db.VarChar(128)
|
||||
module String @db.VarChar(64)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
roles RolePermission[]
|
||||
roles RolePermission[]
|
||||
|
||||
@@map("permissions")
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
roleId BigInt @map("role_id")
|
||||
permissionId BigInt @map("permission_id")
|
||||
roleId BigInt @map("role_id")
|
||||
permissionId BigInt @map("permission_id")
|
||||
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
permission Permission @relation(fields: [permissionId], references: [id])
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
permission Permission @relation(fields: [permissionId], references: [id])
|
||||
|
||||
@@id([roleId, permissionId])
|
||||
@@map("role_permissions")
|
||||
@@ -142,8 +142,8 @@ model AdminUserRole {
|
||||
roleId BigInt @map("role_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
|
||||
@@map("admin_user_roles")
|
||||
}
|
||||
@@ -151,23 +151,23 @@ model AdminUserRole {
|
||||
// ============ Agent ============
|
||||
|
||||
model AgentProfile {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
level Int
|
||||
parentAgentId BigInt? @map("parent_agent_id")
|
||||
creditLimit Decimal @default(0) @map("credit_limit") @db.Decimal(18, 4)
|
||||
usedCredit Decimal @default(0) @map("used_credit") @db.Decimal(18, 4)
|
||||
directPlayerLiability Decimal @default(0) @map("direct_player_liability") @db.Decimal(18, 4)
|
||||
childAgentExposure Decimal @default(0) @map("child_agent_exposure") @db.Decimal(18, 4)
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
maxSingleDeposit Decimal? @map("max_single_deposit") @db.Decimal(18, 4)
|
||||
maxDailyDeposit Decimal? @map("max_daily_deposit") @db.Decimal(18, 4)
|
||||
cashbackRate Decimal @default(0) @map("cashback_rate") @db.Decimal(8, 4)
|
||||
blockDirectPlayerLogin Boolean @default(false) @map("block_direct_player_login")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
level Int
|
||||
parentAgentId BigInt? @map("parent_agent_id")
|
||||
creditLimit Decimal @default(0) @map("credit_limit") @db.Decimal(18, 4)
|
||||
usedCredit Decimal @default(0) @map("used_credit") @db.Decimal(18, 4)
|
||||
directPlayerLiability Decimal @default(0) @map("direct_player_liability") @db.Decimal(18, 4)
|
||||
childAgentExposure Decimal @default(0) @map("child_agent_exposure") @db.Decimal(18, 4)
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
maxSingleDeposit Decimal? @map("max_single_deposit") @db.Decimal(18, 4)
|
||||
maxDailyDeposit Decimal? @map("max_daily_deposit") @db.Decimal(18, 4)
|
||||
cashbackRate Decimal @default(0) @map("cashback_rate") @db.Decimal(8, 4)
|
||||
blockDirectPlayerLogin Boolean @default(false) @map("block_direct_player_login")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([parentAgentId])
|
||||
@@map("agent_profiles")
|
||||
@@ -215,8 +215,8 @@ model Wallet {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
transactions WalletTransaction[]
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
transactions WalletTransaction[]
|
||||
|
||||
@@map("wallets")
|
||||
}
|
||||
@@ -234,11 +234,12 @@ model WalletTransaction {
|
||||
frozenAfter Decimal @map("frozen_after") @db.Decimal(18, 4)
|
||||
referenceType String? @map("reference_type") @db.VarChar(32)
|
||||
referenceId String? @map("reference_id") @db.VarChar(64)
|
||||
businessKey String? @unique @map("business_key") @db.VarChar(128)
|
||||
operatorId BigInt? @map("operator_id")
|
||||
remark String? @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
wallet Wallet @relation(fields: [walletId], references: [id])
|
||||
wallet Wallet @relation(fields: [walletId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([walletId])
|
||||
@@ -259,7 +260,7 @@ model League {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
matches Match[]
|
||||
matches Match[]
|
||||
|
||||
@@map("leagues")
|
||||
}
|
||||
@@ -296,41 +297,40 @@ model EntityTranslation {
|
||||
}
|
||||
|
||||
model Match {
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
leagueId BigInt @map("league_id")
|
||||
homeTeamId BigInt @map("home_team_id")
|
||||
awayTeamId BigInt @map("away_team_id")
|
||||
startTime DateTime @map("start_time")
|
||||
status String @default("DRAFT") @db.VarChar(32)
|
||||
isHot Boolean @default(false) @map("is_hot")
|
||||
displayOrder Int @default(0) @map("display_order")
|
||||
publishTime DateTime? @map("publish_time")
|
||||
closeTime DateTime? @map("close_time")
|
||||
isOutright Boolean @default(false) @map("is_outright")
|
||||
officialMatchNo Int? @map("official_match_no")
|
||||
stage String? @db.VarChar(32)
|
||||
groupName String? @map("group_name") @db.VarChar(8)
|
||||
liveMatchId BigInt? @unique @map("live_match_id")
|
||||
additionMatchId BigInt? @map("addition_match_id")
|
||||
channelId String? @map("channel_id") @db.VarChar(64)
|
||||
matchName String? @map("match_name") @db.VarChar(200)
|
||||
venueJson Json? @map("venue_json")
|
||||
kickoffJson Json? @map("kickoff_json")
|
||||
externalStatus String? @map("external_status") @db.VarChar(32)
|
||||
correctScoreEnabled Boolean @default(true) @map("correct_score_enabled")
|
||||
createdBy BigInt? @map("created_by")
|
||||
updatedBy BigInt? @map("updated_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
leagueId BigInt @map("league_id")
|
||||
homeTeamId BigInt @map("home_team_id")
|
||||
awayTeamId BigInt @map("away_team_id")
|
||||
startTime DateTime @map("start_time")
|
||||
status String @default("DRAFT") @db.VarChar(32)
|
||||
isHot Boolean @default(false) @map("is_hot")
|
||||
displayOrder Int @default(0) @map("display_order")
|
||||
publishTime DateTime? @map("publish_time")
|
||||
closeTime DateTime? @map("close_time")
|
||||
isOutright Boolean @default(false) @map("is_outright")
|
||||
officialMatchNo Int? @map("official_match_no")
|
||||
stage String? @db.VarChar(32)
|
||||
groupName String? @map("group_name") @db.VarChar(8)
|
||||
liveMatchId BigInt? @unique @map("live_match_id")
|
||||
additionMatchId BigInt? @map("addition_match_id")
|
||||
channelId String? @map("channel_id") @db.VarChar(64)
|
||||
matchName String? @map("match_name") @db.VarChar(200)
|
||||
venueJson Json? @map("venue_json")
|
||||
kickoffJson Json? @map("kickoff_json")
|
||||
externalStatus String? @map("external_status") @db.VarChar(32)
|
||||
createdBy BigInt? @map("created_by")
|
||||
updatedBy BigInt? @map("updated_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
league League @relation(fields: [leagueId], references: [id])
|
||||
homeTeam Team @relation("HomeTeam", fields: [homeTeamId], references: [id])
|
||||
awayTeam Team @relation("AwayTeam", fields: [awayTeamId], references: [id])
|
||||
score MatchScore?
|
||||
markets Market[]
|
||||
settlements SettlementBatch[]
|
||||
league League @relation(fields: [leagueId], references: [id])
|
||||
homeTeam Team @relation("HomeTeam", fields: [homeTeamId], references: [id])
|
||||
awayTeam Team @relation("AwayTeam", fields: [awayTeamId], references: [id])
|
||||
score MatchScore?
|
||||
markets Market[]
|
||||
settlements SettlementBatch[]
|
||||
|
||||
@@index([status])
|
||||
@@index([startTime])
|
||||
@@ -339,40 +339,56 @@ model Match {
|
||||
}
|
||||
|
||||
model MatchScore {
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @unique @map("match_id")
|
||||
htHomeScore Int? @map("ht_home_score")
|
||||
htAwayScore Int? @map("ht_away_score")
|
||||
ftHomeScore Int? @map("ft_home_score")
|
||||
ftAwayScore Int? @map("ft_away_score")
|
||||
winnerTeamId BigInt? @map("winner_team_id")
|
||||
recordedBy BigInt? @map("recorded_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @unique @map("match_id")
|
||||
htHomeScore Int? @map("ht_home_score")
|
||||
htAwayScore Int? @map("ht_away_score")
|
||||
ftHomeScore Int? @map("ft_home_score")
|
||||
ftAwayScore Int? @map("ft_away_score")
|
||||
homeCorners Int? @map("home_corners")
|
||||
awayCorners Int? @map("away_corners")
|
||||
homeYellowCards Int? @map("home_yellow_cards")
|
||||
awayYellowCards Int? @map("away_yellow_cards")
|
||||
homeRedCards Int? @map("home_red_cards")
|
||||
awayRedCards Int? @map("away_red_cards")
|
||||
homeCards Int? @map("home_cards")
|
||||
awayCards Int? @map("away_cards")
|
||||
winnerTeamId BigInt? @map("winner_team_id")
|
||||
recordedBy BigInt? @map("recorded_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
|
||||
@@map("match_scores")
|
||||
}
|
||||
|
||||
model Market {
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @map("match_id")
|
||||
marketType String @map("market_type") @db.VarChar(64)
|
||||
period String @db.VarChar(16)
|
||||
lineValue Decimal? @map("line_value") @db.Decimal(8, 2)
|
||||
status String @default("OPEN") @db.VarChar(20)
|
||||
allowSingle Boolean @default(true) @map("allow_single")
|
||||
allowParlay Boolean @default(true) @map("allow_parlay")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
promoLabel String? @map("promo_label") @db.VarChar(100)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @map("match_id")
|
||||
marketType String @map("market_type") @db.VarChar(64)
|
||||
marketKey String? @map("market_key") @db.VarChar(64)
|
||||
lineKey String? @map("line_key") @db.VarChar(180)
|
||||
period String @db.VarChar(16)
|
||||
lineValue Decimal? @map("line_value") @db.Decimal(8, 2)
|
||||
paramsJson Json? @map("params_json")
|
||||
status String @default("OPEN") @db.VarChar(20)
|
||||
allowSingle Boolean @default(true) @map("allow_single")
|
||||
allowParlay Boolean @default(true) @map("allow_parlay")
|
||||
showOnPlayer Boolean @default(true) @map("show_on_player")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
promoLabel String? @map("promo_label") @db.VarChar(100)
|
||||
promoLabelI18n Json? @map("promo_label_i18n")
|
||||
nameI18n Json? @map("name_i18n")
|
||||
templateItemId BigInt? @map("template_item_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
selections MarketSelection[]
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
selections MarketSelection[]
|
||||
|
||||
@@index([matchId])
|
||||
@@index([matchId, lineKey])
|
||||
@@index([marketType])
|
||||
@@map("markets")
|
||||
}
|
||||
@@ -382,6 +398,7 @@ model MarketSelection {
|
||||
marketId BigInt @map("market_id")
|
||||
selectionCode String @map("selection_code") @db.VarChar(64)
|
||||
selectionName String @map("selection_name") @db.VarChar(255)
|
||||
nameI18n Json? @map("name_i18n")
|
||||
odds Decimal @db.Decimal(18, 6)
|
||||
oddsVersion BigInt @default(1) @map("odds_version")
|
||||
status String @default("OPEN") @db.VarChar(20)
|
||||
@@ -389,13 +406,80 @@ model MarketSelection {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
market Market @relation(fields: [marketId], references: [id])
|
||||
oddsLogs OddsChangeLog[]
|
||||
market Market @relation(fields: [marketId], references: [id])
|
||||
oddsLogs OddsChangeLog[]
|
||||
|
||||
@@index([marketId])
|
||||
@@map("market_selections")
|
||||
}
|
||||
|
||||
model MarketTemplate {
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
name String @db.VarChar(128)
|
||||
nameI18n Json? @map("name_i18n")
|
||||
description String? @db.VarChar(500)
|
||||
isDefault Boolean @default(false) @map("is_default")
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
items MarketTemplateItem[]
|
||||
|
||||
@@index([sportType, status])
|
||||
@@index([isDefault])
|
||||
@@map("market_templates")
|
||||
}
|
||||
|
||||
model MarketTemplateItem {
|
||||
id BigInt @id @default(autoincrement())
|
||||
templateId BigInt @map("template_id")
|
||||
marketType String @map("market_type") @db.VarChar(64)
|
||||
marketKey String? @map("market_key") @db.VarChar(64)
|
||||
lineKey String @map("line_key") @db.VarChar(180)
|
||||
period String @db.VarChar(16)
|
||||
lineValue Decimal? @map("line_value") @db.Decimal(8, 2)
|
||||
paramsJson Json? @map("params_json")
|
||||
status String @default("OPEN") @db.VarChar(20)
|
||||
allowSingle Boolean @default(true) @map("allow_single")
|
||||
allowParlay Boolean @default(true) @map("allow_parlay")
|
||||
showOnPlayer Boolean @default(true) @map("show_on_player")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
promoLabel String? @map("promo_label") @db.VarChar(100)
|
||||
promoLabelI18n Json? @map("promo_label_i18n")
|
||||
nameI18n Json? @map("name_i18n")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
template MarketTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
selections MarketTemplateSelection[]
|
||||
|
||||
@@unique([templateId, lineKey])
|
||||
@@index([templateId])
|
||||
@@index([marketType])
|
||||
@@map("market_template_items")
|
||||
}
|
||||
|
||||
model MarketTemplateSelection {
|
||||
id BigInt @id @default(autoincrement())
|
||||
templateItemId BigInt @map("template_item_id")
|
||||
selectionCode String @map("selection_code") @db.VarChar(64)
|
||||
selectionName String @map("selection_name") @db.VarChar(255)
|
||||
nameI18n Json? @map("name_i18n")
|
||||
odds Decimal @db.Decimal(18, 6)
|
||||
status String @default("OPEN") @db.VarChar(20)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
item MarketTemplateItem @relation(fields: [templateItemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([templateItemId, selectionCode])
|
||||
@@index([templateItemId])
|
||||
@@map("market_template_selections")
|
||||
}
|
||||
|
||||
model OddsChangeLog {
|
||||
id BigInt @id @default(autoincrement())
|
||||
selectionId BigInt @map("selection_id")
|
||||
@@ -405,7 +489,7 @@ model OddsChangeLog {
|
||||
changedBy BigInt? @map("changed_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
selection MarketSelection @relation(fields: [selectionId], references: [id])
|
||||
selection MarketSelection @relation(fields: [selectionId], references: [id])
|
||||
|
||||
@@index([selectionId])
|
||||
@@map("odds_change_logs")
|
||||
@@ -433,9 +517,9 @@ model Bet {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
selections BetSelection[]
|
||||
cashbackClaims CashbackBet[]
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
selections BetSelection[]
|
||||
cashbackClaims CashbackBet[]
|
||||
|
||||
@@unique([userId, requestId])
|
||||
@@index([userId])
|
||||
@@ -453,6 +537,7 @@ model BetSelection {
|
||||
selectionId BigInt @map("selection_id")
|
||||
marketType String @map("market_type") @db.VarChar(64)
|
||||
period String? @db.VarChar(16)
|
||||
marketNameSnapshot String? @map("market_name_snapshot") @db.VarChar(255)
|
||||
selectionNameSnapshot String @map("selection_name_snapshot") @db.VarChar(255)
|
||||
handicapLine Decimal? @map("handicap_line") @db.Decimal(8, 2)
|
||||
totalLine Decimal? @map("total_line") @db.Decimal(8, 2)
|
||||
@@ -463,7 +548,7 @@ model BetSelection {
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
bet Bet @relation(fields: [betId], references: [id])
|
||||
bet Bet @relation(fields: [betId], references: [id])
|
||||
|
||||
@@index([betId])
|
||||
@@index([matchId])
|
||||
@@ -473,41 +558,50 @@ model BetSelection {
|
||||
// ============ Settlement ============
|
||||
|
||||
model SettlementBatch {
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @map("match_id")
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(64)
|
||||
htHomeScore Int? @map("ht_home_score")
|
||||
htAwayScore Int? @map("ht_away_score")
|
||||
ftHomeScore Int? @map("ft_home_score")
|
||||
ftAwayScore Int? @map("ft_away_score")
|
||||
status String @default("PREVIEW") @db.VarChar(20)
|
||||
totalBets Int @default(0) @map("total_bets")
|
||||
totalPayout Decimal @default(0) @map("total_payout") @db.Decimal(18, 4)
|
||||
totalRefund Decimal @default(0) @map("total_refund") @db.Decimal(18, 4)
|
||||
operatorId BigInt? @map("operator_id")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
isResettle Boolean @default(false) @map("is_resettle")
|
||||
reason String? @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @map("match_id")
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(64)
|
||||
htHomeScore Int? @map("ht_home_score")
|
||||
htAwayScore Int? @map("ht_away_score")
|
||||
ftHomeScore Int? @map("ft_home_score")
|
||||
ftAwayScore Int? @map("ft_away_score")
|
||||
homeCorners Int? @map("home_corners")
|
||||
awayCorners Int? @map("away_corners")
|
||||
homeYellowCards Int? @map("home_yellow_cards")
|
||||
awayYellowCards Int? @map("away_yellow_cards")
|
||||
homeRedCards Int? @map("home_red_cards")
|
||||
awayRedCards Int? @map("away_red_cards")
|
||||
homeCards Int? @map("home_cards")
|
||||
awayCards Int? @map("away_cards")
|
||||
status String @default("PREVIEW") @db.VarChar(20)
|
||||
totalBets Int @default(0) @map("total_bets")
|
||||
totalPayout Decimal @default(0) @map("total_payout") @db.Decimal(18, 4)
|
||||
totalRefund Decimal @default(0) @map("total_refund") @db.Decimal(18, 4)
|
||||
operatorId BigInt? @map("operator_id")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
isResettle Boolean @default(false) @map("is_resettle")
|
||||
reason String? @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
items SettlementItem[]
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
items SettlementItem[]
|
||||
|
||||
@@index([matchId])
|
||||
@@map("settlement_batches")
|
||||
}
|
||||
|
||||
model SettlementItem {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
betId BigInt @map("bet_id")
|
||||
userId BigInt @map("user_id")
|
||||
result String @db.VarChar(32)
|
||||
payout Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
betId BigInt @map("bet_id")
|
||||
userId BigInt @map("user_id")
|
||||
result String @db.VarChar(32)
|
||||
payout Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
batch SettlementBatch @relation(fields: [batchId], references: [id])
|
||||
batch SettlementBatch @relation(fields: [batchId], references: [id])
|
||||
|
||||
@@unique([batchId, betId])
|
||||
@@index([batchId])
|
||||
@@index([betId])
|
||||
@@map("settlement_items")
|
||||
@@ -516,57 +610,59 @@ model SettlementItem {
|
||||
// ============ Cashback ============
|
||||
|
||||
model CashbackRule {
|
||||
id BigInt @id @default(autoincrement())
|
||||
name String @db.VarChar(128)
|
||||
targetType String @map("target_type") @db.VarChar(32)
|
||||
targetId BigInt? @map("target_id")
|
||||
rate Decimal @db.Decimal(8, 4)
|
||||
marketType String? @map("market_type") @db.VarChar(64)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
name String @db.VarChar(128)
|
||||
targetType String @map("target_type") @db.VarChar(32)
|
||||
targetId BigInt? @map("target_id")
|
||||
rate Decimal @db.Decimal(8, 4)
|
||||
marketType String? @map("market_type") @db.VarChar(64)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("cashback_rules")
|
||||
}
|
||||
|
||||
model CashbackBatch {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(64)
|
||||
periodStart DateTime @map("period_start")
|
||||
periodEnd DateTime @map("period_end")
|
||||
status String @default("PREVIEW") @db.VarChar(20)
|
||||
totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(18, 4)
|
||||
totalEffectiveStake Decimal @default(0) @map("total_effective_stake") @db.Decimal(18, 4)
|
||||
totalBetCount Int @default(0) @map("total_bet_count")
|
||||
playerCount Int @default(0) @map("player_count")
|
||||
operatorId BigInt? @map("operator_id")
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(64)
|
||||
periodStart DateTime @map("period_start")
|
||||
periodEnd DateTime @map("period_end")
|
||||
status String @default("PREVIEW") @db.VarChar(20)
|
||||
totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(18, 4)
|
||||
totalEffectiveStake Decimal @default(0) @map("total_effective_stake") @db.Decimal(18, 4)
|
||||
totalBetCount Int @default(0) @map("total_bet_count")
|
||||
playerCount Int @default(0) @map("player_count")
|
||||
operatorId BigInt? @map("operator_id")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
items CashbackItem[]
|
||||
bets CashbackBet[]
|
||||
items CashbackItem[]
|
||||
bets CashbackBet[]
|
||||
|
||||
@@map("cashback_batches")
|
||||
}
|
||||
|
||||
model CashbackItem {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
userId BigInt @map("user_id")
|
||||
effectiveStake Decimal @map("effective_stake") @db.Decimal(18, 4)
|
||||
betCount Int @default(0) @map("bet_count")
|
||||
rate Decimal @db.Decimal(8, 4)
|
||||
amount Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
userId BigInt @map("user_id")
|
||||
effectiveStake Decimal @map("effective_stake") @db.Decimal(18, 4)
|
||||
betCount Int @default(0) @map("bet_count")
|
||||
rate Decimal @db.Decimal(8, 4)
|
||||
amount Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
batch CashbackBatch @relation(fields: [batchId], references: [id])
|
||||
batch CashbackBatch @relation(fields: [batchId], references: [id])
|
||||
|
||||
@@index([batchId])
|
||||
@@index([userId])
|
||||
@@map("cashback_items")
|
||||
}
|
||||
|
||||
/** 返水批次占用的注单(每笔注单全局仅能计入一次待发放/已发放批次) */
|
||||
/**
|
||||
* 返水批次占用的注单(每笔注单全局仅能计入一次待发放/已发放批次)
|
||||
*/
|
||||
model CashbackBet {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
@@ -576,8 +672,8 @@ model CashbackBet {
|
||||
amount Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
batch CashbackBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
||||
bet Bet @relation(fields: [betId], references: [id])
|
||||
batch CashbackBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
||||
bet Bet @relation(fields: [betId], references: [id])
|
||||
|
||||
@@index([batchId])
|
||||
@@index([userId])
|
||||
@@ -612,7 +708,7 @@ model ContentTranslation {
|
||||
body String? @db.Text
|
||||
imageUrl String? @map("image_url") @db.VarChar(500)
|
||||
|
||||
content Content @relation(fields: [contentId], references: [id])
|
||||
content Content @relation(fields: [contentId], references: [id])
|
||||
|
||||
@@unique([contentId, locale])
|
||||
@@map("content_translations")
|
||||
@@ -649,20 +745,20 @@ model UploadedFile {
|
||||
// ============ Manual Deposit / Recharge ============
|
||||
|
||||
model PaymentMethod {
|
||||
id BigInt @id @default(autoincrement())
|
||||
methodType String @map("method_type") @db.VarChar(20)
|
||||
bankName String? @map("bank_name") @db.VarChar(128)
|
||||
accountHolder String? @map("account_holder") @db.VarChar(128)
|
||||
accountNumber String? @map("account_number") @db.VarChar(128)
|
||||
usdtAddress String? @map("usdt_address") @db.VarChar(256)
|
||||
qrCodeUrl String? @map("qr_code_url") @db.VarChar(500)
|
||||
displayName String? @map("display_name") @db.VarChar(128)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
showOnPlayer Boolean @default(true) @map("show_on_player")
|
||||
createdBy BigInt? @map("created_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
methodType String @map("method_type") @db.VarChar(20)
|
||||
bankName String? @map("bank_name") @db.VarChar(128)
|
||||
accountHolder String? @map("account_holder") @db.VarChar(128)
|
||||
accountNumber String? @map("account_number") @db.VarChar(128)
|
||||
usdtAddress String? @map("usdt_address") @db.VarChar(256)
|
||||
qrCodeUrl String? @map("qr_code_url") @db.VarChar(500)
|
||||
displayName String? @map("display_name") @db.VarChar(128)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
showOnPlayer Boolean @default(true) @map("show_on_player")
|
||||
createdBy BigInt? @map("created_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
depositOrders DepositOrder[]
|
||||
|
||||
@@ -699,28 +795,28 @@ model DepositOrder {
|
||||
// ============ System Config & Audit ============
|
||||
|
||||
model SystemConfig {
|
||||
id BigInt @id @default(autoincrement())
|
||||
configKey String @unique @map("config_key") @db.VarChar(128)
|
||||
configValue String @map("config_value") @db.Text
|
||||
description String? @db.VarChar(255)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
configKey String @unique @map("config_key") @db.VarChar(128)
|
||||
configValue String @map("config_value") @db.Text
|
||||
description String? @db.VarChar(255)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("system_configs")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id BigInt @id @default(autoincrement())
|
||||
operatorId BigInt? @map("operator_id")
|
||||
operatorType String @map("operator_type") @db.VarChar(20)
|
||||
action String @db.VarChar(128)
|
||||
module String @db.VarChar(64)
|
||||
targetType String? @map("target_type") @db.VarChar(32)
|
||||
targetId String? @map("target_id") @db.VarChar(64)
|
||||
beforeData String? @map("before_data") @db.Text
|
||||
afterData String? @map("after_data") @db.Text
|
||||
ipAddress String? @map("ip_address") @db.VarChar(45)
|
||||
userAgent String? @map("user_agent") @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
operatorId BigInt? @map("operator_id")
|
||||
operatorType String @map("operator_type") @db.VarChar(20)
|
||||
action String @db.VarChar(128)
|
||||
module String @db.VarChar(64)
|
||||
targetType String? @map("target_type") @db.VarChar(32)
|
||||
targetId String? @map("target_id") @db.VarChar(64)
|
||||
beforeData String? @map("before_data") @db.Text
|
||||
afterData String? @map("after_data") @db.Text
|
||||
ipAddress String? @map("ip_address") @db.VarChar(45)
|
||||
userAgent String? @map("user_agent") @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([operatorId])
|
||||
@@index([module])
|
||||
|
||||
@@ -25,6 +25,62 @@ const outrightTeams = [...outrightSrc.matchAll(/code: '([^']+)', names: \{ 'zh-C
|
||||
en: m[3],
|
||||
}));
|
||||
|
||||
const CODE_TO_FLAG_ISO = {
|
||||
ALG: 'dz',
|
||||
ARG: 'ar',
|
||||
AUS: 'au',
|
||||
AUT: 'at',
|
||||
BEL: 'be',
|
||||
BIH: 'ba',
|
||||
BRA: 'br',
|
||||
CAN: 'ca',
|
||||
CIV: 'ci',
|
||||
COD: 'cd',
|
||||
COL: 'co',
|
||||
CPV: 'cv',
|
||||
CRO: 'hr',
|
||||
CUW: 'cw',
|
||||
CZE: 'cz',
|
||||
ECU: 'ec',
|
||||
EGY: 'eg',
|
||||
ENG: 'gb-eng',
|
||||
ESP: 'es',
|
||||
FRA: 'fr',
|
||||
GER: 'de',
|
||||
GHA: 'gh',
|
||||
HAI: 'ht',
|
||||
IRN: 'ir',
|
||||
IRQ: 'iq',
|
||||
JOR: 'jo',
|
||||
JPN: 'jp',
|
||||
KOR: 'kr',
|
||||
KSA: 'sa',
|
||||
MAR: 'ma',
|
||||
MEX: 'mx',
|
||||
NED: 'nl',
|
||||
NOR: 'no',
|
||||
NZL: 'nz',
|
||||
PAN: 'pa',
|
||||
PAR: 'py',
|
||||
POR: 'pt',
|
||||
QAT: 'qa',
|
||||
RSA: 'za',
|
||||
SCO: 'gb-sct',
|
||||
SEN: 'sn',
|
||||
SUI: 'ch',
|
||||
SWE: 'se',
|
||||
TUN: 'tn',
|
||||
TUR: 'tr',
|
||||
URU: 'uy',
|
||||
USA: 'us',
|
||||
UZB: 'uz',
|
||||
};
|
||||
|
||||
function flagUrlForCode(code) {
|
||||
const iso = CODE_TO_FLAG_ISO[code];
|
||||
return iso ? `https://flagcdn.com/${iso}.svg` : '';
|
||||
}
|
||||
|
||||
function pickNames(names) {
|
||||
return {
|
||||
zh: names?.zh ?? null,
|
||||
@@ -37,11 +93,12 @@ function pickNames(names) {
|
||||
}
|
||||
|
||||
function pickTeam(team) {
|
||||
const code = resolveCanonicalCode(team);
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
names: pickNames(team.names),
|
||||
image: team.image ?? '',
|
||||
image: code ? flagUrlForCode(code) : '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,7 +122,7 @@ function slimMatch(m) {
|
||||
},
|
||||
homeTeam: pickTeam(m.homeTeam),
|
||||
awayTeam: pickTeam(m.awayTeam),
|
||||
status: { state: m.status.state, isHot: m.status.isHot ?? 0 },
|
||||
status: { state: m.status.state, isHot: 0 },
|
||||
venue: {
|
||||
names: pickNames(m.venue?.names),
|
||||
city: pickNames(m.venue?.city),
|
||||
@@ -76,7 +133,6 @@ function slimMatch(m) {
|
||||
}
|
||||
|
||||
function resolveCanonicalCode(team) {
|
||||
if (team.id == null) return null;
|
||||
const en = (team.name || team.names?.en || '').toLowerCase();
|
||||
const zh = team.names?.zh || '';
|
||||
const hit = outrightTeams.find(
|
||||
@@ -104,13 +160,16 @@ for (const m of raw.matches || []) {
|
||||
}
|
||||
|
||||
const zhiboToCode = {};
|
||||
const logoByCode = {};
|
||||
const unmatched = [];
|
||||
const missingFlagCodes = new Set();
|
||||
for (const [id, team] of teamById) {
|
||||
const code = resolveCanonicalCode(team);
|
||||
if (code) {
|
||||
zhiboToCode[id] = code;
|
||||
if (team.image) logoByCode[code] = team.image;
|
||||
const flagUrl = flagUrlForCode(code);
|
||||
if (!flagUrl) {
|
||||
missingFlagCodes.add(code);
|
||||
}
|
||||
} else {
|
||||
unmatched.push({ id, name: team.name });
|
||||
}
|
||||
@@ -125,9 +184,9 @@ const mapLines = Object.entries(zhiboToCode)
|
||||
.map(([id, code]) => ` ${id}: '${code}',`)
|
||||
.join('\n');
|
||||
|
||||
const logoLines = Object.entries(logoByCode)
|
||||
const flagIsoLines = Object.entries(CODE_TO_FLAG_ISO)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([code, url]) => ` ${code}: '${url.replace(/'/g, "\\'")}',`)
|
||||
.map(([code, iso]) => ` ${code}: '${iso}',`)
|
||||
.join('\n');
|
||||
|
||||
const mapTs = `/** 由 build-wc2026-seed-json.mjs 生成 — zhibo externalId → WC2026 canonical code */
|
||||
@@ -135,10 +194,17 @@ export const WC2026_ZIBO_ID_TO_CODE: Record<number, string> = {
|
||||
${mapLines}
|
||||
};
|
||||
|
||||
/** zhibo 球队 logo(seed 时写入 teams.logo_url) */
|
||||
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = {
|
||||
${logoLines}
|
||||
/** WC2026 默认国家/地区国旗(FlagCDN,seed 时写入 teams.logo_url) */
|
||||
export const WC2026_FLAG_ISO_BY_CODE: Record<string, string> = {
|
||||
${flagIsoLines}
|
||||
};
|
||||
|
||||
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(WC2026_FLAG_ISO_BY_CODE).map(([code, iso]) => [
|
||||
code,
|
||||
\`https://flagcdn.com/\${iso}.svg\`,
|
||||
]),
|
||||
);
|
||||
`;
|
||||
|
||||
const mapOut = path.join(seedDataDir, 'wc2026-zhibo-team-map.ts');
|
||||
@@ -150,3 +216,7 @@ if (unmatched.length) {
|
||||
console.warn('Unmatched teams:', unmatched);
|
||||
process.exit(1);
|
||||
}
|
||||
if (missingFlagCodes.size) {
|
||||
console.warn('Missing FlagCDN ISO mapping:', [...missingFlagCodes].sort());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,14 @@ type AdminUploadUser = {
|
||||
permissions?: string[];
|
||||
};
|
||||
|
||||
function parseMatchStartTime(value: string): Date {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw appBadRequest('MATCH_START_TIME_INVALID');
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function uploadCategory(value?: string): UploadCategory {
|
||||
const category = (value || 'contents').trim();
|
||||
if (UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
|
||||
@@ -503,9 +511,6 @@ class CreatePlatformMatchDto {
|
||||
@IsString()
|
||||
awayTeamLogoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
correctScoreEnabled?: boolean;
|
||||
}
|
||||
|
||||
class UpdatePlatformMatchDto {
|
||||
@@ -560,9 +565,6 @@ class UpdatePlatformMatchDto {
|
||||
@IsString()
|
||||
awayTeamLogoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
correctScoreEnabled?: boolean;
|
||||
}
|
||||
|
||||
class ReopenMatchDto {
|
||||
@@ -591,6 +593,12 @@ class UpdateMarketDto {
|
||||
@IsString()
|
||||
promoLabel?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
promoLabelI18n?: Record<string, string> | null;
|
||||
|
||||
@IsOptional()
|
||||
nameI18n?: Record<string, string> | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
@@ -598,6 +606,10 @@ class UpdateMarketDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
lineValue?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showOnPlayer?: boolean;
|
||||
}
|
||||
|
||||
class UpdateSelectionDto {
|
||||
@@ -605,6 +617,9 @@ class UpdateSelectionDto {
|
||||
@IsString()
|
||||
selectionName?: string;
|
||||
|
||||
@IsOptional()
|
||||
nameI18n?: Record<string, string> | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1.01)
|
||||
@@ -637,6 +652,38 @@ class ScoreDto {
|
||||
@IsNumber()
|
||||
ftAway?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeCorners?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayCorners?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeYellowCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayYellowCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeRedCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayRedCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayCards?: number;
|
||||
|
||||
/** 冠军盘结算:获胜球队 ID */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@@ -674,6 +721,136 @@ class MarketTemplatesDto {
|
||||
marketTypes!: string[];
|
||||
}
|
||||
|
||||
class MarketDraftSelectionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsString()
|
||||
selectionCode!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
selectionName?: string;
|
||||
|
||||
@IsOptional()
|
||||
nameI18n?: Record<string, string> | null;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1.01)
|
||||
odds!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
class MarketDraftDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsString()
|
||||
marketType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
marketKey?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lineKey?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
period?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
lineValue?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
paramsJson?: Record<string, unknown> | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowSingle?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowParlay?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showOnPlayer?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoLabel?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
promoLabelI18n?: Record<string, string> | null;
|
||||
|
||||
@IsOptional()
|
||||
nameI18n?: Record<string, string> | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
selections?: MarketDraftSelectionDto[];
|
||||
}
|
||||
|
||||
class MarketTemplateSaveDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
nameI18n?: Record<string, string> | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
items?: MarketDraftDto[];
|
||||
}
|
||||
|
||||
class ApplyMarketTemplateDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
templateId?: string;
|
||||
}
|
||||
|
||||
class BulkMatchMarketsDto {
|
||||
@IsArray()
|
||||
markets!: MarketDraftDto[];
|
||||
}
|
||||
|
||||
class UpdateOddsDto {
|
||||
@IsNumber()
|
||||
odds!: number;
|
||||
@@ -892,6 +1069,38 @@ class ResettlePreviewDto {
|
||||
@IsNumber()
|
||||
ftAway?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeCorners?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayCorners?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeYellowCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayYellowCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeRedCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayRedCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
@@ -1665,7 +1874,7 @@ export class AdminController {
|
||||
awayTeamEn: dto.awayTeamEn,
|
||||
awayTeamZh: dto.awayTeamZh,
|
||||
awayTeamMs: dto.awayTeamMs,
|
||||
startTime: new Date(dto.startTime),
|
||||
startTime: parseMatchStartTime(dto.startTime),
|
||||
isHot: dto.isHot,
|
||||
displayOrder: dto.displayOrder,
|
||||
matchName: dto.matchName,
|
||||
@@ -1673,7 +1882,6 @@ export class AdminController {
|
||||
groupName: dto.groupName,
|
||||
homeTeamLogoUrl: dto.homeTeamLogoUrl,
|
||||
awayTeamLogoUrl: dto.awayTeamLogoUrl,
|
||||
correctScoreEnabled: dto.correctScoreEnabled,
|
||||
updatedBy: operatorId,
|
||||
});
|
||||
await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId);
|
||||
@@ -1725,9 +1933,8 @@ export class AdminController {
|
||||
awayTeamEn: dto.awayTeamEn,
|
||||
awayTeamZh: dto.awayTeamZh,
|
||||
awayTeamMs: dto.awayTeamMs,
|
||||
startTime: new Date(dto.startTime),
|
||||
startTime: parseMatchStartTime(dto.startTime),
|
||||
isHot: dto.isHot,
|
||||
correctScoreEnabled: dto.correctScoreEnabled,
|
||||
displayOrder: dto.displayOrder,
|
||||
matchName: dto.matchName,
|
||||
stage: dto.stage,
|
||||
@@ -1775,7 +1982,7 @@ export class AdminController {
|
||||
@Post('matches/:id/reopen')
|
||||
@RequirePermissions(P.matches)
|
||||
async reopenMatch(@Param('id') id: string, @Body() dto: ReopenMatchDto) {
|
||||
const startTime = dto.startTime ? new Date(dto.startTime) : undefined;
|
||||
const startTime = dto.startTime ? parseMatchStartTime(dto.startTime) : undefined;
|
||||
const match = await this.matches.reopenMatch(BigInt(id), startTime);
|
||||
return jsonResponse(match);
|
||||
}
|
||||
@@ -1783,11 +1990,58 @@ export class AdminController {
|
||||
@Post('matches/:id/cancel')
|
||||
@RequirePermissions(P.matches)
|
||||
async cancelMatch(@Param('id') id: string) {
|
||||
await this.matches.cancelMatch(BigInt(id));
|
||||
const voided = await this.settlement.voidMatchBets(BigInt(id));
|
||||
const voided = await this.settlement.cancelMatchAndVoidBets(BigInt(id));
|
||||
return jsonResponse(voided);
|
||||
}
|
||||
|
||||
@Get('market-definitions')
|
||||
@RequirePermissions(P.matches)
|
||||
async listMarketDefinitions() {
|
||||
return jsonResponse(this.markets.listMarketDefinitions());
|
||||
}
|
||||
|
||||
@Get('market-templates')
|
||||
@RequirePermissions(P.matches)
|
||||
async listMarketTemplates() {
|
||||
const templates = await this.markets.listTemplates();
|
||||
return jsonResponse(templates);
|
||||
}
|
||||
|
||||
@Post('market-templates')
|
||||
@RequirePermissions(P.matches)
|
||||
async createMarketTemplate(@Body() dto: MarketTemplateSaveDto) {
|
||||
const template = await this.markets.createTemplate(dto);
|
||||
return jsonResponse(template);
|
||||
}
|
||||
|
||||
@Get('market-templates/:id')
|
||||
@RequirePermissions(P.matches)
|
||||
async getMarketTemplate(@Param('id') id: string) {
|
||||
const template = await this.markets.getTemplate(BigInt(id));
|
||||
return jsonResponse(template);
|
||||
}
|
||||
|
||||
@Put('market-templates/:id')
|
||||
@RequirePermissions(P.matches)
|
||||
async updateMarketTemplate(@Param('id') id: string, @Body() dto: MarketTemplateSaveDto) {
|
||||
const template = await this.markets.updateTemplate(BigInt(id), dto);
|
||||
return jsonResponse(template);
|
||||
}
|
||||
|
||||
@Post('market-templates/:id/duplicate')
|
||||
@RequirePermissions(P.matches)
|
||||
async duplicateMarketTemplate(@Param('id') id: string) {
|
||||
const template = await this.markets.duplicateTemplate(BigInt(id));
|
||||
return jsonResponse(template);
|
||||
}
|
||||
|
||||
@Post('market-templates/:id/set-default')
|
||||
@RequirePermissions(P.matches)
|
||||
async setDefaultMarketTemplate(@Param('id') id: string) {
|
||||
const template = await this.markets.setDefaultTemplate(BigInt(id));
|
||||
return jsonResponse(template);
|
||||
}
|
||||
|
||||
@Post('matches/:id/markets/templates')
|
||||
@RequirePermissions(P.matches)
|
||||
async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) {
|
||||
@@ -1795,6 +2049,27 @@ export class AdminController {
|
||||
return jsonResponse(markets);
|
||||
}
|
||||
|
||||
@Post('matches/:id/markets/apply-template')
|
||||
@RequirePermissions(P.matches)
|
||||
async applyMarketTemplate(@Param('id') id: string, @Body() dto: ApplyMarketTemplateDto) {
|
||||
const result = await this.markets.applyTemplateToMatch(
|
||||
BigInt(id),
|
||||
dto.templateId ? BigInt(dto.templateId) : null,
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Put('matches/:id/markets/bulk')
|
||||
@RequirePermissions(P.matches)
|
||||
async bulkSaveMatchMarkets(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: BulkMatchMarketsDto,
|
||||
) {
|
||||
const result = await this.markets.saveMatchMarkets(BigInt(id), dto.markets, operatorId);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Put('matches/:id/odds')
|
||||
@RequirePermissions(P.matches)
|
||||
async batchUpdateMatchOdds(
|
||||
@@ -1815,8 +2090,11 @@ export class AdminController {
|
||||
async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) {
|
||||
const market = await this.markets.updateMarket(BigInt(id), {
|
||||
promoLabel: dto.promoLabel,
|
||||
promoLabelI18n: dto.promoLabelI18n,
|
||||
nameI18n: dto.nameI18n,
|
||||
status: dto.status,
|
||||
lineValue: dto.lineValue,
|
||||
showOnPlayer: dto.showOnPlayer,
|
||||
});
|
||||
return jsonResponse(market);
|
||||
}
|
||||
@@ -1832,6 +2110,7 @@ export class AdminController {
|
||||
BigInt(id),
|
||||
{
|
||||
selectionName: dto.selectionName,
|
||||
nameI18n: dto.nameI18n,
|
||||
odds: dto.odds,
|
||||
status: dto.status,
|
||||
},
|
||||
@@ -2057,6 +2336,16 @@ export class AdminController {
|
||||
dto.ftAway ?? 0,
|
||||
operatorId,
|
||||
dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
|
||||
{
|
||||
homeCorners: dto.homeCorners ?? null,
|
||||
awayCorners: dto.awayCorners ?? null,
|
||||
homeYellowCards: dto.homeYellowCards ?? null,
|
||||
awayYellowCards: dto.awayYellowCards ?? null,
|
||||
homeRedCards: dto.homeRedCards ?? null,
|
||||
awayRedCards: dto.awayRedCards ?? null,
|
||||
homeCards: dto.homeCards ?? null,
|
||||
awayCards: dto.awayCards ?? null,
|
||||
},
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
@@ -2074,6 +2363,14 @@ export class AdminController {
|
||||
htAway: dto?.htAway,
|
||||
ftHome: dto?.ftHome,
|
||||
ftAway: dto?.ftAway,
|
||||
homeCorners: dto?.homeCorners,
|
||||
awayCorners: dto?.awayCorners,
|
||||
homeYellowCards: dto?.homeYellowCards,
|
||||
awayYellowCards: dto?.awayYellowCards,
|
||||
homeRedCards: dto?.homeRedCards,
|
||||
awayRedCards: dto?.awayRedCards,
|
||||
homeCards: dto?.homeCards,
|
||||
awayCards: dto?.awayCards,
|
||||
winnerTeamId: dto?.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
|
||||
page: dto?.page ? Math.max(1, dto.page) : 1,
|
||||
pageSize: dto?.pageSize ? Math.min(100, Math.max(1, dto.pageSize)) : 10,
|
||||
@@ -2123,6 +2420,14 @@ export class AdminController {
|
||||
htAway: dto.htAway ?? 0,
|
||||
ftHome: dto.ftHome ?? 0,
|
||||
ftAway: dto.ftAway ?? 0,
|
||||
homeCorners: dto.homeCorners ?? null,
|
||||
awayCorners: dto.awayCorners ?? null,
|
||||
homeYellowCards: dto.homeYellowCards ?? null,
|
||||
awayYellowCards: dto.awayYellowCards ?? null,
|
||||
homeRedCards: dto.homeRedCards ?? null,
|
||||
awayRedCards: dto.awayRedCards ?? null,
|
||||
homeCards: dto.homeCards ?? null,
|
||||
awayCards: dto.awayCards ?? null,
|
||||
},
|
||||
operatorId,
|
||||
dto.reason,
|
||||
|
||||
@@ -93,6 +93,28 @@ class UpdateProfileDto {
|
||||
username?: string;
|
||||
}
|
||||
|
||||
function safeTimeZone(input?: string): string {
|
||||
const value = input?.trim();
|
||||
if (!value) return 'UTC';
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: value }).format(new Date());
|
||||
return value;
|
||||
} catch {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
function dayKeyInTimeZone(date: Date, timeZone: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(date);
|
||||
const map = new Map(parts.map((part) => [part.type, part.value]));
|
||||
return `${map.get('year')}-${map.get('month')}-${map.get('day')}`;
|
||||
}
|
||||
|
||||
@ApiTags('Player')
|
||||
@Controller('player')
|
||||
@UseGuards(JwtAuthGuard, PlayerGuard)
|
||||
@@ -160,6 +182,7 @@ export class PlayerController {
|
||||
async home(
|
||||
@CurrentUser('locale') userLocale: string | undefined,
|
||||
@Headers('x-locale') headerLocale?: string,
|
||||
@Headers('x-time-zone') headerTimeZone?: string,
|
||||
) {
|
||||
const locale = userLocale || headerLocale || 'zh-CN';
|
||||
const [banners, announcements, allMatches] = await Promise.all([
|
||||
@@ -167,19 +190,13 @@ export class PlayerController {
|
||||
this.content.listActiveAnnouncements(locale),
|
||||
this.matches.listPublished(locale, undefined, { includeMarkets: false }),
|
||||
]);
|
||||
const now = new Date();
|
||||
const isKickoffToday = (startTime: string) => {
|
||||
const d = new Date(startTime);
|
||||
return (
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate()
|
||||
);
|
||||
};
|
||||
const timeZone = safeTimeZone(headerTimeZone);
|
||||
const todayKey = dayKeyInTimeZone(new Date(), timeZone);
|
||||
const hotMatches = (allMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot);
|
||||
const todayMatches = (allMatches as Array<{ startTime: string }>).filter((m) =>
|
||||
isKickoffToday(m.startTime),
|
||||
);
|
||||
const todayMatches = (allMatches as Array<{ startTime: string }>).filter((m) => {
|
||||
const kickoff = new Date(m.startTime);
|
||||
return !Number.isNaN(kickoff.getTime()) && dayKeyInTimeZone(kickoff, timeZone) === todayKey;
|
||||
});
|
||||
return jsonResponse({
|
||||
banners,
|
||||
announcements,
|
||||
|
||||
97
apps/api/src/domains/agent/agent-credit.service.spec.ts
Normal file
97
apps/api/src/domains/agent/agent-credit.service.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { AgentCreditService } from './agent-credit.service';
|
||||
import { expectAppError } from '../../testing/prisma-mock';
|
||||
|
||||
describe('AgentCreditService', () => {
|
||||
const tx = {
|
||||
$queryRaw: jest.fn(),
|
||||
user: {
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
agentClosure: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
agentProfile: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
walletTransaction: {
|
||||
findUnique: jest.fn(),
|
||||
aggregate: jest.fn(),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
...tx,
|
||||
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
const funds = {
|
||||
deposit: jest.fn(),
|
||||
withdraw: jest.fn(),
|
||||
};
|
||||
|
||||
let service: AgentCreditService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new AgentCreditService(prisma as never, funds as never);
|
||||
tx.walletTransaction.findUnique.mockResolvedValue(null);
|
||||
tx.user.findFirst.mockResolvedValue({ id: 10n, parentId: 1n, userType: 'PLAYER' });
|
||||
tx.user.findMany.mockResolvedValue([]);
|
||||
tx.agentClosure.findMany.mockResolvedValue([{ ancestorId: 1n, depth: 0 }]);
|
||||
tx.agentProfile.findMany.mockResolvedValue([]);
|
||||
tx.agentProfile.update.mockResolvedValue({});
|
||||
tx.$queryRaw.mockResolvedValue([
|
||||
{
|
||||
user_id: 1n,
|
||||
parent_agent_id: null,
|
||||
credit_limit: new Decimal(1000),
|
||||
used_credit: new Decimal(0),
|
||||
max_single_deposit: null,
|
||||
max_daily_deposit: new Decimal(100),
|
||||
},
|
||||
]);
|
||||
tx.agentProfile.findUnique.mockResolvedValue({
|
||||
userId: 1n,
|
||||
parentAgentId: null,
|
||||
creditLimit: new Decimal(1000),
|
||||
usedCredit: new Decimal(0),
|
||||
maxSingleDeposit: null,
|
||||
maxDailyDeposit: new Decimal(100),
|
||||
});
|
||||
tx.walletTransaction.aggregate.mockResolvedValue({ _sum: { amount: new Decimal(80) } });
|
||||
});
|
||||
|
||||
it('counts prior AGENT_DEPOSIT postings when enforcing the daily top-up limit', async () => {
|
||||
await expect(
|
||||
service.depositToPlayer(1n, 10n, 30, 'REQ-DAILY-LIMIT'),
|
||||
).rejects.toMatchObject(expectAppError('AGENT_DAILY_TOPUP_LIMIT'));
|
||||
|
||||
expect(tx.walletTransaction.aggregate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
operatorId: 1n,
|
||||
transactionType: { in: ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(funds.deposit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recalculates the changed agent and every ancestor in closure order', async () => {
|
||||
tx.agentClosure.findMany.mockResolvedValue([
|
||||
{ ancestorId: 3n, depth: 0 },
|
||||
{ ancestorId: 2n, depth: 1 },
|
||||
{ ancestorId: 1n, depth: 2 },
|
||||
]);
|
||||
|
||||
await service.recalculateUsedCredit(3n);
|
||||
|
||||
expect(tx.agentProfile.update.mock.calls.map(([arg]) => arg.where.userId)).toEqual([
|
||||
3n,
|
||||
2n,
|
||||
1n,
|
||||
]);
|
||||
});
|
||||
});
|
||||
474
apps/api/src/domains/agent/agent-credit.service.ts
Normal file
474
apps/api/src/domains/agent/agent-credit.service.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { FundsPostingService } from '../ledger/funds-posting.service';
|
||||
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
type PrismaClientLike = PrismaService | TxClient;
|
||||
|
||||
const AGENT_DAILY_DEPOSIT_TX_TYPES = ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] as const;
|
||||
|
||||
function dec(v: Decimal | null | undefined) {
|
||||
return v?.toString() ?? '0';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentCreditService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private funds: FundsPostingService,
|
||||
) {}
|
||||
|
||||
async getProfile(agentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
});
|
||||
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
return { ...profile, availableCredit: available };
|
||||
}
|
||||
|
||||
async recalculateUsedCredit(agentId: bigint, tx?: TxClient) {
|
||||
const client = tx ?? this.prisma;
|
||||
const cascadeIds = await this.getCreditCascadeIds(agentId, client);
|
||||
let requestedUsedCredit = new Decimal(0);
|
||||
|
||||
for (const id of cascadeIds) {
|
||||
const usedCredit = await this.recalculateOneUsedCredit(id, client);
|
||||
if (id === agentId) requestedUsedCredit = usedCredit;
|
||||
}
|
||||
|
||||
return requestedUsedCredit;
|
||||
}
|
||||
|
||||
private async recalculateOneUsedCredit(agentId: bigint, client: PrismaClientLike) {
|
||||
const directPlayers = await client.user.findMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER' },
|
||||
include: { wallet: true },
|
||||
});
|
||||
|
||||
let directLiability = new Decimal(0);
|
||||
for (const p of directPlayers) {
|
||||
if (p.wallet) {
|
||||
directLiability = directLiability
|
||||
.add(p.wallet.availableBalance)
|
||||
.add(p.wallet.frozenBalance);
|
||||
}
|
||||
}
|
||||
|
||||
const childAgents = await client.agentProfile.findMany({
|
||||
where: { parentAgentId: agentId },
|
||||
});
|
||||
|
||||
let childExposure = new Decimal(0);
|
||||
for (const child of childAgents) {
|
||||
const exposure = Decimal.max(child.creditLimit, child.usedCredit);
|
||||
childExposure = childExposure.add(exposure);
|
||||
}
|
||||
|
||||
const usedCredit = directLiability.add(childExposure);
|
||||
|
||||
await client.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: {
|
||||
usedCredit,
|
||||
directPlayerLiability: directLiability,
|
||||
childAgentExposure: childExposure,
|
||||
},
|
||||
});
|
||||
|
||||
return usedCredit;
|
||||
}
|
||||
|
||||
private async getCreditCascadeIds(agentId: bigint, client: PrismaClientLike) {
|
||||
const closureRows = await client.agentClosure.findMany({
|
||||
where: { descendantId: agentId },
|
||||
select: { ancestorId: true, depth: true },
|
||||
orderBy: { depth: 'asc' },
|
||||
});
|
||||
if (closureRows.length > 0) {
|
||||
return closureRows.map((row) => row.ancestorId);
|
||||
}
|
||||
|
||||
const ids: bigint[] = [];
|
||||
let current: bigint | null = agentId;
|
||||
while (current) {
|
||||
ids.push(current);
|
||||
const profile: { parentAgentId: bigint | null } | null = await client.agentProfile.findUnique({
|
||||
where: { userId: current },
|
||||
select: { parentAgentId: true },
|
||||
});
|
||||
current = profile?.parentAgentId ?? null;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async adjustCredit(
|
||||
agentId: bigint,
|
||||
amount: Decimal | number,
|
||||
operatorId: bigint,
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
});
|
||||
if (!profile) throw appBadRequest('AGENT_NOT_FOUND');
|
||||
|
||||
const creditBefore = profile.creditLimit;
|
||||
const creditAfter = creditBefore.add(amt);
|
||||
if (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
|
||||
|
||||
if (profile.parentAgentId) {
|
||||
await this.assertChildCreditWithinParent(profile.parentAgentId, profile, creditAfter);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: { creditLimit: creditAfter },
|
||||
});
|
||||
|
||||
await tx.agentCreditTransaction.create({
|
||||
data: {
|
||||
agentId,
|
||||
transactionType: amt.gte(0) ? 'CREDIT_INCREASE' : 'CREDIT_DECREASE',
|
||||
amount: amt,
|
||||
creditBefore,
|
||||
creditAfter,
|
||||
operatorId,
|
||||
requestId,
|
||||
remark,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { creditAfter };
|
||||
}
|
||||
|
||||
async assertPlayerParentCreditForDeposit(playerId: bigint, amount: Decimal | number) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!user?.parentId) return;
|
||||
|
||||
await this.recalculateUsedCredit(user.parentId);
|
||||
const profile = await this.getProfile(user.parentId);
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
const amt = new Decimal(amount);
|
||||
if (available.lt(amt)) {
|
||||
throw appBadRequest('CREDIT_TOPUP_EXCEEDED');
|
||||
}
|
||||
}
|
||||
|
||||
async adminDepositToPlayer(
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
operatorId: bigint,
|
||||
remark?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
await this.assertPlayerParentCreditForDeposit(playerId, amount);
|
||||
const result = await this.funds.deposit({
|
||||
userId: playerId,
|
||||
amount,
|
||||
operatorId,
|
||||
remark: remark ?? '管理员上分',
|
||||
referenceId: requestId,
|
||||
transactionType: 'ADMIN_DEPOSIT',
|
||||
businessKey: requestId ? `admin-deposit:${operatorId}:${requestId}` : undefined,
|
||||
});
|
||||
const player = await this.prisma.user.findUnique({
|
||||
where: { id: playerId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (player?.parentId) {
|
||||
await this.recalculateUsedCredit(player.parentId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async adminWithdrawFromPlayer(
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
operatorId: bigint,
|
||||
remark?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
const result = await this.funds.withdraw({
|
||||
userId: playerId,
|
||||
amount,
|
||||
operatorId,
|
||||
remark: remark ?? '管理员下分',
|
||||
referenceId: requestId,
|
||||
transactionType: 'ADMIN_WITHDRAW',
|
||||
businessKey: requestId ? `admin-withdraw:${operatorId}:${requestId}` : undefined,
|
||||
});
|
||||
const player = await this.prisma.user.findUnique({
|
||||
where: { id: playerId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (player?.parentId) {
|
||||
await this.recalculateUsedCredit(player.parentId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getPlayerTransferContext(
|
||||
playerId: bigint,
|
||||
options: { forAdmin?: boolean; actingAgentId?: bigint } = {},
|
||||
) {
|
||||
const player = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { wallet: true },
|
||||
});
|
||||
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
|
||||
|
||||
if (options.actingAgentId) {
|
||||
await this.requireDirectPlayer(options.actingAgentId, playerId);
|
||||
}
|
||||
|
||||
const creditAgentId = options.forAdmin ? player.parentId : (options.actingAgentId ?? null);
|
||||
|
||||
let credit: Record<string, unknown> | null = null;
|
||||
if (creditAgentId) {
|
||||
await this.recalculateUsedCredit(creditAgentId);
|
||||
const profile = await this.getProfile(creditAgentId);
|
||||
const parent = profile.parentAgentId
|
||||
? await this.prisma.agentProfile.findUnique({ where: { userId: profile.parentAgentId } })
|
||||
: null;
|
||||
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
|
||||
|
||||
let dailyDepositUsed: string | null = null;
|
||||
if (!options.forAdmin) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dailyAgg = await this.prisma.walletTransaction.aggregate({
|
||||
where: {
|
||||
operatorId: creditAgentId,
|
||||
transactionType: { in: [...AGENT_DAILY_DEPOSIT_TX_TYPES] },
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
dailyDepositUsed = dec(dailyAgg._sum.amount);
|
||||
}
|
||||
|
||||
const agentUser = await this.prisma.user.findUnique({
|
||||
where: { id: creditAgentId },
|
||||
select: { username: true },
|
||||
});
|
||||
|
||||
credit = {
|
||||
agentId: creditAgentId.toString(),
|
||||
agentUsername: agentUser?.username ?? '',
|
||||
agentLevel: profile.level,
|
||||
creditLimit: dec(profile.creditLimit),
|
||||
usedCredit: dec(profile.usedCredit),
|
||||
availableCredit: dec(profile.availableCredit),
|
||||
maxSingleDeposit: maxSingleDeposit?.toString() ?? null,
|
||||
maxDailyDeposit: maxDailyDeposit?.toString() ?? null,
|
||||
dailyDepositUsed,
|
||||
appliesDepositLimits: !options.forAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
player: {
|
||||
id: player.id.toString(),
|
||||
username: player.username,
|
||||
availableBalance: dec(player.wallet?.availableBalance),
|
||||
frozenBalance: dec(player.wallet?.frozenBalance),
|
||||
},
|
||||
credit,
|
||||
};
|
||||
}
|
||||
|
||||
async depositToPlayer(
|
||||
agentId: bigint,
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
const businessKey = `agent-deposit:${agentId}:${requestId}`;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.walletTransaction.findUnique({ where: { businessKey } });
|
||||
if (existing) return;
|
||||
|
||||
await this.requireDirectPlayer(agentId, playerId, tx);
|
||||
await this.recalculateUsedCredit(agentId, tx);
|
||||
|
||||
const profile = await this.lockAgentProfile(tx, agentId);
|
||||
const available = new Decimal(profile.credit_limit).sub(profile.used_credit);
|
||||
if (available.lt(amt)) {
|
||||
throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
|
||||
}
|
||||
|
||||
await this.assertAgentDepositLimits(agentId, amt, tx);
|
||||
|
||||
await this.funds.deposit({
|
||||
userId: playerId,
|
||||
amount: amt,
|
||||
operatorId: agentId,
|
||||
remark: remark ?? '代理上分',
|
||||
referenceId: requestId,
|
||||
transactionType: 'AGENT_DEPOSIT',
|
||||
businessKey,
|
||||
tx,
|
||||
});
|
||||
await this.recalculateUsedCredit(agentId, tx);
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async withdrawFromPlayer(
|
||||
agentId: bigint,
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
await this.requireDirectPlayer(agentId, playerId);
|
||||
|
||||
await this.funds.withdraw({
|
||||
userId: playerId,
|
||||
amount,
|
||||
operatorId: agentId,
|
||||
remark: remark ?? '代理下分',
|
||||
referenceId: requestId,
|
||||
transactionType: 'AGENT_WITHDRAW',
|
||||
businessKey: `agent-withdraw:${agentId}:${requestId}`,
|
||||
});
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async requireDirectPlayer(agentId: bigint, playerId: bigint, tx?: TxClient) {
|
||||
const client = tx ?? this.prisma;
|
||||
const player = await client.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { auth: true, wallet: true, preferences: true },
|
||||
});
|
||||
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
|
||||
if (player.parentId !== agentId) {
|
||||
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
private resolveEffectiveDepositLimits(
|
||||
profile: {
|
||||
maxSingleDeposit: Decimal | null;
|
||||
maxDailyDeposit: Decimal | null;
|
||||
},
|
||||
parent?: { maxSingleDeposit: Decimal | null; maxDailyDeposit: Decimal | null } | null,
|
||||
) {
|
||||
let maxSingleDeposit = profile.maxSingleDeposit;
|
||||
let maxDailyDeposit = profile.maxDailyDeposit;
|
||||
|
||||
if (parent) {
|
||||
if (parent.maxSingleDeposit != null) {
|
||||
maxSingleDeposit =
|
||||
maxSingleDeposit != null
|
||||
? Decimal.min(maxSingleDeposit, parent.maxSingleDeposit)
|
||||
: parent.maxSingleDeposit;
|
||||
}
|
||||
if (parent.maxDailyDeposit != null) {
|
||||
maxDailyDeposit =
|
||||
maxDailyDeposit != null
|
||||
? Decimal.min(maxDailyDeposit, parent.maxDailyDeposit)
|
||||
: parent.maxDailyDeposit;
|
||||
}
|
||||
}
|
||||
|
||||
return { maxSingleDeposit, maxDailyDeposit };
|
||||
}
|
||||
|
||||
private async assertAgentDepositLimits(
|
||||
creditAgentId: bigint,
|
||||
amount: Decimal,
|
||||
tx?: TxClient,
|
||||
) {
|
||||
const client = tx ?? this.prisma;
|
||||
const profile = await client.agentProfile.findUnique({
|
||||
where: { userId: creditAgentId },
|
||||
});
|
||||
if (!profile) return;
|
||||
|
||||
const parent = profile.parentAgentId
|
||||
? await client.agentProfile.findUnique({
|
||||
where: { userId: profile.parentAgentId },
|
||||
})
|
||||
: null;
|
||||
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
|
||||
|
||||
if (maxSingleDeposit && amount.gt(maxSingleDeposit)) {
|
||||
throw appBadRequest('AGENT_SINGLE_TOPUP_LIMIT');
|
||||
}
|
||||
|
||||
if (maxDailyDeposit) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dailyAgg = await client.walletTransaction.aggregate({
|
||||
where: {
|
||||
operatorId: creditAgentId,
|
||||
transactionType: { in: [...AGENT_DAILY_DEPOSIT_TX_TYPES] },
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
|
||||
if (dailyTotal.gt(maxDailyDeposit)) {
|
||||
throw appBadRequest('AGENT_DAILY_TOPUP_LIMIT');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async lockAgentProfile(tx: TxClient, agentId: bigint) {
|
||||
const profiles = await tx.$queryRaw<
|
||||
Array<{
|
||||
user_id: bigint;
|
||||
parent_agent_id: bigint | null;
|
||||
credit_limit: Decimal;
|
||||
used_credit: Decimal;
|
||||
max_single_deposit: Decimal | null;
|
||||
max_daily_deposit: Decimal | null;
|
||||
}>
|
||||
>`
|
||||
SELECT user_id, parent_agent_id, credit_limit, used_credit, max_single_deposit, max_daily_deposit
|
||||
FROM agent_profiles
|
||||
WHERE user_id = ${agentId}
|
||||
FOR UPDATE
|
||||
`;
|
||||
if (!profiles.length) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
|
||||
return profiles[0];
|
||||
}
|
||||
|
||||
private async assertChildCreditWithinParent(
|
||||
parentAgentId: bigint,
|
||||
childProfile: { userId: bigint; creditLimit: Decimal; usedCredit: Decimal },
|
||||
creditAfter: Decimal,
|
||||
) {
|
||||
const parent = await this.getProfile(parentAgentId);
|
||||
const parentAvailable = new Decimal(parent.creditLimit).sub(parent.usedCredit);
|
||||
const oldExposure = Decimal.max(childProfile.creditLimit, childProfile.usedCredit);
|
||||
const newExposure = Decimal.max(creditAfter, childProfile.usedCredit);
|
||||
const exposureDelta = newExposure.sub(oldExposure);
|
||||
if (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
|
||||
if (creditAfter.gt(parent.creditLimit)) throw appBadRequest('CREDIT_EXCEEDS_PARENT');
|
||||
if (exposureDelta.gt(0) && exposureDelta.gt(parentAvailable)) {
|
||||
throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
|
||||
}
|
||||
}
|
||||
}
|
||||
92
apps/api/src/domains/agent/agent-network.service.spec.ts
Normal file
92
apps/api/src/domains/agent/agent-network.service.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { AgentNetworkService } from './agent-network.service';
|
||||
|
||||
describe('AgentNetworkService', () => {
|
||||
let prisma: {
|
||||
agentClosure: { findMany: jest.Mock };
|
||||
agentProfile: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
user: { findMany: jest.Mock; findFirst: jest.Mock };
|
||||
};
|
||||
let service: AgentNetworkService;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
agentClosure: { findMany: jest.fn() },
|
||||
agentProfile: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
};
|
||||
service = new AgentNetworkService(prisma as never);
|
||||
});
|
||||
|
||||
it('resolves portal scope from AgentClosure rows', async () => {
|
||||
prisma.agentProfile.findUnique.mockResolvedValue({ userId: 1n, level: 2 });
|
||||
prisma.agentClosure.findMany.mockResolvedValue([
|
||||
{ descendantId: 1n, depth: 0 },
|
||||
{ descendantId: 2n, depth: 1 },
|
||||
{ descendantId: 4n, depth: 1 },
|
||||
{ descendantId: 3n, depth: 2 },
|
||||
]);
|
||||
|
||||
const scope = await service.resolveScope(1n);
|
||||
|
||||
expect(scope.rootAgentId).toBe(1n);
|
||||
expect(scope.rootLevel).toBe(2);
|
||||
expect(scope.subtreeIds).toEqual([1n, 2n, 4n, 3n]);
|
||||
expect(scope.descendantIds).toEqual([2n, 4n, 3n]);
|
||||
expect(scope.directChildIds).toEqual([2n, 4n]);
|
||||
expect(scope.subtreeIdSet.has('3')).toBe(true);
|
||||
expect(prisma.agentProfile.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to AgentProfile adjacency when closure rows are absent', async () => {
|
||||
prisma.agentProfile.findUnique.mockResolvedValue({ userId: 1n, level: 1 });
|
||||
prisma.agentClosure.findMany.mockResolvedValue([]);
|
||||
prisma.agentProfile.findMany.mockImplementation(({ where }: { where: { parentAgentId: bigint } }) => {
|
||||
const childrenByParent = new Map<bigint, Array<{ userId: bigint }>>([
|
||||
[1n, [{ userId: 2n }, { userId: 4n }]],
|
||||
[2n, [{ userId: 3n }]],
|
||||
[3n, []],
|
||||
[4n, []],
|
||||
]);
|
||||
return Promise.resolve(childrenByParent.get(where.parentAgentId) ?? []);
|
||||
});
|
||||
|
||||
const scope = await service.resolveScope(1n);
|
||||
|
||||
expect(scope.subtreeIds).toEqual([1n, 2n, 4n, 3n]);
|
||||
expect(scope.descendantIds).toEqual([2n, 4n, 3n]);
|
||||
expect(scope.directChildIds).toEqual([2n, 4n]);
|
||||
});
|
||||
|
||||
it('rejects agent outside descendant scope', async () => {
|
||||
prisma.agentClosure.findMany.mockResolvedValue([
|
||||
{ descendantId: 1n, depth: 0 },
|
||||
{ descendantId: 2n, depth: 1 },
|
||||
]);
|
||||
|
||||
await expect(service.assertDescendantAgent(1n, 9n)).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: 'NOT_SUB_AGENT' }),
|
||||
});
|
||||
expect(prisma.agentProfile.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns direct child profile for matching parent', async () => {
|
||||
const profile = { userId: 2n, parentAgentId: 1n };
|
||||
prisma.agentProfile.findUnique.mockResolvedValue(profile);
|
||||
|
||||
await expect(service.assertDirectChildAgent(1n, 2n)).resolves.toBe(profile);
|
||||
});
|
||||
|
||||
it('rejects non-direct child agent', async () => {
|
||||
prisma.agentProfile.findUnique.mockResolvedValue({ userId: 2n, parentAgentId: 9n });
|
||||
|
||||
await expect(service.assertDirectChildAgent(1n, 2n)).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: 'NOT_SUB_AGENT' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
337
apps/api/src/domains/agent/agent-network.service.ts
Normal file
337
apps/api/src/domains/agent/agent-network.service.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
||||
|
||||
export interface AgentScope {
|
||||
rootAgentId: bigint;
|
||||
rootLevel: number;
|
||||
subtreeIds: bigint[];
|
||||
descendantIds: bigint[];
|
||||
directChildIds: bigint[];
|
||||
subtreeIdSet: Set<string>;
|
||||
}
|
||||
|
||||
type ClosureRow = {
|
||||
ancestorId?: bigint;
|
||||
descendantId: bigint;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
function uniqueBigints(values: bigint[]) {
|
||||
const seen = new Set<string>();
|
||||
const out: bigint[] = [];
|
||||
for (const value of values) {
|
||||
const key = value.toString();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentNetworkService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
|
||||
return this.buildAncestorChainMap(parentAgentIds, undefined);
|
||||
}
|
||||
|
||||
async buildScopedAncestorChainMap(
|
||||
parentAgentIds: (bigint | null | undefined)[],
|
||||
rootAgentId: bigint,
|
||||
) {
|
||||
return this.buildAncestorChainMap(parentAgentIds, rootAgentId);
|
||||
}
|
||||
|
||||
async resolveScope(rootAgentId: bigint): Promise<AgentScope> {
|
||||
const [profile, closureRows] = await Promise.all([
|
||||
this.prisma.agentProfile.findUnique({
|
||||
where: { userId: rootAgentId },
|
||||
}),
|
||||
this.findSubtreeClosureRows(rootAgentId),
|
||||
]);
|
||||
|
||||
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
|
||||
|
||||
const hasClosureRows = closureRows.length > 0;
|
||||
const subtreeIds = hasClosureRows
|
||||
? this.idsFromSubtreeClosure(rootAgentId, closureRows)
|
||||
: await this.getSubtreeAgentIdsFromProfiles(rootAgentId);
|
||||
const descendantIds = subtreeIds.filter((id) => id !== rootAgentId);
|
||||
const directChildIds = hasClosureRows
|
||||
? uniqueBigints(
|
||||
closureRows
|
||||
.filter((row) => row.depth === 1)
|
||||
.map((row) => row.descendantId),
|
||||
)
|
||||
: await this.getDirectChildAgentIds(rootAgentId);
|
||||
|
||||
return {
|
||||
rootAgentId,
|
||||
rootLevel: profile.level,
|
||||
subtreeIds,
|
||||
descendantIds,
|
||||
directChildIds,
|
||||
subtreeIdSet: new Set(subtreeIds.map((id) => id.toString())),
|
||||
};
|
||||
}
|
||||
|
||||
assertAgentInScope(scope: AgentScope, agentId: bigint) {
|
||||
if (!scope.subtreeIdSet.has(agentId.toString())) {
|
||||
throw appForbidden('NOT_SUB_AGENT');
|
||||
}
|
||||
}
|
||||
|
||||
async requirePlayerInPortalSubtree(rootAgentId: bigint, playerId: bigint) {
|
||||
const scope = await this.resolveScope(rootAgentId);
|
||||
const player = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { wallet: true, preferences: true, auth: true },
|
||||
});
|
||||
if (!player?.parentId || !scope.subtreeIdSet.has(player.parentId.toString())) {
|
||||
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
|
||||
}
|
||||
return { player, scope, isDirect: player.parentId.toString() === rootAgentId.toString() };
|
||||
}
|
||||
|
||||
async getChildAgents(agentId: bigint) {
|
||||
return this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: agentId },
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
|
||||
async assertDescendantAgent(rootAgentId: bigint, targetAgentId: bigint) {
|
||||
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
|
||||
if (!subtreeIds.some((id) => id === targetAgentId)) {
|
||||
throw appForbidden('NOT_SUB_AGENT');
|
||||
}
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: targetAgentId },
|
||||
});
|
||||
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
|
||||
return profile;
|
||||
}
|
||||
|
||||
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: subAgentId },
|
||||
});
|
||||
if (!profile || profile.parentAgentId !== parentAgentId) {
|
||||
throw appForbidden('NOT_SUB_AGENT');
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async getSubtreeAgentIds(agentId: bigint) {
|
||||
const closureRows = await this.findSubtreeClosureRows(agentId);
|
||||
if (closureRows.length > 0) {
|
||||
return this.idsFromSubtreeClosure(agentId, closureRows);
|
||||
}
|
||||
return this.getSubtreeAgentIdsFromProfiles(agentId);
|
||||
}
|
||||
|
||||
private async buildAncestorChainMap(
|
||||
parentAgentIds: (bigint | null | undefined)[],
|
||||
scopedRootAgentId: bigint | undefined,
|
||||
) {
|
||||
const agentIds = uniqueBigints(parentAgentIds.filter((id): id is bigint => id != null));
|
||||
const map = new Map<string, string[]>();
|
||||
if (agentIds.length === 0) return map;
|
||||
|
||||
const closureRows = await this.prisma.agentClosure.findMany({
|
||||
where: { descendantId: { in: agentIds } },
|
||||
select: { ancestorId: true, descendantId: true, depth: true },
|
||||
orderBy: [{ descendantId: 'asc' }, { depth: 'desc' }],
|
||||
});
|
||||
const rowsByDescendant = new Map<string, ClosureRow[]>();
|
||||
for (const row of closureRows) {
|
||||
const key = row.descendantId.toString();
|
||||
rowsByDescendant.set(key, [...(rowsByDescendant.get(key) ?? []), row]);
|
||||
}
|
||||
|
||||
const idsNeedingProfileFallback: bigint[] = [];
|
||||
const ancestorIds = new Set<bigint>();
|
||||
for (const id of agentIds) {
|
||||
const rows = rowsByDescendant.get(id.toString());
|
||||
if (!rows || rows.length === 0) {
|
||||
idsNeedingProfileFallback.push(id);
|
||||
continue;
|
||||
}
|
||||
const scopedRows = scopedRootAgentId
|
||||
? this.sliceRowsToScopedRoot(rows, scopedRootAgentId)
|
||||
: rows;
|
||||
for (const row of scopedRows) {
|
||||
if (row.ancestorId) ancestorIds.add(row.ancestorId);
|
||||
}
|
||||
}
|
||||
|
||||
const users =
|
||||
ancestorIds.size > 0
|
||||
? await this.prisma.user.findMany({
|
||||
where: { id: { in: [...ancestorIds] } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [];
|
||||
const usernameMap = new Map(users.map((u) => [u.id.toString(), u.username]));
|
||||
|
||||
for (const id of agentIds) {
|
||||
const rows = rowsByDescendant.get(id.toString());
|
||||
if (!rows || rows.length === 0) continue;
|
||||
|
||||
const scopedRows = scopedRootAgentId
|
||||
? this.sliceRowsToScopedRoot(rows, scopedRootAgentId)
|
||||
: rows;
|
||||
const chain = scopedRows
|
||||
.map((row) => (row.ancestorId ? usernameMap.get(row.ancestorId.toString()) : undefined))
|
||||
.filter((username): username is string => Boolean(username));
|
||||
map.set(id.toString(), chain.length === scopedRows.length ? chain : []);
|
||||
}
|
||||
|
||||
if (idsNeedingProfileFallback.length > 0) {
|
||||
const fallbackMap = scopedRootAgentId
|
||||
? await this.buildScopedAncestorChainMapFromProfiles(idsNeedingProfileFallback, scopedRootAgentId)
|
||||
: await this.buildAncestorChainMapFromProfiles(idsNeedingProfileFallback);
|
||||
for (const [key, value] of fallbackMap) {
|
||||
map.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of parentAgentIds) {
|
||||
if (id && !map.has(id.toString())) {
|
||||
map.set(id.toString(), []);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private sliceRowsToScopedRoot(rows: ClosureRow[], rootAgentId: bigint) {
|
||||
const rootRow = rows.find((row) => row.ancestorId === rootAgentId);
|
||||
if (!rootRow) return [];
|
||||
return rows.filter((row) => row.depth <= rootRow.depth);
|
||||
}
|
||||
|
||||
private async buildAncestorChainMapFromProfiles(agentIds: bigint[]) {
|
||||
const cache = await this.loadAncestorProfileCache(agentIds, undefined);
|
||||
|
||||
const build = (startId: bigint | null | undefined): string[] => {
|
||||
const chain: string[] = [];
|
||||
let cur = startId ?? null;
|
||||
while (cur) {
|
||||
const hit = cache.get(cur.toString());
|
||||
if (!hit) break;
|
||||
chain.unshift(hit.username);
|
||||
cur = hit.parentAgentId;
|
||||
}
|
||||
return chain;
|
||||
};
|
||||
|
||||
return new Map(agentIds.map((id) => [id.toString(), build(id)]));
|
||||
}
|
||||
|
||||
private async buildScopedAncestorChainMapFromProfiles(agentIds: bigint[], rootAgentId: bigint) {
|
||||
const rootKey = rootAgentId.toString();
|
||||
const cache = await this.loadAncestorProfileCache(agentIds, rootAgentId);
|
||||
|
||||
const build = (startId: bigint | null | undefined): string[] => {
|
||||
if (!startId) return [];
|
||||
const chain: string[] = [];
|
||||
let cur: bigint | null = startId;
|
||||
let reachedRoot = false;
|
||||
while (cur) {
|
||||
const hit = cache.get(cur.toString());
|
||||
if (!hit) return [];
|
||||
chain.unshift(hit.username);
|
||||
if (cur.toString() === rootKey) {
|
||||
reachedRoot = true;
|
||||
break;
|
||||
}
|
||||
cur = hit.parentAgentId;
|
||||
}
|
||||
return reachedRoot ? chain : [];
|
||||
};
|
||||
|
||||
return new Map(agentIds.map((id) => [id.toString(), build(id)]));
|
||||
}
|
||||
|
||||
private async loadAncestorProfileCache(agentIds: bigint[], stopBeforeParentOf?: bigint) {
|
||||
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
|
||||
const pending = new Set(agentIds);
|
||||
if (stopBeforeParentOf) pending.add(stopBeforeParentOf);
|
||||
const stopKey = stopBeforeParentOf?.toString();
|
||||
|
||||
while (pending.size > 0) {
|
||||
const batch = [...pending];
|
||||
pending.clear();
|
||||
const profiles = await this.prisma.agentProfile.findMany({
|
||||
where: { userId: { in: batch } },
|
||||
select: {
|
||||
userId: true,
|
||||
parentAgentId: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
for (const profile of profiles) {
|
||||
cache.set(profile.userId.toString(), {
|
||||
username: profile.user.username,
|
||||
parentAgentId: profile.parentAgentId,
|
||||
});
|
||||
if (
|
||||
profile.parentAgentId &&
|
||||
profile.parentAgentId.toString() !== stopKey &&
|
||||
!cache.has(profile.parentAgentId.toString())
|
||||
) {
|
||||
pending.add(profile.parentAgentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
private async findSubtreeClosureRows(agentId: bigint) {
|
||||
return this.prisma.agentClosure.findMany({
|
||||
where: { ancestorId: agentId },
|
||||
select: { descendantId: true, depth: true },
|
||||
orderBy: [{ depth: 'asc' }, { descendantId: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
private idsFromSubtreeClosure(agentId: bigint, rows: ClosureRow[]) {
|
||||
return uniqueBigints([agentId, ...rows.map((row) => row.descendantId)]);
|
||||
}
|
||||
|
||||
private async getDirectChildAgentIds(agentId: bigint) {
|
||||
const children = await this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: agentId },
|
||||
select: { userId: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return children.map((child) => child.userId);
|
||||
}
|
||||
|
||||
private async getSubtreeAgentIdsFromProfiles(agentId: bigint) {
|
||||
const ids: bigint[] = [];
|
||||
const queue: bigint[] = [agentId];
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const key = current.toString();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
ids.push(current);
|
||||
|
||||
const children = await this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: current },
|
||||
select: { userId: true },
|
||||
});
|
||||
for (const child of children) {
|
||||
queue.push(child.userId);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AgentsService } from './agents.service';
|
||||
import { AgentNetworkService } from './agent-network.service';
|
||||
import { AgentCreditService } from './agent-credit.service';
|
||||
import { WalletModule } from '../ledger/wallet.module';
|
||||
import { AuthModule } from '../identity/auth.module';
|
||||
import { SystemConfigModule } from '../../shared/config/system-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule, AuthModule, SystemConfigModule],
|
||||
providers: [AgentsService],
|
||||
exports: [AgentsService],
|
||||
providers: [AgentsService, AgentNetworkService, AgentCreditService],
|
||||
exports: [AgentsService, AgentNetworkService, AgentCreditService],
|
||||
})
|
||||
export class AgentsModule {}
|
||||
|
||||
100
apps/api/src/domains/agent/agents.service.spec.ts
Normal file
100
apps/api/src/domains/agent/agents.service.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { AgentsService } from './agents.service';
|
||||
import { createPrismaMock } from '../../testing/prisma-mock';
|
||||
|
||||
describe('AgentsService', () => {
|
||||
const parentAgentId = 1n;
|
||||
const createdAgentId = 2n;
|
||||
|
||||
const tx = {
|
||||
user: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
userAuth: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
userPreference: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
userInvite: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
agentProfile: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
agentClosure: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const prisma = createPrismaMock(tx);
|
||||
const auth = {
|
||||
hashPassword: jest.fn(),
|
||||
};
|
||||
const systemConfig = {
|
||||
getAgentHierarchySettings: jest.fn(),
|
||||
};
|
||||
const network = {};
|
||||
const credit = {
|
||||
recalculateUsedCredit: jest.fn(),
|
||||
};
|
||||
|
||||
let service: AgentsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new AgentsService(
|
||||
prisma as never,
|
||||
auth as never,
|
||||
systemConfig as never,
|
||||
network as never,
|
||||
credit as never,
|
||||
);
|
||||
|
||||
systemConfig.getAgentHierarchySettings.mockResolvedValue({ maxAgentLevel: 3 });
|
||||
auth.hashPassword.mockResolvedValue('hashed-password');
|
||||
tx.agentProfile.findUnique.mockResolvedValue({
|
||||
userId: parentAgentId,
|
||||
level: 1,
|
||||
creditLimit: new Decimal(1000),
|
||||
usedCredit: new Decimal(0),
|
||||
cashbackRate: new Decimal(10),
|
||||
maxSingleDeposit: null,
|
||||
maxDailyDeposit: null,
|
||||
});
|
||||
tx.user.create.mockResolvedValue({
|
||||
id: createdAgentId,
|
||||
username: 'agent-child',
|
||||
userType: 'AGENT',
|
||||
});
|
||||
tx.user.findUnique.mockResolvedValue(null);
|
||||
tx.user.update.mockResolvedValue({});
|
||||
tx.userInvite.findUnique.mockResolvedValue(null);
|
||||
tx.userInvite.create.mockResolvedValue({});
|
||||
tx.userAuth.create.mockResolvedValue({});
|
||||
tx.userPreference.create.mockResolvedValue({});
|
||||
tx.agentProfile.create.mockResolvedValue({});
|
||||
tx.agentClosure.create.mockResolvedValue({});
|
||||
tx.agentClosure.findMany.mockResolvedValue([
|
||||
{ ancestorId: parentAgentId, depth: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('recalculates parent credit exposure after creating a child agent', async () => {
|
||||
await service.createAgent(99n, {
|
||||
username: 'agent-child',
|
||||
password: 'secret',
|
||||
level: 2,
|
||||
parentAgentId,
|
||||
creditLimit: 300,
|
||||
});
|
||||
|
||||
expect(credit.recalculateUsedCredit).toHaveBeenCalledWith(parentAgentId);
|
||||
expect(credit.recalculateUsedCredit).not.toHaveBeenCalledWith(createdAgentId);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { AuthService } from '../identity/auth.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
@@ -10,6 +9,8 @@ import { generateBatchNo } from '../../shared/common/decorators';
|
||||
import { assertPlayerUsername, validateInitialDepositRemark } from '@thebet365/shared';
|
||||
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
||||
import { assignInviteCodeWithHistory } from '../../shared/common/invite-code.util';
|
||||
import { AgentNetworkService, type AgentScope } from './agent-network.service';
|
||||
import { AgentCreditService } from './agent-credit.service';
|
||||
|
||||
function dec(v: Decimal | null | undefined) {
|
||||
return v?.toString() ?? '0';
|
||||
@@ -23,9 +24,10 @@ function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
|
||||
export class AgentsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private auth: AuthService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private network: AgentNetworkService,
|
||||
private credit: AgentCreditService,
|
||||
) {}
|
||||
|
||||
async getMaxAgentLevel(): Promise<number> {
|
||||
@@ -39,52 +41,7 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
private async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
|
||||
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
|
||||
const pending = new Set<bigint>();
|
||||
|
||||
for (const id of parentAgentIds) {
|
||||
if (id) pending.add(id);
|
||||
}
|
||||
|
||||
while (pending.size > 0) {
|
||||
const batch = [...pending];
|
||||
pending.clear();
|
||||
const profiles = await this.prisma.agentProfile.findMany({
|
||||
where: { userId: { in: batch } },
|
||||
select: {
|
||||
userId: true,
|
||||
parentAgentId: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
for (const profile of profiles) {
|
||||
cache.set(profile.userId.toString(), {
|
||||
username: profile.user.username,
|
||||
parentAgentId: profile.parentAgentId,
|
||||
});
|
||||
if (profile.parentAgentId && !cache.has(profile.parentAgentId.toString())) {
|
||||
pending.add(profile.parentAgentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const build = (startId: bigint | null | undefined): string[] => {
|
||||
const chain: string[] = [];
|
||||
let cur = startId ?? null;
|
||||
while (cur) {
|
||||
const hit = cache.get(cur.toString());
|
||||
if (!hit) break;
|
||||
chain.unshift(hit.username);
|
||||
cur = hit.parentAgentId;
|
||||
}
|
||||
return chain;
|
||||
};
|
||||
|
||||
const map = new Map<string, string[]>();
|
||||
for (const id of parentAgentIds) {
|
||||
if (id) map.set(id.toString(), build(id));
|
||||
}
|
||||
return map;
|
||||
return this.network.buildAgentAncestorChainMap(parentAgentIds);
|
||||
}
|
||||
|
||||
private async agentCashbackRateMap(agentUserIds: bigint[]): Promise<Map<string, string>> {
|
||||
@@ -142,100 +99,23 @@ export class AgentsService {
|
||||
parentAgentIds: (bigint | null | undefined)[],
|
||||
rootAgentId: bigint,
|
||||
) {
|
||||
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
|
||||
const pending = new Set<bigint>();
|
||||
const rootKey = rootAgentId.toString();
|
||||
|
||||
for (const id of parentAgentIds) {
|
||||
if (id) pending.add(id);
|
||||
}
|
||||
pending.add(rootAgentId);
|
||||
|
||||
while (pending.size > 0) {
|
||||
const batch = [...pending];
|
||||
pending.clear();
|
||||
const profiles = await this.prisma.agentProfile.findMany({
|
||||
where: { userId: { in: batch } },
|
||||
select: {
|
||||
userId: true,
|
||||
parentAgentId: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
for (const profile of profiles) {
|
||||
cache.set(profile.userId.toString(), {
|
||||
username: profile.user.username,
|
||||
parentAgentId: profile.parentAgentId,
|
||||
});
|
||||
if (
|
||||
profile.parentAgentId &&
|
||||
profile.parentAgentId.toString() !== rootKey &&
|
||||
!cache.has(profile.parentAgentId.toString())
|
||||
) {
|
||||
pending.add(profile.parentAgentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const build = (startId: bigint | null | undefined): string[] => {
|
||||
if (!startId) return [];
|
||||
const chain: string[] = [];
|
||||
let cur: bigint | null = startId;
|
||||
let reachedRoot = false;
|
||||
while (cur) {
|
||||
const hit = cache.get(cur.toString());
|
||||
if (!hit) return [];
|
||||
chain.unshift(hit.username);
|
||||
if (cur.toString() === rootKey) {
|
||||
reachedRoot = true;
|
||||
break;
|
||||
}
|
||||
cur = hit.parentAgentId;
|
||||
}
|
||||
return reachedRoot ? chain : [];
|
||||
};
|
||||
|
||||
const map = new Map<string, string[]>();
|
||||
for (const id of parentAgentIds) {
|
||||
if (id) map.set(id.toString(), build(id));
|
||||
}
|
||||
return map;
|
||||
return this.network.buildScopedAncestorChainMap(parentAgentIds, rootAgentId);
|
||||
}
|
||||
|
||||
private async getAgentPortalScope(rootAgentId: bigint) {
|
||||
const profile = await this.getProfile(rootAgentId);
|
||||
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
|
||||
const descendantIds = subtreeIds.filter((id) => id !== rootAgentId);
|
||||
const subtreeIdSet = new Set(subtreeIds.map((id) => id.toString()));
|
||||
return {
|
||||
rootAgentId,
|
||||
rootLevel: profile.level,
|
||||
subtreeIds,
|
||||
descendantIds,
|
||||
subtreeIdSet,
|
||||
};
|
||||
private async getAgentPortalScope(rootAgentId: bigint): Promise<AgentScope> {
|
||||
return this.network.resolveScope(rootAgentId);
|
||||
}
|
||||
|
||||
private assertAgentInPortalSubtree(
|
||||
scope: { subtreeIdSet: Set<string> },
|
||||
scope: AgentScope,
|
||||
agentId: bigint,
|
||||
) {
|
||||
if (!scope.subtreeIdSet.has(agentId.toString())) {
|
||||
throw appForbidden('NOT_SUB_AGENT');
|
||||
}
|
||||
this.network.assertAgentInScope(scope, agentId);
|
||||
}
|
||||
|
||||
/** 代理端:玩家必须挂在当前代理子树内某代理下(自己或下级) */
|
||||
async requirePlayerInPortalSubtree(rootAgentId: bigint, playerId: bigint) {
|
||||
const scope = await this.getAgentPortalScope(rootAgentId);
|
||||
const player = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { wallet: true, preferences: true, auth: true },
|
||||
});
|
||||
if (!player?.parentId || !scope.subtreeIdSet.has(player.parentId.toString())) {
|
||||
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
|
||||
}
|
||||
return { player, scope, isDirect: player.parentId.toString() === rootAgentId.toString() };
|
||||
return this.network.requirePlayerInPortalSubtree(rootAgentId, playerId);
|
||||
}
|
||||
|
||||
private async validateAgentLevel(level: number, parentAgentId?: bigint) {
|
||||
@@ -270,51 +150,11 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
async getProfile(agentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
});
|
||||
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
return { ...profile, availableCredit: available };
|
||||
return this.credit.getProfile(agentId);
|
||||
}
|
||||
|
||||
async recalculateUsedCredit(agentId: bigint) {
|
||||
const directPlayers = await this.prisma.user.findMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER' },
|
||||
include: { wallet: true },
|
||||
});
|
||||
|
||||
let directLiability = new Decimal(0);
|
||||
for (const p of directPlayers) {
|
||||
if (p.wallet) {
|
||||
directLiability = directLiability
|
||||
.add(p.wallet.availableBalance)
|
||||
.add(p.wallet.frozenBalance);
|
||||
}
|
||||
}
|
||||
|
||||
const childAgents = await this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: agentId },
|
||||
});
|
||||
|
||||
let childExposure = new Decimal(0);
|
||||
for (const child of childAgents) {
|
||||
const exposure = Decimal.max(child.creditLimit, child.usedCredit);
|
||||
childExposure = childExposure.add(exposure);
|
||||
}
|
||||
|
||||
const usedCredit = directLiability.add(childExposure);
|
||||
|
||||
await this.prisma.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: {
|
||||
usedCredit,
|
||||
directPlayerLiability: directLiability,
|
||||
childAgentExposure: childExposure,
|
||||
},
|
||||
});
|
||||
|
||||
return usedCredit;
|
||||
return this.credit.recalculateUsedCredit(agentId);
|
||||
}
|
||||
|
||||
async adjustCredit(
|
||||
@@ -324,45 +164,7 @@ export class AgentsService {
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
});
|
||||
if (!profile) throw appBadRequest('AGENT_NOT_FOUND');
|
||||
|
||||
const creditBefore = profile.creditLimit;
|
||||
const creditAfter = creditBefore.add(amt);
|
||||
if (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
|
||||
|
||||
if (profile.parentAgentId) {
|
||||
await this.assertChildCreditWithinParent(profile.parentAgentId, profile, creditAfter);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: { creditLimit: creditAfter },
|
||||
});
|
||||
|
||||
await tx.agentCreditTransaction.create({
|
||||
data: {
|
||||
agentId,
|
||||
transactionType: amt.gte(0) ? 'CREDIT_INCREASE' : 'CREDIT_DECREASE',
|
||||
amount: amt,
|
||||
creditBefore,
|
||||
creditAfter,
|
||||
operatorId,
|
||||
requestId,
|
||||
remark,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (profile.parentAgentId) {
|
||||
await this.recalculateUsedCredit(profile.parentAgentId);
|
||||
}
|
||||
|
||||
return { creditAfter };
|
||||
return this.credit.adjustCredit(agentId, amount, operatorId, requestId, remark);
|
||||
}
|
||||
|
||||
/** 代理只能操作直属玩家(parentId === 当前代理) */
|
||||
@@ -462,19 +264,7 @@ export class AgentsService {
|
||||
|
||||
/** 玩家有所属代理时,上分金额不得超过该代理当前可用授信(会先重算 usedCredit) */
|
||||
async assertPlayerParentCreditForDeposit(playerId: bigint, amount: Decimal | number) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!user?.parentId) return;
|
||||
|
||||
await this.recalculateUsedCredit(user.parentId);
|
||||
const profile = await this.getProfile(user.parentId);
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
const amt = new Decimal(amount);
|
||||
if (available.lt(amt)) {
|
||||
throw appBadRequest('CREDIT_TOPUP_EXCEEDED');
|
||||
}
|
||||
return this.credit.assertPlayerParentCreditForDeposit(playerId, amount);
|
||||
}
|
||||
|
||||
/** 管理员给玩家上分:校验上级授信后入账,并刷新代理占用额度 */
|
||||
@@ -485,23 +275,7 @@ export class AgentsService {
|
||||
remark?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
await this.assertPlayerParentCreditForDeposit(playerId, amount);
|
||||
const result = await this.wallet.deposit(
|
||||
playerId,
|
||||
amount,
|
||||
operatorId,
|
||||
remark ?? '管理员上分',
|
||||
requestId,
|
||||
'ADMIN_DEPOSIT',
|
||||
);
|
||||
const player = await this.prisma.user.findUnique({
|
||||
where: { id: playerId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (player?.parentId) {
|
||||
await this.recalculateUsedCredit(player.parentId);
|
||||
}
|
||||
return result;
|
||||
return this.credit.adminDepositToPlayer(playerId, amount, operatorId, remark, requestId);
|
||||
}
|
||||
|
||||
/** 管理员给玩家下分:扣款后刷新上级代理占用额度 */
|
||||
@@ -512,22 +286,7 @@ export class AgentsService {
|
||||
remark?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
const result = await this.wallet.withdraw(
|
||||
playerId,
|
||||
amount,
|
||||
operatorId,
|
||||
remark ?? '管理员下分',
|
||||
requestId,
|
||||
'ADMIN_WITHDRAW',
|
||||
);
|
||||
const player = await this.prisma.user.findUnique({
|
||||
where: { id: playerId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (player?.parentId) {
|
||||
await this.recalculateUsedCredit(player.parentId);
|
||||
}
|
||||
return result;
|
||||
return this.credit.adminWithdrawFromPlayer(playerId, amount, operatorId, remark, requestId);
|
||||
}
|
||||
|
||||
/** 上下分弹窗:玩家余额 + 授信代理可用额度/限额上下文 */
|
||||
@@ -535,70 +294,7 @@ export class AgentsService {
|
||||
playerId: bigint,
|
||||
options: { forAdmin?: boolean; actingAgentId?: bigint } = {},
|
||||
) {
|
||||
const player = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { wallet: true },
|
||||
});
|
||||
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
|
||||
|
||||
if (options.actingAgentId) {
|
||||
await this.requireDirectPlayer(options.actingAgentId, playerId);
|
||||
}
|
||||
|
||||
const creditAgentId = options.forAdmin ? player.parentId : (options.actingAgentId ?? null);
|
||||
|
||||
let credit: Record<string, unknown> | null = null;
|
||||
if (creditAgentId) {
|
||||
await this.recalculateUsedCredit(creditAgentId);
|
||||
const profile = await this.getProfile(creditAgentId);
|
||||
const parent = profile.parentAgentId
|
||||
? await this.prisma.agentProfile.findUnique({ where: { userId: profile.parentAgentId } })
|
||||
: null;
|
||||
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
|
||||
|
||||
let dailyDepositUsed: string | null = null;
|
||||
if (!options.forAdmin) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dailyAgg = await this.prisma.walletTransaction.aggregate({
|
||||
where: {
|
||||
operatorId: creditAgentId,
|
||||
transactionType: 'MANUAL_DEPOSIT',
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
dailyDepositUsed = dec(dailyAgg._sum.amount);
|
||||
}
|
||||
|
||||
const agentUser = await this.prisma.user.findUnique({
|
||||
where: { id: creditAgentId },
|
||||
select: { username: true },
|
||||
});
|
||||
|
||||
credit = {
|
||||
agentId: creditAgentId.toString(),
|
||||
agentUsername: agentUser?.username ?? '',
|
||||
agentLevel: profile.level,
|
||||
creditLimit: dec(profile.creditLimit),
|
||||
usedCredit: dec(profile.usedCredit),
|
||||
availableCredit: dec(profile.availableCredit),
|
||||
maxSingleDeposit: maxSingleDeposit?.toString() ?? null,
|
||||
maxDailyDeposit: maxDailyDeposit?.toString() ?? null,
|
||||
dailyDepositUsed,
|
||||
appliesDepositLimits: !options.forAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
player: {
|
||||
id: player.id.toString(),
|
||||
username: player.username,
|
||||
availableBalance: dec(player.wallet?.availableBalance),
|
||||
frozenBalance: dec(player.wallet?.frozenBalance),
|
||||
},
|
||||
credit,
|
||||
};
|
||||
return this.credit.getPlayerTransferContext(playerId, options);
|
||||
}
|
||||
|
||||
private async assertAgentDepositLimits(creditAgentId: bigint, amount: Decimal) {
|
||||
@@ -624,9 +320,9 @@ export class AgentsService {
|
||||
const dailyAgg = await this.prisma.walletTransaction.aggregate({
|
||||
where: {
|
||||
operatorId: creditAgentId,
|
||||
transactionType: 'MANUAL_DEPOSIT',
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
transactionType: { in: ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] },
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
|
||||
@@ -660,22 +356,7 @@ export class AgentsService {
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
await this.requireDirectPlayer(agentId, playerId);
|
||||
|
||||
const profile = await this.getProfile(agentId);
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
const amt = new Decimal(amount);
|
||||
|
||||
if (available.lt(amt)) {
|
||||
throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
|
||||
}
|
||||
|
||||
await this.assertAgentDepositLimits(agentId, amt);
|
||||
|
||||
await this.wallet.deposit(playerId, amt, agentId, remark ?? '代理上分', requestId, 'AGENT_DEPOSIT');
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { success: true };
|
||||
return this.credit.depositToPlayer(agentId, playerId, amount, requestId, remark);
|
||||
}
|
||||
|
||||
async withdrawFromPlayer(
|
||||
@@ -685,19 +366,7 @@ export class AgentsService {
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
await this.requireDirectPlayer(agentId, playerId);
|
||||
|
||||
await this.wallet.withdraw(
|
||||
playerId,
|
||||
amount,
|
||||
agentId,
|
||||
remark ?? '代理下分',
|
||||
requestId,
|
||||
'AGENT_WITHDRAW',
|
||||
);
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { success: true };
|
||||
return this.credit.withdrawFromPlayer(agentId, playerId, amount, requestId, remark);
|
||||
}
|
||||
|
||||
async getDirectPlayerDetail(agentId: bigint, playerId: bigint) {
|
||||
@@ -1508,7 +1177,7 @@ export class AgentsService {
|
||||
|
||||
const hash = await this.auth.hashPassword(data.password);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const locale = data.locale ?? 'zh-CN';
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
@@ -1564,13 +1233,16 @@ export class AgentsService {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (data.parentAgentId) {
|
||||
await this.recalculateUsedCredit(data.parentAgentId);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
if (data.parentAgentId) {
|
||||
await this.recalculateUsedCredit(data.parentAgentId);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async createPlayer(
|
||||
@@ -1707,18 +1379,7 @@ export class AgentsService {
|
||||
if (!remarkResult.ok) throw appBadRequest(remarkResult.code);
|
||||
const requestId =
|
||||
data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
|
||||
await this.assertPlayerParentCreditForDeposit(user.id, initial);
|
||||
await this.wallet.deposit(
|
||||
user.id,
|
||||
initial,
|
||||
operatorId,
|
||||
remarkResult.remark,
|
||||
requestId,
|
||||
'ADMIN_DEPOSIT',
|
||||
);
|
||||
if (parentId) {
|
||||
await this.recalculateUsedCredit(parentId);
|
||||
}
|
||||
await this.credit.adminDepositToPlayer(user.id, initial, operatorId, remarkResult.remark, requestId);
|
||||
}
|
||||
|
||||
return user;
|
||||
@@ -1799,10 +1460,7 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
async getChildAgents(agentId: bigint) {
|
||||
return this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: agentId },
|
||||
include: { user: true },
|
||||
});
|
||||
return this.network.getChildAgents(agentId);
|
||||
}
|
||||
|
||||
async listChildAgentsSummary(parentAgentId: bigint) {
|
||||
@@ -1980,15 +1638,7 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
async assertDescendantAgent(rootAgentId: bigint, targetAgentId: bigint) {
|
||||
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
|
||||
if (!subtreeIds.some((id) => id === targetAgentId)) {
|
||||
throw appForbidden('NOT_SUB_AGENT');
|
||||
}
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: targetAgentId },
|
||||
});
|
||||
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
|
||||
return profile;
|
||||
return this.network.assertDescendantAgent(rootAgentId, targetAgentId);
|
||||
}
|
||||
|
||||
async countSubtreeAgentsByLevel(rootAgentId: bigint) {
|
||||
@@ -2242,13 +1892,7 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: subAgentId },
|
||||
});
|
||||
if (!profile || profile.parentAgentId !== parentAgentId) {
|
||||
throw appForbidden('NOT_SUB_AGENT');
|
||||
}
|
||||
return profile;
|
||||
return this.network.assertDirectChildAgent(parentAgentId, subAgentId);
|
||||
}
|
||||
|
||||
async getSubAgentForParent(parentAgentId: bigint, subAgentId: bigint) {
|
||||
@@ -2275,27 +1919,7 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
async getSubtreeAgentIds(agentId: bigint) {
|
||||
const ids: bigint[] = [];
|
||||
const queue: bigint[] = [agentId];
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const key = current.toString();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
ids.push(current);
|
||||
|
||||
const children = await this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: current },
|
||||
select: { userId: true },
|
||||
});
|
||||
for (const child of children) {
|
||||
queue.push(child.userId);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
return this.network.getSubtreeAgentIds(agentId);
|
||||
}
|
||||
|
||||
async getReportSummary(agentId: bigint) {
|
||||
|
||||
153
apps/api/src/domains/betting/bets.service.spec.ts
Normal file
153
apps/api/src/domains/betting/bets.service.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { BetsService } from './bets.service';
|
||||
import { expectAppError } from '../../testing/prisma-mock';
|
||||
|
||||
describe('BetsService', () => {
|
||||
const tx = {
|
||||
bet: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
marketSelection: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
...tx,
|
||||
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
const funds = {
|
||||
freezeBet: jest.fn(),
|
||||
};
|
||||
const bettingLimits = {
|
||||
validateBet: jest.fn(),
|
||||
};
|
||||
|
||||
let service: BetsService;
|
||||
|
||||
function selection(id: bigint, oddsVersion: bigint, matchId = 88n) {
|
||||
return {
|
||||
id,
|
||||
marketId: id + 100n,
|
||||
selectionCode: id === 1n ? 'HOME' : 'AWAY',
|
||||
selectionName: `Selection ${id}`,
|
||||
nameI18n: null,
|
||||
odds: new Decimal(id === 1n ? 1.9 : 2.1),
|
||||
oddsVersion,
|
||||
status: 'OPEN',
|
||||
market: {
|
||||
matchId,
|
||||
marketType: 'FT_1X2',
|
||||
period: 'FT',
|
||||
nameI18n: null,
|
||||
lineValue: null,
|
||||
allowSingle: true,
|
||||
allowParlay: true,
|
||||
showOnPlayer: true,
|
||||
status: 'OPEN',
|
||||
match: {
|
||||
status: 'PUBLISHED',
|
||||
sportType: 'FOOTBALL',
|
||||
isOutright: false,
|
||||
startTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new BetsService(prisma as never, funds as never, bettingLimits as never);
|
||||
tx.bet.findUnique.mockResolvedValue(null);
|
||||
tx.bet.create.mockResolvedValue({ id: 123n, betNo: 'BET-OK', selections: [] });
|
||||
bettingLimits.validateBet.mockResolvedValue(undefined);
|
||||
tx.marketSelection.findUnique.mockImplementation(({ where }: { where: { id: bigint } }) =>
|
||||
Promise.resolve(selection(where.id, where.id)),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a parlay with multiple legs from the same match', async () => {
|
||||
await expect(
|
||||
service.placeParlayBet(
|
||||
7n,
|
||||
3n,
|
||||
[
|
||||
{ selectionId: 1n, oddsVersion: 1n },
|
||||
{ selectionId: 2n, oddsVersion: 2n },
|
||||
],
|
||||
50,
|
||||
'REQ-SAME-MATCH',
|
||||
),
|
||||
).rejects.toMatchObject(expectAppError('PARLAY_SAME_MATCH_FORBIDDEN'));
|
||||
|
||||
expect(tx.bet.create).not.toHaveBeenCalled();
|
||||
expect(bettingLimits.validateBet).not.toHaveBeenCalled();
|
||||
expect(funds.freezeBet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a parlay only when every leg is from a different match', async () => {
|
||||
tx.marketSelection.findUnique.mockImplementation(({ where }: { where: { id: bigint } }) =>
|
||||
Promise.resolve(selection(where.id, where.id, where.id === 1n ? 88n : 99n)),
|
||||
);
|
||||
|
||||
await service.placeParlayBet(
|
||||
7n,
|
||||
3n,
|
||||
[
|
||||
{ selectionId: 1n, oddsVersion: 1n },
|
||||
{ selectionId: 2n, oddsVersion: 2n },
|
||||
],
|
||||
50,
|
||||
'REQ-SAME-MATCH',
|
||||
);
|
||||
|
||||
expect(tx.bet.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
betType: 'PARLAY',
|
||||
selections: expect.objectContaining({
|
||||
create: expect.arrayContaining([
|
||||
expect.objectContaining({ matchId: 88n, selectionId: 1n }),
|
||||
expect.objectContaining({ matchId: 99n, selectionId: 2n }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(bettingLimits.validateBet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ betType: 'PARLAY', tx }),
|
||||
);
|
||||
expect(funds.freezeBet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 7n, stake: expect.any(Decimal), tx }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a single bet when the market disables single betting', async () => {
|
||||
tx.marketSelection.findUnique.mockResolvedValue({
|
||||
...selection(1n, 1n),
|
||||
market: {
|
||||
...selection(1n, 1n).market,
|
||||
allowSingle: false,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.placeSingleBet(7n, 3n, 1n, 1n, 50, 'REQ-SINGLE-DISABLED'),
|
||||
).rejects.toMatchObject(expectAppError('SINGLE_MARKET_NOT_ALLOWED'));
|
||||
|
||||
expect(tx.bet.create).not.toHaveBeenCalled();
|
||||
expect(bettingLimits.validateBet).not.toHaveBeenCalled();
|
||||
expect(funds.freezeBet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('revalidates odds version inside the placement transaction', async () => {
|
||||
tx.marketSelection.findUnique.mockResolvedValue(selection(1n, 2n));
|
||||
|
||||
await expect(
|
||||
service.placeSingleBet(7n, 3n, 1n, 1n, 50, 'REQ-ODDS-CHANGED'),
|
||||
).rejects.toMatchObject(expectAppError('ODDS_CHANGED'));
|
||||
|
||||
expect(tx.bet.create).not.toHaveBeenCalled();
|
||||
expect(funds.freezeBet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { FundsPostingService } from '../ledger/funds-posting.service';
|
||||
import { BettingLimitsService } from './betting-limits.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBetNo } from '../../shared/common/decorators';
|
||||
@@ -10,12 +10,17 @@ import {
|
||||
PARLAY_MIN_LEGS,
|
||||
PARLAY_MAX_LEGS,
|
||||
canSelectForParlay,
|
||||
hasDuplicateParlayMatch,
|
||||
defaultMarketName,
|
||||
defaultSelectionName,
|
||||
isPreMatchKickoff,
|
||||
isSupportedSport,
|
||||
resolveMarketText,
|
||||
resolveTranslationFallback,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
type PrismaClientLike = PrismaService | TxClient;
|
||||
|
||||
type MatchContext = {
|
||||
matchLabel: string;
|
||||
leagueName: string;
|
||||
@@ -25,6 +30,7 @@ type BetSelectionRow = {
|
||||
matchId: bigint | null;
|
||||
marketType: string;
|
||||
period: string | null;
|
||||
marketNameSnapshot: string | null;
|
||||
selectionNameSnapshot: string;
|
||||
handicapLine: Decimal | null;
|
||||
totalLine: Decimal | null;
|
||||
@@ -32,6 +38,16 @@ type BetSelectionRow = {
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
type SelectionWithMarketLabel = {
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
nameI18n: unknown;
|
||||
market: {
|
||||
marketType: string;
|
||||
nameI18n: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
interface BetSelectionInput {
|
||||
selectionId: bigint;
|
||||
oddsVersion: bigint;
|
||||
@@ -42,16 +58,17 @@ interface BetSelectionInput {
|
||||
export class BetsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private funds: FundsPostingService,
|
||||
private bettingLimits: BettingLimitsService,
|
||||
) {}
|
||||
|
||||
private async validateSelection(
|
||||
selectionId: bigint,
|
||||
oddsVersion: bigint,
|
||||
options?: { forParlay?: boolean },
|
||||
options?: { forParlay?: boolean; tx?: TxClient },
|
||||
) {
|
||||
const selection = await this.prisma.marketSelection.findUnique({
|
||||
const client: PrismaClientLike = options?.tx ?? this.prisma;
|
||||
const selection = await client.marketSelection.findUnique({
|
||||
where: { id: selectionId },
|
||||
include: { market: { include: { match: true } } },
|
||||
});
|
||||
@@ -59,6 +76,10 @@ export class BetsService {
|
||||
if (!selection) throw appBadRequest('SELECTION_NOT_FOUND');
|
||||
if (selection.status !== 'OPEN') throw appBadRequest('SELECTION_CLOSED');
|
||||
if (selection.market.status !== 'OPEN') throw appBadRequest('MARKET_CLOSED');
|
||||
if (selection.market.showOnPlayer === false) throw appBadRequest('MARKET_CLOSED');
|
||||
if (!options?.forParlay && selection.market.allowSingle === false) {
|
||||
throw appBadRequest('SINGLE_MARKET_NOT_ALLOWED');
|
||||
}
|
||||
if (selection.market.match.status !== 'PUBLISHED') {
|
||||
throw appBadRequest('MATCH_NOT_BETTING');
|
||||
}
|
||||
@@ -68,14 +89,6 @@ export class BetsService {
|
||||
if (!selection.market.match.isOutright && !isPreMatchKickoff(selection.market.match.startTime)) {
|
||||
throw appBadRequest('PRE_MATCH_ONLY');
|
||||
}
|
||||
// Block correct-score bets when the match has the CS toggle turned off
|
||||
const CS_MARKET_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'];
|
||||
if (
|
||||
CS_MARKET_TYPES.includes(selection.market.marketType) &&
|
||||
!(selection.market.match.correctScoreEnabled ?? true)
|
||||
) {
|
||||
throw appBadRequest('CORRECT_SCORE_DISABLED');
|
||||
}
|
||||
if (selection.oddsVersion !== oddsVersion) {
|
||||
throw appBadRequest('ODDS_CHANGED');
|
||||
}
|
||||
@@ -112,57 +125,68 @@ export class BetsService {
|
||||
) {
|
||||
if (stake <= 0) throw appBadRequest('INVALID_STAKE');
|
||||
|
||||
const existing = await this.prisma.bet.findUnique({
|
||||
where: { userId_requestId: { userId, requestId } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const selection = await this.validateSelection(selectionId, oddsVersion, { forParlay: false });
|
||||
const odds = new Decimal(selection.odds.toString());
|
||||
const stakeDec = new Decimal(stake);
|
||||
const potentialReturn = stakeDec.mul(odds);
|
||||
await this.bettingLimits.validateBet({
|
||||
userId,
|
||||
betType: 'SINGLE',
|
||||
stake,
|
||||
potentialReturn,
|
||||
});
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const bet = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.bet.create({
|
||||
data: {
|
||||
betNo,
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.bet.findUnique({
|
||||
where: { userId_requestId: { userId, requestId } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const selection = await this.validateSelection(selectionId, oddsVersion, {
|
||||
forParlay: false,
|
||||
tx,
|
||||
});
|
||||
const locale = await this.resolveUserLocale(userId, tx);
|
||||
const odds = new Decimal(selection.odds.toString());
|
||||
const potentialReturn = stakeDec.mul(odds);
|
||||
await this.bettingLimits.validateBet({
|
||||
userId,
|
||||
agentId,
|
||||
betType: 'SINGLE',
|
||||
stake: stakeDec,
|
||||
totalOdds: odds,
|
||||
stake,
|
||||
potentialReturn,
|
||||
requestId,
|
||||
selections: {
|
||||
create: {
|
||||
matchId: selection.market.matchId,
|
||||
marketId: selection.marketId,
|
||||
selectionId: selection.id,
|
||||
marketType: selection.market.marketType,
|
||||
period: selection.market.period,
|
||||
selectionNameSnapshot: selection.selectionName,
|
||||
handicapLine: selection.market.lineValue,
|
||||
totalLine: selection.market.lineValue,
|
||||
odds,
|
||||
oddsVersion,
|
||||
tx,
|
||||
});
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const created = await tx.bet.create({
|
||||
data: {
|
||||
betNo,
|
||||
userId,
|
||||
agentId,
|
||||
betType: 'SINGLE',
|
||||
stake: stakeDec,
|
||||
totalOdds: odds,
|
||||
potentialReturn,
|
||||
requestId,
|
||||
selections: {
|
||||
create: {
|
||||
matchId: selection.market.matchId,
|
||||
marketId: selection.marketId,
|
||||
selectionId: selection.id,
|
||||
marketType: selection.market.marketType,
|
||||
period: selection.market.period,
|
||||
marketNameSnapshot: this.marketNameSnapshot(selection, locale),
|
||||
selectionNameSnapshot: this.selectionNameSnapshot(selection, locale),
|
||||
handicapLine: selection.market.lineValue,
|
||||
totalLine: selection.market.lineValue,
|
||||
odds,
|
||||
oddsVersion,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { selections: true },
|
||||
include: { selections: true },
|
||||
});
|
||||
|
||||
await this.funds.freezeBet({ userId, stake: stakeDec, betNo, tx });
|
||||
return created;
|
||||
});
|
||||
|
||||
await this.wallet.freezeForBet(userId, stakeDec, betNo);
|
||||
return created;
|
||||
});
|
||||
|
||||
return bet;
|
||||
} catch (error) {
|
||||
const existing = await this.findExistingRequestBetOnUniqueError(userId, requestId, error);
|
||||
if (existing) return existing;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async placeParlayBet(
|
||||
@@ -177,73 +201,100 @@ export class BetsService {
|
||||
throw appBadRequest('PARLAY_LEG_COUNT_INVALID', { min: PARLAY_MIN_LEGS, max: PARLAY_MAX_LEGS });
|
||||
}
|
||||
|
||||
const existing = await this.prisma.bet.findUnique({
|
||||
where: { userId_requestId: { userId, requestId } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
|
||||
|
||||
for (const leg of legs) {
|
||||
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, { forParlay: true });
|
||||
selections.push(sel);
|
||||
}
|
||||
|
||||
const matchIds = selections.map((s) => s.market.matchId);
|
||||
if (hasDuplicateParlayMatch(matchIds)) {
|
||||
throw appBadRequest('PARLAY_SAME_MATCH_FORBIDDEN');
|
||||
}
|
||||
|
||||
let totalOdds = new Decimal(1);
|
||||
for (const sel of selections) {
|
||||
totalOdds = totalOdds.mul(sel.odds.toString());
|
||||
}
|
||||
|
||||
const stakeDec = new Decimal(stake);
|
||||
const potentialReturn = stakeDec.mul(totalOdds);
|
||||
await this.bettingLimits.validateBet({
|
||||
userId,
|
||||
betType: 'PARLAY',
|
||||
stake,
|
||||
potentialReturn,
|
||||
});
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const bet = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.bet.create({
|
||||
data: {
|
||||
betNo,
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.bet.findUnique({
|
||||
where: { userId_requestId: { userId, requestId } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
|
||||
for (const leg of legs) {
|
||||
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, {
|
||||
forParlay: true,
|
||||
tx,
|
||||
});
|
||||
selections.push(sel);
|
||||
}
|
||||
const matchIds = new Set<string>();
|
||||
for (const sel of selections) {
|
||||
const matchKey = sel.market.matchId.toString();
|
||||
if (matchIds.has(matchKey)) {
|
||||
throw appBadRequest('PARLAY_SAME_MATCH_FORBIDDEN');
|
||||
}
|
||||
matchIds.add(matchKey);
|
||||
}
|
||||
const locale = await this.resolveUserLocale(userId, tx);
|
||||
|
||||
let totalOdds = new Decimal(1);
|
||||
for (const sel of selections) {
|
||||
totalOdds = totalOdds.mul(sel.odds.toString());
|
||||
}
|
||||
|
||||
const potentialReturn = stakeDec.mul(totalOdds);
|
||||
await this.bettingLimits.validateBet({
|
||||
userId,
|
||||
agentId,
|
||||
betType: 'PARLAY',
|
||||
stake: stakeDec,
|
||||
totalOdds,
|
||||
stake,
|
||||
potentialReturn,
|
||||
requestId,
|
||||
selections: {
|
||||
create: selections.map((sel, i) => ({
|
||||
matchId: sel.market.matchId,
|
||||
marketId: sel.marketId,
|
||||
selectionId: sel.id,
|
||||
marketType: sel.market.marketType,
|
||||
period: sel.market.period,
|
||||
selectionNameSnapshot: sel.selectionName,
|
||||
handicapLine: sel.market.lineValue,
|
||||
totalLine: sel.market.lineValue,
|
||||
odds: sel.odds,
|
||||
oddsVersion: legs[i].oddsVersion,
|
||||
sortOrder: i,
|
||||
})),
|
||||
tx,
|
||||
});
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const created = await tx.bet.create({
|
||||
data: {
|
||||
betNo,
|
||||
userId,
|
||||
agentId,
|
||||
betType: 'PARLAY',
|
||||
stake: stakeDec,
|
||||
totalOdds,
|
||||
potentialReturn,
|
||||
requestId,
|
||||
selections: {
|
||||
create: selections.map((sel, i) => ({
|
||||
matchId: sel.market.matchId,
|
||||
marketId: sel.marketId,
|
||||
selectionId: sel.id,
|
||||
marketType: sel.market.marketType,
|
||||
period: sel.market.period,
|
||||
marketNameSnapshot: this.marketNameSnapshot(sel, locale),
|
||||
selectionNameSnapshot: this.selectionNameSnapshot(sel, locale),
|
||||
handicapLine: sel.market.lineValue,
|
||||
totalLine: sel.market.lineValue,
|
||||
odds: sel.odds,
|
||||
oddsVersion: legs[i].oddsVersion,
|
||||
sortOrder: i,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { selections: true },
|
||||
include: { selections: true },
|
||||
});
|
||||
|
||||
await this.funds.freezeBet({ userId, stake: stakeDec, betNo, tx });
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
const existing = await this.findExistingRequestBetOnUniqueError(userId, requestId, error);
|
||||
if (existing) return existing;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await this.wallet.freezeForBet(userId, stakeDec, betNo);
|
||||
return created;
|
||||
});
|
||||
|
||||
return bet;
|
||||
private async findExistingRequestBetOnUniqueError(
|
||||
userId: bigint,
|
||||
requestId: string,
|
||||
error: unknown,
|
||||
) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
return this.prisma.bet.findUnique({ where: { userId_requestId: { userId, requestId } } });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getUserBets(
|
||||
@@ -392,6 +443,7 @@ export class BetsService {
|
||||
matchLabel: ctx?.matchLabel ?? '—',
|
||||
leagueName: ctx?.leagueName ?? '',
|
||||
marketType: s.marketType,
|
||||
marketName: s.marketNameSnapshot ?? null,
|
||||
period: s.period,
|
||||
selectionName: s.selectionNameSnapshot,
|
||||
odds: this.dec(s.odds),
|
||||
@@ -584,6 +636,7 @@ export class BetsService {
|
||||
matchLabel: preview?.matchLabel ?? '—',
|
||||
leagueName: preview?.leagueName ?? '',
|
||||
marketType: s.marketType,
|
||||
marketName: s.marketNameSnapshot,
|
||||
period: s.period,
|
||||
selectionName: s.selectionNameSnapshot,
|
||||
handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null,
|
||||
@@ -596,4 +649,37 @@ export class BetsService {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveUserLocale(userId: bigint, client: PrismaClientLike) {
|
||||
const userDelegate = (client as PrismaClientLike & {
|
||||
user?: {
|
||||
findUnique?: PrismaService['user']['findUnique'];
|
||||
};
|
||||
}).user;
|
||||
if (!userDelegate?.findUnique) return 'zh-CN';
|
||||
const user = await userDelegate.findUnique({
|
||||
where: { id: userId },
|
||||
select: { locale: true, preferences: { select: { locale: true } } },
|
||||
});
|
||||
return user?.preferences?.locale || user?.locale || 'zh-CN';
|
||||
}
|
||||
|
||||
private marketNameSnapshot(selection: SelectionWithMarketLabel, locale: string) {
|
||||
const market = selection.market;
|
||||
return (
|
||||
resolveMarketText(market.nameI18n, locale, defaultMarketName(market.marketType, locale)) ||
|
||||
market.marketType
|
||||
);
|
||||
}
|
||||
|
||||
private selectionNameSnapshot(selection: SelectionWithMarketLabel, locale: string) {
|
||||
return (
|
||||
resolveMarketText(
|
||||
selection.nameI18n,
|
||||
locale,
|
||||
defaultSelectionName(selection.market.marketType, selection.selectionCode, locale) ||
|
||||
selection.selectionName,
|
||||
) || selection.selectionName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BettingLimitsService } from './betting-limits.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { expectAppError } from '../../testing/prisma-mock';
|
||||
|
||||
describe('BettingLimitsService', () => {
|
||||
const prisma = {
|
||||
@@ -27,7 +28,7 @@ describe('BettingLimitsService', () => {
|
||||
stake: 0.5,
|
||||
potentialReturn: new Decimal(1),
|
||||
}),
|
||||
).rejects.toThrow('Minimum stake is 1');
|
||||
).rejects.toMatchObject(expectAppError('MIN_STAKE'));
|
||||
});
|
||||
|
||||
it('rejects stake above single max', async () => {
|
||||
@@ -38,7 +39,7 @@ describe('BettingLimitsService', () => {
|
||||
stake: 60000,
|
||||
potentialReturn: new Decimal(70000),
|
||||
}),
|
||||
).rejects.toThrow('Maximum stake is 50000');
|
||||
).rejects.toMatchObject(expectAppError('MAX_STAKE'));
|
||||
});
|
||||
|
||||
it('rejects potential return above payout cap', async () => {
|
||||
@@ -49,6 +50,6 @@ describe('BettingLimitsService', () => {
|
||||
stake: 100,
|
||||
potentialReturn: new Decimal(600000),
|
||||
}),
|
||||
).rejects.toThrow('Potential return exceeds limit');
|
||||
).rejects.toMatchObject(expectAppError('MAX_PAYOUT'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appBadRequest } from '../../shared/common/app-error';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
type PrismaClientLike = PrismaService | TxClient;
|
||||
|
||||
export type BettingLimits = {
|
||||
minStake: number;
|
||||
maxStakeSingle: number;
|
||||
@@ -34,8 +38,12 @@ const DEFAULTS: BettingLimits = {
|
||||
export class BettingLimitsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private async getNumber(key: string, fallback: number): Promise<number> {
|
||||
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||||
private async getNumber(
|
||||
key: string,
|
||||
fallback: number,
|
||||
client: PrismaClientLike = this.prisma,
|
||||
): Promise<number> {
|
||||
const row = await client.systemConfig.findUnique({ where: { configKey: key } });
|
||||
if (!row) return fallback;
|
||||
const n = Number(row.configValue);
|
||||
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||
@@ -49,14 +57,15 @@ export class BettingLimitsService {
|
||||
});
|
||||
}
|
||||
|
||||
async getLimits(): Promise<BettingLimits> {
|
||||
async getLimits(tx?: TxClient): Promise<BettingLimits> {
|
||||
const client = tx ?? this.prisma;
|
||||
return {
|
||||
minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake),
|
||||
maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle),
|
||||
maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay),
|
||||
maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle),
|
||||
maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay),
|
||||
dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit),
|
||||
minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake, client),
|
||||
maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle, client),
|
||||
maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay, client),
|
||||
maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle, client),
|
||||
maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay, client),
|
||||
dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit, client),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,8 +94,10 @@ export class BettingLimitsService {
|
||||
betType: 'SINGLE' | 'PARLAY';
|
||||
stake: number;
|
||||
potentialReturn: Decimal;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
const limits = await this.getLimits();
|
||||
const client = params.tx ?? this.prisma;
|
||||
const limits = await this.getLimits(params.tx);
|
||||
const stake = params.stake;
|
||||
|
||||
if (stake < limits.minStake) {
|
||||
@@ -110,7 +121,7 @@ export class BettingLimitsService {
|
||||
const endOfDay = new Date();
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const agg = await this.prisma.bet.aggregate({
|
||||
const agg = await client.bet.aggregate({
|
||||
where: {
|
||||
userId: params.userId,
|
||||
placedAt: { gte: startOfDay, lte: endOfDay },
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('CatalogArchiveService', () => {
|
||||
entityTranslation: { findFirst: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let matches: { betStatsForMatches: jest.Mock };
|
||||
let matchBetStats: { betStatsForMatches: jest.Mock };
|
||||
let service: CatalogArchiveService;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -34,8 +34,8 @@ describe('CatalogArchiveService', () => {
|
||||
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
$transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise<void>) => fn(prisma)),
|
||||
};
|
||||
matches = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
||||
service = new CatalogArchiveService(prisma as never, matches as never);
|
||||
matchBetStats = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
||||
service = new CatalogArchiveService(prisma as never, matchBetStats as never);
|
||||
});
|
||||
|
||||
const baseMatch = {
|
||||
@@ -71,21 +71,13 @@ describe('CatalogArchiveService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('archive with force soft-deletes and cancels match', async () => {
|
||||
it('archive with force rejects draft matches', async () => {
|
||||
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'DRAFT' });
|
||||
prisma.match.update.mockResolvedValue({});
|
||||
|
||||
const result = await service.archiveMatch(matchId, { force: true });
|
||||
|
||||
expect(result.matchId).toBe(matchId.toString());
|
||||
expect(prisma.marketSelection.updateMany).toHaveBeenCalled();
|
||||
expect(prisma.market.updateMany).toHaveBeenCalled();
|
||||
expect(prisma.match.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: matchId },
|
||||
data: expect.objectContaining({ status: 'CANCELLED', deletedAt: expect.any(Date) }),
|
||||
}),
|
||||
);
|
||||
await expect(service.archiveMatch(matchId, { force: true })).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: 'MATCH_DELETE_DRAFT_ONLY' }),
|
||||
});
|
||||
expect(prisma.match.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('league preview blocks when child match is not terminal', async () => {
|
||||
@@ -102,7 +94,7 @@ describe('CatalogArchiveService', () => {
|
||||
awayTeam: { code: 'B' },
|
||||
},
|
||||
]);
|
||||
matches.betStatsForMatches.mockResolvedValue(
|
||||
matchBetStats.betStatsForMatches.mockResolvedValue(
|
||||
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
|
||||
);
|
||||
|
||||
@@ -129,7 +121,7 @@ describe('CatalogArchiveService', () => {
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: matchId, status: 'SETTLED' }]);
|
||||
matches.betStatsForMatches.mockResolvedValue(
|
||||
matchBetStats.betStatsForMatches.mockResolvedValue(
|
||||
new Map([[matchId.toString(), { betCount: 1, totalStake: '100', pendingCount: 0 }]]),
|
||||
);
|
||||
|
||||
@@ -163,7 +155,7 @@ describe('CatalogArchiveService', () => {
|
||||
awayTeam: { code: 'B' },
|
||||
},
|
||||
]);
|
||||
matches.betStatsForMatches.mockResolvedValue(
|
||||
matchBetStats.betStatsForMatches.mockResolvedValue(
|
||||
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appBadRequest, appConflict, appNotFound } from '../../shared/common/app-error';
|
||||
import { MatchesService } from './matches.service';
|
||||
import { MatchBetStatsService } from './match-bet-stats.service';
|
||||
|
||||
const TERMINAL_MATCH_STATUSES = new Set(['SETTLED', 'CANCELLED', 'VOID']);
|
||||
|
||||
@@ -39,7 +39,7 @@ export type LeagueArchivePreview = {
|
||||
export class CatalogArchiveService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private matches: MatchesService,
|
||||
private matchBetStats: MatchBetStatsService,
|
||||
) {}
|
||||
|
||||
async getMatchArchivePreview(matchId: bigint): Promise<MatchArchivePreview> {
|
||||
@@ -114,7 +114,7 @@ export class CatalogArchiveService {
|
||||
});
|
||||
|
||||
const matchIds = matches.map((m) => m.id);
|
||||
const stats = await this.matches.betStatsForMatches(matchIds);
|
||||
const stats = await this.matchBetStats.betStatsForMatches(matchIds);
|
||||
const previewBatches = matchIds.length
|
||||
? await this.prisma.settlementBatch.findMany({
|
||||
where: { matchId: { in: matchIds }, status: 'PREVIEW' },
|
||||
|
||||
71
apps/api/src/domains/catalog/match-bet-stats.service.spec.ts
Normal file
71
apps/api/src/domains/catalog/match-bet-stats.service.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { MatchBetStatsService } from './match-bet-stats.service';
|
||||
|
||||
describe('MatchBetStatsService', () => {
|
||||
let prisma: {
|
||||
betSelection: { findMany: jest.Mock };
|
||||
};
|
||||
let service: MatchBetStatsService;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
betSelection: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
service = new MatchBetStatsService(prisma as never);
|
||||
});
|
||||
|
||||
it('returns empty stats without querying when match ids are empty', async () => {
|
||||
const stats = await service.betStatsForMatches([]);
|
||||
|
||||
expect(stats.size).toBe(0);
|
||||
expect(prisma.betSelection.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates bets and fills zero stats for matches without bets', async () => {
|
||||
const matchId = BigInt(10);
|
||||
const emptyMatchId = BigInt(11);
|
||||
prisma.betSelection.findMany.mockResolvedValue([
|
||||
{
|
||||
matchId,
|
||||
betId: BigInt(100),
|
||||
bet: { stake: new Decimal(50), status: 'PENDING' },
|
||||
},
|
||||
{
|
||||
matchId,
|
||||
betId: BigInt(100),
|
||||
bet: { stake: new Decimal(50), status: 'PENDING' },
|
||||
},
|
||||
{
|
||||
matchId,
|
||||
betId: BigInt(101),
|
||||
bet: { stake: new Decimal(25), status: 'WON' },
|
||||
},
|
||||
{
|
||||
matchId: null,
|
||||
betId: BigInt(102),
|
||||
bet: { stake: new Decimal(99), status: 'PENDING' },
|
||||
},
|
||||
]);
|
||||
|
||||
const stats = await service.betStatsForMatches([matchId, emptyMatchId]);
|
||||
|
||||
expect(prisma.betSelection.findMany).toHaveBeenCalledWith({
|
||||
where: { matchId: { in: [matchId, emptyMatchId] } },
|
||||
select: {
|
||||
matchId: true,
|
||||
betId: true,
|
||||
bet: { select: { stake: true, status: true } },
|
||||
},
|
||||
});
|
||||
expect(stats.get(matchId.toString())).toEqual({
|
||||
betCount: 2,
|
||||
totalStake: '75',
|
||||
pendingCount: 1,
|
||||
});
|
||||
expect(stats.get(emptyMatchId.toString())).toEqual({
|
||||
betCount: 0,
|
||||
totalStake: '0',
|
||||
pendingCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
73
apps/api/src/domains/catalog/match-bet-stats.service.ts
Normal file
73
apps/api/src/domains/catalog/match-bet-stats.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
|
||||
export type MatchBetStatsSummary = {
|
||||
betCount: number;
|
||||
totalStake: string;
|
||||
pendingCount: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MatchBetStatsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
|
||||
async betStatsForMatches(
|
||||
matchIds: bigint[],
|
||||
): Promise<Map<string, MatchBetStatsSummary>> {
|
||||
const result = new Map<string, MatchBetStatsSummary>();
|
||||
if (!matchIds.length) return result;
|
||||
|
||||
const legs = await this.prisma.betSelection.findMany({
|
||||
where: { matchId: { in: matchIds } },
|
||||
select: {
|
||||
matchId: true,
|
||||
betId: true,
|
||||
bet: { select: { stake: true, status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const byMatch = new Map<
|
||||
string,
|
||||
Map<string, { stake: Decimal; status: string }>
|
||||
>();
|
||||
for (const leg of legs) {
|
||||
if (leg.matchId == null) continue;
|
||||
const mid = leg.matchId.toString();
|
||||
if (!byMatch.has(mid)) byMatch.set(mid, new Map());
|
||||
const bets = byMatch.get(mid)!;
|
||||
if (!bets.has(leg.betId.toString())) {
|
||||
bets.set(leg.betId.toString(), {
|
||||
stake: leg.bet.stake,
|
||||
status: leg.bet.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of matchIds) {
|
||||
const mid = id.toString();
|
||||
const bets = byMatch.get(mid);
|
||||
if (!bets) {
|
||||
result.set(mid, {
|
||||
betCount: 0,
|
||||
totalStake: '0',
|
||||
pendingCount: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let totalStake = new Decimal(0);
|
||||
let pendingCount = 0;
|
||||
for (const b of bets.values()) {
|
||||
totalStake = totalStake.add(b.stake);
|
||||
if (b.status === 'PENDING') pendingCount += 1;
|
||||
}
|
||||
result.set(mid, {
|
||||
betCount: bets.size,
|
||||
totalStake: totalStake.toString(),
|
||||
pendingCount,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MarketsModule } from '../odds/markets.module';
|
||||
import { CatalogArchiveService } from './catalog-archive.service';
|
||||
import { MatchBetStatsService } from './match-bet-stats.service';
|
||||
import { MatchesService } from './matches.service';
|
||||
import { OutrightService } from './outright.service';
|
||||
|
||||
@Module({
|
||||
imports: [MarketsModule],
|
||||
providers: [MatchesService, OutrightService, CatalogArchiveService],
|
||||
exports: [MatchesService, OutrightService, CatalogArchiveService],
|
||||
providers: [MatchesService, MatchBetStatsService, OutrightService, CatalogArchiveService],
|
||||
exports: [MatchesService, MatchBetStatsService, OutrightService, CatalogArchiveService],
|
||||
})
|
||||
export class MatchesModule {}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
jest.mock('@thebet365/shared', () => ({
|
||||
isPreMatchKickoff: jest.fn(() => true),
|
||||
PARLAY_MARKET_TYPES: [],
|
||||
resolveTranslationFallback: jest.fn(
|
||||
(translations: Map<string, string>, locale: string) =>
|
||||
translations.get(locale) ?? translations.get('zh-CN') ?? translations.get('en-US') ?? null,
|
||||
),
|
||||
}));
|
||||
|
||||
import { MatchesService } from './matches.service';
|
||||
|
||||
describe('MatchesService publish/unpublish', () => {
|
||||
@@ -11,6 +20,7 @@ describe('MatchesService publish/unpublish', () => {
|
||||
settlementBatch: { deleteMany: jest.Mock };
|
||||
};
|
||||
let outright: { syncWithLeaguePublished: jest.Mock };
|
||||
let matchBetStats: { betStatsForMatches: jest.Mock };
|
||||
let service: MatchesService;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -28,7 +38,8 @@ describe('MatchesService publish/unpublish', () => {
|
||||
settlementBatch: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
||||
};
|
||||
outright = { syncWithLeaguePublished: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new MatchesService(prisma as never, outright as never);
|
||||
matchBetStats = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
||||
service = new MatchesService(prisma as never, outright as never, matchBetStats as never);
|
||||
});
|
||||
|
||||
describe('updatePlatformLeague unpublish', () => {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isPreMatchKickoff, MarketType, resolveTranslationFallback } from '@thebet365/shared';
|
||||
import {
|
||||
defaultMarketName,
|
||||
defaultSelectionName,
|
||||
isPreMatchKickoff,
|
||||
isSettlementSupportedMarketType,
|
||||
PARLAY_MARKET_TYPES,
|
||||
resolveMarketText,
|
||||
resolveTranslationFallback,
|
||||
sanitizeLocalizedText,
|
||||
} from '@thebet365/shared';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||||
|
||||
export type MatchBetStatsSummary = {
|
||||
betCount: number;
|
||||
totalStake: string;
|
||||
pendingCount: number;
|
||||
};
|
||||
import { MatchBetStatsService, type MatchBetStatsSummary } from './match-bet-stats.service';
|
||||
import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboMatchesBundleExport, ZhiboTeamExport } from './zhibo-match.types';
|
||||
import {
|
||||
leagueCodeFromExport,
|
||||
@@ -25,17 +29,11 @@ import {
|
||||
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
|
||||
import { OutrightService } from './outright.service';
|
||||
|
||||
export type { MatchBetStatsSummary } from './match-bet-stats.service';
|
||||
|
||||
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
|
||||
|
||||
const PLAYER_PARLAY_MARKET_TYPES = [
|
||||
MarketType.FT_HANDICAP,
|
||||
MarketType.FT_OVER_UNDER,
|
||||
MarketType.FT_1X2,
|
||||
MarketType.FT_ODD_EVEN,
|
||||
MarketType.HT_HANDICAP,
|
||||
MarketType.HT_OVER_UNDER,
|
||||
MarketType.HT_1X2,
|
||||
] as const;
|
||||
const PLAYER_PARLAY_MARKET_TYPES = PARLAY_MARKET_TYPES;
|
||||
|
||||
export type ListPublishedOptions = {
|
||||
/** 响应中是否包含 markets(列表页默认 false,仅摘要) */
|
||||
@@ -49,6 +47,7 @@ export class MatchesService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private outright: OutrightService,
|
||||
private matchBetStats: MatchBetStatsService,
|
||||
) {}
|
||||
|
||||
async createLeague(code: string, translations: Record<string, string>) {
|
||||
@@ -89,7 +88,6 @@ export class MatchesService {
|
||||
awayTeamId: bigint;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
correctScoreEnabled?: boolean;
|
||||
displayOrder?: number;
|
||||
createdBy?: bigint;
|
||||
status?: string;
|
||||
@@ -115,7 +113,6 @@ export class MatchesService {
|
||||
awayTeamId: data.awayTeamId,
|
||||
startTime: data.startTime,
|
||||
isHot: data.isHot ?? false,
|
||||
correctScoreEnabled: data.correctScoreEnabled ?? true,
|
||||
displayOrder: data.displayOrder ?? 0,
|
||||
createdBy: data.createdBy,
|
||||
status,
|
||||
@@ -624,59 +621,7 @@ export class MatchesService {
|
||||
async betStatsForMatches(
|
||||
matchIds: bigint[],
|
||||
): Promise<Map<string, MatchBetStatsSummary>> {
|
||||
const result = new Map<string, MatchBetStatsSummary>();
|
||||
if (!matchIds.length) return result;
|
||||
|
||||
const legs = await this.prisma.betSelection.findMany({
|
||||
where: { matchId: { in: matchIds } },
|
||||
select: {
|
||||
matchId: true,
|
||||
betId: true,
|
||||
bet: { select: { stake: true, status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const byMatch = new Map<
|
||||
string,
|
||||
Map<string, { stake: Decimal; status: string }>
|
||||
>();
|
||||
for (const leg of legs) {
|
||||
if (leg.matchId == null) continue;
|
||||
const mid = leg.matchId.toString();
|
||||
if (!byMatch.has(mid)) byMatch.set(mid, new Map());
|
||||
const bets = byMatch.get(mid)!;
|
||||
if (!bets.has(leg.betId.toString())) {
|
||||
bets.set(leg.betId.toString(), {
|
||||
stake: leg.bet.stake,
|
||||
status: leg.bet.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of matchIds) {
|
||||
const mid = id.toString();
|
||||
const bets = byMatch.get(mid);
|
||||
if (!bets) {
|
||||
result.set(mid, {
|
||||
betCount: 0,
|
||||
totalStake: '0',
|
||||
pendingCount: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let totalStake = new Decimal(0);
|
||||
let pendingCount = 0;
|
||||
for (const b of bets.values()) {
|
||||
totalStake = totalStake.add(b.stake);
|
||||
if (b.status === 'PENDING') pendingCount += 1;
|
||||
}
|
||||
result.set(mid, {
|
||||
betCount: bets.size,
|
||||
totalStake: totalStake.toString(),
|
||||
pendingCount,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return this.matchBetStats.betStatsForMatches(matchIds);
|
||||
}
|
||||
|
||||
private async upsertTeamByCode(data: {
|
||||
@@ -718,7 +663,6 @@ export class MatchesService {
|
||||
awayTeamMs?: string;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
correctScoreEnabled?: boolean;
|
||||
displayOrder?: number;
|
||||
matchName?: string;
|
||||
stage?: string;
|
||||
@@ -833,7 +777,6 @@ export class MatchesService {
|
||||
awayTeamId: awayTeam.id,
|
||||
startTime: data.startTime,
|
||||
isHot: data.isHot ?? false,
|
||||
correctScoreEnabled: data.correctScoreEnabled ?? true,
|
||||
displayOrder: data.displayOrder ?? 0,
|
||||
createdBy: data.createdBy,
|
||||
status: 'DRAFT',
|
||||
@@ -881,7 +824,6 @@ export class MatchesService {
|
||||
status: match.status,
|
||||
isOutright: match.isOutright,
|
||||
isHot: match.isHot,
|
||||
correctScoreEnabled: match.correctScoreEnabled,
|
||||
displayOrder: match.displayOrder,
|
||||
startTime: match.startTime.toISOString(),
|
||||
leagueId: match.leagueId.toString(),
|
||||
@@ -909,21 +851,36 @@ export class MatchesService {
|
||||
htAway: scoreRow.htAwayScore ?? 0,
|
||||
ftHome: scoreRow.ftHomeScore ?? 0,
|
||||
ftAway: scoreRow.ftAwayScore ?? 0,
|
||||
homeCorners: scoreRow.homeCorners ?? null,
|
||||
awayCorners: scoreRow.awayCorners ?? null,
|
||||
homeYellowCards: scoreRow.homeYellowCards ?? null,
|
||||
awayYellowCards: scoreRow.awayYellowCards ?? null,
|
||||
homeRedCards: scoreRow.homeRedCards ?? null,
|
||||
awayRedCards: scoreRow.awayRedCards ?? null,
|
||||
homeCards: scoreRow.homeCards ?? null,
|
||||
awayCards: scoreRow.awayCards ?? null,
|
||||
winnerTeamId: scoreRow.winnerTeamId?.toString() ?? null,
|
||||
}
|
||||
: null,
|
||||
markets: markets.map((m) => ({
|
||||
id: m.id.toString(),
|
||||
marketType: m.marketType,
|
||||
marketKey: m.marketKey ?? m.marketType,
|
||||
lineKey: m.lineKey ?? null,
|
||||
period: m.period,
|
||||
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
|
||||
paramsJson: m.paramsJson ?? null,
|
||||
status: m.status,
|
||||
showOnPlayer: m.showOnPlayer,
|
||||
promoLabel: m.promoLabel ?? '',
|
||||
promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n),
|
||||
nameI18n: sanitizeLocalizedText(m.nameI18n),
|
||||
sortOrder: m.sortOrder,
|
||||
selections: m.selections.map((s) => ({
|
||||
id: s.id.toString(),
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: sanitizeLocalizedText(s.nameI18n),
|
||||
odds: Number(s.odds),
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder,
|
||||
@@ -949,7 +906,6 @@ export class MatchesService {
|
||||
groupName?: string;
|
||||
homeTeamLogoUrl?: string;
|
||||
awayTeamLogoUrl?: string;
|
||||
correctScoreEnabled?: boolean;
|
||||
updatedBy?: bigint;
|
||||
},
|
||||
) {
|
||||
@@ -1006,7 +962,6 @@ export class MatchesService {
|
||||
matchName,
|
||||
stage: data.stage !== undefined ? data.stage.trim() || null : match.stage,
|
||||
groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName,
|
||||
correctScoreEnabled: data.correctScoreEnabled ?? match.correctScoreEnabled,
|
||||
updatedBy: data.updatedBy,
|
||||
},
|
||||
});
|
||||
@@ -1261,7 +1216,6 @@ export class MatchesService {
|
||||
startTime: Date;
|
||||
status?: string;
|
||||
isHot?: boolean;
|
||||
correctScoreEnabled?: boolean;
|
||||
displayOrder?: number;
|
||||
matchName?: string | null;
|
||||
stage?: string | null;
|
||||
@@ -1270,10 +1224,18 @@ export class MatchesService {
|
||||
awayTeam?: { code: string; logoUrl?: string | null };
|
||||
league?: { logoUrl?: string | null };
|
||||
score?: {
|
||||
htHomeScore: number;
|
||||
htAwayScore: number;
|
||||
ftHomeScore: number;
|
||||
ftAwayScore: number;
|
||||
htHomeScore: number | null;
|
||||
htAwayScore: number | null;
|
||||
ftHomeScore: number | null;
|
||||
ftAwayScore: number | null;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
} | null;
|
||||
markets?: Array<Record<string, unknown>>;
|
||||
};
|
||||
@@ -1295,7 +1257,6 @@ export class MatchesService {
|
||||
awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
|
||||
startTime: m.startTime.toISOString(),
|
||||
isHot: m.isHot ?? false,
|
||||
correctScoreEnabled: m.correctScoreEnabled ?? true,
|
||||
displayOrder: m.displayOrder ?? 0,
|
||||
matchName: m.matchName ?? null,
|
||||
stage: m.stage ?? null,
|
||||
@@ -1303,10 +1264,18 @@ export class MatchesService {
|
||||
status: m.status ?? 'PUBLISHED',
|
||||
score: m.score
|
||||
? {
|
||||
htHome: m.score.htHomeScore,
|
||||
htAway: m.score.htAwayScore,
|
||||
ftHome: m.score.ftHomeScore,
|
||||
ftAway: m.score.ftAwayScore,
|
||||
htHome: m.score.htHomeScore ?? null,
|
||||
htAway: m.score.htAwayScore ?? null,
|
||||
ftHome: m.score.ftHomeScore ?? null,
|
||||
ftAway: m.score.ftAwayScore ?? null,
|
||||
homeCorners: m.score.homeCorners ?? null,
|
||||
awayCorners: m.score.awayCorners ?? null,
|
||||
homeYellowCards: m.score.homeYellowCards ?? null,
|
||||
awayYellowCards: m.score.awayYellowCards ?? null,
|
||||
homeRedCards: m.score.homeRedCards ?? null,
|
||||
awayRedCards: m.score.awayRedCards ?? null,
|
||||
homeCards: m.score.homeCards ?? null,
|
||||
awayCards: m.score.awayCards ?? null,
|
||||
}
|
||||
: null,
|
||||
bettingOpen: this.isMatchBettingOpen({
|
||||
@@ -1327,29 +1296,49 @@ export class MatchesService {
|
||||
}),
|
||||
};
|
||||
if (m.markets && !options?.omitMarkets) {
|
||||
const csEnabled = m.correctScoreEnabled ?? true;
|
||||
const CORRECT_SCORE_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'];
|
||||
return {
|
||||
...base,
|
||||
markets: m.markets
|
||||
.filter((market) => csEnabled || !CORRECT_SCORE_TYPES.includes(market.marketType as string))
|
||||
.filter(
|
||||
(market) =>
|
||||
((market.showOnPlayer as boolean | undefined) ?? true) &&
|
||||
isSettlementSupportedMarketType(market.marketType as string),
|
||||
)
|
||||
.map((market) => ({
|
||||
id: (market.id as bigint).toString(),
|
||||
marketType: market.marketType as string,
|
||||
period: market.period as string,
|
||||
status: (market.status as string) ?? 'OPEN',
|
||||
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
|
||||
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
|
||||
promoLabel: (market.promoLabel as string | null | undefined) ?? null,
|
||||
selections: ((market.selections as Array<Record<string, unknown>>) ?? []).map((s) => ({
|
||||
id: (s.id as bigint).toString(),
|
||||
selectionCode: s.selectionCode as string,
|
||||
selectionName: s.selectionName as string,
|
||||
status: (s.status as string) ?? 'OPEN',
|
||||
odds: Number(s.odds),
|
||||
oddsVersion: (s.oddsVersion as bigint).toString(),
|
||||
id: (market.id as bigint).toString(),
|
||||
marketType: market.marketType as string,
|
||||
marketKey: (market.marketKey as string | null | undefined) ?? (market.marketType as string),
|
||||
lineKey: (market.lineKey as string | null | undefined) ?? null,
|
||||
period: market.period as string,
|
||||
status: (market.status as string) ?? 'OPEN',
|
||||
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
|
||||
allowSingle: (market.allowSingle as boolean | undefined) ?? true,
|
||||
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
|
||||
marketDisplayName:
|
||||
resolveMarketText(market.nameI18n, locale, defaultMarketName(market.marketType as string, locale)) ||
|
||||
(market.marketType as string),
|
||||
promoLabel:
|
||||
resolveMarketText(
|
||||
market.promoLabelI18n,
|
||||
locale,
|
||||
(market.promoLabel as string | null | undefined) ?? '',
|
||||
) || null,
|
||||
selections: ((market.selections as Array<Record<string, unknown>>) ?? []).map((s) => ({
|
||||
id: (s.id as bigint).toString(),
|
||||
selectionCode: s.selectionCode as string,
|
||||
selectionName: s.selectionName as string,
|
||||
selectionDisplayName:
|
||||
resolveMarketText(
|
||||
s.nameI18n,
|
||||
locale,
|
||||
defaultSelectionName(market.marketType as string, s.selectionCode as string, locale) ||
|
||||
(s.selectionName as string),
|
||||
) || (s.selectionName as string),
|
||||
status: (s.status as string) ?? 'OPEN',
|
||||
odds: Number(s.odds),
|
||||
oddsVersion: (s.oddsVersion as bigint).toString(),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return base;
|
||||
@@ -1392,7 +1381,7 @@ export class MatchesService {
|
||||
}
|
||||
|
||||
private playerMarketInclude = {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
|
||||
include: {
|
||||
selections: {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
@@ -1404,7 +1393,7 @@ export class MatchesService {
|
||||
|
||||
/** 仅 status 字段,用于列表页计算 bettingOpen,不返回给客户端 */
|
||||
private playerMarketStatusInclude = {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
|
||||
select: {
|
||||
status: true,
|
||||
selections: { select: { status: true } },
|
||||
@@ -1421,6 +1410,7 @@ export class MatchesService {
|
||||
|
||||
const marketWhere = {
|
||||
status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] },
|
||||
showOnPlayer: true,
|
||||
...(parlayMarketsOnly ? { marketType: { in: [...PLAYER_PARLAY_MARKET_TYPES] } } : {}),
|
||||
};
|
||||
|
||||
@@ -1576,8 +1566,8 @@ export class MatchesService {
|
||||
SH_CORRECT_SCORE: { 'zh-CN': '下半场波胆', 'en-US': '2H Correct Score', 'ms-MY': 'Skor Tepat PB2' },
|
||||
};
|
||||
const entry = labels[marketType];
|
||||
if (!entry) return marketType;
|
||||
return entry[locale] ?? entry['en-US'] ?? marketType;
|
||||
if (!entry) return defaultMarketName(marketType, locale);
|
||||
return entry[locale] ?? entry['en-US'] ?? defaultMarketName(marketType, locale);
|
||||
}
|
||||
|
||||
async enrichBetsForHistory(
|
||||
@@ -1594,6 +1584,7 @@ export class MatchesService {
|
||||
selections: Array<{
|
||||
matchId: bigint | null;
|
||||
marketType: string;
|
||||
marketNameSnapshot?: string | null;
|
||||
selectionNameSnapshot: string;
|
||||
odds: unknown;
|
||||
resultStatus?: string | null;
|
||||
@@ -1662,7 +1653,7 @@ export class MatchesService {
|
||||
const m = mid ? matchMeta.get(mid) : undefined;
|
||||
return {
|
||||
marketType: sel.marketType,
|
||||
marketLabel: this.marketLabelKey(sel.marketType, locale),
|
||||
marketLabel: sel.marketNameSnapshot || this.marketLabelKey(sel.marketType, locale),
|
||||
selectionName: sel.selectionNameSnapshot,
|
||||
odds: sel.odds,
|
||||
resultStatus: sel.resultStatus,
|
||||
|
||||
@@ -92,19 +92,30 @@ export async function syncWc2026OutrightMarket(
|
||||
startTime: new Date('2027-07-01T00:00:00Z'),
|
||||
status: 'PUBLISHED',
|
||||
publishTime: new Date(),
|
||||
isHot: true,
|
||||
isHot: false,
|
||||
displayOrder: 0,
|
||||
},
|
||||
});
|
||||
} else if (match.status === 'DRAFT' || match.status === 'SETTLED' || match.status === 'CLOSED') {
|
||||
match = await prisma.match.update({
|
||||
where: { id: match.id },
|
||||
data: {
|
||||
status: 'PUBLISHED',
|
||||
publishTime: match.publishTime ?? new Date(),
|
||||
closeTime: null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const shouldPublish =
|
||||
match.status === 'DRAFT' || match.status === 'SETTLED' || match.status === 'CLOSED';
|
||||
if (match.isHot || shouldPublish) {
|
||||
const updateData: {
|
||||
isHot: boolean;
|
||||
status?: string;
|
||||
publishTime?: Date;
|
||||
closeTime?: null;
|
||||
} = { isHot: false };
|
||||
if (shouldPublish) {
|
||||
updateData.status = 'PUBLISHED';
|
||||
updateData.publishTime = match.publishTime ?? new Date();
|
||||
updateData.closeTime = null;
|
||||
}
|
||||
match = await prisma.match.update({
|
||||
where: { id: match.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let market = await prisma.market.findFirst({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DepositService } from './deposit.service';
|
||||
import { WalletModule } from '../ledger/wallet.module';
|
||||
import { AgentsModule } from '../agent/agents.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule],
|
||||
imports: [WalletModule, AgentsModule],
|
||||
providers: [DepositService],
|
||||
exports: [DepositService],
|
||||
})
|
||||
|
||||
119
apps/api/src/domains/deposit/deposit.service.spec.ts
Normal file
119
apps/api/src/domains/deposit/deposit.service.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { DepositService } from './deposit.service';
|
||||
import { expectAppError } from '../../testing/prisma-mock';
|
||||
|
||||
describe('DepositService', () => {
|
||||
const tx = {
|
||||
$queryRaw: jest.fn(),
|
||||
depositOrder: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
bet: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
agentProfile: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
...tx,
|
||||
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
const funds = {
|
||||
deposit: jest.fn(),
|
||||
withdraw: jest.fn(),
|
||||
};
|
||||
const credit = {
|
||||
recalculateUsedCredit: jest.fn(),
|
||||
};
|
||||
|
||||
let service: DepositService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new DepositService(prisma as never, funds as never, credit as never);
|
||||
tx.$queryRaw.mockResolvedValue([{ id: 1n }]);
|
||||
tx.depositOrder.findUnique.mockResolvedValue({
|
||||
id: 1n,
|
||||
orderNo: 'DEP-1',
|
||||
playerId: 7n,
|
||||
amount: new Decimal(100),
|
||||
approvedAmount: new Decimal(100),
|
||||
status: 'APPROVED',
|
||||
reviewedAt: new Date(),
|
||||
screenshotUrl: '/uploads/a.png',
|
||||
});
|
||||
tx.bet.findMany.mockResolvedValue([]);
|
||||
tx.user.findFirst.mockResolvedValue(null);
|
||||
tx.agentProfile.findUnique.mockResolvedValue(null);
|
||||
credit.recalculateUsedCredit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('posts approved deposits as player wallet transactions and refreshes parent credit', async () => {
|
||||
tx.depositOrder.findUnique.mockResolvedValue({
|
||||
id: 1n,
|
||||
orderNo: 'DEP-1',
|
||||
playerId: 7n,
|
||||
amount: new Decimal(100),
|
||||
approvedAmount: null,
|
||||
status: 'PENDING',
|
||||
reviewedAt: null,
|
||||
screenshotUrl: '/uploads/a.png',
|
||||
});
|
||||
tx.user.findFirst.mockResolvedValue({ parentId: 3n });
|
||||
tx.agentProfile.findUnique.mockResolvedValue({
|
||||
creditLimit: new Decimal(1000),
|
||||
usedCredit: new Decimal(100),
|
||||
});
|
||||
|
||||
await service.approveDepositOrder(1n, 2n, 100, 'Bank receipt checked');
|
||||
|
||||
expect(tx.depositOrder.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'APPROVED',
|
||||
approvedAmount: new Decimal(100),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(funds.deposit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7n,
|
||||
amount: new Decimal(100),
|
||||
operatorId: 2n,
|
||||
remark: 'Bank receipt checked',
|
||||
referenceId: 'DEP-1',
|
||||
transactionType: 'PLAYER_DEPOSIT',
|
||||
businessKey: 'deposit:DEP-1:approve',
|
||||
tx,
|
||||
}),
|
||||
);
|
||||
expect(credit.recalculateUsedCredit).toHaveBeenCalledTimes(2);
|
||||
expect(credit.recalculateUsedCredit).toHaveBeenNthCalledWith(1, 3n, tx);
|
||||
expect(credit.recalculateUsedCredit).toHaveBeenNthCalledWith(2, 3n, tx);
|
||||
});
|
||||
|
||||
it('blocks approved deposit revoke when bets exist after approval', async () => {
|
||||
tx.bet.findMany.mockResolvedValue([{ id: 99n }]);
|
||||
|
||||
await expect(
|
||||
service.reopenDepositOrderForReview(1n, 2n),
|
||||
).rejects.toMatchObject(expectAppError('DEPOSIT_REVOKE_SETTLED_BETS'));
|
||||
|
||||
expect(funds.withdraw).not.toHaveBeenCalled();
|
||||
expect(tx.depositOrder.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not delete a funded deposit order', async () => {
|
||||
await expect(
|
||||
service.deleteDepositOrder(1n, 2n),
|
||||
).rejects.toMatchObject(expectAppError('DEPOSIT_ORDER_FUNDED_DELETE_FORBIDDEN'));
|
||||
|
||||
expect(tx.depositOrder.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@ import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { FundsPostingService } from '../ledger/funds-posting.service';
|
||||
import { AgentCreditService } from '../agent/agent-credit.service';
|
||||
import { appBadRequest } from '../../shared/common/app-error';
|
||||
import { deleteUploadFileByUrl } from '../../shared/uploads/delete-upload-file';
|
||||
|
||||
@@ -20,7 +21,8 @@ const DEPOSIT_REVOKE_WINDOW_MS = 5 * 60 * 1000;
|
||||
export class DepositService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private funds: FundsPostingService,
|
||||
private credit: AgentCreditService,
|
||||
) {}
|
||||
|
||||
// ============ Payment Methods (Admin CRUD) ============
|
||||
@@ -413,6 +415,7 @@ export class DepositService {
|
||||
if (order.status !== 'PENDING') throw appBadRequest('ORDER_NOT_PENDING');
|
||||
|
||||
const creditAmount = approvedAmount != null ? new Decimal(approvedAmount) : order.amount;
|
||||
const parentAgentId = await this.assertParentCreditForDeposit(tx, order.playerId, creditAmount);
|
||||
|
||||
await tx.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
@@ -426,14 +429,19 @@ export class DepositService {
|
||||
});
|
||||
|
||||
// Credit player wallet
|
||||
await this.wallet.deposit(
|
||||
order.playerId,
|
||||
creditAmount,
|
||||
await this.funds.deposit({
|
||||
userId: order.playerId,
|
||||
amount: creditAmount,
|
||||
operatorId,
|
||||
remark ?? `Deposit order ${order.orderNo}`,
|
||||
order.orderNo,
|
||||
'PLAYER_DEPOSIT',
|
||||
);
|
||||
remark: remark ?? `Deposit order ${order.orderNo}`,
|
||||
referenceId: order.orderNo,
|
||||
transactionType: 'PLAYER_DEPOSIT',
|
||||
tx,
|
||||
businessKey: `deposit:${order.orderNo}:approve`,
|
||||
});
|
||||
if (parentAgentId) {
|
||||
await this.credit.recalculateUsedCredit(parentAgentId, tx);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
@@ -469,86 +477,76 @@ export class DepositService {
|
||||
remark: string,
|
||||
) {
|
||||
const credit = order.approvedAmount ?? order.amount;
|
||||
await this.wallet.withdraw(
|
||||
order.playerId,
|
||||
credit,
|
||||
await this.funds.withdraw({
|
||||
userId: order.playerId,
|
||||
amount: credit,
|
||||
operatorId,
|
||||
remark,
|
||||
order.orderNo,
|
||||
'PLAYER_DEPOSIT_REVERSAL',
|
||||
);
|
||||
referenceId: order.orderNo,
|
||||
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
|
||||
businessKey: `deposit:${order.orderNo}:reverse`,
|
||||
});
|
||||
}
|
||||
|
||||
/** 已拒绝:恢复待审核;已通过(5 分钟内):作废期间待结算注单并扣回入账 */
|
||||
/** 已拒绝:恢复待审核;已通过(5 分钟内且未产生下注):扣回入账并恢复待审核 */
|
||||
async reopenDepositOrderForReview(orderId: bigint, operatorId: bigint) {
|
||||
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING');
|
||||
|
||||
if (order.status === 'REJECTED') {
|
||||
await this.prisma.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
approvedAmount: null,
|
||||
reviewerId: null,
|
||||
reviewedAt: null,
|
||||
rejectReason: null,
|
||||
remark: null,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (order.status !== 'APPROVED') {
|
||||
throw appBadRequest('ORDER_NOT_APPROVED');
|
||||
}
|
||||
|
||||
if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) {
|
||||
throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED');
|
||||
}
|
||||
|
||||
const reviewedAt = order.reviewedAt;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const order = await this.lockDepositOrder(tx, orderId);
|
||||
if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING');
|
||||
|
||||
if (order.status === 'REJECTED') {
|
||||
await tx.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
approvedAmount: null,
|
||||
reviewerId: null,
|
||||
reviewedAt: null,
|
||||
rejectReason: null,
|
||||
remark: null,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (order.status !== 'APPROVED') {
|
||||
throw appBadRequest('ORDER_NOT_APPROVED');
|
||||
}
|
||||
|
||||
if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) {
|
||||
throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED');
|
||||
}
|
||||
|
||||
await this.lockPlayerWallet(tx, order.playerId);
|
||||
const reviewedAt = order.reviewedAt;
|
||||
const betsAfterReview = await tx.bet.findMany({
|
||||
where: {
|
||||
userId: order.playerId,
|
||||
placedAt: { gte: reviewedAt },
|
||||
status: { not: 'VOID' },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const settled = betsAfterReview.filter((b) => b.status !== 'PENDING');
|
||||
if (settled.length > 0) {
|
||||
if (betsAfterReview.length > 0) {
|
||||
throw appBadRequest('DEPOSIT_REVOKE_SETTLED_BETS');
|
||||
}
|
||||
|
||||
for (const bet of betsAfterReview) {
|
||||
await this.wallet.settleBet(
|
||||
bet.userId,
|
||||
bet.stake,
|
||||
bet.stake,
|
||||
bet.betNo,
|
||||
'VOID',
|
||||
tx,
|
||||
);
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
const credit = order.approvedAmount ?? order.amount;
|
||||
await this.wallet.withdraw(
|
||||
order.playerId,
|
||||
credit,
|
||||
const parentAgentId = await this.findPlayerParentAgentId(tx, order.playerId);
|
||||
await this.funds.withdraw({
|
||||
userId: order.playerId,
|
||||
amount: credit,
|
||||
operatorId,
|
||||
`Revoke approved deposit ${order.orderNo}`,
|
||||
order.orderNo,
|
||||
'PLAYER_DEPOSIT_REVERSAL',
|
||||
remark: `Revoke approved deposit ${order.orderNo}`,
|
||||
referenceId: order.orderNo,
|
||||
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
|
||||
tx,
|
||||
);
|
||||
businessKey: `deposit:${order.orderNo}:reopen-reverse`,
|
||||
});
|
||||
if (parentAgentId) {
|
||||
await this.credit.recalculateUsedCredit(parentAgentId, tx);
|
||||
}
|
||||
|
||||
await tx.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
@@ -562,14 +560,17 @@ export class DepositService {
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, voidedBets: betsAfterReview.length };
|
||||
return { success: true, voidedBets: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除充值订单记录及截图(不调整玩家钱包或注单,与撤销无关) */
|
||||
/** 删除充值订单记录及截图,仅允许删除未资金化订单。 */
|
||||
async deleteDepositOrder(orderId: bigint, _operatorId: bigint) {
|
||||
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
if (order.status === 'APPROVED') {
|
||||
throw appBadRequest('DEPOSIT_ORDER_FUNDED_DELETE_FORBIDDEN');
|
||||
}
|
||||
|
||||
const screenshotUrl = order.screenshotUrl;
|
||||
await this.prisma.depositOrder.delete({ where: { id: orderId } });
|
||||
@@ -577,4 +578,51 @@ export class DepositService {
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async lockDepositOrder(tx: Prisma.TransactionClient, orderId: bigint) {
|
||||
const rows = await tx.$queryRaw<Array<{ id: bigint }>>`
|
||||
SELECT id FROM deposit_orders WHERE id = ${orderId} FOR UPDATE
|
||||
`;
|
||||
if (!rows.length) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
const order = await tx.depositOrder.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
return order;
|
||||
}
|
||||
|
||||
private async lockPlayerWallet(tx: Prisma.TransactionClient, playerId: bigint) {
|
||||
const rows = await tx.$queryRaw<Array<{ id: bigint }>>`
|
||||
SELECT id FROM wallets WHERE user_id = ${playerId} FOR UPDATE
|
||||
`;
|
||||
if (!rows.length) throw appBadRequest('WALLET_NOT_FOUND');
|
||||
}
|
||||
|
||||
private async findPlayerParentAgentId(tx: Prisma.TransactionClient, playerId: bigint) {
|
||||
const player = await tx.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
select: { parentId: true },
|
||||
});
|
||||
return player?.parentId ?? null;
|
||||
}
|
||||
|
||||
private async assertParentCreditForDeposit(
|
||||
tx: Prisma.TransactionClient,
|
||||
playerId: bigint,
|
||||
amount: Decimal,
|
||||
) {
|
||||
const parentAgentId = await this.findPlayerParentAgentId(tx, playerId);
|
||||
if (!parentAgentId) return null;
|
||||
|
||||
await this.credit.recalculateUsedCredit(parentAgentId, tx);
|
||||
const profile = await tx.agentProfile.findUnique({
|
||||
where: { userId: parentAgentId },
|
||||
select: { creditLimit: true, usedCredit: true },
|
||||
});
|
||||
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
|
||||
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
if (available.lt(amount)) {
|
||||
throw appBadRequest('CREDIT_TOPUP_EXCEEDED');
|
||||
}
|
||||
return parentAgentId;
|
||||
}
|
||||
}
|
||||
|
||||
78
apps/api/src/domains/ledger/funds-posting.service.spec.ts
Normal file
78
apps/api/src/domains/ledger/funds-posting.service.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { FundsPostingService } from './funds-posting.service';
|
||||
|
||||
describe('FundsPostingService', () => {
|
||||
const wallet = {
|
||||
deposit: jest.fn(),
|
||||
freezeForBet: jest.fn(),
|
||||
settleBet: jest.fn(),
|
||||
applyResettleDelta: jest.fn(),
|
||||
};
|
||||
let service: FundsPostingService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new FundsPostingService(wallet as never);
|
||||
});
|
||||
|
||||
it('freezes bet funds with a stable business key', async () => {
|
||||
await service.freezeBet({
|
||||
userId: 1n,
|
||||
stake: new Decimal(50),
|
||||
betNo: 'BET-001',
|
||||
tx: {} as never,
|
||||
});
|
||||
|
||||
expect(wallet.freezeForBet).toHaveBeenCalledWith(
|
||||
1n,
|
||||
expect.any(Decimal),
|
||||
'BET-001',
|
||||
expect.anything(),
|
||||
'bet:BET-001:freeze',
|
||||
);
|
||||
});
|
||||
|
||||
it('settles bets with batch and bet identity in the business key', async () => {
|
||||
await service.settleBet({
|
||||
userId: 2n,
|
||||
stake: new Decimal(100),
|
||||
payout: new Decimal(180),
|
||||
betNo: 'BET-002',
|
||||
batchNo: 'SETTLE-001',
|
||||
result: 'WIN',
|
||||
tx: {} as never,
|
||||
});
|
||||
|
||||
expect(wallet.settleBet).toHaveBeenCalledWith(
|
||||
2n,
|
||||
expect.any(Decimal),
|
||||
expect.any(Decimal),
|
||||
'BET-002',
|
||||
'WIN',
|
||||
expect.anything(),
|
||||
'settle:SETTLE-001:BET-002',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes caller-provided business keys for transfer postings', async () => {
|
||||
await service.deposit({
|
||||
userId: 3n,
|
||||
amount: 25,
|
||||
operatorId: 9n,
|
||||
referenceId: 'REQ-1',
|
||||
transactionType: 'ADMIN_DEPOSIT',
|
||||
businessKey: 'admin-deposit:9:REQ-1',
|
||||
});
|
||||
|
||||
expect(wallet.deposit).toHaveBeenCalledWith(
|
||||
3n,
|
||||
25,
|
||||
9n,
|
||||
undefined,
|
||||
'REQ-1',
|
||||
'ADMIN_DEPOSIT',
|
||||
undefined,
|
||||
'admin-deposit:9:REQ-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
124
apps/api/src/domains/ledger/funds-posting.service.ts
Normal file
124
apps/api/src/domains/ledger/funds-posting.service.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { WalletService } from './wallet.service';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
|
||||
@Injectable()
|
||||
export class FundsPostingService {
|
||||
constructor(private wallet: WalletService) {}
|
||||
|
||||
deposit(command: {
|
||||
userId: bigint;
|
||||
amount: Decimal | number;
|
||||
operatorId: bigint;
|
||||
remark?: string;
|
||||
referenceId?: string;
|
||||
transactionType?: string;
|
||||
businessKey?: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.deposit(
|
||||
command.userId,
|
||||
command.amount,
|
||||
command.operatorId,
|
||||
command.remark,
|
||||
command.referenceId,
|
||||
command.transactionType ?? 'MANUAL_DEPOSIT',
|
||||
command.tx,
|
||||
command.businessKey,
|
||||
);
|
||||
}
|
||||
|
||||
withdraw(command: {
|
||||
userId: bigint;
|
||||
amount: Decimal | number;
|
||||
operatorId: bigint;
|
||||
remark?: string;
|
||||
referenceId?: string;
|
||||
transactionType?: string;
|
||||
businessKey?: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.withdraw(
|
||||
command.userId,
|
||||
command.amount,
|
||||
command.operatorId,
|
||||
command.remark,
|
||||
command.referenceId,
|
||||
command.transactionType ?? 'MANUAL_WITHDRAW',
|
||||
command.tx,
|
||||
command.businessKey,
|
||||
);
|
||||
}
|
||||
|
||||
freezeBet(command: {
|
||||
userId: bigint;
|
||||
stake: Decimal | number;
|
||||
betNo: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.freezeForBet(
|
||||
command.userId,
|
||||
command.stake,
|
||||
command.betNo,
|
||||
command.tx,
|
||||
`bet:${command.betNo}:freeze`,
|
||||
);
|
||||
}
|
||||
|
||||
settleBet(command: {
|
||||
userId: bigint;
|
||||
stake: Decimal;
|
||||
payout: Decimal;
|
||||
betNo: string;
|
||||
batchNo: string;
|
||||
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE';
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.settleBet(
|
||||
command.userId,
|
||||
command.stake,
|
||||
command.payout,
|
||||
command.betNo,
|
||||
command.result,
|
||||
command.tx,
|
||||
`settle:${command.batchNo}:${command.betNo}`,
|
||||
);
|
||||
}
|
||||
|
||||
voidBet(command: {
|
||||
userId: bigint;
|
||||
stake: Decimal;
|
||||
betNo: string;
|
||||
businessKey: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.settleBet(
|
||||
command.userId,
|
||||
command.stake,
|
||||
command.stake,
|
||||
command.betNo,
|
||||
'VOID',
|
||||
command.tx,
|
||||
command.businessKey,
|
||||
);
|
||||
}
|
||||
|
||||
applyResettleDelta(command: {
|
||||
userId: bigint;
|
||||
delta: Decimal;
|
||||
betNo: string;
|
||||
batchNo: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.applyResettleDelta(
|
||||
command.userId,
|
||||
command.delta,
|
||||
command.betNo,
|
||||
command.tx,
|
||||
`resettle:${command.batchNo}:${command.betNo}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WalletService } from './wallet.service';
|
||||
import { FundsPostingService } from './funds-posting.service';
|
||||
|
||||
@Module({
|
||||
providers: [WalletService],
|
||||
exports: [WalletService],
|
||||
providers: [WalletService, FundsPostingService],
|
||||
exports: [WalletService, FundsPostingService],
|
||||
})
|
||||
export class WalletModule {}
|
||||
|
||||
75
apps/api/src/domains/ledger/wallet.service.spec.ts
Normal file
75
apps/api/src/domains/ledger/wallet.service.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { WalletService } from './wallet.service';
|
||||
import { expectAppError } from '../../testing/prisma-mock';
|
||||
|
||||
describe('WalletService', () => {
|
||||
const tx = {
|
||||
$queryRaw: jest.fn(),
|
||||
wallet: {
|
||||
update: jest.fn(),
|
||||
},
|
||||
walletTransaction: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
|
||||
let service: WalletService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new WalletService(prisma as never);
|
||||
tx.walletTransaction.findUnique.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('rejects settlement when frozen funds are lower than the stake', async () => {
|
||||
tx.$queryRaw.mockResolvedValue([
|
||||
{
|
||||
id: 1n,
|
||||
available_balance: new Decimal(10),
|
||||
frozen_balance: new Decimal(40),
|
||||
version: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.settleBet(
|
||||
99n,
|
||||
new Decimal(50),
|
||||
new Decimal(0),
|
||||
'BET-LOW-FROZEN',
|
||||
'LOSE',
|
||||
undefined,
|
||||
'settle:BATCH:BET-LOW-FROZEN',
|
||||
),
|
||||
).rejects.toMatchObject(expectAppError('WALLET_FROZEN_INSUFFICIENT'));
|
||||
|
||||
expect(tx.wallet.update).not.toHaveBeenCalled();
|
||||
expect(tx.walletTransaction.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists player-approved deposits in transfer transactions', async () => {
|
||||
const listPrisma = {
|
||||
walletTransaction: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
};
|
||||
const listService = new WalletService(listPrisma as never);
|
||||
|
||||
await listService.listTransferTransactions({});
|
||||
|
||||
expect(listPrisma.walletTransaction.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
transactionType: expect.objectContaining({
|
||||
in: expect.arrayContaining(['PLAYER_DEPOSIT']),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,11 @@ import { generateTransactionId } from '../../shared/common/decorators';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
|
||||
type WalletPostingOptions = {
|
||||
tx?: TxClient;
|
||||
businessKey?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WalletService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -31,6 +36,11 @@ export class WalletService {
|
||||
return wallets[0];
|
||||
}
|
||||
|
||||
private async findPosting(client: TxClient, businessKey?: string | null) {
|
||||
if (!businessKey) return null;
|
||||
return client.walletTransaction.findUnique({ where: { businessKey } });
|
||||
}
|
||||
|
||||
async deposit(
|
||||
userId: bigint,
|
||||
amount: Decimal | number,
|
||||
@@ -38,16 +48,24 @@ export class WalletService {
|
||||
remark?: string,
|
||||
referenceId?: string,
|
||||
transactionType = 'MANUAL_DEPOSIT',
|
||||
tx?: TxClient,
|
||||
businessKey?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const w = await this.lockWallet(tx, userId);
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return { balanceAfter: existing.balanceAfter };
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return { balanceAfter: afterLockExisting.balanceAfter };
|
||||
|
||||
const balanceBefore = new Decimal(w.available_balance);
|
||||
const balanceAfter = balanceBefore.add(amt);
|
||||
|
||||
await tx.wallet.update({
|
||||
await client.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
@@ -55,7 +73,7 @@ export class WalletService {
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
await client.walletTransaction.create({
|
||||
data: {
|
||||
transactionId: generateTransactionId(),
|
||||
userId,
|
||||
@@ -68,13 +86,17 @@ export class WalletService {
|
||||
frozenAfter: w.frozen_balance,
|
||||
referenceType: 'DEPOSIT',
|
||||
referenceId,
|
||||
businessKey,
|
||||
operatorId,
|
||||
remark,
|
||||
},
|
||||
});
|
||||
|
||||
return { balanceAfter };
|
||||
});
|
||||
};
|
||||
|
||||
if (tx) return run(tx);
|
||||
return this.prisma.$transaction(run);
|
||||
}
|
||||
|
||||
async withdraw(
|
||||
@@ -85,12 +107,19 @@ export class WalletService {
|
||||
referenceId?: string,
|
||||
transactionType = 'MANUAL_WITHDRAW',
|
||||
tx?: TxClient,
|
||||
businessKey?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
|
||||
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return { balanceAfter: existing.balanceAfter };
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return { balanceAfter: afterLockExisting.balanceAfter };
|
||||
|
||||
const balanceBefore = new Decimal(w.available_balance);
|
||||
if (balanceBefore.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE');
|
||||
const balanceAfter = balanceBefore.sub(amt);
|
||||
@@ -116,6 +145,7 @@ export class WalletService {
|
||||
frozenAfter: w.frozen_balance,
|
||||
referenceType: 'WITHDRAW',
|
||||
referenceId,
|
||||
businessKey,
|
||||
operatorId,
|
||||
remark,
|
||||
},
|
||||
@@ -128,18 +158,30 @@ export class WalletService {
|
||||
return this.prisma.$transaction(run);
|
||||
}
|
||||
|
||||
async freezeForBet(userId: bigint, stake: Decimal | number, betId: string) {
|
||||
async freezeForBet(
|
||||
userId: bigint,
|
||||
stake: Decimal | number,
|
||||
betId: string,
|
||||
tx?: TxClient,
|
||||
businessKey = `bet:${betId}:freeze`,
|
||||
) {
|
||||
const amt = new Decimal(stake);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const w = await this.lockWallet(tx, userId);
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return;
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return;
|
||||
|
||||
const avail = new Decimal(w.available_balance);
|
||||
if (avail.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE');
|
||||
|
||||
const balanceAfter = avail.sub(amt);
|
||||
const frozenAfter = new Decimal(w.frozen_balance).add(amt);
|
||||
|
||||
await tx.wallet.update({
|
||||
await client.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
@@ -148,7 +190,7 @@ export class WalletService {
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
await client.walletTransaction.create({
|
||||
data: {
|
||||
transactionId: generateTransactionId(),
|
||||
userId,
|
||||
@@ -161,9 +203,13 @@ export class WalletService {
|
||||
frozenAfter,
|
||||
referenceType: 'BET',
|
||||
referenceId: betId,
|
||||
businessKey,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (tx) return run(tx);
|
||||
return this.prisma.$transaction(run);
|
||||
}
|
||||
|
||||
async settleBet(
|
||||
@@ -173,8 +219,12 @@ export class WalletService {
|
||||
betId: string,
|
||||
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
|
||||
tx?: TxClient,
|
||||
businessKey?: string,
|
||||
) {
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return;
|
||||
|
||||
const txTypeMap: Record<string, string> = {
|
||||
WIN: 'BET_SETTLE_WIN',
|
||||
LOSE: 'BET_SETTLE_LOSE',
|
||||
@@ -185,16 +235,22 @@ export class WalletService {
|
||||
};
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return;
|
||||
|
||||
const avail = new Decimal(w.available_balance);
|
||||
const frozen = new Decimal(w.frozen_balance);
|
||||
const frozenAfter = frozen.sub(stake);
|
||||
if (frozenAfter.lt(0)) {
|
||||
throw appBadRequest('WALLET_FROZEN_INSUFFICIENT');
|
||||
}
|
||||
const balanceAfter = avail.add(payout);
|
||||
|
||||
await client.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
frozenBalance: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
|
||||
frozenBalance: frozenAfter,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
@@ -209,9 +265,10 @@ export class WalletService {
|
||||
balanceBefore: avail,
|
||||
balanceAfter,
|
||||
frozenBefore: frozen,
|
||||
frozenAfter: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
|
||||
frozenAfter,
|
||||
referenceType: 'BET',
|
||||
referenceId: betId,
|
||||
businessKey,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -226,11 +283,18 @@ export class WalletService {
|
||||
delta: Decimal,
|
||||
betNo: string,
|
||||
tx?: TxClient,
|
||||
businessKey?: string,
|
||||
) {
|
||||
if (delta.eq(0)) return;
|
||||
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return;
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return;
|
||||
|
||||
const avail = new Decimal(w.available_balance);
|
||||
const balanceAfter = avail.add(delta);
|
||||
const txType = delta.gt(0) ? 'BET_SETTLE_WIN' : 'RESETTLE_REVERSE';
|
||||
@@ -256,6 +320,7 @@ export class WalletService {
|
||||
frozenAfter: w.frozen_balance,
|
||||
referenceType: 'BET',
|
||||
referenceId: betNo,
|
||||
businessKey,
|
||||
remark: 'Resettlement adjustment',
|
||||
},
|
||||
});
|
||||
@@ -817,6 +882,7 @@ export class WalletService {
|
||||
'AGENT_DEPOSIT',
|
||||
'AGENT_WITHDRAW',
|
||||
'INITIAL_DEPOSIT',
|
||||
'PLAYER_DEPOSIT',
|
||||
];
|
||||
const where: Prisma.WalletTransactionWhereInput = {
|
||||
transactionType: params.transactionType?.trim()
|
||||
|
||||
137
apps/api/src/domains/odds/markets.service.spec.ts
Normal file
137
apps/api/src/domains/odds/markets.service.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { MarketsService } from './markets.service';
|
||||
|
||||
function selection(selectionCode: string, odds = 1.9) {
|
||||
return {
|
||||
selectionCode,
|
||||
selectionName: selectionCode,
|
||||
odds,
|
||||
status: 'OPEN',
|
||||
};
|
||||
}
|
||||
|
||||
function errorCode(error: unknown) {
|
||||
const response = (error as { getResponse?: () => unknown }).getResponse?.();
|
||||
return (response as { code?: string } | undefined)?.code;
|
||||
}
|
||||
|
||||
function createPrismaMock() {
|
||||
let marketId = 100n;
|
||||
let selectionId = 1000n;
|
||||
return {
|
||||
match: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 1n }),
|
||||
},
|
||||
market: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(async ({ data }) => ({ id: marketId++, ...data })),
|
||||
update: jest.fn().mockImplementation(async ({ where, data }) => ({ id: where.id, ...data })),
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => ({ id: where.id, selections: [] })),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
marketSelection: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn().mockImplementation(async ({ data }) => ({ id: selectionId++, ...data })),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
oddsChangeLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('MarketsService line value rules', () => {
|
||||
it('exposes usesLineValue in market definitions', () => {
|
||||
const service = new MarketsService(createPrismaMock() as never);
|
||||
|
||||
const definitions = service.listMarketDefinitions();
|
||||
|
||||
expect(definitions.find((d) => d.marketType === 'FT_HANDICAP')?.usesLineValue).toBe(true);
|
||||
expect(definitions.find((d) => d.marketType === 'FT_1X2')?.usesLineValue).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects line markets without a numeric line value', async () => {
|
||||
const service = new MarketsService(createPrismaMock() as never);
|
||||
|
||||
let caughtCode: string | undefined;
|
||||
try {
|
||||
await service.saveMatchMarkets(1n, [
|
||||
{
|
||||
marketType: 'FT_HANDICAP',
|
||||
lineValue: null,
|
||||
selections: [selection('HOME'), selection('AWAY')],
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
caughtCode = errorCode(error);
|
||||
}
|
||||
expect(caughtCode).toBe('MARKET_LINE_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects non-line markets with a line value', async () => {
|
||||
const service = new MarketsService(createPrismaMock() as never);
|
||||
|
||||
let caughtCode: string | undefined;
|
||||
try {
|
||||
await service.saveMatchMarkets(1n, [
|
||||
{
|
||||
marketType: 'FT_1X2',
|
||||
lineValue: 1,
|
||||
selections: [selection('HOME'), selection('DRAW'), selection('AWAY')],
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
caughtCode = errorCode(error);
|
||||
}
|
||||
expect(caughtCode).toBe('MARKET_LINE_NOT_ALLOWED');
|
||||
});
|
||||
|
||||
it('allows non-line markets with null line value', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new MarketsService(prisma as never);
|
||||
|
||||
const result = await service.saveMatchMarkets(1n, [
|
||||
{
|
||||
marketType: 'FT_1X2',
|
||||
lineValue: null,
|
||||
selections: [selection('HOME'), selection('DRAW'), selection('AWAY')],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ updated: 1, closed: 0 });
|
||||
expect(prisma.market.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
marketType: 'FT_1X2',
|
||||
lineKey: 'FT_1X2:none',
|
||||
lineValue: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps distinct line keys for copied line markets', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new MarketsService(prisma as never);
|
||||
|
||||
const result = await service.saveMatchMarkets(1n, [
|
||||
{
|
||||
marketType: 'FT_HANDICAP',
|
||||
lineValue: -0.5,
|
||||
selections: [selection('HOME'), selection('AWAY')],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_HANDICAP',
|
||||
lineValue: -0.25,
|
||||
selections: [selection('HOME'), selection('AWAY')],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ updated: 2, closed: 0 });
|
||||
expect(prisma.market.create.mock.calls.map(([arg]) => arg.data.lineKey)).toEqual([
|
||||
'FT_HANDICAP:-0.50',
|
||||
'FT_HANDICAP:-0.25',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,189 +1,291 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import {
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
} from '../settlement/domain/settlement-calculator';
|
||||
DEFAULT_MARKET_TYPES,
|
||||
FOOTBALL_MARKET_CATALOG,
|
||||
buildMarketLineKey,
|
||||
buildMarketTemplate,
|
||||
defaultSelectionName,
|
||||
isSettlementSupportedMarketType,
|
||||
marketUsesLineValue,
|
||||
resolveMarketText,
|
||||
sanitizeLocalizedText,
|
||||
type LocalizedText,
|
||||
} from '@thebet365/shared';
|
||||
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||||
|
||||
type LocalizedInput = Record<string, string | undefined | null>;
|
||||
|
||||
type SelectionDraft = {
|
||||
id?: string;
|
||||
selectionCode: string;
|
||||
selectionName?: string;
|
||||
nameI18n?: LocalizedInput | null;
|
||||
odds: number;
|
||||
status?: string;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
type MarketDraft = {
|
||||
id?: string;
|
||||
marketType: string;
|
||||
marketKey?: string | null;
|
||||
lineKey?: string | null;
|
||||
period?: string;
|
||||
lineValue?: number | null;
|
||||
paramsJson?: Record<string, unknown> | null;
|
||||
status?: string;
|
||||
allowSingle?: boolean;
|
||||
allowParlay?: boolean;
|
||||
showOnPlayer?: boolean;
|
||||
sortOrder?: number;
|
||||
promoLabel?: string | null;
|
||||
promoLabelI18n?: LocalizedInput | null;
|
||||
nameI18n?: LocalizedInput | null;
|
||||
selections?: SelectionDraft[];
|
||||
};
|
||||
|
||||
type TemplateDraft = {
|
||||
name?: string;
|
||||
nameI18n?: LocalizedInput | null;
|
||||
description?: string | null;
|
||||
isDefault?: boolean;
|
||||
status?: string;
|
||||
sortOrder?: number;
|
||||
items?: MarketDraft[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MarketsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async generateTemplates(matchId: bigint, marketTypes: string[]) {
|
||||
listMarketDefinitions() {
|
||||
return Object.entries(FOOTBALL_MARKET_CATALOG).map(([marketType, entry]) => ({
|
||||
marketType,
|
||||
marketKey: entry.marketKey,
|
||||
period: entry.period,
|
||||
sortOrder: entry.sortOrder,
|
||||
defaultLineValue: entry.defaultLineValue,
|
||||
allowSingle: entry.allowSingle,
|
||||
allowParlay: entry.allowParlay,
|
||||
showOnPlayer: entry.showOnPlayer,
|
||||
settlementSupported: entry.settlementSupported,
|
||||
settlementKind: entry.settlementKind,
|
||||
renderType: entry.renderType,
|
||||
usesLineValue: marketUsesLineValue(marketType),
|
||||
nameI18n: entry.nameI18n,
|
||||
selectionTemplate: entry.selectionTemplate,
|
||||
}));
|
||||
}
|
||||
|
||||
async listTemplates() {
|
||||
await this.ensureDefaultTemplate();
|
||||
const templates = await this.prisma.marketTemplate.findMany({
|
||||
where: { sportType: 'FOOTBALL' },
|
||||
include: { items: { include: { selections: true } } },
|
||||
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return templates.map((t) => this.mapTemplate(t));
|
||||
}
|
||||
|
||||
async getTemplate(templateId: bigint) {
|
||||
await this.ensureDefaultTemplate();
|
||||
const template = await this.prisma.marketTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
include: {
|
||||
items: {
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!template) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
|
||||
return this.mapTemplate(template);
|
||||
}
|
||||
|
||||
async createTemplate(data: TemplateDraft) {
|
||||
const nameI18n = sanitizeLocalizedText(data.nameI18n);
|
||||
const name = data.name?.trim() || resolveMarketText(nameI18n, 'zh-CN', '足球盘口模板');
|
||||
const template = await this.prisma.marketTemplate.create({
|
||||
data: {
|
||||
sportType: 'FOOTBALL',
|
||||
name,
|
||||
nameI18n: this.jsonOrNull(nameI18n),
|
||||
description: data.description?.trim() || null,
|
||||
isDefault: data.isDefault ?? false,
|
||||
status: data.status ?? 'ACTIVE',
|
||||
sortOrder: data.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
if (data.items?.length) {
|
||||
await this.replaceTemplateItems(template.id, data.items);
|
||||
}
|
||||
if (data.isDefault) await this.setDefaultTemplate(template.id);
|
||||
return this.getTemplate(template.id);
|
||||
}
|
||||
|
||||
async updateTemplate(templateId: bigint, data: TemplateDraft) {
|
||||
const existing = await this.prisma.marketTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!existing) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
|
||||
const nameI18n =
|
||||
data.nameI18n !== undefined
|
||||
? sanitizeLocalizedText(data.nameI18n)
|
||||
: this.asLocalizedText(existing.nameI18n);
|
||||
await this.prisma.marketTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
...(data.name !== undefined
|
||||
? { name: data.name.trim() || resolveMarketText(nameI18n, 'zh-CN', existing.name) }
|
||||
: {}),
|
||||
...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(nameI18n) } : {}),
|
||||
...(data.description !== undefined ? { description: data.description?.trim() || null } : {}),
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
...(data.sortOrder !== undefined ? { sortOrder: data.sortOrder } : {}),
|
||||
...(data.isDefault !== undefined ? { isDefault: data.isDefault } : {}),
|
||||
},
|
||||
});
|
||||
if (data.items) await this.replaceTemplateItems(templateId, data.items);
|
||||
if (data.isDefault) await this.setDefaultTemplate(templateId);
|
||||
return this.getTemplate(templateId);
|
||||
}
|
||||
|
||||
async duplicateTemplate(templateId: bigint) {
|
||||
const source = await this.prisma.marketTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
if (!source) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
|
||||
const copy = await this.prisma.marketTemplate.create({
|
||||
data: {
|
||||
sportType: source.sportType,
|
||||
name: `${source.name} Copy`,
|
||||
nameI18n: source.nameI18n ?? undefined,
|
||||
description: source.description,
|
||||
isDefault: false,
|
||||
status: source.status,
|
||||
sortOrder: source.sortOrder + 1,
|
||||
},
|
||||
});
|
||||
await this.replaceTemplateItems(
|
||||
copy.id,
|
||||
source.items.map((item) => ({
|
||||
marketType: item.marketType,
|
||||
marketKey: item.marketKey,
|
||||
lineKey: item.lineKey,
|
||||
period: item.period,
|
||||
lineValue: item.lineValue == null ? null : Number(item.lineValue),
|
||||
paramsJson: (item.paramsJson as Record<string, unknown> | null) ?? null,
|
||||
status: item.status,
|
||||
allowSingle: item.allowSingle,
|
||||
allowParlay: item.allowParlay,
|
||||
showOnPlayer: item.showOnPlayer,
|
||||
sortOrder: item.sortOrder,
|
||||
promoLabel: item.promoLabel,
|
||||
promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
|
||||
nameI18n: this.asLocalizedText(item.nameI18n),
|
||||
selections: item.selections.map((s) => ({
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: this.asLocalizedText(s.nameI18n),
|
||||
odds: Number(s.odds),
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
return this.getTemplate(copy.id);
|
||||
}
|
||||
|
||||
async setDefaultTemplate(templateId: bigint) {
|
||||
const existing = await this.prisma.marketTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!existing) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.marketTemplate.updateMany({
|
||||
where: { sportType: 'FOOTBALL', id: { not: templateId } },
|
||||
data: { isDefault: false },
|
||||
}),
|
||||
this.prisma.marketTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: { isDefault: true, status: 'ACTIVE' },
|
||||
}),
|
||||
]);
|
||||
return this.getTemplate(templateId);
|
||||
}
|
||||
|
||||
async applyTemplateToMatch(matchId: bigint, templateId?: bigint | null) {
|
||||
const match = await this.prisma.match.findUnique({ where: { id: matchId } });
|
||||
if (!match) throw appNotFound('MATCH_NOT_FOUND');
|
||||
const template = templateId
|
||||
? await this.prisma.marketTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
|
||||
})
|
||||
: await this.getDefaultTemplateRecord();
|
||||
if (!template) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
|
||||
|
||||
const created = [];
|
||||
const results = [];
|
||||
for (const item of template.items) {
|
||||
if (item.status === 'DELETED') continue;
|
||||
const draft: MarketDraft = {
|
||||
marketType: item.marketType,
|
||||
marketKey: item.marketKey,
|
||||
lineKey: item.lineKey,
|
||||
period: item.period,
|
||||
lineValue: item.lineValue == null ? null : Number(item.lineValue),
|
||||
paramsJson: (item.paramsJson as Record<string, unknown> | null) ?? null,
|
||||
status: item.status,
|
||||
allowSingle: item.allowSingle,
|
||||
allowParlay: item.allowParlay,
|
||||
showOnPlayer: item.showOnPlayer,
|
||||
sortOrder: item.sortOrder,
|
||||
promoLabel: item.promoLabel,
|
||||
promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
|
||||
nameI18n: this.asLocalizedText(item.nameI18n),
|
||||
selections: item.selections.map((s) => ({
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: this.asLocalizedText(s.nameI18n),
|
||||
odds: Number(s.odds),
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
};
|
||||
results.push(await this.upsertMatchMarket(matchId, draft, undefined, item.id));
|
||||
}
|
||||
return { templateId: template.id.toString(), applied: results.length };
|
||||
}
|
||||
|
||||
for (const marketType of marketTypes) {
|
||||
const existing = await this.prisma.market.findFirst({
|
||||
where: { matchId, marketType },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
const config = this.getMarketConfig(marketType);
|
||||
const market = await this.prisma.market.create({
|
||||
data: {
|
||||
matchId,
|
||||
marketType,
|
||||
period: config.period,
|
||||
lineValue: config.lineValue,
|
||||
allowSingle: true,
|
||||
allowParlay: config.allowParlay,
|
||||
sortOrder: config.sortOrder,
|
||||
selections: {
|
||||
create: config.selections.map((s, i) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
odds: s.odds ?? 1.01,
|
||||
sortOrder: i,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { selections: true },
|
||||
});
|
||||
created.push(market);
|
||||
async saveMatchMarkets(matchId: bigint, items: MarketDraft[], operatorId?: bigint) {
|
||||
const match = await this.prisma.match.findUnique({ where: { id: matchId } });
|
||||
if (!match) throw appNotFound('MATCH_NOT_FOUND');
|
||||
const saved = [];
|
||||
const activeIds = new Set<string>();
|
||||
for (const item of items) {
|
||||
const market = await this.upsertMatchMarket(matchId, item, operatorId);
|
||||
if (!market) throw appNotFound('MARKET_NOT_FOUND');
|
||||
activeIds.add(market.id.toString());
|
||||
saved.push(market);
|
||||
}
|
||||
|
||||
return created;
|
||||
const existing = await this.prisma.market.findMany({ where: { matchId }, select: { id: true } });
|
||||
const missing = existing.filter((m) => !activeIds.has(m.id.toString())).map((m) => m.id);
|
||||
if (missing.length) {
|
||||
await this.prisma.market.updateMany({
|
||||
where: { id: { in: missing } },
|
||||
data: { status: 'CLOSED', showOnPlayer: false },
|
||||
});
|
||||
}
|
||||
return { updated: saved.length, closed: missing.length };
|
||||
}
|
||||
|
||||
private formatHandicapName(side: 'home' | 'away', line: number, half = false) {
|
||||
const sideLabel = side === 'home' ? '主队' : '客队';
|
||||
const value = side === 'home' ? line : -line;
|
||||
const lineText = value > 0 ? `+${value}` : `${value}`;
|
||||
return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`;
|
||||
}
|
||||
|
||||
private formatOuName(side: 'over' | 'under', line: number, half = false) {
|
||||
const sideLabel = side === 'over' ? '大' : '小';
|
||||
return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`;
|
||||
}
|
||||
|
||||
private formatScoreName(code: string) {
|
||||
return code.replace('SCORE_', '').replace('_', '-');
|
||||
}
|
||||
|
||||
private getMarketConfig(marketType: string) {
|
||||
const configs: Record<string, {
|
||||
period: string;
|
||||
lineValue?: number;
|
||||
allowParlay: boolean;
|
||||
sortOrder: number;
|
||||
selections: Array<{ code: string; name: string; odds?: number }>;
|
||||
}> = {
|
||||
FT_1X2: {
|
||||
period: 'FT',
|
||||
allowParlay: true,
|
||||
sortOrder: 1,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '主胜', odds: 2.5 },
|
||||
{ code: 'DRAW', name: '和', odds: 3.2 },
|
||||
{ code: 'AWAY', name: '客胜', odds: 2.8 },
|
||||
],
|
||||
},
|
||||
HT_1X2: {
|
||||
period: 'HT',
|
||||
allowParlay: true,
|
||||
sortOrder: 5,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '半场主胜', odds: 3.0 },
|
||||
{ code: 'DRAW', name: '半场和', odds: 2.0 },
|
||||
{ code: 'AWAY', name: '半场客胜', odds: 3.5 },
|
||||
],
|
||||
},
|
||||
FT_HANDICAP: {
|
||||
period: 'FT',
|
||||
lineValue: -0.5,
|
||||
allowParlay: true,
|
||||
sortOrder: 2,
|
||||
selections: [
|
||||
{ code: 'HOME', name: this.formatHandicapName('home', -0.5), odds: 1.9 },
|
||||
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5), odds: 1.9 },
|
||||
],
|
||||
},
|
||||
HT_HANDICAP: {
|
||||
period: 'HT',
|
||||
lineValue: -0.5,
|
||||
allowParlay: true,
|
||||
sortOrder: 6,
|
||||
selections: [
|
||||
{ code: 'HOME', name: this.formatHandicapName('home', -0.5, true), odds: 1.9 },
|
||||
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5, true), odds: 1.9 },
|
||||
],
|
||||
},
|
||||
FT_OVER_UNDER: {
|
||||
period: 'FT',
|
||||
lineValue: 2.5,
|
||||
allowParlay: true,
|
||||
sortOrder: 3,
|
||||
selections: [
|
||||
{ code: 'OVER', name: this.formatOuName('over', 2.5), odds: 1.85 },
|
||||
{ code: 'UNDER', name: this.formatOuName('under', 2.5), odds: 1.95 },
|
||||
],
|
||||
},
|
||||
HT_OVER_UNDER: {
|
||||
period: 'HT',
|
||||
lineValue: 1.5,
|
||||
allowParlay: true,
|
||||
sortOrder: 7,
|
||||
selections: [
|
||||
{ code: 'OVER', name: this.formatOuName('over', 1.5, true), odds: 2.0 },
|
||||
{ code: 'UNDER', name: this.formatOuName('under', 1.5, true), odds: 1.75 },
|
||||
],
|
||||
},
|
||||
FT_ODD_EVEN: {
|
||||
period: 'FT',
|
||||
allowParlay: true,
|
||||
sortOrder: 4,
|
||||
selections: [
|
||||
{ code: 'ODD', name: '单', odds: 1.9 },
|
||||
{ code: 'EVEN', name: '双', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
FT_CORRECT_SCORE: {
|
||||
period: 'FT',
|
||||
allowParlay: true,
|
||||
sortOrder: 8,
|
||||
selections: FT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||
code,
|
||||
name: this.formatScoreName(code),
|
||||
odds: 8.0,
|
||||
})),
|
||||
},
|
||||
HT_CORRECT_SCORE: {
|
||||
period: 'HT',
|
||||
allowParlay: true,
|
||||
sortOrder: 9,
|
||||
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||
code,
|
||||
name: this.formatScoreName(code),
|
||||
odds: 6.0,
|
||||
})),
|
||||
},
|
||||
SH_CORRECT_SCORE: {
|
||||
period: 'SH',
|
||||
allowParlay: true,
|
||||
sortOrder: 10,
|
||||
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||
code,
|
||||
name: this.formatScoreName(code),
|
||||
odds: 6.0,
|
||||
})),
|
||||
},
|
||||
OUTRIGHT_WINNER: {
|
||||
period: 'OUTRIGHT',
|
||||
allowParlay: false,
|
||||
sortOrder: 1,
|
||||
selections: [],
|
||||
},
|
||||
};
|
||||
|
||||
const config = configs[marketType];
|
||||
if (!config) throw appBadRequest('UNKNOWN_MARKET_TYPE', { marketType });
|
||||
return config;
|
||||
async generateTemplates(matchId: bigint, marketTypes: string[]) {
|
||||
const items = marketTypes.map((marketType, i) => {
|
||||
const config = this.getMarketConfig(marketType);
|
||||
return this.defaultDraftFromConfig(marketType, config.defaultLineValue, i);
|
||||
});
|
||||
const result = await this.saveMatchMarkets(matchId, items);
|
||||
return { created: result.updated };
|
||||
}
|
||||
|
||||
async updateOdds(selectionId: bigint, newOdds: number, operatorId: bigint) {
|
||||
@@ -226,24 +328,42 @@ export class MarketsService {
|
||||
|
||||
async updateMarket(
|
||||
marketId: bigint,
|
||||
data: { promoLabel?: string | null; status?: string; lineValue?: number | null },
|
||||
data: {
|
||||
promoLabel?: string | null;
|
||||
promoLabelI18n?: LocalizedInput | null;
|
||||
nameI18n?: LocalizedInput | null;
|
||||
status?: string;
|
||||
lineValue?: number | null;
|
||||
showOnPlayer?: boolean;
|
||||
},
|
||||
) {
|
||||
const market = await this.prisma.market.findUnique({ where: { id: marketId } });
|
||||
if (!market) throw appNotFound('MARKET_NOT_FOUND');
|
||||
const lineValue = data.lineValue !== undefined ? data.lineValue : market.lineValue == null ? null : Number(market.lineValue);
|
||||
if (data.lineValue !== undefined) {
|
||||
this.assertLineValueAllowed(market.marketType, lineValue);
|
||||
}
|
||||
const lineKey =
|
||||
data.lineValue !== undefined
|
||||
? buildMarketLineKey(market.marketType, lineValue, (market.paramsJson as Record<string, unknown> | null) ?? null)
|
||||
: market.lineKey;
|
||||
|
||||
return this.prisma.market.update({
|
||||
where: { id: marketId },
|
||||
data: {
|
||||
...(data.promoLabel !== undefined ? { promoLabel: data.promoLabel?.trim() || null } : {}),
|
||||
...(data.promoLabelI18n !== undefined ? { promoLabelI18n: this.jsonOrNull(sanitizeLocalizedText(data.promoLabelI18n)) } : {}),
|
||||
...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(sanitizeLocalizedText(data.nameI18n)) } : {}),
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
...(data.lineValue !== undefined ? { lineValue: data.lineValue } : {}),
|
||||
...(data.lineValue !== undefined ? { lineValue, lineKey } : {}),
|
||||
...(data.showOnPlayer !== undefined ? { showOnPlayer: data.showOnPlayer } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateSelection(
|
||||
selectionId: bigint,
|
||||
data: { selectionName?: string; odds?: number; status?: string },
|
||||
data: { selectionName?: string; nameI18n?: LocalizedInput | null; odds?: number; status?: string },
|
||||
operatorId?: bigint,
|
||||
) {
|
||||
const selection = await this.prisma.marketSelection.findUnique({
|
||||
@@ -253,15 +373,355 @@ export class MarketsService {
|
||||
|
||||
if (data.odds != null) {
|
||||
if (!operatorId) throw appBadRequest('OPERATOR_REQUIRED');
|
||||
return this.updateOdds(selectionId, data.odds, operatorId);
|
||||
await this.updateOdds(selectionId, data.odds, operatorId);
|
||||
}
|
||||
|
||||
return this.prisma.marketSelection.update({
|
||||
where: { id: selectionId },
|
||||
data: {
|
||||
...(data.selectionName !== undefined ? { selectionName: data.selectionName.trim() } : {}),
|
||||
...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(sanitizeLocalizedText(data.nameI18n)) } : {}),
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDefaultTemplate() {
|
||||
const existing = await this.prisma.marketTemplate.findFirst({
|
||||
where: { sportType: 'FOOTBALL', isDefault: true, status: 'ACTIVE' },
|
||||
});
|
||||
if (existing) return existing;
|
||||
const template = await this.prisma.marketTemplate.create({
|
||||
data: {
|
||||
sportType: 'FOOTBALL',
|
||||
name: '默认足球盘口模板',
|
||||
nameI18n: this.jsonOrNull({
|
||||
'zh-CN': '默认足球盘口模板',
|
||||
'en-US': 'Default Football Market Template',
|
||||
'ms-MY': 'Templat Pasaran Bola Sepak Lalai',
|
||||
}),
|
||||
isDefault: true,
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
await this.replaceTemplateItems(
|
||||
template.id,
|
||||
DEFAULT_MARKET_TYPES.map((marketType, i) => {
|
||||
const config = this.getMarketConfig(marketType);
|
||||
return this.defaultDraftFromConfig(marketType, config.defaultLineValue, i);
|
||||
}),
|
||||
);
|
||||
return template;
|
||||
}
|
||||
|
||||
private async getDefaultTemplateRecord() {
|
||||
await this.ensureDefaultTemplate();
|
||||
return this.prisma.marketTemplate.findFirst({
|
||||
where: { sportType: 'FOOTBALL', isDefault: true, status: 'ACTIVE' },
|
||||
include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
private async replaceTemplateItems(templateId: bigint, items: MarketDraft[]) {
|
||||
await this.prisma.marketTemplateItem.deleteMany({ where: { templateId } });
|
||||
for (const [index, item] of items.entries()) {
|
||||
const normalized = this.normalizeDraft(item, index);
|
||||
await this.prisma.marketTemplateItem.create({
|
||||
data: {
|
||||
templateId,
|
||||
marketType: normalized.marketType,
|
||||
marketKey: normalized.marketKey,
|
||||
lineKey: normalized.lineKey,
|
||||
period: normalized.period,
|
||||
lineValue: normalized.lineValue,
|
||||
paramsJson: this.jsonValueOrNull(normalized.paramsJson),
|
||||
status: normalized.status,
|
||||
allowSingle: normalized.allowSingle,
|
||||
allowParlay: normalized.allowParlay,
|
||||
showOnPlayer: normalized.showOnPlayer,
|
||||
sortOrder: normalized.sortOrder,
|
||||
promoLabel: normalized.promoLabel,
|
||||
promoLabelI18n: this.jsonOrNull(normalized.promoLabelI18n),
|
||||
nameI18n: this.jsonOrNull(normalized.nameI18n),
|
||||
selections: {
|
||||
create: normalized.selections.map((s) => ({
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: this.jsonOrNull(s.nameI18n),
|
||||
odds: s.odds,
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertMatchMarket(
|
||||
matchId: bigint,
|
||||
draft: MarketDraft,
|
||||
operatorId?: bigint,
|
||||
templateItemId?: bigint,
|
||||
) {
|
||||
const normalized = this.normalizeDraft(draft, draft.sortOrder ?? 0);
|
||||
const existing = draft.id
|
||||
? await this.prisma.market.findFirst({ where: { id: BigInt(draft.id), matchId }, include: { selections: true } })
|
||||
: await this.prisma.market.findFirst({ where: { matchId, lineKey: normalized.lineKey }, include: { selections: true } });
|
||||
|
||||
const showOnPlayer = normalized.showOnPlayer && isSettlementSupportedMarketType(normalized.marketType);
|
||||
const data = {
|
||||
marketType: normalized.marketType,
|
||||
marketKey: normalized.marketKey,
|
||||
lineKey: normalized.lineKey,
|
||||
period: normalized.period,
|
||||
lineValue: normalized.lineValue,
|
||||
paramsJson: this.jsonValueOrNull(normalized.paramsJson),
|
||||
status: normalized.status,
|
||||
allowSingle: normalized.allowSingle,
|
||||
allowParlay: normalized.allowParlay,
|
||||
showOnPlayer,
|
||||
sortOrder: normalized.sortOrder,
|
||||
promoLabel: normalized.promoLabel,
|
||||
promoLabelI18n: this.jsonOrNull(normalized.promoLabelI18n),
|
||||
nameI18n: this.jsonOrNull(normalized.nameI18n),
|
||||
templateItemId: templateItemId ?? undefined,
|
||||
};
|
||||
|
||||
const market = existing
|
||||
? await this.prisma.market.update({ where: { id: existing.id }, data })
|
||||
: await this.prisma.market.create({ data: { matchId, ...data } });
|
||||
|
||||
await this.syncMarketSelections(market.id, normalized.selections, operatorId);
|
||||
return this.prisma.market.findUnique({
|
||||
where: { id: market.id },
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
private async syncMarketSelections(
|
||||
marketId: bigint,
|
||||
selections: Array<SelectionDraft & { selectionName: string; nameI18n: LocalizedText; status: string; sortOrder: number }>,
|
||||
operatorId?: bigint,
|
||||
) {
|
||||
const existing = await this.prisma.marketSelection.findMany({ where: { marketId } });
|
||||
const seen = new Set<string>();
|
||||
for (const sel of selections) {
|
||||
if (!sel.odds || sel.odds <= 1) throw appBadRequest('ODDS_MIN');
|
||||
const selectionId = sel.id;
|
||||
const current = selectionId
|
||||
? existing.find((s) => s.id === BigInt(selectionId))
|
||||
: existing.find((s) => s.selectionCode === sel.selectionCode);
|
||||
if (current) {
|
||||
seen.add(current.id.toString());
|
||||
const oddsChanged = Number(current.odds) !== Number(sel.odds);
|
||||
const newVersion = oddsChanged ? current.oddsVersion + BigInt(1) : current.oddsVersion;
|
||||
if (oddsChanged) {
|
||||
await this.prisma.oddsChangeLog.create({
|
||||
data: {
|
||||
selectionId: current.id,
|
||||
oldOdds: current.odds,
|
||||
newOdds: sel.odds,
|
||||
oddsVersion: newVersion,
|
||||
changedBy: operatorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.prisma.marketSelection.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
selectionCode: sel.selectionCode,
|
||||
selectionName: sel.selectionName,
|
||||
nameI18n: this.jsonOrNull(sel.nameI18n),
|
||||
odds: sel.odds,
|
||||
oddsVersion: newVersion,
|
||||
status: sel.status,
|
||||
sortOrder: sel.sortOrder,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const created = await this.prisma.marketSelection.create({
|
||||
data: {
|
||||
marketId,
|
||||
selectionCode: sel.selectionCode,
|
||||
selectionName: sel.selectionName,
|
||||
nameI18n: this.jsonOrNull(sel.nameI18n),
|
||||
odds: sel.odds,
|
||||
status: sel.status,
|
||||
sortOrder: sel.sortOrder,
|
||||
},
|
||||
});
|
||||
seen.add(created.id.toString());
|
||||
}
|
||||
}
|
||||
const toClose = existing.filter((s) => !seen.has(s.id.toString())).map((s) => s.id);
|
||||
if (toClose.length) {
|
||||
await this.prisma.marketSelection.updateMany({
|
||||
where: { id: { in: toClose } },
|
||||
data: { status: 'CLOSED' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeDraft(item: MarketDraft, fallbackOrder: number) {
|
||||
const config = this.getMarketConfig(item.marketType);
|
||||
const lineValue = item.lineValue !== undefined ? item.lineValue : config.defaultLineValue;
|
||||
this.assertLineValueAllowed(item.marketType, lineValue);
|
||||
const paramsJson = item.paramsJson ?? config.defaultParams ?? null;
|
||||
const lineKey = item.lineKey?.trim() || buildMarketLineKey(item.marketType, lineValue, paramsJson);
|
||||
const nameI18n = {
|
||||
...config.nameI18n,
|
||||
...sanitizeLocalizedText(item.nameI18n),
|
||||
};
|
||||
const promoLabelI18n = sanitizeLocalizedText(item.promoLabelI18n);
|
||||
const sourceSelections = item.selections?.length
|
||||
? item.selections
|
||||
: config.selectionTemplate.map((s, index) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
nameI18n: s.nameI18n,
|
||||
odds: s.odds,
|
||||
status: 'OPEN',
|
||||
sortOrder: index,
|
||||
}));
|
||||
return {
|
||||
marketType: item.marketType,
|
||||
marketKey: item.marketKey?.trim() || config.marketKey,
|
||||
lineKey,
|
||||
period: item.period || config.period,
|
||||
lineValue,
|
||||
paramsJson,
|
||||
status: item.status || 'OPEN',
|
||||
allowSingle: item.allowSingle ?? config.allowSingle,
|
||||
allowParlay: item.allowParlay ?? config.allowParlay,
|
||||
showOnPlayer: item.showOnPlayer ?? config.showOnPlayer,
|
||||
sortOrder: item.sortOrder ?? config.sortOrder ?? fallbackOrder,
|
||||
promoLabel: item.promoLabel?.trim() || null,
|
||||
promoLabelI18n,
|
||||
nameI18n,
|
||||
selections: sourceSelections.map((s, index) => {
|
||||
const defaultName = defaultSelectionName(item.marketType, s.selectionCode, 'zh-CN');
|
||||
const rawNameI18n = sanitizeLocalizedText(s.nameI18n);
|
||||
const nameI18n = Object.keys(rawNameI18n).length
|
||||
? rawNameI18n
|
||||
: { 'zh-CN': s.selectionName?.trim() || defaultName };
|
||||
return {
|
||||
id: 'id' in s ? s.id : undefined,
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName?.trim() || resolveMarketText(nameI18n, 'zh-CN', defaultName),
|
||||
nameI18n,
|
||||
odds: Number(s.odds),
|
||||
status: s.status || 'OPEN',
|
||||
sortOrder: s.sortOrder ?? index,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private defaultDraftFromConfig(marketType: string, lineValue: number | null, index: number): MarketDraft {
|
||||
const config = this.getMarketConfig(marketType);
|
||||
this.assertLineValueAllowed(marketType, lineValue);
|
||||
return {
|
||||
marketType,
|
||||
marketKey: config.marketKey,
|
||||
lineKey: buildMarketLineKey(marketType, lineValue, null),
|
||||
period: config.period,
|
||||
lineValue,
|
||||
status: 'OPEN',
|
||||
allowSingle: config.allowSingle,
|
||||
allowParlay: config.allowParlay,
|
||||
showOnPlayer: config.showOnPlayer,
|
||||
sortOrder: config.sortOrder ?? index,
|
||||
nameI18n: config.nameI18n,
|
||||
selections: config.selectionTemplate.map((s, i) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
nameI18n: s.nameI18n,
|
||||
odds: s.odds,
|
||||
status: 'OPEN',
|
||||
sortOrder: i,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private mapTemplate(template: Prisma.MarketTemplateGetPayload<{ include: { items: { include: { selections: true } } } }>) {
|
||||
return {
|
||||
id: template.id.toString(),
|
||||
sportType: template.sportType,
|
||||
name: template.name,
|
||||
nameI18n: this.asLocalizedText(template.nameI18n),
|
||||
description: template.description,
|
||||
isDefault: template.isDefault,
|
||||
status: template.status,
|
||||
sortOrder: template.sortOrder,
|
||||
items: template.items
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((item) => ({
|
||||
id: item.id.toString(),
|
||||
marketType: item.marketType,
|
||||
marketKey: item.marketKey,
|
||||
lineKey: item.lineKey,
|
||||
period: item.period,
|
||||
lineValue: item.lineValue == null ? null : Number(item.lineValue),
|
||||
paramsJson: item.paramsJson,
|
||||
status: item.status,
|
||||
allowSingle: item.allowSingle,
|
||||
allowParlay: item.allowParlay,
|
||||
showOnPlayer: item.showOnPlayer,
|
||||
sortOrder: item.sortOrder,
|
||||
promoLabel: item.promoLabel ?? '',
|
||||
promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
|
||||
nameI18n: this.asLocalizedText(item.nameI18n),
|
||||
selections: item.selections
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((s) => ({
|
||||
id: s.id.toString(),
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: this.asLocalizedText(s.nameI18n),
|
||||
odds: Number(s.odds),
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private getMarketConfig(marketType: string) {
|
||||
try {
|
||||
return buildMarketTemplate(marketType);
|
||||
} catch {
|
||||
throw appBadRequest('UNKNOWN_MARKET_TYPE', { marketType });
|
||||
}
|
||||
}
|
||||
|
||||
private assertLineValueAllowed(marketType: string, lineValue: number | null | undefined) {
|
||||
if (marketUsesLineValue(marketType)) {
|
||||
if (typeof lineValue !== 'number' || !Number.isFinite(lineValue)) {
|
||||
throw appBadRequest('MARKET_LINE_REQUIRED');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (lineValue != null) {
|
||||
throw appBadRequest('MARKET_LINE_NOT_ALLOWED');
|
||||
}
|
||||
}
|
||||
|
||||
private jsonOrNull(value: LocalizedText | Record<string, unknown> | null | undefined) {
|
||||
if (!value || !Object.keys(value).length) return Prisma.JsonNull;
|
||||
return value as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
private jsonValueOrNull(value: Record<string, unknown> | null | undefined) {
|
||||
if (value == null) return Prisma.JsonNull;
|
||||
return value as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
private asLocalizedText(value: unknown): LocalizedText {
|
||||
return sanitizeLocalizedText(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../../ledger/wallet.service';
|
||||
import { FundsPostingService } from '../../ledger/funds-posting.service';
|
||||
import { SystemConfigService } from '../../../shared/config/system-config.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBatchNo } from '../../../shared/common/decorators';
|
||||
@@ -33,7 +33,7 @@ type BetCashbackLine = {
|
||||
export class CashbackService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private funds: FundsPostingService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
@@ -498,29 +498,33 @@ export class CashbackService {
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of batch.items) {
|
||||
if (item.amount.gt(0)) {
|
||||
await this.wallet.deposit(
|
||||
item.userId,
|
||||
item.amount,
|
||||
operatorId,
|
||||
`Cashback batch ${batch.batchNo}`,
|
||||
batch.batchNo,
|
||||
'CASHBACK_DEPOSIT',
|
||||
);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of batch.items) {
|
||||
if (item.amount.gt(0)) {
|
||||
await this.funds.deposit({
|
||||
userId: item.userId,
|
||||
amount: item.amount,
|
||||
operatorId,
|
||||
remark: `Cashback batch ${batch.batchNo}`,
|
||||
referenceId: batch.batchNo,
|
||||
transactionType: 'CASHBACK_DEPOSIT',
|
||||
tx,
|
||||
businessKey: `cashback:${batch.batchNo}:${item.userId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (betIds.length > 0) {
|
||||
await this.prisma.bet.updateMany({
|
||||
where: { id: { in: betIds } },
|
||||
data: { isCashbacked: true },
|
||||
if (betIds.length > 0) {
|
||||
await tx.bet.updateMany({
|
||||
where: { id: { in: betIds } },
|
||||
data: { isCashbacked: true },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.cashbackBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.cashbackBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import {
|
||||
canSelectForParlay,
|
||||
hasDuplicateParlayMatch,
|
||||
isQuarterHandicapOrTotal,
|
||||
PARLAY_MAX_LEGS,
|
||||
PARLAY_MIN_LEGS,
|
||||
@@ -460,13 +459,13 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
|
||||
{
|
||||
id: 'B007',
|
||||
suite: 'betting',
|
||||
name: '同场串关应拒绝',
|
||||
name: '同场串关允许',
|
||||
uatRef: 'B007',
|
||||
run: () => {
|
||||
expectTrue('same match blocked', hasDuplicateParlayMatch(['1', '1']), { matchIds: ['1', '1'] });
|
||||
expectFalse('different matches ok', hasDuplicateParlayMatch(['1', '2', '3']), {
|
||||
matchIds: ['1', '2', '3'],
|
||||
});
|
||||
const first = canSelectForParlay({ marketType: 'FT_1X2', allowParlay: true });
|
||||
const second = canSelectForParlay({ marketType: 'FT_1X2', allowParlay: true });
|
||||
expectTrue('first same-match leg ok', first.ok, { matchId: '1' });
|
||||
expectTrue('second same-match leg ok', second.ok, { matchId: '1' });
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -169,6 +169,97 @@ describe('SettlementCalculator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Additional score-derived markets', () => {
|
||||
it('settles home and away team total goals', () => {
|
||||
const s = { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 };
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_TEAM_TOTAL_HOME',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 1.5,
|
||||
score: s,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_TEAM_TOTAL_AWAY',
|
||||
selectionCode: 'UNDER',
|
||||
totalLine: 1.5,
|
||||
score: s,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
});
|
||||
|
||||
it('settles half time / full time combinations', () => {
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'HT_FT',
|
||||
selectionCode: 'HOME_HOME',
|
||||
score,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'HT_FT',
|
||||
selectionCode: 'DRAW_HOME',
|
||||
score,
|
||||
}),
|
||||
).toBe('LOSE');
|
||||
});
|
||||
|
||||
it('settles total goals ranges', () => {
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_TOTAL_GOALS',
|
||||
selectionCode: 'TG_2_3',
|
||||
score,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_TOTAL_GOALS',
|
||||
selectionCode: 'TG_7_PLUS',
|
||||
score,
|
||||
}),
|
||||
).toBe('LOSE');
|
||||
});
|
||||
|
||||
it('settles corners handicap and totals from match stats', () => {
|
||||
const s = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 1 };
|
||||
const stats = { homeCorners: 7, awayCorners: 4 };
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_CORNERS_HANDICAP',
|
||||
selectionCode: 'HOME',
|
||||
handicapLine: -2.5,
|
||||
score: s,
|
||||
stats,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_CORNERS_OVER_UNDER',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 10.5,
|
||||
score: s,
|
||||
stats,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
});
|
||||
|
||||
it('settles cards total from match stats', () => {
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_CARDS_OVER_UNDER',
|
||||
selectionCode: 'UNDER',
|
||||
totalLine: 5.5,
|
||||
score,
|
||||
stats: { homeCards: 2, awayCards: 3 },
|
||||
}),
|
||||
).toBe('WIN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parlay', () => {
|
||||
it('S016: all win', () => {
|
||||
const result = calculateParlayPayout(100, [
|
||||
|
||||
@@ -9,12 +9,24 @@ export interface ScoreInput {
|
||||
ftAway: number;
|
||||
}
|
||||
|
||||
export interface MatchStatsInput {
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
}
|
||||
|
||||
export interface SettlementInput {
|
||||
marketType: string;
|
||||
selectionCode: string;
|
||||
handicapLine?: number | null;
|
||||
totalLine?: number | null;
|
||||
score: ScoreInput;
|
||||
stats?: MatchStatsInput | null;
|
||||
templateScores?: string[];
|
||||
/** 冠军盘:获胜球队 code,如 FRA、BRA */
|
||||
winnerTeamCode?: string | null;
|
||||
@@ -143,8 +155,41 @@ function settleCorrectScore(
|
||||
return 'LOSE';
|
||||
}
|
||||
|
||||
function resultSide(home: number, away: number): 'HOME' | 'DRAW' | 'AWAY' {
|
||||
if (home > away) return 'HOME';
|
||||
if (home < away) return 'AWAY';
|
||||
return 'DRAW';
|
||||
}
|
||||
|
||||
function settleHtFt(score: ScoreInput, selectionCode: string): SelectionResult {
|
||||
const expected = `${resultSide(score.htHome, score.htAway)}_${resultSide(score.ftHome, score.ftAway)}`;
|
||||
return selectionCode === expected ? 'WIN' : 'LOSE';
|
||||
}
|
||||
|
||||
function settleTotalGoalsRange(totalGoals: number, selectionCode: string): SelectionResult {
|
||||
if (selectionCode === 'TG_0_1') return totalGoals <= 1 ? 'WIN' : 'LOSE';
|
||||
if (selectionCode === 'TG_2_3') return totalGoals >= 2 && totalGoals <= 3 ? 'WIN' : 'LOSE';
|
||||
if (selectionCode === 'TG_4_6') return totalGoals >= 4 && totalGoals <= 6 ? 'WIN' : 'LOSE';
|
||||
if (selectionCode === 'TG_7_PLUS') return totalGoals >= 7 ? 'WIN' : 'LOSE';
|
||||
|
||||
const exact = selectionCode.match(/^GOALS_(\d+)$/);
|
||||
if (exact) return totalGoals === Number(exact[1]) ? 'WIN' : 'LOSE';
|
||||
const exactPlus = selectionCode.match(/^GOALS_(\d+)_PLUS$/);
|
||||
if (exactPlus) return totalGoals >= Number(exactPlus[1]) ? 'WIN' : 'LOSE';
|
||||
|
||||
return 'LOSE';
|
||||
}
|
||||
|
||||
function requireStat(value: number | null | undefined, field: keyof MatchStatsInput): number {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
throw new Error(`SETTLEMENT_STAT_MISSING:${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function settleSelection(input: SettlementInput): SelectionResult {
|
||||
const { marketType, selectionCode, handicapLine, totalLine, score } = input;
|
||||
const stats = input.stats ?? {};
|
||||
const templates = input.templateScores ?? [];
|
||||
|
||||
switch (marketType) {
|
||||
@@ -189,6 +234,35 @@ export function settleSelection(input: SettlementInput): SelectionResult {
|
||||
const total = score.htHome + score.htAway;
|
||||
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
|
||||
}
|
||||
case 'FT_TEAM_TOTAL_HOME':
|
||||
return settleOverUnder(score.ftHome, totalLine ?? 0, selectionCode === 'OVER');
|
||||
case 'FT_TEAM_TOTAL_AWAY':
|
||||
return settleOverUnder(score.ftAway, totalLine ?? 0, selectionCode === 'OVER');
|
||||
case 'HT_FT':
|
||||
return settleHtFt(score, selectionCode);
|
||||
case 'FT_TOTAL_GOALS':
|
||||
return settleTotalGoalsRange(score.ftHome + score.ftAway, selectionCode);
|
||||
case 'FT_CORNERS_HANDICAP': {
|
||||
const homeCorners = requireStat(stats.homeCorners, 'homeCorners');
|
||||
const awayCorners = requireStat(stats.awayCorners, 'awayCorners');
|
||||
const line = handicapLine ?? 0;
|
||||
const isHome = selectionCode === 'HOME';
|
||||
const corners = isHome ? homeCorners : awayCorners;
|
||||
const opp = isHome ? awayCorners : homeCorners;
|
||||
return settleHandicap(corners, opp, isHome ? line : -line, isHome);
|
||||
}
|
||||
case 'FT_CORNERS_OVER_UNDER': {
|
||||
const total =
|
||||
requireStat(stats.homeCorners, 'homeCorners') +
|
||||
requireStat(stats.awayCorners, 'awayCorners');
|
||||
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
|
||||
}
|
||||
case 'FT_CARDS_OVER_UNDER': {
|
||||
const total =
|
||||
requireStat(stats.homeCards, 'homeCards') +
|
||||
requireStat(stats.awayCards, 'awayCards');
|
||||
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
|
||||
}
|
||||
case 'FT_CORRECT_SCORE':
|
||||
return settleCorrectScore(score.ftHome, score.ftAway, selectionCode, templates);
|
||||
case 'HT_CORRECT_SCORE':
|
||||
@@ -262,18 +336,8 @@ export function calculateParlayPayout(
|
||||
return { betResult: 'WON', payout: s.mul(combinedOdds), effectiveOdds: combinedOdds };
|
||||
}
|
||||
|
||||
export { isQuarterHandicapOrTotal } from '@thebet365/shared';
|
||||
|
||||
export const FT_CORRECT_SCORE_TEMPLATE = [
|
||||
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW',
|
||||
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'SCORE_3_1', 'SCORE_3_2',
|
||||
'SCORE_4_0', 'SCORE_4_1', 'SCORE_4_2', 'SCORE_4_3', 'OTHER_HOME',
|
||||
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'SCORE_1_3', 'SCORE_2_3',
|
||||
'SCORE_0_4', 'SCORE_1_4', 'SCORE_2_4', 'SCORE_3_4', 'OTHER_AWAY',
|
||||
];
|
||||
|
||||
export const HT_CORRECT_SCORE_TEMPLATE = [
|
||||
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'OTHER_DRAW',
|
||||
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'OTHER_HOME',
|
||||
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'OTHER_AWAY',
|
||||
];
|
||||
export {
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
isQuarterHandicapOrTotal,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
} from './settlement-calculator';
|
||||
} from '@thebet365/shared';
|
||||
|
||||
const SNAPSHOT_1X2: Record<string, string> = {
|
||||
主胜: 'HOME',
|
||||
@@ -31,7 +31,7 @@ export function resolveSelectionCode(
|
||||
}
|
||||
|
||||
export function templateScoresForMarket(marketType: string): string[] {
|
||||
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE;
|
||||
if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE;
|
||||
if (marketType === 'FT_CORRECT_SCORE') return [...FT_CORRECT_SCORE_TEMPLATE];
|
||||
if (marketType.includes('CORRECT_SCORE')) return [...HT_CORRECT_SCORE_TEMPLATE];
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ describe('SettlementService outright winner flow', () => {
|
||||
let matchFindFirst: jest.Mock;
|
||||
let matchUpdate: jest.Mock;
|
||||
let teamFindUnique: jest.Mock;
|
||||
let marketFindMany: jest.Mock;
|
||||
let marketSelectionFindFirst: jest.Mock;
|
||||
let marketSelectionFindMany: jest.Mock;
|
||||
let matchScoreFindUnique: jest.Mock;
|
||||
@@ -90,6 +91,7 @@ describe('SettlementService outright winner flow', () => {
|
||||
matchFindFirst = jest.fn().mockResolvedValue(outrightMatch);
|
||||
matchUpdate = jest.fn().mockResolvedValue({});
|
||||
teamFindUnique = jest.fn().mockResolvedValue(winnerTeam);
|
||||
marketFindMany = jest.fn().mockResolvedValue([]);
|
||||
marketSelectionFindFirst = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: winningSelId, selectionCode: 'BRA' });
|
||||
@@ -111,18 +113,30 @@ describe('SettlementService outright winner flow', () => {
|
||||
betFindMany = jest.fn();
|
||||
transaction = jest.fn(async (fn: (client: unknown) => Promise<void>) =>
|
||||
fn({
|
||||
matchScore: { upsert: matchScoreUpsert },
|
||||
bet: { update: jest.fn().mockResolvedValue({}) },
|
||||
team: { findUnique: teamFindUnique },
|
||||
market: { findMany: marketFindMany },
|
||||
marketSelection: { findMany: marketSelectionFindMany },
|
||||
matchScore: { upsert: matchScoreUpsert, findUnique: matchScoreFindUnique },
|
||||
bet: {
|
||||
findMany: betFindMany,
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
betSelection: { update: jest.fn().mockResolvedValue({}) },
|
||||
settlementItem: { create: jest.fn().mockResolvedValue({}) },
|
||||
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
|
||||
settlementBatch: {
|
||||
findUnique: settlementBatchFindUnique,
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
match: { update: jest.fn().mockResolvedValue({}) },
|
||||
}),
|
||||
);
|
||||
|
||||
const prisma = {
|
||||
match: { findFirst: matchFindFirst, update: matchUpdate },
|
||||
match: { count: jest.fn().mockResolvedValue(0), findFirst: matchFindFirst, update: matchUpdate },
|
||||
team: { findUnique: teamFindUnique },
|
||||
market: { findMany: marketFindMany },
|
||||
marketSelection: {
|
||||
findFirst: marketSelectionFindFirst,
|
||||
findMany: marketSelectionFindMany,
|
||||
@@ -169,20 +183,233 @@ describe('SettlementService outright winner flow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('previewSettlement requires stats for visible stats markets', async () => {
|
||||
matchFindFirst.mockResolvedValue({
|
||||
id: matchId,
|
||||
isOutright: false,
|
||||
status: 'CLOSED',
|
||||
deletedAt: null,
|
||||
});
|
||||
marketFindMany.mockResolvedValue([{ marketType: 'FT_CORNERS_OVER_UNDER' }]);
|
||||
betFindMany.mockResolvedValue([]);
|
||||
|
||||
try {
|
||||
await service.previewSettlement(matchId, operatorId, {
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 1,
|
||||
ftAway: 1,
|
||||
});
|
||||
throw new Error('Expected previewSettlement to reject');
|
||||
} catch (err) {
|
||||
const response = (err as { getResponse?: () => unknown }).getResponse?.();
|
||||
expect(response).toEqual(
|
||||
expect.objectContaining({
|
||||
code: 'SETTLEMENT_FACTS_REQUIRED',
|
||||
params: expect.objectContaining({ fields: 'homeCorners,awayCorners' }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('derives total cards from yellow and red cards for settlement previews', async () => {
|
||||
matchFindFirst.mockResolvedValue({
|
||||
id: matchId,
|
||||
isOutright: false,
|
||||
status: 'CLOSED',
|
||||
deletedAt: null,
|
||||
});
|
||||
marketFindMany.mockResolvedValue([{ marketType: 'FT_CARDS_OVER_UNDER' }]);
|
||||
betFindMany.mockResolvedValue([]);
|
||||
|
||||
await service.previewSettlement(matchId, operatorId, {
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 2,
|
||||
ftAway: 1,
|
||||
homeYellowCards: 2,
|
||||
homeRedCards: 1,
|
||||
awayYellowCards: 4,
|
||||
awayRedCards: 0,
|
||||
});
|
||||
|
||||
expect(settlementBatchCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
homeYellowCards: 2,
|
||||
homeRedCards: 1,
|
||||
awayYellowCards: 4,
|
||||
awayRedCards: 0,
|
||||
homeCards: 3,
|
||||
awayCards: 4,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a parlay pending until every match in the ticket is settled', async () => {
|
||||
matchFindFirst.mockResolvedValue({
|
||||
id: matchId,
|
||||
isOutright: false,
|
||||
status: 'CLOSED',
|
||||
deletedAt: null,
|
||||
});
|
||||
marketSelectionFindMany.mockResolvedValue([
|
||||
{ id: BigInt(501), selectionCode: 'HOME' },
|
||||
{ id: BigInt(502), selectionCode: 'AWAY' },
|
||||
]);
|
||||
betFindMany.mockResolvedValue([
|
||||
{
|
||||
id: BigInt(2001),
|
||||
betNo: 'PARLAY-PENDING',
|
||||
betType: 'PARLAY',
|
||||
status: 'PENDING',
|
||||
stake: new Decimal(100),
|
||||
agentId: null,
|
||||
userId: BigInt(60),
|
||||
user: { id: BigInt(60) },
|
||||
selections: [
|
||||
{
|
||||
id: BigInt(3001),
|
||||
matchId,
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(501),
|
||||
selectionNameSnapshot: 'Home',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: null,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: BigInt(3002),
|
||||
matchId: BigInt(101),
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(502),
|
||||
selectionNameSnapshot: 'Away',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: null,
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const preview = await service.previewSettlement(matchId, operatorId, {
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 0,
|
||||
ftAway: 1,
|
||||
});
|
||||
|
||||
expect(preview.pendingOtherMatches).toBe(1);
|
||||
expect(preview.lostOnThisMatch).toBe(0);
|
||||
expect(preview.items.items).toEqual([
|
||||
expect.objectContaining({
|
||||
betNo: 'PARLAY-PENDING',
|
||||
result: 'PENDING_OTHER_MATCHES',
|
||||
payout: '0',
|
||||
note: '本场腿已出结果,待其他场次结算后统一结算',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('previews a parlay result only after the other legs already have results', async () => {
|
||||
matchFindFirst.mockResolvedValue({
|
||||
id: matchId,
|
||||
isOutright: false,
|
||||
status: 'CLOSED',
|
||||
deletedAt: null,
|
||||
});
|
||||
marketSelectionFindMany.mockResolvedValue([
|
||||
{ id: BigInt(501), selectionCode: 'HOME' },
|
||||
{ id: BigInt(502), selectionCode: 'AWAY' },
|
||||
]);
|
||||
betFindMany.mockResolvedValue([
|
||||
{
|
||||
id: BigInt(2002),
|
||||
betNo: 'PARLAY-READY',
|
||||
betType: 'PARLAY',
|
||||
status: 'PENDING',
|
||||
stake: new Decimal(100),
|
||||
agentId: null,
|
||||
userId: BigInt(61),
|
||||
user: { id: BigInt(61) },
|
||||
selections: [
|
||||
{
|
||||
id: BigInt(3003),
|
||||
matchId,
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(501),
|
||||
selectionNameSnapshot: 'Home',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: null,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: BigInt(3004),
|
||||
matchId: BigInt(101),
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(502),
|
||||
selectionNameSnapshot: 'Away',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: 'WIN',
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const preview = await service.previewSettlement(matchId, operatorId, {
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 0,
|
||||
ftAway: 1,
|
||||
});
|
||||
|
||||
expect(preview.pendingOtherMatches).toBe(0);
|
||||
expect(preview.items.items).toEqual([
|
||||
expect.objectContaining({
|
||||
betNo: 'PARLAY-READY',
|
||||
result: 'LOST',
|
||||
payout: '0',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('confirmSettlement settles outright bets as WON/LOST using stored winnerTeamId', async () => {
|
||||
const txBetUpdate = jest.fn().mockResolvedValue({});
|
||||
const txBetUpdateMany = jest.fn().mockResolvedValue({ count: 1 });
|
||||
transaction.mockImplementation(async (fn: (client: unknown) => Promise<void>) => {
|
||||
await fn({
|
||||
matchScore: { upsert: matchScoreUpsert },
|
||||
bet: { update: txBetUpdate },
|
||||
team: { findUnique: teamFindUnique },
|
||||
market: { findMany: marketFindMany },
|
||||
marketSelection: { findMany: marketSelectionFindMany },
|
||||
matchScore: { upsert: matchScoreUpsert, findUnique: matchScoreFindUnique },
|
||||
bet: {
|
||||
findMany: betFindMany,
|
||||
update: txBetUpdate,
|
||||
updateMany: txBetUpdateMany,
|
||||
},
|
||||
betSelection: { update: jest.fn().mockResolvedValue({}) },
|
||||
settlementItem: { create: jest.fn().mockResolvedValue({}) },
|
||||
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
|
||||
settlementBatch: {
|
||||
findUnique: settlementBatchFindUnique,
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
match: { update: jest.fn().mockResolvedValue({}) },
|
||||
});
|
||||
});
|
||||
settlementBatchFindUnique.mockResolvedValue({
|
||||
id: batchId,
|
||||
batchNo: 'SETTLE-001',
|
||||
matchId,
|
||||
status: 'PREVIEW',
|
||||
htHomeScore: 0,
|
||||
@@ -209,31 +436,31 @@ describe('SettlementService outright winner flow', () => {
|
||||
}),
|
||||
);
|
||||
expect(wallet.settleBet).toHaveBeenCalledTimes(2);
|
||||
expect(wallet.settleBet.mock.calls[0]).toEqual([
|
||||
winningBet.userId,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
'BET-WIN',
|
||||
'WIN',
|
||||
expect.anything(),
|
||||
]);
|
||||
expect(wallet.settleBet.mock.calls[1]).toEqual([
|
||||
losingBet.userId,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
'BET-LOSE',
|
||||
'LOSE',
|
||||
expect.anything(),
|
||||
]);
|
||||
expect(txBetUpdate).toHaveBeenCalledWith(
|
||||
expect(wallet.settleBet.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
where: { id: winningBetId },
|
||||
userId: winningBet.userId,
|
||||
betNo: 'BET-WIN',
|
||||
batchNo: 'SETTLE-001',
|
||||
result: 'WIN',
|
||||
}),
|
||||
);
|
||||
expect(wallet.settleBet.mock.calls[1][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
userId: losingBet.userId,
|
||||
betNo: 'BET-LOSE',
|
||||
batchNo: 'SETTLE-001',
|
||||
result: 'LOSE',
|
||||
}),
|
||||
);
|
||||
expect(txBetUpdateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: winningBetId, status: 'PENDING' },
|
||||
data: expect.objectContaining({ status: 'WON' }),
|
||||
}),
|
||||
);
|
||||
expect(txBetUpdate).toHaveBeenCalledWith(
|
||||
expect(txBetUpdateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: losingBetId },
|
||||
where: { id: losingBetId, status: 'PENDING' },
|
||||
data: expect.objectContaining({ status: 'LOST' }),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { FundsPostingService } from '../ledger/funds-posting.service';
|
||||
import { AgentsService } from '../agent/agents.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
calculatePayout,
|
||||
calculateParlayPayout,
|
||||
ScoreInput,
|
||||
MatchStatsInput,
|
||||
SelectionResult,
|
||||
} from './domain/settlement-calculator';
|
||||
import {
|
||||
@@ -18,6 +20,15 @@ import {
|
||||
} from './domain/settlement-helpers';
|
||||
|
||||
const SETTLEMENT_ENTRY_STATUSES = new Set(['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED']);
|
||||
const STAT_MARKET_REQUIREMENTS = {
|
||||
FT_CORNERS_HANDICAP: ['homeCorners', 'awayCorners'],
|
||||
FT_CORNERS_OVER_UNDER: ['homeCorners', 'awayCorners'],
|
||||
FT_CARDS_OVER_UNDER: ['homeCards', 'awayCards'],
|
||||
} as const satisfies Record<string, readonly (keyof MatchStatsInput)[]>;
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
type PrismaClientLike = PrismaService | TxClient;
|
||||
type SettlementScoreSource = ScoreInput & MatchStatsInput & { winnerTeamId?: bigint | null };
|
||||
|
||||
type BetSelectionLeg = {
|
||||
id: bigint;
|
||||
@@ -36,13 +47,17 @@ type BetSelectionLeg = {
|
||||
export class SettlementService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private funds: FundsPostingService,
|
||||
private agents: AgentsService,
|
||||
) {}
|
||||
|
||||
private async resolveWinnerTeamCode(winnerTeamId: bigint | null | undefined): Promise<string | null> {
|
||||
private async resolveWinnerTeamCode(
|
||||
winnerTeamId: bigint | null | undefined,
|
||||
tx?: TxClient,
|
||||
): Promise<string | null> {
|
||||
if (!winnerTeamId) return null;
|
||||
const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } });
|
||||
const client: PrismaClientLike = tx ?? this.prisma;
|
||||
const team = await client.team.findUnique({ where: { id: winnerTeamId } });
|
||||
return team?.code ?? null;
|
||||
}
|
||||
|
||||
@@ -68,6 +83,7 @@ export class SettlementService {
|
||||
sel: BetSelectionLeg,
|
||||
selectionCode: string,
|
||||
scoreInput: ScoreInput,
|
||||
statsInput: MatchStatsInput,
|
||||
winnerTeamCode: string | null,
|
||||
) {
|
||||
return {
|
||||
@@ -76,6 +92,7 @@ export class SettlementService {
|
||||
handicapLine: sel.handicapLine != null ? Number(sel.handicapLine) : null,
|
||||
totalLine: sel.totalLine != null ? Number(sel.totalLine) : null,
|
||||
score: scoreInput,
|
||||
stats: statsInput,
|
||||
templateScores: templateScoresForMarket(sel.marketType),
|
||||
winnerTeamCode,
|
||||
};
|
||||
@@ -85,9 +102,71 @@ export class SettlementService {
|
||||
sel: BetSelectionLeg,
|
||||
selectionCode: string,
|
||||
scoreInput: ScoreInput,
|
||||
statsInput: MatchStatsInput,
|
||||
winnerTeamCode: string | null,
|
||||
): SelectionResult {
|
||||
return settleSelection(this.buildSettleInput(sel, selectionCode, scoreInput, winnerTeamCode));
|
||||
return settleSelection(this.buildSettleInput(sel, selectionCode, scoreInput, statsInput, winnerTeamCode));
|
||||
}
|
||||
|
||||
private cardTotalFromBreakdown(
|
||||
total: number | null | undefined,
|
||||
yellow: number | null | undefined,
|
||||
red: number | null | undefined,
|
||||
): number | null {
|
||||
const hasBreakdown = yellow != null || red != null;
|
||||
if (!hasBreakdown) return total ?? null;
|
||||
if (yellow == null || red == null) return null;
|
||||
return yellow + red;
|
||||
}
|
||||
|
||||
private statsInputFromSource(source: MatchStatsInput): MatchStatsInput {
|
||||
const homeYellowCards = source.homeYellowCards ?? null;
|
||||
const awayYellowCards = source.awayYellowCards ?? null;
|
||||
const homeRedCards = source.homeRedCards ?? null;
|
||||
const awayRedCards = source.awayRedCards ?? null;
|
||||
return {
|
||||
homeCorners: source.homeCorners ?? null,
|
||||
awayCorners: source.awayCorners ?? null,
|
||||
homeYellowCards,
|
||||
awayYellowCards,
|
||||
homeRedCards,
|
||||
awayRedCards,
|
||||
homeCards: this.cardTotalFromBreakdown(source.homeCards, homeYellowCards, homeRedCards),
|
||||
awayCards: this.cardTotalFromBreakdown(source.awayCards, awayYellowCards, awayRedCards),
|
||||
};
|
||||
}
|
||||
|
||||
private collectRequiredStatFields(marketTypes: Iterable<string>) {
|
||||
const fields = new Set<keyof MatchStatsInput>();
|
||||
for (const marketType of marketTypes) {
|
||||
const required = STAT_MARKET_REQUIREMENTS[marketType as keyof typeof STAT_MARKET_REQUIREMENTS];
|
||||
if (!required) continue;
|
||||
for (const field of required) fields.add(field);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private async assertRequiredStatsAvailable(
|
||||
matchId: bigint,
|
||||
statsInput: MatchStatsInput,
|
||||
betMarketTypes: Iterable<string>,
|
||||
client?: PrismaClientLike,
|
||||
) {
|
||||
const db = client ?? this.prisma;
|
||||
const visibleMarkets = await db.market.findMany({
|
||||
where: { matchId, showOnPlayer: true },
|
||||
select: { marketType: true },
|
||||
});
|
||||
const required = this.collectRequiredStatFields([
|
||||
...visibleMarkets.map((market) => market.marketType),
|
||||
...betMarketTypes,
|
||||
]);
|
||||
const missing = [...required].filter((field) => statsInput[field] == null);
|
||||
if (missing.length) {
|
||||
throw appBadRequest('SETTLEMENT_FACTS_REQUIRED', {
|
||||
fields: missing.join(','),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private walletResultFromSelection(
|
||||
@@ -118,6 +197,7 @@ export class SettlementService {
|
||||
ftAway: number,
|
||||
operatorId: bigint,
|
||||
winnerTeamId?: bigint,
|
||||
statsInput: MatchStatsInput = {},
|
||||
) {
|
||||
const match = await this.prisma.match.findFirst({
|
||||
where: { id: matchId, deletedAt: null },
|
||||
@@ -146,6 +226,7 @@ export class SettlementService {
|
||||
}
|
||||
}
|
||||
|
||||
const stats = this.statsInputFromSource(statsInput);
|
||||
await this.prisma.matchScore.upsert({
|
||||
where: { matchId },
|
||||
create: {
|
||||
@@ -154,6 +235,14 @@ export class SettlementService {
|
||||
htAwayScore: match.isOutright ? 0 : htAway,
|
||||
ftHomeScore: match.isOutright ? 0 : ftHome,
|
||||
ftAwayScore: match.isOutright ? 0 : ftAway,
|
||||
homeCorners: match.isOutright ? null : stats.homeCorners,
|
||||
awayCorners: match.isOutright ? null : stats.awayCorners,
|
||||
homeYellowCards: match.isOutright ? null : stats.homeYellowCards,
|
||||
awayYellowCards: match.isOutright ? null : stats.awayYellowCards,
|
||||
homeRedCards: match.isOutright ? null : stats.homeRedCards,
|
||||
awayRedCards: match.isOutright ? null : stats.awayRedCards,
|
||||
homeCards: match.isOutright ? null : stats.homeCards,
|
||||
awayCards: match.isOutright ? null : stats.awayCards,
|
||||
winnerTeamId: match.isOutright ? winnerTeamId : null,
|
||||
recordedBy: operatorId,
|
||||
},
|
||||
@@ -162,6 +251,14 @@ export class SettlementService {
|
||||
htAwayScore: match.isOutright ? 0 : htAway,
|
||||
ftHomeScore: match.isOutright ? 0 : ftHome,
|
||||
ftAwayScore: match.isOutright ? 0 : ftAway,
|
||||
homeCorners: match.isOutright ? null : stats.homeCorners,
|
||||
awayCorners: match.isOutright ? null : stats.awayCorners,
|
||||
homeYellowCards: match.isOutright ? null : stats.homeYellowCards,
|
||||
awayYellowCards: match.isOutright ? null : stats.awayYellowCards,
|
||||
homeRedCards: match.isOutright ? null : stats.homeRedCards,
|
||||
awayRedCards: match.isOutright ? null : stats.awayRedCards,
|
||||
homeCards: match.isOutright ? null : stats.homeCards,
|
||||
awayCards: match.isOutright ? null : stats.awayCards,
|
||||
winnerTeamId: match.isOutright ? winnerTeamId : null,
|
||||
recordedBy: operatorId,
|
||||
},
|
||||
@@ -172,7 +269,15 @@ export class SettlementService {
|
||||
data: { status: 'PENDING_SETTLEMENT' },
|
||||
});
|
||||
|
||||
return { matchId, htHome, htAway, ftHome, ftAway, winnerTeamId: winnerTeamId?.toString() ?? null };
|
||||
return {
|
||||
matchId,
|
||||
htHome,
|
||||
htAway,
|
||||
ftHome,
|
||||
ftAway,
|
||||
...stats,
|
||||
winnerTeamId: winnerTeamId?.toString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async previewSettlement(
|
||||
@@ -185,6 +290,14 @@ export class SettlementService {
|
||||
htAway?: number;
|
||||
ftHome?: number;
|
||||
ftAway?: number;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
winnerTeamId?: bigint;
|
||||
},
|
||||
) {
|
||||
@@ -208,6 +321,14 @@ export class SettlementService {
|
||||
htAwayScore: scoreSource.htAway,
|
||||
ftHomeScore: scoreSource.ftHome,
|
||||
ftAwayScore: scoreSource.ftAway,
|
||||
homeCorners: scoreSource.homeCorners ?? null,
|
||||
awayCorners: scoreSource.awayCorners ?? null,
|
||||
homeYellowCards: scoreSource.homeYellowCards ?? null,
|
||||
awayYellowCards: scoreSource.awayYellowCards ?? null,
|
||||
homeRedCards: scoreSource.homeRedCards ?? null,
|
||||
awayRedCards: scoreSource.awayRedCards ?? null,
|
||||
homeCards: scoreSource.homeCards ?? null,
|
||||
awayCards: scoreSource.awayCards ?? null,
|
||||
status: 'PREVIEW',
|
||||
totalBets: computation.pendingBets.length,
|
||||
totalPayout: computation.totalPayout,
|
||||
@@ -248,6 +369,14 @@ export class SettlementService {
|
||||
htAway: batch.htAwayScore ?? 0,
|
||||
ftHome: batch.ftHomeScore ?? 0,
|
||||
ftAway: batch.ftAwayScore ?? 0,
|
||||
homeCorners: batch.homeCorners,
|
||||
awayCorners: batch.awayCorners,
|
||||
homeYellowCards: batch.homeYellowCards,
|
||||
awayYellowCards: batch.awayYellowCards,
|
||||
homeRedCards: batch.homeRedCards,
|
||||
awayRedCards: batch.awayRedCards,
|
||||
homeCards: batch.homeCards,
|
||||
awayCards: batch.awayCards,
|
||||
winnerTeamId: existingScore?.winnerTeamId ?? null,
|
||||
});
|
||||
const itemsPage = this.paginatePreviewItems(computation.items, opts);
|
||||
@@ -343,12 +472,21 @@ export class SettlementService {
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
winnerTeamId?: bigint | null;
|
||||
},
|
||||
operatorId: bigint,
|
||||
tx?: Parameters<Parameters<PrismaService['$transaction']>[0]>[0],
|
||||
) {
|
||||
const client = tx ?? this.prisma;
|
||||
const stats = this.statsInputFromSource(scoreSource);
|
||||
await client.matchScore.upsert({
|
||||
where: { matchId },
|
||||
create: {
|
||||
@@ -357,6 +495,14 @@ export class SettlementService {
|
||||
htAwayScore: scoreSource.htAway,
|
||||
ftHomeScore: scoreSource.ftHome,
|
||||
ftAwayScore: scoreSource.ftAway,
|
||||
homeCorners: stats.homeCorners,
|
||||
awayCorners: stats.awayCorners,
|
||||
homeYellowCards: stats.homeYellowCards,
|
||||
awayYellowCards: stats.awayYellowCards,
|
||||
homeRedCards: stats.homeRedCards,
|
||||
awayRedCards: stats.awayRedCards,
|
||||
homeCards: stats.homeCards,
|
||||
awayCards: stats.awayCards,
|
||||
winnerTeamId: scoreSource.winnerTeamId ?? null,
|
||||
recordedBy: operatorId,
|
||||
},
|
||||
@@ -365,6 +511,14 @@ export class SettlementService {
|
||||
htAwayScore: scoreSource.htAway,
|
||||
ftHomeScore: scoreSource.ftHome,
|
||||
ftAwayScore: scoreSource.ftAway,
|
||||
homeCorners: stats.homeCorners,
|
||||
awayCorners: stats.awayCorners,
|
||||
homeYellowCards: stats.homeYellowCards,
|
||||
awayYellowCards: stats.awayYellowCards,
|
||||
homeRedCards: stats.homeRedCards,
|
||||
awayRedCards: stats.awayRedCards,
|
||||
homeCards: stats.homeCards,
|
||||
awayCards: stats.awayCards,
|
||||
winnerTeamId: scoreSource.winnerTeamId ?? null,
|
||||
recordedBy: operatorId,
|
||||
},
|
||||
@@ -379,6 +533,14 @@ export class SettlementService {
|
||||
htAway?: number;
|
||||
ftHome?: number;
|
||||
ftAway?: number;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
winnerTeamId?: bigint;
|
||||
},
|
||||
) {
|
||||
@@ -387,6 +549,14 @@ export class SettlementService {
|
||||
opts?.htAway !== undefined ||
|
||||
opts?.ftHome !== undefined ||
|
||||
opts?.ftAway !== undefined ||
|
||||
opts?.homeCorners !== undefined ||
|
||||
opts?.awayCorners !== undefined ||
|
||||
opts?.homeYellowCards !== undefined ||
|
||||
opts?.awayYellowCards !== undefined ||
|
||||
opts?.homeRedCards !== undefined ||
|
||||
opts?.awayRedCards !== undefined ||
|
||||
opts?.homeCards !== undefined ||
|
||||
opts?.awayCards !== undefined ||
|
||||
opts?.winnerTeamId !== undefined;
|
||||
|
||||
if (hasRequestScore) {
|
||||
@@ -402,11 +572,22 @@ export class SettlementService {
|
||||
});
|
||||
if (!outrightSel) throw appBadRequest('SETTLEMENT_WINNER_NOT_IN_MARKET');
|
||||
}
|
||||
const stats = this.statsInputFromSource({
|
||||
homeCorners: opts?.homeCorners ?? null,
|
||||
awayCorners: opts?.awayCorners ?? null,
|
||||
homeYellowCards: opts?.homeYellowCards ?? null,
|
||||
awayYellowCards: opts?.awayYellowCards ?? null,
|
||||
homeRedCards: opts?.homeRedCards ?? null,
|
||||
awayRedCards: opts?.awayRedCards ?? null,
|
||||
homeCards: opts?.homeCards ?? null,
|
||||
awayCards: opts?.awayCards ?? null,
|
||||
});
|
||||
return {
|
||||
htHome: opts?.htHome ?? 0,
|
||||
htAway: opts?.htAway ?? 0,
|
||||
ftHome: opts?.ftHome ?? 0,
|
||||
ftAway: opts?.ftAway ?? 0,
|
||||
...stats,
|
||||
winnerTeamId: isOutright ? (opts?.winnerTeamId ?? null) : null,
|
||||
};
|
||||
}
|
||||
@@ -419,21 +600,24 @@ export class SettlementService {
|
||||
htAway: score.htAwayScore ?? 0,
|
||||
ftHome: score.ftHomeScore ?? 0,
|
||||
ftAway: score.ftAwayScore ?? 0,
|
||||
homeCorners: score.homeCorners,
|
||||
awayCorners: score.awayCorners,
|
||||
homeYellowCards: score.homeYellowCards,
|
||||
awayYellowCards: score.awayYellowCards,
|
||||
homeRedCards: score.homeRedCards,
|
||||
awayRedCards: score.awayRedCards,
|
||||
homeCards: score.homeCards,
|
||||
awayCards: score.awayCards,
|
||||
winnerTeamId: score.winnerTeamId,
|
||||
};
|
||||
}
|
||||
|
||||
private async computePreviewComputation(
|
||||
matchId: bigint,
|
||||
scoreSource?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
winnerTeamId?: bigint | null;
|
||||
},
|
||||
scoreSource?: SettlementScoreSource,
|
||||
) {
|
||||
let scoreInput: ScoreInput;
|
||||
let statsInput: MatchStatsInput;
|
||||
let winnerTeamCode: string | null;
|
||||
|
||||
if (scoreSource) {
|
||||
@@ -443,6 +627,7 @@ export class SettlementService {
|
||||
ftHome: scoreSource.ftHome,
|
||||
ftAway: scoreSource.ftAway,
|
||||
};
|
||||
statsInput = this.statsInputFromSource(scoreSource);
|
||||
winnerTeamCode = await this.resolveWinnerTeamCode(scoreSource.winnerTeamId ?? null);
|
||||
} else {
|
||||
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
|
||||
@@ -453,6 +638,7 @@ export class SettlementService {
|
||||
ftHome: score.ftHomeScore ?? 0,
|
||||
ftAway: score.ftAwayScore ?? 0,
|
||||
};
|
||||
statsInput = this.statsInputFromSource(score);
|
||||
winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
|
||||
}
|
||||
|
||||
@@ -465,6 +651,11 @@ export class SettlementService {
|
||||
});
|
||||
|
||||
const selectionCodes = await this.loadSelectionCodes(pendingBets);
|
||||
await this.assertRequiredStatsAvailable(
|
||||
matchId,
|
||||
statsInput,
|
||||
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
|
||||
);
|
||||
|
||||
let totalPayout = new Decimal(0);
|
||||
let totalRefund = new Decimal(0);
|
||||
@@ -487,7 +678,7 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||
if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 1;
|
||||
items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout });
|
||||
@@ -504,7 +695,7 @@ export class SettlementService {
|
||||
);
|
||||
return {
|
||||
odds: sel.odds,
|
||||
result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
|
||||
result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
|
||||
};
|
||||
});
|
||||
const parlay = calculateParlayPayout(bet.stake, legResults);
|
||||
@@ -528,7 +719,7 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const legResult = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const legResult = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
if (legResult === 'WIN' || legResult === 'HALF_WIN') wonLegsOnMatch += 1;
|
||||
}
|
||||
|
||||
@@ -536,6 +727,7 @@ export class SettlementService {
|
||||
bet,
|
||||
matchId,
|
||||
scoreInput,
|
||||
statsInput,
|
||||
winnerTeamCode,
|
||||
selectionCodes,
|
||||
);
|
||||
@@ -552,16 +744,6 @@ export class SettlementService {
|
||||
} else if (preview.betResult === 'WON') {
|
||||
totalPayout = totalPayout.add(preview.payout);
|
||||
}
|
||||
} else if (preview.kind === 'LOST_ON_THIS_MATCH') {
|
||||
lostOnThisMatch += 1;
|
||||
items.push({
|
||||
betId: bet.id,
|
||||
betNo: bet.betNo,
|
||||
betType: 'PARLAY',
|
||||
result: 'LOST',
|
||||
payout: preview.payout,
|
||||
note: '本场已有输腿,串关整单作废',
|
||||
});
|
||||
} else {
|
||||
pendingOtherMatches += 1;
|
||||
items.push({
|
||||
@@ -570,7 +752,7 @@ export class SettlementService {
|
||||
betType: 'PARLAY',
|
||||
result: 'PENDING_OTHER_MATCHES',
|
||||
payout: new Decimal(0),
|
||||
note: '本场腿已出结果,待其他场次结算后派彩',
|
||||
note: '本场腿已出结果,待其他场次结算后统一结算',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -593,24 +775,12 @@ export class SettlementService {
|
||||
bet: { stake: Decimal; selections: BetSelectionLeg[] },
|
||||
matchId: bigint,
|
||||
scoreInput: ScoreInput,
|
||||
statsInput: MatchStatsInput,
|
||||
winnerTeamCode: string | null,
|
||||
selectionCodes: Map<string, string | null>,
|
||||
):
|
||||
| { kind: 'SETTLED'; betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal }
|
||||
| { kind: 'LOST_ON_THIS_MATCH'; payout: Decimal }
|
||||
| { kind: 'PENDING_OTHER_MATCHES' } {
|
||||
for (const sel of bet.selections) {
|
||||
if (sel.matchId?.toString() !== matchId.toString()) continue;
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
if (result === 'LOSE' || result === 'HALF_LOSE') {
|
||||
return { kind: 'LOST_ON_THIS_MATCH', payout: new Decimal(0) };
|
||||
}
|
||||
}
|
||||
|
||||
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
|
||||
for (const sel of bet.selections) {
|
||||
if (sel.matchId?.toString() === matchId.toString()) {
|
||||
@@ -620,7 +790,7 @@ export class SettlementService {
|
||||
);
|
||||
legResults.push({
|
||||
odds: sel.odds,
|
||||
result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
|
||||
result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
|
||||
});
|
||||
} else if (sel.resultStatus) {
|
||||
legResults.push({
|
||||
@@ -641,6 +811,7 @@ export class SettlementService {
|
||||
|
||||
private async loadSelectionCodes(
|
||||
bets: Array<{ selections: Array<{ selectionId: bigint }> }>,
|
||||
tx?: TxClient,
|
||||
): Promise<Map<string, string | null>> {
|
||||
const ids = new Set<bigint>();
|
||||
for (const bet of bets) {
|
||||
@@ -648,7 +819,8 @@ export class SettlementService {
|
||||
}
|
||||
if (!ids.size) return new Map();
|
||||
|
||||
const rows = await this.prisma.marketSelection.findMany({
|
||||
const client: PrismaClientLike = tx ?? this.prisma;
|
||||
const rows = await client.marketSelection.findMany({
|
||||
where: { id: { in: [...ids] } },
|
||||
select: { id: true, selectionCode: true },
|
||||
});
|
||||
@@ -670,36 +842,63 @@ export class SettlementService {
|
||||
await this.assertOutrightLeagueFixturesSettled(batch.match);
|
||||
}
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: batch.htHomeScore ?? 0,
|
||||
htAway: batch.htAwayScore ?? 0,
|
||||
ftHome: batch.ftHomeScore ?? 0,
|
||||
ftAway: batch.ftAwayScore ?? 0,
|
||||
};
|
||||
const existingScore = await this.prisma.matchScore.findUnique({
|
||||
where: { matchId: batch.matchId },
|
||||
});
|
||||
const winnerTeamCode = await this.resolveWinnerTeamCode(existingScore?.winnerTeamId ?? null);
|
||||
|
||||
const pendingBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
selections: { some: { matchId: batch.matchId } },
|
||||
},
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } }, user: true },
|
||||
});
|
||||
|
||||
const selectionCodes = await this.loadSelectionCodes(pendingBets);
|
||||
const agentIds = new Set<bigint>();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.settlementBatch.updateMany({
|
||||
where: { id: batchId, status: 'PREVIEW' },
|
||||
data: { status: 'CONFIRMING', operatorId },
|
||||
});
|
||||
if (claimed.count !== 1) throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
|
||||
|
||||
const currentBatch = await tx.settlementBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: { match: true },
|
||||
});
|
||||
if (!currentBatch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
|
||||
if (currentBatch.match.status !== 'PENDING_SETTLEMENT') {
|
||||
throw appBadRequest('MATCH_NOT_SETTLEABLE');
|
||||
}
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: currentBatch.htHomeScore ?? 0,
|
||||
htAway: currentBatch.htAwayScore ?? 0,
|
||||
ftHome: currentBatch.ftHomeScore ?? 0,
|
||||
ftAway: currentBatch.ftAwayScore ?? 0,
|
||||
};
|
||||
const statsInput = this.statsInputFromSource(currentBatch);
|
||||
const existingScore = await tx.matchScore.findUnique({
|
||||
where: { matchId: currentBatch.matchId },
|
||||
});
|
||||
const winnerTeamCode = await this.resolveWinnerTeamCode(
|
||||
existingScore?.winnerTeamId ?? null,
|
||||
tx,
|
||||
);
|
||||
|
||||
const pendingBets = await tx.bet.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
selections: { some: { matchId: currentBatch.matchId } },
|
||||
},
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } }, user: true },
|
||||
});
|
||||
const selectionCodes = await this.loadSelectionCodes(pendingBets, tx);
|
||||
await this.assertRequiredStatsAvailable(
|
||||
currentBatch.matchId,
|
||||
statsInput,
|
||||
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
|
||||
tx,
|
||||
);
|
||||
let settledCount = 0;
|
||||
|
||||
await this.upsertMatchScoreRecord(
|
||||
batch.matchId,
|
||||
currentBatch.matchId,
|
||||
{
|
||||
htHome: scoreInput.htHome,
|
||||
htAway: scoreInput.htAway,
|
||||
ftHome: scoreInput.ftHome,
|
||||
ftAway: scoreInput.ftAway,
|
||||
...statsInput,
|
||||
winnerTeamId: existingScore?.winnerTeamId ?? null,
|
||||
},
|
||||
operatorId,
|
||||
@@ -713,34 +912,37 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||
const betStatus = this.betStatusFromSelection(result);
|
||||
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
const updatedBet = await tx.bet.updateMany({
|
||||
where: { id: bet.id, status: 'PENDING' },
|
||||
data: {
|
||||
status: betStatus,
|
||||
actualReturn: payout,
|
||||
settledAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (updatedBet.count !== 1) continue;
|
||||
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
data: { resultStatus: result, effectiveOdds: sel.odds },
|
||||
});
|
||||
|
||||
await this.wallet.settleBet(
|
||||
bet.userId,
|
||||
bet.stake,
|
||||
await this.funds.settleBet({
|
||||
userId: bet.userId,
|
||||
stake: bet.stake,
|
||||
payout,
|
||||
bet.betNo,
|
||||
this.walletResultFromSelection(result),
|
||||
betNo: bet.betNo,
|
||||
batchNo: batch.batchNo,
|
||||
result: this.walletResultFromSelection(result),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
settledCount += 1;
|
||||
|
||||
await tx.settlementItem.create({
|
||||
data: {
|
||||
@@ -758,7 +960,7 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
legResults.push({ odds: sel.odds, result });
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
@@ -773,29 +975,33 @@ export class SettlementService {
|
||||
? 'PUSH'
|
||||
: 'WON';
|
||||
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
const updatedBet = await tx.bet.updateMany({
|
||||
where: { id: bet.id, status: 'PENDING' },
|
||||
data: {
|
||||
status: betStatus,
|
||||
actualReturn: parlayResult.payout,
|
||||
settledAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (updatedBet.count !== 1) continue;
|
||||
|
||||
await this.wallet.settleBet(
|
||||
bet.userId,
|
||||
bet.stake,
|
||||
parlayResult.payout,
|
||||
bet.betNo,
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOSE'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WIN',
|
||||
await this.funds.settleBet({
|
||||
userId: bet.userId,
|
||||
stake: bet.stake,
|
||||
payout: parlayResult.payout,
|
||||
betNo: bet.betNo,
|
||||
batchNo: batch.batchNo,
|
||||
result:
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOSE'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WIN',
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
settledCount += 1;
|
||||
|
||||
await tx.settlementItem.create({
|
||||
data: {
|
||||
@@ -813,7 +1019,7 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
data: { resultStatus: result },
|
||||
@@ -840,29 +1046,33 @@ export class SettlementService {
|
||||
? 'PUSH'
|
||||
: 'WON';
|
||||
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
const updatedBet = await tx.bet.updateMany({
|
||||
where: { id: bet.id, status: 'PENDING' },
|
||||
data: {
|
||||
status: betStatus,
|
||||
actualReturn: parlayResult.payout,
|
||||
settledAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (updatedBet.count !== 1) continue;
|
||||
|
||||
await this.wallet.settleBet(
|
||||
bet.userId,
|
||||
bet.stake,
|
||||
parlayResult.payout,
|
||||
bet.betNo,
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOSE'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WIN',
|
||||
await this.funds.settleBet({
|
||||
userId: bet.userId,
|
||||
stake: bet.stake,
|
||||
payout: parlayResult.payout,
|
||||
betNo: bet.betNo,
|
||||
batchNo: batch.batchNo,
|
||||
result:
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOSE'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WIN',
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
settledCount += 1;
|
||||
|
||||
await tx.settlementItem.create({
|
||||
data: {
|
||||
@@ -879,11 +1089,16 @@ export class SettlementService {
|
||||
|
||||
await tx.settlementBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
||||
data: {
|
||||
status: 'CONFIRMED',
|
||||
confirmedAt: new Date(),
|
||||
operatorId,
|
||||
totalBets: settledCount,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.match.update({
|
||||
where: { id: batch.matchId },
|
||||
where: { id: currentBatch.matchId },
|
||||
data: { status: 'SETTLED' },
|
||||
});
|
||||
});
|
||||
@@ -1090,6 +1305,7 @@ export class SettlementService {
|
||||
},
|
||||
matchId: bigint,
|
||||
scoreInput: ScoreInput,
|
||||
statsInput: MatchStatsInput,
|
||||
winnerTeamCode: string | null,
|
||||
selectionCodes: Map<string, string | null>,
|
||||
) {
|
||||
@@ -1099,7 +1315,7 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||
return {
|
||||
payout,
|
||||
@@ -1118,7 +1334,7 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
legUpdates.set(sel.id.toString(), result);
|
||||
} else {
|
||||
if (!sel.resultStatus) {
|
||||
@@ -1142,7 +1358,7 @@ export class SettlementService {
|
||||
|
||||
async previewResettlement(
|
||||
matchId: bigint,
|
||||
scoreInput: ScoreInput,
|
||||
scoreInput: ScoreInput & MatchStatsInput,
|
||||
operatorId: bigint,
|
||||
reason?: string,
|
||||
winnerTeamId?: bigint,
|
||||
@@ -1158,6 +1374,7 @@ export class SettlementService {
|
||||
const winnerTeamCode = winnerTeamId
|
||||
? await this.resolveWinnerTeamCode(winnerTeamId)
|
||||
: null;
|
||||
const statsInput = this.statsInputFromSource(scoreInput);
|
||||
|
||||
const settledBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
@@ -1168,6 +1385,11 @@ export class SettlementService {
|
||||
});
|
||||
|
||||
const selectionCodes = await this.loadSelectionCodes(settledBets);
|
||||
await this.assertRequiredStatsAvailable(
|
||||
matchId,
|
||||
statsInput,
|
||||
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
|
||||
);
|
||||
const items: Array<{
|
||||
betId: bigint;
|
||||
betNo: string;
|
||||
@@ -1187,6 +1409,7 @@ export class SettlementService {
|
||||
bet,
|
||||
matchId,
|
||||
scoreInput,
|
||||
statsInput,
|
||||
winnerTeamCode,
|
||||
selectionCodes,
|
||||
);
|
||||
@@ -1215,6 +1438,14 @@ export class SettlementService {
|
||||
htAwayScore: scoreInput.htAway,
|
||||
ftHomeScore: scoreInput.ftHome,
|
||||
ftAwayScore: scoreInput.ftAway,
|
||||
homeCorners: statsInput.homeCorners ?? null,
|
||||
awayCorners: statsInput.awayCorners ?? null,
|
||||
homeYellowCards: statsInput.homeYellowCards ?? null,
|
||||
awayYellowCards: statsInput.awayYellowCards ?? null,
|
||||
homeRedCards: statsInput.homeRedCards ?? null,
|
||||
awayRedCards: statsInput.awayRedCards ?? null,
|
||||
homeCards: statsInput.homeCards ?? null,
|
||||
awayCards: statsInput.awayCards ?? null,
|
||||
status: 'PREVIEW',
|
||||
totalBets: items.length,
|
||||
totalPayout: totalTopup,
|
||||
@@ -1233,6 +1464,7 @@ export class SettlementService {
|
||||
htAway: scoreInput.htAway,
|
||||
ftHome: scoreInput.ftHome,
|
||||
ftAway: scoreInput.ftAway,
|
||||
...statsInput,
|
||||
winnerTeamId,
|
||||
},
|
||||
operatorId,
|
||||
@@ -1259,36 +1491,60 @@ export class SettlementService {
|
||||
if (!batch.isResettle) throw appBadRequest('RESETTLE_BATCH_ONLY');
|
||||
if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: batch.htHomeScore ?? 0,
|
||||
htAway: batch.htAwayScore ?? 0,
|
||||
ftHome: batch.ftHomeScore ?? 0,
|
||||
ftAway: batch.ftAwayScore ?? 0,
|
||||
};
|
||||
const existingScore = await this.prisma.matchScore.findUnique({
|
||||
where: { matchId: batch.matchId },
|
||||
});
|
||||
const winnerTeamCode = await this.resolveWinnerTeamCode(existingScore?.winnerTeamId ?? null);
|
||||
|
||||
const settledBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
|
||||
selections: { some: { matchId: batch.matchId } },
|
||||
},
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
|
||||
const selectionCodes = await this.loadSelectionCodes(settledBets);
|
||||
const agentIds = new Set<bigint>();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.settlementBatch.updateMany({
|
||||
where: { id: batchId, status: 'PREVIEW' },
|
||||
data: { status: 'CONFIRMING', operatorId },
|
||||
});
|
||||
if (claimed.count !== 1) throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
|
||||
|
||||
const currentBatch = await tx.settlementBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: { match: true },
|
||||
});
|
||||
if (!currentBatch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: currentBatch.htHomeScore ?? 0,
|
||||
htAway: currentBatch.htAwayScore ?? 0,
|
||||
ftHome: currentBatch.ftHomeScore ?? 0,
|
||||
ftAway: currentBatch.ftAwayScore ?? 0,
|
||||
};
|
||||
const statsInput = this.statsInputFromSource(currentBatch);
|
||||
const existingScore = await tx.matchScore.findUnique({
|
||||
where: { matchId: currentBatch.matchId },
|
||||
});
|
||||
const winnerTeamCode = await this.resolveWinnerTeamCode(
|
||||
existingScore?.winnerTeamId ?? null,
|
||||
tx,
|
||||
);
|
||||
|
||||
const settledBets = await tx.bet.findMany({
|
||||
where: {
|
||||
status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
|
||||
selections: { some: { matchId: currentBatch.matchId } },
|
||||
},
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
const selectionCodes = await this.loadSelectionCodes(settledBets, tx);
|
||||
await this.assertRequiredStatsAvailable(
|
||||
currentBatch.matchId,
|
||||
statsInput,
|
||||
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
|
||||
tx,
|
||||
);
|
||||
let affectedCount = 0;
|
||||
|
||||
await this.upsertMatchScoreRecord(
|
||||
batch.matchId,
|
||||
currentBatch.matchId,
|
||||
{
|
||||
htHome: scoreInput.htHome,
|
||||
htAway: scoreInput.htAway,
|
||||
ftHome: scoreInput.ftHome,
|
||||
ftAway: scoreInput.ftAway,
|
||||
...statsInput,
|
||||
winnerTeamId: existingScore?.winnerTeamId ?? null,
|
||||
},
|
||||
operatorId,
|
||||
@@ -1299,8 +1555,9 @@ export class SettlementService {
|
||||
const oldPayout = new Decimal(bet.actualReturn);
|
||||
const outcome = await this.computeBetOutcome(
|
||||
bet,
|
||||
batch.matchId,
|
||||
currentBatch.matchId,
|
||||
scoreInput,
|
||||
statsInput,
|
||||
winnerTeamCode,
|
||||
selectionCodes,
|
||||
);
|
||||
@@ -1328,9 +1585,16 @@ export class SettlementService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.wallet.applyResettleDelta(bet.userId, delta, bet.betNo, tx);
|
||||
await this.funds.applyResettleDelta({
|
||||
userId: bet.userId,
|
||||
delta,
|
||||
betNo: bet.betNo,
|
||||
batchNo: currentBatch.batchNo,
|
||||
tx,
|
||||
});
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
affectedCount += 1;
|
||||
|
||||
await tx.settlementItem.create({
|
||||
data: {
|
||||
@@ -1345,7 +1609,12 @@ export class SettlementService {
|
||||
|
||||
await tx.settlementBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
|
||||
data: {
|
||||
status: 'CONFIRMED',
|
||||
confirmedAt: new Date(),
|
||||
operatorId,
|
||||
totalBets: affectedCount,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1357,18 +1626,57 @@ export class SettlementService {
|
||||
}
|
||||
|
||||
async voidMatchBets(matchId: bigint) {
|
||||
const bets = await this.prisma.bet.findMany({
|
||||
where: { status: 'PENDING', selections: { some: { matchId } } },
|
||||
return this.voidMatchBetsInTransaction(matchId);
|
||||
}
|
||||
|
||||
async cancelMatchAndVoidBets(matchId: bigint) {
|
||||
return this.voidMatchBetsInTransaction(matchId, { cancelMatch: true });
|
||||
}
|
||||
|
||||
private async voidMatchBetsInTransaction(
|
||||
matchId: bigint,
|
||||
options: { cancelMatch?: boolean } = {},
|
||||
) {
|
||||
const agentIds = new Set<bigint>();
|
||||
|
||||
const voidedCount = await this.prisma.$transaction(async (tx) => {
|
||||
if (options.cancelMatch) {
|
||||
await tx.match.update({
|
||||
where: { id: matchId },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
}
|
||||
|
||||
const bets = await tx.bet.findMany({
|
||||
where: { status: 'PENDING', selections: { some: { matchId } } },
|
||||
});
|
||||
|
||||
let count = 0;
|
||||
for (const bet of bets) {
|
||||
const updated = await tx.bet.updateMany({
|
||||
where: { id: bet.id, status: 'PENDING' },
|
||||
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
|
||||
});
|
||||
if (updated.count !== 1) continue;
|
||||
|
||||
await this.funds.voidBet({
|
||||
userId: bet.userId,
|
||||
stake: bet.stake,
|
||||
betNo: bet.betNo,
|
||||
businessKey: `void:${matchId}:${bet.betNo}`,
|
||||
tx,
|
||||
});
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
count += 1;
|
||||
}
|
||||
|
||||
return count;
|
||||
});
|
||||
|
||||
for (const bet of bets) {
|
||||
await this.wallet.settleBet(bet.userId, bet.stake, bet.stake, bet.betNo, 'VOID');
|
||||
await this.prisma.bet.update({
|
||||
where: { id: bet.id },
|
||||
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
|
||||
});
|
||||
for (const agentId of agentIds) {
|
||||
await this.agents.recalculateUsedCredit(agentId);
|
||||
}
|
||||
|
||||
return { voidedCount: bets.length };
|
||||
return { voidedCount };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import Decimal from 'decimal.js';
|
||||
import {
|
||||
calculatePayout,
|
||||
settleSelection,
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
type ScoreInput,
|
||||
} from './domain/settlement-calculator';
|
||||
import {
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
export type SmartScoreStrategy =
|
||||
| 'MIN_PAYOUT'
|
||||
@@ -39,8 +41,8 @@ export type ScoreEvaluation = {
|
||||
};
|
||||
|
||||
function templateForMarket(marketType: string): string[] {
|
||||
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE;
|
||||
if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE;
|
||||
if (marketType === 'FT_CORRECT_SCORE') return [...FT_CORRECT_SCORE_TEMPLATE];
|
||||
if (marketType.includes('CORRECT_SCORE')) return [...HT_CORRECT_SCORE_TEMPLATE];
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { ZhiboMatchExport, ZhiboTeamExport } from '../../domains/catalog/zh
|
||||
import {
|
||||
leagueCodeFromExport,
|
||||
resolveInternalStatus,
|
||||
resolveIsHot,
|
||||
resolveStartTime,
|
||||
teamCodeFromExport,
|
||||
toKickoffJson,
|
||||
@@ -263,7 +262,7 @@ async function upsertWc2026GroupMatch(prisma: PrismaClient, item: ZhiboMatchExpo
|
||||
homeTeamId: homeTeam.id,
|
||||
awayTeamId: awayTeam.id,
|
||||
startTime,
|
||||
isHot: resolveIsHot(item),
|
||||
isHot: false,
|
||||
displayOrder: item.sortOrder,
|
||||
status,
|
||||
publishTime: status === 'PUBLISHED' ? new Date() : undefined,
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png"
|
||||
"image": "https://flagcdn.com/mx.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11173,
|
||||
@@ -46,11 +46,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png"
|
||||
"image": "https://flagcdn.com/za.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -105,7 +105,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png"
|
||||
"image": "https://flagcdn.com/kr.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 50467,
|
||||
@@ -122,7 +122,7 @@
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -190,11 +190,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png"
|
||||
"image": "https://flagcdn.com/za.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -249,7 +249,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png"
|
||||
"image": "https://flagcdn.com/mx.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11261,
|
||||
@@ -262,11 +262,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png"
|
||||
"image": "https://flagcdn.com/kr.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -334,11 +334,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png"
|
||||
"image": "https://flagcdn.com/mx.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -393,7 +393,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png"
|
||||
"image": "https://flagcdn.com/za.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11261,
|
||||
@@ -406,11 +406,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png"
|
||||
"image": "https://flagcdn.com/kr.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -465,7 +465,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png"
|
||||
"image": "https://flagcdn.com/ca.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11153,
|
||||
@@ -478,11 +478,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png"
|
||||
"image": "https://flagcdn.com/ba.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -537,7 +537,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png"
|
||||
"image": "https://flagcdn.com/qa.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11036,
|
||||
@@ -550,11 +550,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png"
|
||||
"image": "https://flagcdn.com/ch.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -609,7 +609,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png"
|
||||
"image": "https://flagcdn.com/ch.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11153,
|
||||
@@ -622,11 +622,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png"
|
||||
"image": "https://flagcdn.com/ba.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -681,7 +681,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png"
|
||||
"image": "https://flagcdn.com/ca.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11267,
|
||||
@@ -694,11 +694,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png"
|
||||
"image": "https://flagcdn.com/qa.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -753,7 +753,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png"
|
||||
"image": "https://flagcdn.com/ch.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11166,
|
||||
@@ -766,11 +766,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png"
|
||||
"image": "https://flagcdn.com/ca.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -825,7 +825,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": ""
|
||||
"image": "https://flagcdn.com/ba.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": null,
|
||||
@@ -838,11 +838,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": ""
|
||||
"image": "https://flagcdn.com/qa.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "off",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -897,7 +897,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png"
|
||||
"image": "https://flagcdn.com/br.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11182,
|
||||
@@ -910,11 +910,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png"
|
||||
"image": "https://flagcdn.com/ma.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -969,7 +969,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png"
|
||||
"image": "https://flagcdn.com/ht.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11030,
|
||||
@@ -982,11 +982,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png"
|
||||
"image": "https://flagcdn.com/gb-sct.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1041,7 +1041,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png"
|
||||
"image": "https://flagcdn.com/gb-sct.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11182,
|
||||
@@ -1054,11 +1054,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png"
|
||||
"image": "https://flagcdn.com/ma.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1113,7 +1113,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png"
|
||||
"image": "https://flagcdn.com/br.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11270,
|
||||
@@ -1126,11 +1126,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png"
|
||||
"image": "https://flagcdn.com/ht.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1185,7 +1185,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png"
|
||||
"image": "https://flagcdn.com/gb-sct.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11150,
|
||||
@@ -1198,11 +1198,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png"
|
||||
"image": "https://flagcdn.com/br.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1257,7 +1257,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png"
|
||||
"image": "https://flagcdn.com/ma.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11270,
|
||||
@@ -1270,11 +1270,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png"
|
||||
"image": "https://flagcdn.com/ht.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1329,7 +1329,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png"
|
||||
"image": "https://flagcdn.com/us.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11148,
|
||||
@@ -1342,11 +1342,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png"
|
||||
"image": "https://flagcdn.com/py.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1383,11 +1383,11 @@
|
||||
"zh": "2026 世界杯"
|
||||
},
|
||||
"kickoff": {
|
||||
"utcTimeStart": 1781323200,
|
||||
"utcTimeStop": 1781330400,
|
||||
"utcIso": "2026-06-13T04:00:00Z",
|
||||
"chinaTime": "2026-06-13 12:00:00 Asia/Shanghai",
|
||||
"venueTime": "2026-06-12 21:00:00 America/Los_Angeles",
|
||||
"utcTimeStart": 1781409600,
|
||||
"utcTimeStop": 1781416800,
|
||||
"utcIso": "2026-06-14T04:00:00Z",
|
||||
"chinaTime": "2026-06-14 12:00:00 Asia/Shanghai",
|
||||
"venueTime": "2026-06-13 21:00:00 America/Los_Angeles",
|
||||
"venueTimezone": "America/Los_Angeles"
|
||||
},
|
||||
"homeTeam": {
|
||||
@@ -1401,7 +1401,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png"
|
||||
"image": "https://flagcdn.com/au.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 50468,
|
||||
@@ -1418,7 +1418,7 @@
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1473,7 +1473,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png"
|
||||
"image": "https://flagcdn.com/us.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11273,
|
||||
@@ -1486,11 +1486,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png"
|
||||
"image": "https://flagcdn.com/au.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1558,11 +1558,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png"
|
||||
"image": "https://flagcdn.com/py.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1630,11 +1630,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png"
|
||||
"image": "https://flagcdn.com/us.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1689,7 +1689,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png"
|
||||
"image": "https://flagcdn.com/py.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11273,
|
||||
@@ -1702,11 +1702,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png"
|
||||
"image": "https://flagcdn.com/au.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1761,7 +1761,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png"
|
||||
"image": "https://flagcdn.com/de.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 19979,
|
||||
@@ -1774,11 +1774,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png"
|
||||
"image": "https://flagcdn.com/cw.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1846,11 +1846,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png"
|
||||
"image": "https://flagcdn.com/ec.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1905,7 +1905,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png"
|
||||
"image": "https://flagcdn.com/de.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 50469,
|
||||
@@ -1922,7 +1922,7 @@
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1977,7 +1977,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png"
|
||||
"image": "https://flagcdn.com/ec.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 19979,
|
||||
@@ -1990,11 +1990,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png"
|
||||
"image": "https://flagcdn.com/cw.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "off",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2049,7 +2049,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png"
|
||||
"image": "https://flagcdn.com/ec.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11038,
|
||||
@@ -2062,11 +2062,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png"
|
||||
"image": "https://flagcdn.com/de.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2121,7 +2121,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png"
|
||||
"image": "https://flagcdn.com/cw.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 50469,
|
||||
@@ -2138,7 +2138,7 @@
|
||||
},
|
||||
"status": {
|
||||
"state": "off",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2193,7 +2193,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png"
|
||||
"image": "https://flagcdn.com/nl.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11266,
|
||||
@@ -2206,11 +2206,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png"
|
||||
"image": "https://flagcdn.com/jp.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2265,7 +2265,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png"
|
||||
"image": "https://flagcdn.com/se.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11190,
|
||||
@@ -2278,11 +2278,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png"
|
||||
"image": "https://flagcdn.com/tn.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2337,7 +2337,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png"
|
||||
"image": "https://flagcdn.com/nl.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11032,
|
||||
@@ -2350,11 +2350,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png"
|
||||
"image": "https://flagcdn.com/se.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2409,7 +2409,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png"
|
||||
"image": "https://flagcdn.com/tn.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11266,
|
||||
@@ -2422,11 +2422,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png"
|
||||
"image": "https://flagcdn.com/jp.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2481,7 +2481,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png"
|
||||
"image": "https://flagcdn.com/jp.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11032,
|
||||
@@ -2494,11 +2494,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png"
|
||||
"image": "https://flagcdn.com/se.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2553,7 +2553,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png"
|
||||
"image": "https://flagcdn.com/tn.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11034,
|
||||
@@ -2566,11 +2566,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png"
|
||||
"image": "https://flagcdn.com/nl.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2625,7 +2625,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png"
|
||||
"image": "https://flagcdn.com/be.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11109,
|
||||
@@ -2638,11 +2638,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png"
|
||||
"image": "https://flagcdn.com/eg.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2697,7 +2697,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png"
|
||||
"image": "https://flagcdn.com/ir.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 12310,
|
||||
@@ -2710,11 +2710,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png"
|
||||
"image": "https://flagcdn.com/nz.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2769,7 +2769,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png"
|
||||
"image": "https://flagcdn.com/be.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11154,
|
||||
@@ -2782,11 +2782,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png"
|
||||
"image": "https://flagcdn.com/ir.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2841,7 +2841,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png"
|
||||
"image": "https://flagcdn.com/nz.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11109,
|
||||
@@ -2854,11 +2854,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png"
|
||||
"image": "https://flagcdn.com/eg.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2913,7 +2913,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png"
|
||||
"image": "https://flagcdn.com/eg.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11154,
|
||||
@@ -2926,11 +2926,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png"
|
||||
"image": "https://flagcdn.com/ir.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2985,7 +2985,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png"
|
||||
"image": "https://flagcdn.com/nz.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11033,
|
||||
@@ -2998,11 +2998,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png"
|
||||
"image": "https://flagcdn.com/be.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3057,7 +3057,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png"
|
||||
"image": "https://flagcdn.com/es.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11161,
|
||||
@@ -3070,11 +3070,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png"
|
||||
"image": "https://flagcdn.com/cv.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3129,7 +3129,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png"
|
||||
"image": "https://flagcdn.com/sa.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11139,
|
||||
@@ -3142,11 +3142,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png"
|
||||
"image": "https://flagcdn.com/uy.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3201,7 +3201,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png"
|
||||
"image": "https://flagcdn.com/es.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11254,
|
||||
@@ -3214,11 +3214,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png"
|
||||
"image": "https://flagcdn.com/sa.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3273,7 +3273,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png"
|
||||
"image": "https://flagcdn.com/uy.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11161,
|
||||
@@ -3286,11 +3286,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png"
|
||||
"image": "https://flagcdn.com/cv.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3345,7 +3345,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png"
|
||||
"image": "https://flagcdn.com/cv.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11254,
|
||||
@@ -3358,11 +3358,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png"
|
||||
"image": "https://flagcdn.com/sa.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3417,7 +3417,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png"
|
||||
"image": "https://flagcdn.com/uy.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11144,
|
||||
@@ -3430,11 +3430,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png"
|
||||
"image": "https://flagcdn.com/es.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3489,7 +3489,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png"
|
||||
"image": "https://flagcdn.com/fr.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11184,
|
||||
@@ -3502,11 +3502,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png"
|
||||
"image": "https://flagcdn.com/sn.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3561,7 +3561,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png"
|
||||
"image": "https://flagcdn.com/iq.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11029,
|
||||
@@ -3574,11 +3574,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png"
|
||||
"image": "https://flagcdn.com/no.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3633,7 +3633,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png"
|
||||
"image": "https://flagcdn.com/fr.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11238,
|
||||
@@ -3646,11 +3646,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png"
|
||||
"image": "https://flagcdn.com/iq.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3705,7 +3705,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png"
|
||||
"image": "https://flagcdn.com/no.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11184,
|
||||
@@ -3718,11 +3718,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png"
|
||||
"image": "https://flagcdn.com/sn.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3777,7 +3777,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png"
|
||||
"image": "https://flagcdn.com/no.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11037,
|
||||
@@ -3790,11 +3790,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png"
|
||||
"image": "https://flagcdn.com/fr.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3849,7 +3849,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png"
|
||||
"image": "https://flagcdn.com/sn.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11238,
|
||||
@@ -3862,11 +3862,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png"
|
||||
"image": "https://flagcdn.com/iq.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3921,7 +3921,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png"
|
||||
"image": "https://flagcdn.com/ar.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 20195,
|
||||
@@ -3934,11 +3934,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png"
|
||||
"image": "https://flagcdn.com/dz.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3993,7 +3993,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png"
|
||||
"image": "https://flagcdn.com/at.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11244,
|
||||
@@ -4006,11 +4006,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png"
|
||||
"image": "https://flagcdn.com/jo.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4065,7 +4065,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png"
|
||||
"image": "https://flagcdn.com/ar.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11035,
|
||||
@@ -4078,11 +4078,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png"
|
||||
"image": "https://flagcdn.com/at.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4137,7 +4137,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png"
|
||||
"image": "https://flagcdn.com/jo.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 20195,
|
||||
@@ -4150,11 +4150,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png"
|
||||
"image": "https://flagcdn.com/dz.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4209,7 +4209,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png"
|
||||
"image": "https://flagcdn.com/dz.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11035,
|
||||
@@ -4222,11 +4222,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png"
|
||||
"image": "https://flagcdn.com/at.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4281,7 +4281,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png"
|
||||
"image": "https://flagcdn.com/jo.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11138,
|
||||
@@ -4294,11 +4294,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png"
|
||||
"image": "https://flagcdn.com/ar.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4353,7 +4353,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png"
|
||||
"image": "https://flagcdn.com/pt.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 50470,
|
||||
@@ -4370,7 +4370,7 @@
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4425,7 +4425,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png"
|
||||
"image": "https://flagcdn.com/uz.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11147,
|
||||
@@ -4438,11 +4438,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png"
|
||||
"image": "https://flagcdn.com/co.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4497,7 +4497,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png"
|
||||
"image": "https://flagcdn.com/pt.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11239,
|
||||
@@ -4510,11 +4510,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png"
|
||||
"image": "https://flagcdn.com/uz.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4569,7 +4569,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png"
|
||||
"image": "https://flagcdn.com/co.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 50470,
|
||||
@@ -4586,7 +4586,7 @@
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4641,7 +4641,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png"
|
||||
"image": "https://flagcdn.com/co.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11137,
|
||||
@@ -4654,11 +4654,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png"
|
||||
"image": "https://flagcdn.com/pt.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4726,11 +4726,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png"
|
||||
"image": "https://flagcdn.com/uz.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4785,7 +4785,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png"
|
||||
"image": "https://flagcdn.com/gb-eng.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11140,
|
||||
@@ -4798,11 +4798,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png"
|
||||
"image": "https://flagcdn.com/hr.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4857,7 +4857,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png"
|
||||
"image": "https://flagcdn.com/gh.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11169,
|
||||
@@ -4870,11 +4870,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png"
|
||||
"image": "https://flagcdn.com/pa.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4929,7 +4929,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png"
|
||||
"image": "https://flagcdn.com/gb-eng.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11179,
|
||||
@@ -4942,11 +4942,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png"
|
||||
"image": "https://flagcdn.com/gh.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -5001,7 +5001,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png"
|
||||
"image": "https://flagcdn.com/pa.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11140,
|
||||
@@ -5014,11 +5014,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png"
|
||||
"image": "https://flagcdn.com/hr.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -5073,7 +5073,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png"
|
||||
"image": "https://flagcdn.com/pa.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11116,
|
||||
@@ -5086,11 +5086,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png"
|
||||
"image": "https://flagcdn.com/gb-eng.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -5145,7 +5145,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png"
|
||||
"image": "https://flagcdn.com/hr.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11179,
|
||||
@@ -5158,11 +5158,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png"
|
||||
"image": "https://flagcdn.com/gh.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
|
||||
@@ -50,54 +50,61 @@ export const WC2026_ZIBO_ID_TO_CODE: Record<number, string> = {
|
||||
50470: 'COD',
|
||||
};
|
||||
|
||||
/** zhibo 球队 logo(seed 时写入 teams.logo_url) */
|
||||
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = {
|
||||
ALG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png',
|
||||
ARG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png',
|
||||
AUS: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png',
|
||||
AUT: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png',
|
||||
BEL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png',
|
||||
BIH: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png',
|
||||
BRA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png',
|
||||
CAN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png',
|
||||
CIV: 'https://flagcdn.com/ci.svg',
|
||||
COD: 'https://flagcdn.com/cd.svg',
|
||||
COL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png',
|
||||
CPV: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png',
|
||||
CRO: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png',
|
||||
CUW: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png',
|
||||
CZE: 'https://flagcdn.com/cz.svg',
|
||||
ECU: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png',
|
||||
EGY: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png',
|
||||
ENG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png',
|
||||
ESP: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png',
|
||||
FRA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png',
|
||||
GER: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png',
|
||||
GHA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png',
|
||||
HAI: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png',
|
||||
IRN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png',
|
||||
IRQ: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png',
|
||||
JOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png',
|
||||
JPN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png',
|
||||
KOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png',
|
||||
KSA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png',
|
||||
MAR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png',
|
||||
MEX: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png',
|
||||
NED: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png',
|
||||
NOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png',
|
||||
NZL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png',
|
||||
PAN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png',
|
||||
PAR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png',
|
||||
POR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png',
|
||||
QAT: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png',
|
||||
RSA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png',
|
||||
SCO: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png',
|
||||
SEN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png',
|
||||
SUI: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png',
|
||||
SWE: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png',
|
||||
TUN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png',
|
||||
TUR: 'https://flagcdn.com/tr.svg',
|
||||
URU: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png',
|
||||
USA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png',
|
||||
UZB: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png',
|
||||
/** WC2026 默认国家/地区国旗(FlagCDN,seed 时写入 teams.logo_url) */
|
||||
export const WC2026_FLAG_ISO_BY_CODE: Record<string, string> = {
|
||||
ALG: 'dz',
|
||||
ARG: 'ar',
|
||||
AUS: 'au',
|
||||
AUT: 'at',
|
||||
BEL: 'be',
|
||||
BIH: 'ba',
|
||||
BRA: 'br',
|
||||
CAN: 'ca',
|
||||
CIV: 'ci',
|
||||
COD: 'cd',
|
||||
COL: 'co',
|
||||
CPV: 'cv',
|
||||
CRO: 'hr',
|
||||
CUW: 'cw',
|
||||
CZE: 'cz',
|
||||
ECU: 'ec',
|
||||
EGY: 'eg',
|
||||
ENG: 'gb-eng',
|
||||
ESP: 'es',
|
||||
FRA: 'fr',
|
||||
GER: 'de',
|
||||
GHA: 'gh',
|
||||
HAI: 'ht',
|
||||
IRN: 'ir',
|
||||
IRQ: 'iq',
|
||||
JOR: 'jo',
|
||||
JPN: 'jp',
|
||||
KOR: 'kr',
|
||||
KSA: 'sa',
|
||||
MAR: 'ma',
|
||||
MEX: 'mx',
|
||||
NED: 'nl',
|
||||
NOR: 'no',
|
||||
NZL: 'nz',
|
||||
PAN: 'pa',
|
||||
PAR: 'py',
|
||||
POR: 'pt',
|
||||
QAT: 'qa',
|
||||
RSA: 'za',
|
||||
SCO: 'gb-sct',
|
||||
SEN: 'sn',
|
||||
SUI: 'ch',
|
||||
SWE: 'se',
|
||||
TUN: 'tn',
|
||||
TUR: 'tr',
|
||||
URU: 'uy',
|
||||
USA: 'us',
|
||||
UZB: 'uz',
|
||||
};
|
||||
|
||||
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(WC2026_FLAG_ISO_BY_CODE).map(([code, iso]) => [
|
||||
code,
|
||||
`https://flagcdn.com/${iso}.svg`,
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -1,129 +1,12 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { DEFAULT_MARKET_TYPES, buildMarketTemplate } from '@thebet365/shared';
|
||||
|
||||
/** 为演示赛事补齐详情页玩法(与后台 markets 模板一致) */
|
||||
export async function seedDemoMarkets(prisma: PrismaClient, matchId: bigint) {
|
||||
const configs: Array<{
|
||||
marketType: string;
|
||||
period: string;
|
||||
lineValue?: number;
|
||||
sortOrder: number;
|
||||
selections: Array<{ code: string; name: string; odds: number }>;
|
||||
}> = [
|
||||
{
|
||||
marketType: 'FT_1X2',
|
||||
period: 'FT',
|
||||
sortOrder: 1,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '主胜', odds: 2.5 },
|
||||
{ code: 'DRAW', name: '和', odds: 3.2 },
|
||||
{ code: 'AWAY', name: '客胜', odds: 2.8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_HANDICAP',
|
||||
period: 'FT',
|
||||
lineValue: -0.5,
|
||||
sortOrder: 2,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '主 -0.5', odds: 1.9 },
|
||||
{ code: 'AWAY', name: '客 +0.5', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
period: 'FT',
|
||||
lineValue: 2.5,
|
||||
sortOrder: 3,
|
||||
selections: [
|
||||
{ code: 'OVER', name: '大 2.5', odds: 1.85 },
|
||||
{ code: 'UNDER', name: '小 2.5', odds: 1.95 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_ODD_EVEN',
|
||||
period: 'FT',
|
||||
sortOrder: 4,
|
||||
selections: [
|
||||
{ code: 'ODD', name: '单', odds: 1.9 },
|
||||
{ code: 'EVEN', name: '双', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_1X2',
|
||||
period: 'HT',
|
||||
sortOrder: 5,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '半场主', odds: 3.0 },
|
||||
{ code: 'DRAW', name: '半场和', odds: 2.0 },
|
||||
{ code: 'AWAY', name: '半场客', odds: 3.5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_HANDICAP',
|
||||
period: 'HT',
|
||||
lineValue: -0.5,
|
||||
sortOrder: 6,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '半场主 -0.5', odds: 1.9 },
|
||||
{ code: 'AWAY', name: '半场客 +0.5', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_OVER_UNDER',
|
||||
period: 'HT',
|
||||
lineValue: 1.5,
|
||||
sortOrder: 7,
|
||||
selections: [
|
||||
{ code: 'OVER', name: '半场大 1.5', odds: 2.0 },
|
||||
{ code: 'UNDER', name: '半场小 1.5', odds: 1.75 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_CORRECT_SCORE',
|
||||
period: 'FT',
|
||||
sortOrder: 8,
|
||||
selections: [
|
||||
{ code: 'SCORE_1_0', name: '1-0', odds: 4.86 },
|
||||
{ code: 'SCORE_2_0', name: '2-0', odds: 5.22 },
|
||||
{ code: 'SCORE_2_1', name: '2-1', odds: 7.92 },
|
||||
{ code: 'SCORE_3_0', name: '3-0', odds: 8.28 },
|
||||
{ code: 'SCORE_0_0', name: '0-0', odds: 8.64 },
|
||||
{ code: 'SCORE_1_1', name: '1-1', odds: 7.47 },
|
||||
{ code: 'SCORE_2_2', name: '2-2', odds: 24.3 },
|
||||
{ code: 'SCORE_3_3', name: '3-3', odds: 175.5 },
|
||||
{ code: 'SCORE_0_1', name: '0-1', odds: 14.4 },
|
||||
{ code: 'SCORE_0_2', name: '0-2', odds: 45.9 },
|
||||
{ code: 'SCORE_1_2', name: '1-2', odds: 23.4 },
|
||||
{ code: 'SCORE_0_3', name: '0-3', odds: 207 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_CORRECT_SCORE',
|
||||
period: 'HT',
|
||||
sortOrder: 9,
|
||||
selections: [
|
||||
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
|
||||
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
|
||||
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
|
||||
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
|
||||
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
|
||||
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'SH_CORRECT_SCORE',
|
||||
period: 'SH',
|
||||
sortOrder: 10,
|
||||
selections: [
|
||||
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
|
||||
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
|
||||
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
|
||||
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
|
||||
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
|
||||
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const configs = DEFAULT_MARKET_TYPES.map((marketType) => ({
|
||||
marketType,
|
||||
...buildMarketTemplate(marketType),
|
||||
}));
|
||||
|
||||
for (const cfg of configs) {
|
||||
const exists = await prisma.market.findFirst({
|
||||
@@ -133,7 +16,7 @@ export async function seedDemoMarkets(prisma: PrismaClient, matchId: bigint) {
|
||||
if (exists) {
|
||||
const needRefresh =
|
||||
cfg.marketType.includes('CORRECT_SCORE') &&
|
||||
exists._count.selections < cfg.selections.length;
|
||||
exists._count.selections < cfg.selectionTemplate.length;
|
||||
if (needRefresh) {
|
||||
await prisma.market.delete({ where: { id: exists.id } });
|
||||
} else {
|
||||
@@ -145,13 +28,13 @@ export async function seedDemoMarkets(prisma: PrismaClient, matchId: bigint) {
|
||||
matchId,
|
||||
marketType: cfg.marketType,
|
||||
period: cfg.period,
|
||||
lineValue: cfg.lineValue,
|
||||
allowSingle: true,
|
||||
allowParlay: true,
|
||||
lineValue: cfg.defaultLineValue,
|
||||
allowSingle: cfg.allowSingle,
|
||||
allowParlay: cfg.allowParlay,
|
||||
sortOrder: cfg.sortOrder,
|
||||
status: 'OPEN',
|
||||
selections: {
|
||||
create: cfg.selections.map((s, i) => ({
|
||||
create: cfg.selectionTemplate.map((s, i) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
odds: s.odds,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
calculatePayout,
|
||||
isQuarterHandicapOrTotal,
|
||||
} from './domains/settlement/domain/settlement-calculator';
|
||||
import { hasDuplicateParlayMatch } from '@thebet365/shared';
|
||||
import { canSelectForParlay } from '@thebet365/shared';
|
||||
|
||||
/**
|
||||
* Agent credit & wallet integration scenarios (A001-A007)
|
||||
@@ -83,9 +83,11 @@ describe('Bet Validation Rules (B001-B010)', () => {
|
||||
expect(submitted === current).toBe(false);
|
||||
});
|
||||
|
||||
it('B007: same match legs rejected in parlay', () => {
|
||||
expect(hasDuplicateParlayMatch(['1', '1'])).toBe(true);
|
||||
expect(hasDuplicateParlayMatch(['1', '2', '3'])).toBe(false);
|
||||
it('B007: same match legs are allowed in parlay', () => {
|
||||
const first = canSelectForParlay({ marketType: 'FT_1X2', allowParlay: true });
|
||||
const second = canSelectForParlay({ marketType: 'FT_1X2', allowParlay: true });
|
||||
expect(first.ok).toBe(true);
|
||||
expect(second.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('B008: quarter line in parlay rejected', () => {
|
||||
|
||||
@@ -6,6 +6,21 @@ import { AppModule } from './app.module';
|
||||
import { GlobalExceptionFilter } from './shared/common/filters';
|
||||
import { getUploadRoot } from './shared/uploads/upload-paths';
|
||||
|
||||
function envFlag(name: string) {
|
||||
return ['1', 'true', 'yes', 'on'].includes((process.env[name] ?? '').toLowerCase());
|
||||
}
|
||||
|
||||
function resolveCorsOrigin() {
|
||||
const raw = process.env.CORS_ORIGINS?.trim();
|
||||
if (raw) {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return process.env.NODE_ENV === 'production' ? false : true;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
@@ -14,7 +29,7 @@ async function bootstrap() {
|
||||
app.useStaticAssets(getUploadRoot(), { prefix: '/uploads/' });
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
app.enableCors({ origin: resolveCorsOrigin(), credentials: true });
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
@@ -23,18 +38,23 @@ async function bootstrap() {
|
||||
}),
|
||||
);
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('TheBet365 API')
|
||||
.setDescription('足球投注平台 MVP API')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, config));
|
||||
const swaggerEnabled = process.env.NODE_ENV !== 'production' || envFlag('ENABLE_SWAGGER');
|
||||
if (swaggerEnabled) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('TheBet365 API')
|
||||
.setDescription('足球投注平台 MVP API')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, config));
|
||||
}
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
console.log(`API running on http://localhost:${port}`);
|
||||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||
if (swaggerEnabled) {
|
||||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
41
apps/api/src/shared/match-time.spec.ts
Normal file
41
apps/api/src/shared/match-time.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
formatLocalMatchDateTime,
|
||||
isAfterLocalToday,
|
||||
isInLocalToday,
|
||||
isoToPlatformPickerDateTime,
|
||||
platformPickerDateTimeToIso,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
describe('match time helpers', () => {
|
||||
it('converts Malaysia picker time to UTC ISO and back', () => {
|
||||
const iso = platformPickerDateTimeToIso('2026-06-11T19:00:00');
|
||||
|
||||
expect(iso).toBe('2026-06-11T11:00:00.000Z');
|
||||
expect(isoToPlatformPickerDateTime(iso)).toBe('2026-06-11T19:00:00');
|
||||
});
|
||||
|
||||
it('formats the same kickoff in different local time zones with GMT offsets', () => {
|
||||
const malaysia = formatLocalMatchDateTime('2026-06-11T11:00:00.000Z', 'en-US', {
|
||||
variant: 'full',
|
||||
timeZone: 'Asia/Kuala_Lumpur',
|
||||
});
|
||||
|
||||
const newYork = formatLocalMatchDateTime('2026-06-11T11:00:00.000Z', 'en-US', {
|
||||
variant: 'full',
|
||||
timeZone: 'America/New_York',
|
||||
});
|
||||
|
||||
expect(malaysia).toContain('07:00');
|
||||
expect(malaysia).toContain('GMT+8');
|
||||
expect(newYork).toContain('07:00');
|
||||
expect(newYork).toContain('GMT-4');
|
||||
expect(malaysia).not.toBe(newYork);
|
||||
});
|
||||
|
||||
it('uses the player local day for today and early buckets', () => {
|
||||
const now = new Date('2026-06-11T15:00:00-04:00');
|
||||
|
||||
expect(isInLocalToday('2026-06-12T02:00:00.000Z', now, 'America/New_York')).toBe(true);
|
||||
expect(isAfterLocalToday('2026-06-12T05:00:00.000Z', now, 'America/New_York')).toBe(true);
|
||||
});
|
||||
});
|
||||
18
apps/api/src/testing/prisma-mock.ts
Normal file
18
apps/api/src/testing/prisma-mock.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ApiErrorCode } from '@thebet365/shared';
|
||||
|
||||
export function transactionPassthrough<T extends object>(client: T) {
|
||||
return jest.fn(async <R>(fn: (tx: T) => Promise<R>) => fn(client));
|
||||
}
|
||||
|
||||
export function createPrismaMock<T extends object>(client: T): T & { $transaction: jest.Mock } {
|
||||
return {
|
||||
...client,
|
||||
$transaction: transactionPassthrough(client),
|
||||
};
|
||||
}
|
||||
|
||||
export function expectAppError(code: ApiErrorCode) {
|
||||
return expect.objectContaining({
|
||||
response: expect.objectContaining({ code }),
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,12 @@ api.interceptors.request.use((config) => {
|
||||
|
||||
const locale = localStorage.getItem('locale') || 'zh-CN';
|
||||
config.headers['X-Locale'] = locale;
|
||||
try {
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (timeZone) config.headers['X-Time-Zone'] = timeZone;
|
||||
} catch {
|
||||
// Some WebViews may not expose a time zone; the API falls back to UTC.
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
@@ -123,14 +123,23 @@ onUnmounted(stopAutoPlay);
|
||||
class="slide"
|
||||
@click="onBannerClick(banner)"
|
||||
>
|
||||
<img
|
||||
v-if="imageUrl(banner)"
|
||||
:src="imageUrl(banner)"
|
||||
:alt="title(banner)"
|
||||
class="slide-img"
|
||||
:loading="i === 0 ? 'eager' : 'lazy'"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<template v-if="imageUrl(banner)">
|
||||
<img
|
||||
:src="imageUrl(banner)"
|
||||
alt=""
|
||||
class="slide-backdrop"
|
||||
aria-hidden="true"
|
||||
:loading="i === 0 ? 'eager' : 'lazy'"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<img
|
||||
:src="imageUrl(banner)"
|
||||
:alt="title(banner)"
|
||||
class="slide-img"
|
||||
:loading="i === 0 ? 'eager' : 'lazy'"
|
||||
@error="onImgError"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="slide-fallback">{{ title(banner) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,40 +166,81 @@ onUnmounted(stopAutoPlay);
|
||||
<style scoped>
|
||||
.carousel {
|
||||
position: relative;
|
||||
margin: 0 -16px 14px;
|
||||
margin: -12px -16px 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #05080d;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: clamp(148px, 41.86vw, 180px);
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.slide {
|
||||
position: relative;
|
||||
flex: 0 0 100%;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(7, 18, 31, 0.94), rgba(6, 8, 12, 0.98)),
|
||||
#070d15;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slide-img {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
.slide::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.03) 48%, rgba(0, 0, 0, 0.18));
|
||||
box-shadow: inset 0 -1px 0 rgba(212, 175, 55, 0.16);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slide-backdrop {
|
||||
position: absolute;
|
||||
inset: -18px;
|
||||
width: calc(100% + 36px);
|
||||
height: calc(100% + 36px);
|
||||
object-fit: cover;
|
||||
filter: blur(16px);
|
||||
opacity: 0.42;
|
||||
transform: scale(1.04);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slide-img {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slide-fallback {
|
||||
height: 180px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -210,6 +260,7 @@ onUnmounted(stopAutoPlay);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: var(--primary-light);
|
||||
border: 1px solid var(--border);
|
||||
padding-bottom: 5px;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
|
||||
import { useBetSlipStore } from '../stores/betSlip';
|
||||
import {
|
||||
useBetSlipStore,
|
||||
type ParlaySlipError,
|
||||
type SlipItem,
|
||||
type SlipMode,
|
||||
} from '../stores/betSlip';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { formatMoney, parseAmount } from '../utils/localeDisplay';
|
||||
import BetSuccessOverlay from './BetSuccessOverlay.vue';
|
||||
import api from '../api';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
const show = computed({
|
||||
@@ -18,68 +24,246 @@ const show = computed({
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
});
|
||||
|
||||
const activeTab = ref<SlipMode>('single');
|
||||
const loading = ref(false);
|
||||
const balanceLoading = ref(false);
|
||||
const balance = ref<number | null>(null);
|
||||
const error = ref('');
|
||||
const success = ref('');
|
||||
const showSuccess = ref(false);
|
||||
const MIN_STAKE = 5;
|
||||
const MAX_STAKE_INTEGER_LENGTH = 9;
|
||||
const stakeInput = ref('');
|
||||
const keypadKeys = ['1', '2', '3', '4', '5', 'backspace', '6', '7', '8', '9', '0', '00'];
|
||||
|
||||
const activeItems = computed<SlipItem[]>(() => {
|
||||
if (activeTab.value === 'parlay') return slip.parlayItems;
|
||||
return slip.singleItem ? [slip.singleItem] : [];
|
||||
});
|
||||
|
||||
const activeCount = computed(() => activeItems.value.length);
|
||||
const activeTotalOdds = computed(() =>
|
||||
activeItems.value.reduce((acc, item) => acc * item.odds, 1),
|
||||
);
|
||||
const activeEstimatedReturn = computed(() => {
|
||||
if (!activeItems.value.length || !Number.isFinite(slip.stake) || slip.stake <= 0) return 0;
|
||||
if (activeTab.value === 'parlay') return slip.stake * activeTotalOdds.value;
|
||||
return slip.stake * activeItems.value[0].odds;
|
||||
});
|
||||
|
||||
const canSubmitActive = computed(() => {
|
||||
if (activeTab.value === 'parlay') {
|
||||
return slip.parlayItems.length >= PARLAY_MIN_LEGS && slip.parlayItems.length <= PARLAY_MAX_LEGS;
|
||||
}
|
||||
return Boolean(slip.singleItem);
|
||||
});
|
||||
|
||||
const balanceText = computed(() => {
|
||||
if (balanceLoading.value) return t('bet.loading');
|
||||
if (balance.value == null) return '--';
|
||||
return formatMoney(balance.value, locale.value);
|
||||
});
|
||||
|
||||
const stakeText = computed(() => formatMoney(slip.stake, locale.value));
|
||||
const estimatedReturnText = computed(() => formatMoney(activeEstimatedReturn.value, locale.value));
|
||||
const totalOddsText = computed(() => activeTotalOdds.value.toFixed(4).replace(/0+$/, '').replace(/\.$/, ''));
|
||||
|
||||
const parlayWarning = computed(() => {
|
||||
if (activeTab.value !== 'parlay') return '';
|
||||
if (slip.lastParlayError) return parlayErrorMessage(slip.lastParlayError);
|
||||
if (slip.parlayItems.length > 0 && slip.parlayItems.length < PARLAY_MIN_LEGS) {
|
||||
return t('bet.parlay_need_more');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const singleInParlay = computed(() => {
|
||||
const item = slip.singleItem;
|
||||
return Boolean(item && slip.parlayItems.some((leg) => leg.selectionId === item.selectionId));
|
||||
});
|
||||
|
||||
const showFooterParlayAction = computed(() => activeTab.value === 'single' && Boolean(slip.singleItem));
|
||||
|
||||
function genId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function onSuccessDone() {
|
||||
showSuccess.value = false;
|
||||
function selectTab(tab: SlipMode) {
|
||||
activeTab.value = tab;
|
||||
slip.setMode(tab);
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
show.value = false;
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
}
|
||||
|
||||
function onSuccessDone() {
|
||||
showSuccess.value = false;
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
async function loadBalance() {
|
||||
if (!auth.token) {
|
||||
balance.value = null;
|
||||
return;
|
||||
}
|
||||
balanceLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/profile');
|
||||
balance.value = parseAmount(data.data?.wallet?.availableBalance);
|
||||
} catch {
|
||||
balance.value = null;
|
||||
} finally {
|
||||
balanceLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function parlayErrorMessage(reason: ParlaySlipError) {
|
||||
if (reason === 'MAX_LEGS') return t('bet.parlay_max_legs');
|
||||
if (reason === 'QUARTER_LINE') return t('bet.parlay_block_quarter');
|
||||
if (reason === 'OUTRIGHT') return t('bet.parlay_block_outright');
|
||||
if (reason === 'NOT_ALLOWED') return t('bet.parlay_block_not_allowed');
|
||||
if (reason === 'SAME_MATCH') return t('bet.slip_parlay_same_match');
|
||||
return t('bet.parlay_block_not_allowed');
|
||||
}
|
||||
|
||||
function addCurrentToParlay() {
|
||||
if (singleInParlay.value) {
|
||||
activeTab.value = 'parlay';
|
||||
slip.setMode('parlay');
|
||||
error.value = '';
|
||||
return;
|
||||
}
|
||||
const err = slip.addSingleToParlay();
|
||||
activeTab.value = 'parlay';
|
||||
if (err) {
|
||||
error.value = parlayErrorMessage(err);
|
||||
return;
|
||||
}
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
slip.removeItem(id);
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
function stakeAmountToInput(amount: number) {
|
||||
if (!Number.isFinite(amount) || amount <= 0) return '';
|
||||
const rounded = Math.round(amount * 100) / 100;
|
||||
return Number.isInteger(rounded)
|
||||
? String(rounded)
|
||||
: rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function sanitizeStakeInput(raw: string) {
|
||||
const onlyAmountChars = raw.replace(/[^\d.]/g, '');
|
||||
const [integerRaw, ...fractionParts] = onlyAmountChars.split('.');
|
||||
const integerPart = integerRaw.replace(/^0+(?=\d)/, '').slice(0, MAX_STAKE_INTEGER_LENGTH);
|
||||
if (!fractionParts.length) return integerPart;
|
||||
const fractionPart = fractionParts.join('').slice(0, 2);
|
||||
return `${integerPart || '0'}.${fractionPart}`;
|
||||
}
|
||||
|
||||
function commitStakeInput(raw: string) {
|
||||
const clean = sanitizeStakeInput(raw);
|
||||
stakeInput.value = clean;
|
||||
const amount = Number.parseFloat(clean);
|
||||
slip.stake = Number.isFinite(amount) ? amount : 0;
|
||||
}
|
||||
|
||||
function syncStakeInputFromSlip() {
|
||||
stakeInput.value = stakeAmountToInput(Number(slip.stake) || 0);
|
||||
}
|
||||
|
||||
function onStakeInput(event: Event) {
|
||||
commitStakeInput((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function pressStakeKey(key: string) {
|
||||
if (key === 'backspace') {
|
||||
commitStakeInput(stakeInput.value.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
const base = stakeInput.value === '0' ? '' : stakeInput.value;
|
||||
commitStakeInput(`${base}${key}`);
|
||||
}
|
||||
|
||||
function clearStakeInput() {
|
||||
commitStakeInput('');
|
||||
}
|
||||
|
||||
function setStake(amount: number, enforceMinimum = true) {
|
||||
if (!Number.isFinite(amount)) return;
|
||||
const normalized = Math.round((enforceMinimum ? Math.max(MIN_STAKE, amount) : amount) * 100) / 100;
|
||||
slip.stake = normalized;
|
||||
stakeInput.value = stakeAmountToInput(normalized);
|
||||
}
|
||||
|
||||
function addStake(amount: number) {
|
||||
setStake((Number(slip.stake) || 0) + amount);
|
||||
}
|
||||
|
||||
function setMaxStake() {
|
||||
if (balance.value != null && balance.value > 0) setStake(balance.value, false);
|
||||
}
|
||||
|
||||
async function placeBet() {
|
||||
if (!slip.items.length) return;
|
||||
if (!activeItems.value.length) return;
|
||||
if (!auth.token) {
|
||||
auth.showLoginPrompt();
|
||||
return;
|
||||
}
|
||||
if (slip.stake < MIN_STAKE) {
|
||||
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
|
||||
return;
|
||||
}
|
||||
if (balance.value != null && slip.stake > balance.value) {
|
||||
error.value = t('bet.outright_insufficient');
|
||||
return;
|
||||
}
|
||||
if (activeTab.value === 'parlay' && !canSubmitActive.value) {
|
||||
error.value = slip.parlayItems.length > PARLAY_MAX_LEGS
|
||||
? t('bet.parlay_max_legs')
|
||||
: t('bet.parlay_need_more');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
|
||||
try {
|
||||
if (slip.canPlaceParlay) {
|
||||
if (slip.items.length > PARLAY_MAX_LEGS) {
|
||||
error.value = t('bet.parlay_max_legs');
|
||||
return;
|
||||
}
|
||||
if (activeTab.value === 'parlay') {
|
||||
await api.post('/player/bets/parlay', {
|
||||
legs: slip.items.map((i) => ({
|
||||
selectionId: i.selectionId,
|
||||
oddsVersion: i.oddsVersion,
|
||||
legs: slip.parlayItems.map((item) => ({
|
||||
selectionId: item.selectionId,
|
||||
oddsVersion: item.oddsVersion,
|
||||
})),
|
||||
stake: slip.stake,
|
||||
requestId: genId(),
|
||||
});
|
||||
} else if (slip.canPlaceBatchSingles) {
|
||||
for (const item of slip.items) {
|
||||
await api.post('/player/bets/single', {
|
||||
selectionId: item.selectionId,
|
||||
oddsVersion: item.oddsVersion,
|
||||
stake: slip.stake,
|
||||
requestId: genId(),
|
||||
});
|
||||
}
|
||||
slip.clearParlay();
|
||||
} else {
|
||||
error.value = t('bet.parlay_need_more');
|
||||
return;
|
||||
const item = slip.singleItem;
|
||||
if (!item) return;
|
||||
await api.post('/player/bets/single', {
|
||||
selectionId: item.selectionId,
|
||||
oddsVersion: item.oddsVersion,
|
||||
stake: slip.stake,
|
||||
requestId: genId(),
|
||||
});
|
||||
slip.clearSingle();
|
||||
}
|
||||
success.value = t('bet.place_success');
|
||||
slip.clear();
|
||||
showSuccess.value = true;
|
||||
await loadBalance();
|
||||
setTimeout(() => {
|
||||
if (showSuccess.value) {
|
||||
showSuccess.value = false;
|
||||
show.value = false;
|
||||
success.value = '';
|
||||
}
|
||||
}, 2500);
|
||||
if (showSuccess.value) onSuccessDone();
|
||||
}, 2200);
|
||||
} catch (e: unknown) {
|
||||
error.value =
|
||||
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
||||
@@ -88,63 +272,176 @@ async function placeBet() {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
activeTab.value = slip.mode;
|
||||
if (activeTab.value === 'single' && !slip.singleItem && slip.parlayItems.length) {
|
||||
activeTab.value = 'parlay';
|
||||
slip.setMode('parlay');
|
||||
}
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
syncStakeInputFromSlip();
|
||||
loadBalance();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => slip.mode,
|
||||
(mode) => {
|
||||
if (show.value) activeTab.value = mode;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="show" class="overlay" @click.self="show = false">
|
||||
<div v-if="show" class="overlay" @click.self="closeDrawer">
|
||||
<div class="drawer">
|
||||
<div class="drawer-head">
|
||||
<div>
|
||||
<p class="drawer-kicker">{{ t('bet.bet_slip') }}</p>
|
||||
<h3>{{ t('bet.slip_review_title') }}</h3>
|
||||
</div>
|
||||
<button type="button" class="close-btn" :aria-label="t('bet.cancel')" @click="closeDrawer">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="balance-bar">
|
||||
<span>{{ t('bet.slip_balance') }}</span>
|
||||
<strong>{{ balanceText }}</strong>
|
||||
<button type="button" class="balance-refresh" :disabled="balanceLoading" @click="loadBalance">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="slip-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="slip-tab"
|
||||
:class="{ active: activeTab === 'single' }"
|
||||
@click="selectTab('single')"
|
||||
>
|
||||
{{ t('bet.slip_tab_single') }}
|
||||
<span v-if="slip.singleCount" class="tab-count">{{ slip.singleCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="slip-tab"
|
||||
:class="{ active: activeTab === 'parlay' }"
|
||||
@click="selectTab('parlay')"
|
||||
>
|
||||
{{ t('bet.slip_tab_parlay') }}
|
||||
<span v-if="slip.parlayCount" class="tab-count">{{ slip.parlayCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="drawer-body">
|
||||
<div class="drawer-header">
|
||||
<h3>{{ t('bet.bet_slip') }} <span class="count">({{ slip.count }})</span></h3>
|
||||
<button type="button" class="close-btn" :aria-label="t('bet.cancel')" @click="show = false">
|
||||
✕
|
||||
</button>
|
||||
<div v-if="!activeItems.length" class="empty">
|
||||
{{ activeTab === 'parlay' ? t('bet.slip_parlay_empty_hint') : t('bet.slip_empty_hint') }}
|
||||
</div>
|
||||
|
||||
<div v-if="!slip.items.length" class="empty">{{ t('bet.slip_empty_hint') }}</div>
|
||||
|
||||
<div v-for="item in slip.items" :key="item.selectionId" class="slip-item">
|
||||
<div class="item-name">{{ item.matchName }}</div>
|
||||
<div class="item-sel">
|
||||
{{ item.selectionName }} @ <span class="odds">{{ item.odds }}</span>
|
||||
<template v-else>
|
||||
<div v-if="activeTab === 'single' && slip.singleItem" class="slip-item slip-item--single">
|
||||
<div class="item-main">
|
||||
<div class="item-title">{{ slip.singleItem.matchName }}</div>
|
||||
<div v-if="slip.singleItem.marketName" class="item-market">{{ slip.singleItem.marketName }}</div>
|
||||
<div class="item-pick">{{ slip.singleItem.selectionName }}</div>
|
||||
</div>
|
||||
<div class="item-odds">{{ slip.singleItem.odds.toFixed(2) }}</div>
|
||||
</div>
|
||||
<button type="button" class="remove" @click="slip.removeItem(item.selectionId)">
|
||||
{{ t('bet.slip_remove') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="slip.canPlaceParlay" class="mode-hint mode-hint--parlay">
|
||||
{{ t('bet.parlay') }} · {{ t('bet.slip_parlay_odds', { odds: slip.totalOdds.toFixed(2) }) }}
|
||||
</p>
|
||||
<p v-else-if="slip.canPlaceBatchSingles && slip.count > 1" class="mode-hint">
|
||||
{{ t('bet.slip_singles_hint', { n: slip.count }) }}
|
||||
</p>
|
||||
<template v-if="activeTab === 'parlay'">
|
||||
<p v-if="parlayWarning" class="warning">{{ parlayWarning }}</p>
|
||||
<div
|
||||
v-for="item in slip.parlayItems"
|
||||
:key="item.selectionId"
|
||||
class="slip-item"
|
||||
>
|
||||
<div class="item-main">
|
||||
<div class="item-title">{{ item.matchName }}</div>
|
||||
<div v-if="item.marketName" class="item-market">{{ item.marketName }}</div>
|
||||
<div class="item-pick">{{ item.selectionName }}</div>
|
||||
</div>
|
||||
<div class="item-side">
|
||||
<strong>{{ item.odds.toFixed(2) }}</strong>
|
||||
<button type="button" class="remove" @click="removeItem(item.selectionId)">
|
||||
{{ t('bet.slip_remove') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="slip.parlayItems.length" class="parlay-meta">
|
||||
{{ t('bet.slip_parlay_count', { n: slip.parlayItems.length }) }}
|
||||
· {{ t('bet.slip_parlay_odds', { odds: totalOddsText }) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div v-if="slip.items.length" class="stake-area">
|
||||
<label>{{
|
||||
slip.canPlaceBatchSingles && slip.count > 1
|
||||
? t('bet.slip_stake_per_bet')
|
||||
: t('bet.stake')
|
||||
}}</label>
|
||||
<input v-model.number="slip.stake" type="number" min="1" />
|
||||
<div class="return">
|
||||
{{ t('bet.slip_est_return') }}:
|
||||
<strong>{{ slip.potentialReturn.toFixed(2) }}</strong>
|
||||
<div class="stake-area">
|
||||
<div class="stake-head">
|
||||
<span>{{ activeTab === 'parlay' ? t('bet.slip_total_stake') : t('bet.stake') }}</span>
|
||||
<strong>{{ stakeText }}</strong>
|
||||
</div>
|
||||
<div class="stake-input-row">
|
||||
<span class="currency">{{ t('bet.slip_currency') }}</span>
|
||||
<input
|
||||
:value="stakeInput"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
@input="onStakeInput"
|
||||
/>
|
||||
<button type="button" class="input-clear" @click="clearStakeInput">✕</button>
|
||||
</div>
|
||||
<div class="number-pad">
|
||||
<button
|
||||
v-for="key in keypadKeys"
|
||||
:key="key"
|
||||
type="button"
|
||||
class="number-key"
|
||||
:class="{ 'number-key--backspace': key === 'backspace' }"
|
||||
@click="pressStakeKey(key)"
|
||||
>
|
||||
<span v-if="key === 'backspace'" aria-hidden="true">⌫</span>
|
||||
<template v-else>{{ key }}</template>
|
||||
</button>
|
||||
</div>
|
||||
<div class="quick-stakes">
|
||||
<button type="button" @click="setStake(MIN_STAKE)">{{ t('bet.slip_min') }}</button>
|
||||
<button type="button" @click="addStake(50)">+50</button>
|
||||
<button type="button" @click="addStake(100)">+100</button>
|
||||
<button type="button" @click="setMaxStake">{{ t('bet.stake_max') }}</button>
|
||||
</div>
|
||||
<div class="return">
|
||||
<span>{{ t('bet.slip_est_return') }}</span>
|
||||
<strong>{{ estimatedReturnText }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="success" class="success">{{ success }}</p>
|
||||
</div>
|
||||
|
||||
<div class="drawer-foot">
|
||||
<div class="drawer-foot" :class="{ 'drawer-foot--split': showFooterParlayAction }">
|
||||
<button
|
||||
v-if="showFooterParlayAction"
|
||||
type="button"
|
||||
class="btn-add-parlay"
|
||||
@click="addCurrentToParlay"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
{{ t('bet.slip_add_parlay') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="loading || !slip.canSubmit"
|
||||
:disabled="loading || !canSubmitActive"
|
||||
@click="placeBet"
|
||||
>
|
||||
{{ loading ? t('bet.placing') : t('bet.place_bet') }}
|
||||
{{ loading ? t('bet.placing') : t('bet.place_bet_short') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,137 +461,395 @@ async function placeBet() {
|
||||
}
|
||||
|
||||
.drawer {
|
||||
background: linear-gradient(180deg, #222 0%, #141414 100%);
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
max-height: 88vh;
|
||||
background: #101010;
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: 1px solid var(--border-gold-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 -20px 44px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.drawer-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 10px;
|
||||
background: linear-gradient(180deg, #1d1d1d 0%, #111 100%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.drawer-kicker {
|
||||
margin: 0 0 3px;
|
||||
font-size: 11px;
|
||||
color: var(--primary-light);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.drawer-head h3 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.balance-bar {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.18);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.balance-bar strong {
|
||||
justify-self: end;
|
||||
color: var(--primary-light);
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.balance-refresh {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.slip-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
padding: 8px 12px 0;
|
||||
gap: 6px;
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.slip-tab {
|
||||
min-height: 38px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
border: 1px solid var(--border);
|
||||
background: #171717;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.slip-tab.active {
|
||||
border-color: var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.13);
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 6px;
|
||||
border-radius: 9px;
|
||||
background: var(--primary);
|
||||
color: #111;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px 14px 0;
|
||||
padding: 12px 12px 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.drawer-foot {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(14, 14, 14, 0.98);
|
||||
border-top: 1px solid var(--border-gold-soft);
|
||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.drawer-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 20px;
|
||||
padding: 4px;
|
||||
padding: 26px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.slip-item {
|
||||
padding: 10px 12px;
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 11px 12px;
|
||||
background: #151515;
|
||||
border: 1px solid #292929;
|
||||
border-radius: 7px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
.slip-item--single {
|
||||
border-color: var(--border-gold-soft);
|
||||
background: linear-gradient(180deg, rgba(212, 175, 55, 0.12), rgba(18, 18, 18, 0.94));
|
||||
}
|
||||
|
||||
.item-sel {
|
||||
.item-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-market {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.item-pick {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.odds {
|
||||
.item-odds,
|
||||
.item-side strong {
|
||||
color: var(--primary-light);
|
||||
font-weight: 800;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.item-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background: none;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mode-hint {
|
||||
.btn-add-parlay {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #4b370d 0%, #241905 100%);
|
||||
border: 1px solid var(--primary-light);
|
||||
color: #ffe892;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 232, 146, 0.18) inset,
|
||||
0 6px 18px rgba(212, 175, 55, 0.18);
|
||||
}
|
||||
|
||||
.btn-add-parlay span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-light);
|
||||
color: #191100;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.warning,
|
||||
.error {
|
||||
margin: 0 0 10px;
|
||||
padding: 10px 11px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 77, 79, 0.12);
|
||||
border: 1px solid rgba(255, 77, 79, 0.25);
|
||||
color: #ff8b8b;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mode-hint--parlay {
|
||||
.parlay-meta {
|
||||
margin: 8px 2px 10px;
|
||||
color: var(--primary-light);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stake-area {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.stake-area label {
|
||||
font-size: 12px;
|
||||
color: var(--primary-light);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.return {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.return strong {
|
||||
color: var(--primary-light);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
.stake-area {
|
||||
margin: 12px 0 12px;
|
||||
padding: 12px;
|
||||
background: #151515;
|
||||
border: 1px solid #292929;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stake-head,
|
||||
.return {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stake-head strong,
|
||||
.return strong {
|
||||
color: var(--primary-light);
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.stake-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #0c0c0c;
|
||||
}
|
||||
|
||||
.currency {
|
||||
padding: 0 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
border-right: 1px solid #2d2d2d;
|
||||
}
|
||||
|
||||
.stake-input-row input {
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
padding: 0 10px;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.stake-input-row input::-webkit-outer-spin-button,
|
||||
.stake-input-row input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.input-clear {
|
||||
width: 38px;
|
||||
height: 42px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.number-pad,
|
||||
.quick-stakes {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.number-pad {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
.quick-stakes {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.number-key,
|
||||
.quick-stakes button {
|
||||
min-height: 52px;
|
||||
border-radius: 5px;
|
||||
background: #f4f1f6;
|
||||
color: #221f26;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.number-key--backspace span {
|
||||
display: inline-block;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.quick-stakes button {
|
||||
min-height: 50px;
|
||||
background: #7b787d;
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.return {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 24px;
|
||||
font-size: 13px;
|
||||
.drawer-foot {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(14, 14, 14, 0.98);
|
||||
border-top: 1px solid var(--border-gold-soft);
|
||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.drawer-foot--split {
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(180deg, var(--primary-light), var(--primary));
|
||||
color: #111;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,7 @@ const stats = computed(() => {
|
||||
for (const bet of props.items) {
|
||||
total++;
|
||||
const s = bet.status.toUpperCase();
|
||||
if (s === 'WON' || s === 'WIN') { won++; totalReturn += parseAmount(bet.actualReturn); }
|
||||
if (s === 'WON' || s === 'WIN') won++;
|
||||
else if (s === 'LOST' || s === 'LOSE') lost++;
|
||||
else if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') push++;
|
||||
else pending++;
|
||||
@@ -56,11 +56,13 @@ const stats = computed(() => {
|
||||
<span class="stat-label">{{ t('history.stats_push') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-bar">
|
||||
<div class="bar-seg won" :style="{ flex: stats.won || 0.001 }" />
|
||||
<div class="bar-seg lost" :style="{ flex: stats.lost || 0.001 }" />
|
||||
<div class="bar-seg pending" :style="{ flex: stats.pending || 0.001 }" />
|
||||
<div class="bar-seg push" :style="{ flex: stats.push || 0.001 }" />
|
||||
<div class="stats-bar" :class="{ empty: stats.total === 0 }" aria-hidden="true">
|
||||
<template v-if="stats.total > 0">
|
||||
<div v-if="stats.won > 0" class="bar-seg won" :style="{ flex: stats.won }" />
|
||||
<div v-if="stats.lost > 0" class="bar-seg lost" :style="{ flex: stats.lost }" />
|
||||
<div v-if="stats.pending > 0" class="bar-seg pending" :style="{ flex: stats.pending }" />
|
||||
<div v-if="stats.push > 0" class="bar-seg push" :style="{ flex: stats.push }" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="stats-row secondary">
|
||||
<div class="stat-item">
|
||||
@@ -141,8 +143,11 @@ const stats = computed(() => {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stats-bar.empty {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.bar-seg {
|
||||
min-width: 2px;
|
||||
border-radius: 2px;
|
||||
transition: flex 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -23,10 +23,18 @@ const props = defineProps<{
|
||||
bettingOpen?: boolean;
|
||||
matchPhase?: import('../utils/matchPhase').MatchPhase;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
htHome: number | null;
|
||||
htAway: number | null;
|
||||
ftHome: number | null;
|
||||
ftAway: number | null;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
} | null;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { teamFlagUrl } from '../utils/teamFlag';
|
||||
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
match: {
|
||||
@@ -17,10 +18,18 @@ const props = defineProps<{
|
||||
bettingOpen?: boolean;
|
||||
matchPhase?: MatchPhase;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
htHome: number | null;
|
||||
htAway: number | null;
|
||||
ftHome: number | null;
|
||||
ftAway: number | null;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
} | null;
|
||||
};
|
||||
}>();
|
||||
@@ -30,16 +39,10 @@ const emit = defineEmits<{ bet: [id: string] }>();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
function formatKickoff(startTime: string) {
|
||||
const d = new Date(startTime);
|
||||
const now = new Date();
|
||||
const isToday =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
const timeStr = d.toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit' });
|
||||
if (isToday) return `${t('bet.today')} ${timeStr}`;
|
||||
const dateStr = d.toLocaleDateString(locale.value, { month: 'numeric', day: 'numeric' });
|
||||
return `${dateStr} ${timeStr}`;
|
||||
return formatLocalMatchDateTime(startTime, locale.value, {
|
||||
variant: 'compact',
|
||||
todayLabel: t('bet.today'),
|
||||
});
|
||||
}
|
||||
|
||||
const kickoffText = computed(() => formatKickoff(props.match.startTime));
|
||||
@@ -64,7 +67,7 @@ const phaseLabel = computed(() => matchPhaseLabel(t, phase.value));
|
||||
|
||||
const liveScoreText = computed(() => {
|
||||
const s = props.match.score;
|
||||
if (!s) return '';
|
||||
if (!s || s.ftHome == null || s.ftAway == null) return '';
|
||||
return `${s.ftHome} - ${s.ftAway}`;
|
||||
});
|
||||
</script>
|
||||
@@ -216,8 +219,11 @@ const liveScoreText = computed(() => {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
line-height: 1.15;
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
max-width: 96px;
|
||||
overflow-wrap: anywhere;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,12 @@ const props = withDefaults(
|
||||
{ size: 'md' },
|
||||
);
|
||||
|
||||
const useCustomLogo = computed(() => Boolean(props.logoUrl?.trim()));
|
||||
function isCustomLogo(url?: string | null) {
|
||||
const value = url?.trim();
|
||||
return Boolean(value && !value.includes('flagcdn.com'));
|
||||
}
|
||||
|
||||
const useCustomLogo = computed(() => isCustomLogo(props.logoUrl));
|
||||
const src = computed(() =>
|
||||
teamFlagUrl(props.teamCode, props.teamName, props.logoUrl),
|
||||
);
|
||||
|
||||
@@ -9,27 +9,34 @@ const props = defineProps<{
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
selectionDisplayName?: string;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
}>;
|
||||
stakes: Record<string, number>;
|
||||
isSelected: (id: string) => boolean;
|
||||
locked?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:stakes': [Record<string, number>];
|
||||
pick: [id: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const columns = computed(() =>
|
||||
groupCorrectScoreSelections(props.selections, props.marketType, t),
|
||||
groupCorrectScoreSelections(
|
||||
props.selections.map((s) => ({
|
||||
...s,
|
||||
selectionName: s.selectionDisplayName?.trim() || s.selectionName,
|
||||
})),
|
||||
props.marketType,
|
||||
t,
|
||||
),
|
||||
);
|
||||
|
||||
function setStake(sel: CsSelection, raw: string) {
|
||||
function onPick(sel: CsSelection) {
|
||||
if (props.locked) return;
|
||||
const n = Math.max(0, Number(raw) || 0);
|
||||
emit('update:stakes', { ...props.stakes, [sel.id]: n });
|
||||
emit('pick', sel.id);
|
||||
}
|
||||
|
||||
function formatOdds(odds: string) {
|
||||
@@ -48,21 +55,18 @@ function formatOdds(odds: string) {
|
||||
|
||||
<div class="cols-grid">
|
||||
<div v-for="colKey in ['home', 'draw', 'away'] as const" :key="colKey" class="col">
|
||||
<div v-for="sel in columns[colKey]" :key="sel.id" class="score-card">
|
||||
<button
|
||||
v-for="sel in columns[colKey]"
|
||||
:key="sel.id"
|
||||
type="button"
|
||||
class="score-card"
|
||||
:class="{ selected: isSelected(sel.id), 'score-card--locked': locked }"
|
||||
:disabled="locked"
|
||||
@click="onPick(sel)"
|
||||
>
|
||||
<span class="score-line">{{ sel.scoreDisplay }}</span>
|
||||
<span class="odds">{{ formatOdds(sel.odds) }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="stake-input"
|
||||
min="0"
|
||||
step="1"
|
||||
inputmode="decimal"
|
||||
:placeholder="t('bet.stake_placeholder')"
|
||||
:value="stakes[sel.id] ?? ''"
|
||||
:disabled="locked"
|
||||
@input="setStake(sel, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +81,7 @@ function formatOdds(odds: string) {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.cs-panel--locked .stake-input {
|
||||
.cs-panel--locked .score-card {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -108,10 +112,29 @@ function formatOdds(odds: string) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 3px;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
min-height: 42px;
|
||||
padding: 6px 3px;
|
||||
background: #161616;
|
||||
border: 1px solid #282828;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.score-card.selected {
|
||||
border-color: var(--primary);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
.score-card--locked {
|
||||
background: #111;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.score-card--locked .score-line,
|
||||
.score-card--locked .odds {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.score-line {
|
||||
@@ -125,27 +148,4 @@ function formatOdds(odds: string) {
|
||||
font-weight: 700;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.stake-input {
|
||||
width: 100%;
|
||||
padding: 4px 2px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #2a2a2a;
|
||||
background: #0a0a0a;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.stake-input:focus {
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.stake-input::-webkit-outer-spin-button,
|
||||
.stake-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,19 +7,24 @@ const props = defineProps<{
|
||||
id: string;
|
||||
selectionCode?: string;
|
||||
selectionName: string;
|
||||
selectionDisplayName?: string;
|
||||
odds: string;
|
||||
}[];
|
||||
isSelected: (id: string) => boolean;
|
||||
compact?: boolean;
|
||||
locked?: boolean;
|
||||
lineValue?: string | number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ pick: [id: string] }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
function label(sel: (typeof props.selections)[number]) {
|
||||
if (sel.selectionDisplayName?.trim()) return sel.selectionDisplayName;
|
||||
if (sel.selectionCode) {
|
||||
return resolveSelectionLabel(t, sel.selectionCode, sel.selectionName);
|
||||
return resolveSelectionLabel(t, sel.selectionCode, sel.selectionName, {
|
||||
lineValue: props.lineValue,
|
||||
});
|
||||
}
|
||||
return sel.selectionName;
|
||||
}
|
||||
|
||||
@@ -1,801 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useBetSlipStore } from '../../stores/betSlip';
|
||||
import { usePlayerMatches, type ParlayMatch } from '../../composables/usePlayerMatches';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { PARLAY_MAX_LEGS, canSelectForParlay } from '@thebet365/shared';
|
||||
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS, PARLAY_MARKET_GROUPS } from '../../utils/parlayColumns';
|
||||
import BetGuideHelp from '../BetGuideHelp.vue';
|
||||
import GoldSpinner from '../GoldSpinner.vue';
|
||||
import TeamEmblem from '../TeamEmblem.vue';
|
||||
import cardBg from '../../assets/images/card-bg.webp';
|
||||
import { matchPhaseLabel, type MatchPhase } from '../../utils/matchPhase';
|
||||
|
||||
const matchCardBg = `url(${cardBg})`;
|
||||
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
|
||||
|
||||
type TimeFilter = 'all' | 'today';
|
||||
|
||||
interface Selection {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
}
|
||||
|
||||
interface Market {
|
||||
id: string;
|
||||
marketType: string;
|
||||
lineValue?: string | number | null;
|
||||
allowParlay?: boolean;
|
||||
selections: Selection[];
|
||||
}
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const slip = useBetSlipStore();
|
||||
const auth = useAuthStore();
|
||||
|
||||
function goLogin() {
|
||||
auth.showLoginPrompt('/bet');
|
||||
}
|
||||
|
||||
const { parlayMatches, parlayLoading, loadParlay } = usePlayerMatches();
|
||||
const matches = parlayMatches;
|
||||
const loading = parlayLoading;
|
||||
const timeFilter = ref<TimeFilter>('all');
|
||||
const leagueFilter = ref('');
|
||||
const showClosed = ref(false);
|
||||
const collapsed = ref<Set<string>>(new Set());
|
||||
|
||||
const parlayMarketKeys = PARLAY_MARKET_TYPES.map((c) => c.key);
|
||||
|
||||
function syncCollapsedAfterLoad() {
|
||||
const ids = matches.value.map((m) => m.id);
|
||||
// 只保留仍然存在的 id
|
||||
const kept = [...collapsed.value].filter((id) => ids.includes(id));
|
||||
if (kept.length > 0) {
|
||||
collapsed.value = new Set(kept);
|
||||
return;
|
||||
}
|
||||
// 默认只展开第一个,其余折叠
|
||||
if (ids.length > 1) {
|
||||
collapsed.value = new Set(ids.slice(1));
|
||||
} else {
|
||||
collapsed.value = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
useOnLocaleChange(async () => {
|
||||
await loadParlay(true);
|
||||
syncCollapsedAfterLoad();
|
||||
});
|
||||
|
||||
const leagues = computed(() => {
|
||||
const seen = new Set<string>();
|
||||
const list: { id: string; name: string }[] = [];
|
||||
for (const m of matches.value) {
|
||||
const id = m.leagueId ?? m.leagueName;
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
list.push({ id, name: m.leagueName });
|
||||
}
|
||||
}
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return list;
|
||||
});
|
||||
|
||||
function parseLine(v: string | number | null | undefined) {
|
||||
if (v == null || v === '') return null;
|
||||
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function isParlayEligibleMarket(market: Market) {
|
||||
if (!market.selections.length) return false;
|
||||
return canSelectForParlay({
|
||||
marketType: market.marketType,
|
||||
lineValue: parseLine(market.lineValue),
|
||||
allowParlay: market.allowParlay ?? true,
|
||||
}).ok;
|
||||
}
|
||||
|
||||
function hasParlayMarkets(m: ParlayMatch) {
|
||||
return parlayMarketKeys.some((key) => {
|
||||
const market = m.markets?.find((mk) => mk.marketType === key);
|
||||
return market && isParlayEligibleMarket(market);
|
||||
});
|
||||
}
|
||||
|
||||
function getMarket(m: ParlayMatch, marketType: string) {
|
||||
return m.markets?.find((mk) => mk.marketType === marketType);
|
||||
}
|
||||
|
||||
function dayStart(d: Date) {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function isKickoffToday(startTime: string) {
|
||||
const kick = new Date(startTime);
|
||||
const now = new Date();
|
||||
const start = dayStart(now);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
return kick >= start && kick < end;
|
||||
}
|
||||
|
||||
const filteredMatches = computed(() => {
|
||||
let list = matches.value;
|
||||
if (timeFilter.value === 'today') {
|
||||
list = list.filter((m) => isKickoffToday(m.startTime));
|
||||
}
|
||||
if (leagueFilter.value) {
|
||||
list = list.filter((m) => (m.leagueId ?? m.leagueName) === leagueFilter.value);
|
||||
}
|
||||
if (!showClosed.value) {
|
||||
list = list.filter((m) => {
|
||||
const phase = m.matchPhase ?? (m.bettingOpen === false ? 'closed_pending' : 'open');
|
||||
return phase === 'open' || phase === undefined;
|
||||
});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function formatKickoff(startTime: string) {
|
||||
return new Date(startTime).toLocaleString(locale.value, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatOdds(odds: string) {
|
||||
const n = parseFloat(odds);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : odds;
|
||||
}
|
||||
|
||||
function selLabel(sel: Selection) {
|
||||
const key = PARLAY_SELECTION_KEYS[sel.selectionCode];
|
||||
if (key) return t(`bet.${key}`);
|
||||
return sel.selectionName.slice(0, 2);
|
||||
}
|
||||
|
||||
function colLabel(labelKey: string) {
|
||||
return t(`bet.${labelKey}`);
|
||||
}
|
||||
|
||||
function isPicked(selectionId: string) {
|
||||
return slip.items.some((i) => i.selectionId === selectionId);
|
||||
}
|
||||
|
||||
const parlayHint = ref('');
|
||||
|
||||
function pickSelection(match: ParlayMatch, market: Market, sel: Selection) {
|
||||
if (match.bettingOpen === false) return;
|
||||
const err = slip.addParlayLeg({
|
||||
selectionId: sel.id,
|
||||
oddsVersion: String(sel.oddsVersion),
|
||||
matchId: match.id,
|
||||
matchName: `${match.homeTeamName} vs ${match.awayTeamName}`,
|
||||
selectionName: `${selLabel(sel)} ${formatOdds(sel.odds)}`,
|
||||
odds: parseFloat(sel.odds),
|
||||
marketType: market.marketType,
|
||||
lineValue: parseLine(market.lineValue),
|
||||
allowParlay: market.allowParlay,
|
||||
});
|
||||
if (err === 'MAX_LEGS') parlayHint.value = t('bet.parlay_max_legs');
|
||||
else if (err === 'QUARTER_LINE') parlayHint.value = t('bet.parlay_block_quarter');
|
||||
else if (err === 'OUTRIGHT') parlayHint.value = t('bet.parlay_block_outright');
|
||||
else if (err === 'NOT_ALLOWED') parlayHint.value = t('bet.parlay_block_not_allowed');
|
||||
else if (err === 'SAME_MATCH') parlayHint.value = t('bet.parlay_same_match');
|
||||
else parlayHint.value = '';
|
||||
}
|
||||
|
||||
function openSlip() {
|
||||
if (!auth.token) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
slip.openDrawer();
|
||||
}
|
||||
|
||||
const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||
|
||||
const footConfirmLabel = computed(() =>
|
||||
slip.canPlaceParlay ? t('bet.parlay_confirm_parlay') : t('bet.cs_confirm_cell'),
|
||||
);
|
||||
|
||||
function toggleCollapse(id: string) {
|
||||
const next = new Set(collapsed.value);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
collapsed.value = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="parlay-panel" :class="{ 'has-fixed-foot': showParlayFoot }">
|
||||
<div class="toolbar">
|
||||
<select v-model="timeFilter" class="filter-select">
|
||||
<option value="all">{{ t('bet.parlay_filter_all') }}</option>
|
||||
<option value="today">{{ t('bet.tab_today') }}</option>
|
||||
</select>
|
||||
<select v-model="leagueFilter" class="filter-select">
|
||||
<option value="">{{ t('bet.parlay_filter_all') }}</option>
|
||||
<option v-for="lg in leagues" :key="lg.id" :value="lg.id">{{ lg.name }}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="phase-toggle"
|
||||
:class="{ 'phase-toggle--active': showClosed }"
|
||||
@click="showClosed = !showClosed"
|
||||
>
|
||||
{{ showClosed ? t('bet.show_all_matches') : t('bet.show_open_only') }}
|
||||
</button>
|
||||
<BetGuideHelp
|
||||
:title="t('bet.parlay_guide_title')"
|
||||
:aria-label="t('bet.parlay_guide_help')"
|
||||
storage-key="thebet365_parlay_guide_seen"
|
||||
>
|
||||
<p class="intro">{{ t('bet.parlay_desc') }}</p>
|
||||
<ol>
|
||||
<li>{{ t('bet.parlay_guide_1') }}</li>
|
||||
<li>{{ t('bet.parlay_guide_2') }}</li>
|
||||
<li>{{ t('bet.parlay_guide_3') }}</li>
|
||||
</ol>
|
||||
<p class="rules-link">{{ t('bet.guide_rules_link') }}</p>
|
||||
</BetGuideHelp>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredMatches.length" class="match-list">
|
||||
<div
|
||||
v-for="match in filteredMatches"
|
||||
:key="match.id"
|
||||
class="match-card"
|
||||
:class="{
|
||||
collapsed: collapsed.has(match.id),
|
||||
'match-card--phase': (match.matchPhase ?? (match.bettingOpen === false ? 'closed_pending' : 'open')) !== 'open',
|
||||
}"
|
||||
>
|
||||
<button type="button" class="match-head" @click="toggleCollapse(match.id)">
|
||||
<span
|
||||
v-if="(match.matchPhase ?? (match.bettingOpen === false ? 'closed_pending' : 'open')) === 'settled'"
|
||||
class="status-tag status-tag--settled"
|
||||
>{{ matchPhaseLabel(t, 'settled') }}</span>
|
||||
<span
|
||||
v-else-if="(match.matchPhase ?? (match.bettingOpen === false ? 'closed_pending' : 'open')) === 'closed_pending'"
|
||||
class="status-tag status-tag--pending"
|
||||
>{{ matchPhaseLabel(t, 'closed_pending') }}</span>
|
||||
|
||||
<div class="match-head-top">
|
||||
<span class="toggle-icon" :class="{ open: !collapsed.has(match.id) }" aria-hidden="true">
|
||||
<span class="toggle-mark">{{ collapsed.has(match.id) ? '+' : '−' }}</span>
|
||||
</span>
|
||||
<span class="m-league">{{ match.leagueName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="match-head-teams">
|
||||
<div class="m-team home">
|
||||
<TeamEmblem
|
||||
size="sm"
|
||||
:team-code="match.homeTeamCode"
|
||||
:team-name="match.homeTeamName"
|
||||
:logo-url="match.homeTeamLogoUrl"
|
||||
/>
|
||||
<span class="m-name">{{ match.homeTeamName }}</span>
|
||||
</div>
|
||||
<div class="m-center">
|
||||
<span
|
||||
v-if="(match.matchPhase ?? (match.bettingOpen === false ? 'closed_pending' : 'open')) !== 'open' && match.score"
|
||||
class="m-score"
|
||||
>
|
||||
{{ match.score.ftHome }} - {{ match.score.ftAway }}
|
||||
</span>
|
||||
<span v-else class="m-time">{{ formatKickoff(match.startTime) }}</span>
|
||||
</div>
|
||||
<div class="m-team away">
|
||||
<span class="m-name">{{ match.awayTeamName }}</span>
|
||||
<TeamEmblem
|
||||
size="sm"
|
||||
:team-code="match.awayTeamCode"
|
||||
:team-name="match.awayTeamName"
|
||||
:logo-url="match.awayTeamLogoUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-show="!collapsed.has(match.id)" class="market-blocks">
|
||||
<div v-for="group in PARLAY_MARKET_GROUPS" :key="group.headerKey" class="market-group">
|
||||
<div class="group-header">{{ colLabel(group.headerKey) }}</div>
|
||||
<div
|
||||
v-for="col in group.columns"
|
||||
:key="col.key"
|
||||
class="market-row"
|
||||
>
|
||||
<span class="block-label">{{ colLabel(col.labelKey) }}</span>
|
||||
<div class="block-btns">
|
||||
<template
|
||||
v-if="
|
||||
getMarket(match, col.key) &&
|
||||
isParlayEligibleMarket(getMarket(match, col.key)!)
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-for="sel in getMarket(match, col.key)!.selections"
|
||||
:key="sel.id"
|
||||
type="button"
|
||||
class="odd-btn"
|
||||
:class="{
|
||||
picked: isPicked(sel.id),
|
||||
'odd-btn--locked': match.bettingOpen === false,
|
||||
}"
|
||||
:disabled="match.bettingOpen === false"
|
||||
@click="pickSelection(match, getMarket(match, col.key)!, sel)"
|
||||
>
|
||||
<span class="odd-label">{{ selLabel(sel) }}</span>
|
||||
<span class="odd-val">{{ formatOdds(sel.odds) }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<span v-else class="market-empty">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">
|
||||
<span class="empty-icon" aria-hidden="true">⚽</span>
|
||||
<p>{{ t('bet.parlay_empty') }}</p>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showParlayFoot" class="parlay-foot-fixed">
|
||||
<p v-if="parlayHint" class="foot-hint foot-hint--warn">{{ parlayHint }}</p>
|
||||
<p v-else-if="slip.count < 2" class="foot-hint">{{ t('bet.parlay_need_more') }}</p>
|
||||
<p v-else-if="slip.count > PARLAY_MAX_LEGS" class="foot-hint">{{ t('bet.parlay_max_legs') }}</p>
|
||||
<p v-else class="foot-meta">
|
||||
{{ t('bet.bet_slip') }} ({{ slip.count }}) · {{ t('bet.parlay') }}
|
||||
{{ slip.totalOdds.toFixed(2) }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="market-foot-btn"
|
||||
:disabled="!slip.canPlaceParlay"
|
||||
@click="openSlip"
|
||||
>
|
||||
{{ footConfirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.parlay-panel {
|
||||
padding: 0 12px 16px;
|
||||
}
|
||||
|
||||
.parlay-panel.has-fixed-foot {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
background: #141414;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
color: var(--primary-light);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.phase-toggle {
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
background: #141414;
|
||||
border: 1px solid #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phase-toggle--active {
|
||||
color: var(--primary-light);
|
||||
border-color: var(--primary);
|
||||
background: rgba(200, 168, 78, 0.08);
|
||||
}
|
||||
|
||||
.parlay-foot-fixed {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(50px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 95;
|
||||
padding: 10px 12px 10px;
|
||||
background: rgba(14, 14, 14, 0.98);
|
||||
border-top: 1px solid var(--border-gold-soft);
|
||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.foot-hint,
|
||||
.foot-meta {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.foot-hint--warn {
|
||||
color: var(--danger);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.foot-meta {
|
||||
color: var(--primary-light);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.market-foot-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.market-foot-btn:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.match-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.match-card {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
|
||||
border: 1px solid rgba(140, 140, 140, 0.35);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.match-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(100, 100, 100, 0.08) 0%, transparent 50%, rgba(80, 80, 80, 0.05) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.match-card.collapsed {
|
||||
border-color: #222;
|
||||
}
|
||||
|
||||
.match-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.match-head::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
|
||||
opacity: 0.25;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.match-head:active {
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
|
||||
.match-head-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.match-head-teams {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m-team {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m-team.away {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
min-width: 50px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.m-name {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m-vs {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #555;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.m-time {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #141414;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toggle-icon.open {
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.toggle-mark {
|
||||
color: var(--primary-light);
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.m-league {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.match-head :deep(.team-emblem) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.market-blocks {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 10px;
|
||||
border-top: 1px solid rgba(140, 140, 140, 0.15);
|
||||
}
|
||||
|
||||
.market-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px 6px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #888;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0 2px 2px;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
}
|
||||
|
||||
.market-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.block-btns {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.odd-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid #333;
|
||||
min-width: 44px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.odd-btn.picked {
|
||||
border-color: var(--primary);
|
||||
background: rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
|
||||
.odd-btn--locked {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.odd-btn--locked .odd-label,
|
||||
.odd-btn--locked .odd-val {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.match-card--phase {
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
padding: 3px 10px;
|
||||
border-radius: 0 6px 0 8px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.status-tag--settled {
|
||||
background: linear-gradient(180deg, #2a2a3a 0%, #1a1a28 100%);
|
||||
color: #8a9ab8;
|
||||
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
|
||||
border-left: 1px solid rgba(120, 140, 180, 0.3);
|
||||
}
|
||||
|
||||
.status-tag--pending {
|
||||
background: linear-gradient(180deg, #3a3a2a 0%, #28251a 100%);
|
||||
color: #c8a84e;
|
||||
border-bottom: 1px solid rgba(200, 168, 78, 0.3);
|
||||
border-left: 1px solid rgba(200, 168, 78, 0.3);
|
||||
}
|
||||
|
||||
.m-score {
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.odd-label {
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.odd-val {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.market-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
color: #444;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.state,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
display: block;
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -20,40 +20,25 @@ export interface PlayerMatchSummary {
|
||||
bettingOpen?: boolean;
|
||||
matchPhase?: MatchPhase;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
htHome: number | null;
|
||||
htAway: number | null;
|
||||
ftHome: number | null;
|
||||
ftAway: number | null;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ParlayMarketSelection {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
}
|
||||
|
||||
export interface ParlayMarket {
|
||||
id: string;
|
||||
marketType: string;
|
||||
lineValue?: string | number | null;
|
||||
allowParlay?: boolean;
|
||||
selections: ParlayMarketSelection[];
|
||||
}
|
||||
|
||||
export interface ParlayMatch extends PlayerMatchSummary {
|
||||
markets: ParlayMarket[];
|
||||
}
|
||||
|
||||
const summaryMatches = shallowRef<PlayerMatchSummary[]>([]);
|
||||
const parlayMatches = shallowRef<ParlayMatch[]>([]);
|
||||
const summaryLoading = ref(false);
|
||||
const parlayLoading = ref(false);
|
||||
|
||||
let summaryInflight: Promise<void> | null = null;
|
||||
let parlayInflight: Promise<void> | null = null;
|
||||
|
||||
async function loadSummary(force = false): Promise<void> {
|
||||
if (force) summaryMatches.value = [];
|
||||
@@ -76,74 +61,10 @@ async function loadSummary(force = false): Promise<void> {
|
||||
return summaryInflight;
|
||||
}
|
||||
|
||||
async function loadParlay(force = false): Promise<void> {
|
||||
if (force) parlayMatches.value = [];
|
||||
|
||||
const hadData = parlayMatches.value.length > 0;
|
||||
if (!force && hadData) {
|
||||
if (parlayInflight) return parlayInflight;
|
||||
parlayInflight = (async () => {
|
||||
try {
|
||||
const { data } = await api.get('/player/matches', { params: { scope: 'parlay' } });
|
||||
mergeParlayOdds((data.data ?? []) as ParlayMatch[]);
|
||||
} finally {
|
||||
parlayInflight = null;
|
||||
}
|
||||
})();
|
||||
return parlayInflight;
|
||||
}
|
||||
|
||||
if (parlayInflight) return parlayInflight;
|
||||
parlayLoading.value = true;
|
||||
|
||||
parlayInflight = (async () => {
|
||||
try {
|
||||
const { data } = await api.get('/player/matches', { params: { scope: 'parlay' } });
|
||||
parlayMatches.value = (data.data ?? []) as ParlayMatch[];
|
||||
} catch {
|
||||
parlayMatches.value = [];
|
||||
} finally {
|
||||
parlayLoading.value = false;
|
||||
parlayInflight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return parlayInflight;
|
||||
}
|
||||
|
||||
function mergeParlayOdds(fresh: ParlayMatch[]) {
|
||||
const matchMap = new Map<string, ParlayMatch>();
|
||||
for (const m of fresh) matchMap.set(m.id, m);
|
||||
|
||||
for (const match of parlayMatches.value) {
|
||||
const freshMatch = matchMap.get(match.id);
|
||||
if (!freshMatch) continue;
|
||||
const marketMap = new Map<string, ParlayMarket>();
|
||||
for (const mk of freshMatch.markets) marketMap.set(mk.id, mk);
|
||||
for (const market of match.markets) {
|
||||
const freshMarket = marketMap.get(market.id);
|
||||
if (!freshMarket) continue;
|
||||
const selMap = new Map<string, ParlayMarketSelection>();
|
||||
for (const s of freshMarket.selections) selMap.set(s.id, s);
|
||||
for (const sel of market.selections) {
|
||||
const fs = selMap.get(sel.id);
|
||||
if (fs) {
|
||||
sel.odds = fs.odds;
|
||||
sel.oddsVersion = fs.oddsVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 赛事列表与串关共享缓存,避免重复拉取大 JSON */
|
||||
export function usePlayerMatches() {
|
||||
return {
|
||||
summaryMatches,
|
||||
parlayMatches,
|
||||
summaryLoading,
|
||||
parlayLoading,
|
||||
loadSummary,
|
||||
loadParlay,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -242,7 +242,6 @@ export default {
|
||||
parlay: 'Parlay',
|
||||
tab_matches: 'Matches',
|
||||
tab_outright: 'Outright',
|
||||
tab_parlay: 'Parlay',
|
||||
tab_today: 'Today',
|
||||
tab_early: 'Early',
|
||||
show_open_only: 'Open only',
|
||||
@@ -271,23 +270,10 @@ export default {
|
||||
outright_shown_count: '{shown} / {total} teams shown',
|
||||
outright_load_more: 'Load more',
|
||||
cancel: 'Cancel',
|
||||
parlay_title: 'Parlay',
|
||||
parlay_guide_title: 'How to parlay',
|
||||
parlay_guide_help: 'Parlay help',
|
||||
parlay_desc: 'Combine 2–5 pre-match legs (2-fold to 5-fold). No live, outright, or quarter-ball HDP/O-U in parlay.',
|
||||
parlay_guide_1: 'Tap odds in the list; selected cells show a gold border. Tap again to remove',
|
||||
parlay_guide_2: 'Pick 2–5 legs from different matches. No outright or quarter-ball HDP/O-U',
|
||||
parlay_guide_3: 'Tap Confirm order at the bottom, enter stake in the bet slip, and submit',
|
||||
parlay_max_legs: 'Parlay allows up to 5 legs',
|
||||
parlay_block_outright: 'Outright cannot be parlayed',
|
||||
parlay_block_quarter: 'Quarter-ball HDP/O-U cannot be parlayed',
|
||||
parlay_block_not_allowed: 'This market cannot be parlayed',
|
||||
parlay_filter_all: 'All',
|
||||
parlay_empty: 'No matches available for parlay betting',
|
||||
parlay_same_match: 'Cannot parlay selections from the same match',
|
||||
parlay_same_match_singles: '{n} selection(s) → {n} separate single bet(s)',
|
||||
parlay_confirm_singles: 'Place {n} single bet(s)',
|
||||
parlay_confirm_parlay: 'Place parlay',
|
||||
parlay_need_more: 'Select at least 2 legs for parlay',
|
||||
back: 'Back',
|
||||
refresh: 'Refresh',
|
||||
@@ -309,10 +295,6 @@ export default {
|
||||
market_ht_handicap: 'HT Handicap',
|
||||
market_ht_ou: 'HT O/U',
|
||||
market_ht_1x2: 'HT 1X2',
|
||||
parlay_lbl_handicap: 'Handicap',
|
||||
parlay_lbl_ou: 'O/U',
|
||||
parlay_lbl_1x2: '1X2',
|
||||
parlay_lbl_oe: 'Odd/Even',
|
||||
parlay_sel_home: 'H',
|
||||
parlay_sel_away: 'A',
|
||||
parlay_sel_draw: 'D',
|
||||
@@ -326,26 +308,32 @@ export default {
|
||||
col_home: 'Home',
|
||||
col_draw: 'Draw',
|
||||
col_away: 'Away',
|
||||
cs_stake_required: 'Enter stake on at least one score',
|
||||
cs_stake_required: 'Select at least one score',
|
||||
cs_confirm_title: 'Confirm correct score bets',
|
||||
cs_confirm_count: '{n} bet(s)',
|
||||
cs_confirm_total_stake: 'Total stake',
|
||||
cs_place_success: 'Bet placed',
|
||||
cs_place_failed: 'Bet failed',
|
||||
kickoff_time: 'Kickoff: ',
|
||||
result_stats_title: 'Match facts',
|
||||
result_ht: 'HT',
|
||||
result_ft: 'FT',
|
||||
result_corners: 'Corners',
|
||||
result_yellow_cards: 'Yellow',
|
||||
result_red_cards: 'Red',
|
||||
guide_title: 'How to bet',
|
||||
guide_help_aria: 'Betting help',
|
||||
guide_got_it: 'Got it',
|
||||
guide_flow_normal: 'Handicap / O-U / 1X2 etc.',
|
||||
guide_normal_1: 'Tap Expand to show odds',
|
||||
guide_normal_2: 'Tap one odds to select (gold border); tap again to cancel',
|
||||
guide_normal_3: 'Tap Place order under that market, enter stake and confirm',
|
||||
guide_normal_1: 'All markets are shown by default',
|
||||
guide_normal_2: 'Tap any odds to open the bet drawer',
|
||||
guide_normal_3: 'Enter stake in the drawer, then place a single bet or add it to parlay',
|
||||
guide_flow_cs: 'Correct score',
|
||||
guide_cs_1: 'Expand and enter stake on each score',
|
||||
guide_cs_2: 'Enter stakes, then tap Place order at the bottom of that market',
|
||||
guide_cs_3: 'Multiple scores = multiple bets',
|
||||
guide_cs_1: 'Tap the score you want in the score table',
|
||||
guide_cs_2: 'The bet drawer opens so you can enter stake and submit',
|
||||
guide_cs_3: 'Score picks are single bets and can also be added to parlay',
|
||||
guide_flow_parlay: 'Parlay (2–5 legs)',
|
||||
guide_parlay_1: 'This page is for singles and correct score. Parlay: tap Bet in the bottom nav, then the Parlay tab at the top of that page, pick 2–5 different matches, and submit from the bet slip.',
|
||||
guide_parlay_1: 'Tap any odds to open the drawer, then tap Add to parlay to put the current selection into your parlay slip.',
|
||||
guide_rules_link: 'Full rules: Profile → Betting Rules.',
|
||||
mode_cs_tag: 'Bet here',
|
||||
mode_slip_tag: 'Add to slip',
|
||||
@@ -360,12 +348,22 @@ export default {
|
||||
slip_bar_ready: '1 selection',
|
||||
slip_bar_go: 'Bet slip',
|
||||
cs_top_hint: '① Enter stake ② Tap Confirm bet above',
|
||||
slip_empty_hint: 'Tap odds to add to bet slip',
|
||||
slip_empty_hint: 'Tap odds to open the bet drawer',
|
||||
slip_remove: 'Remove',
|
||||
slip_singles_hint: '{n} single bet(s). Parlay: Bet page → top Parlay tab.',
|
||||
slip_stake_per_bet: 'Stake per bet',
|
||||
slip_est_return: 'Est. total return',
|
||||
slip_est_return: 'Est. max payout',
|
||||
slip_parlay_odds: 'Combined odds {odds}',
|
||||
slip_review_title: 'Review selection',
|
||||
slip_balance: 'Account balance',
|
||||
slip_tab_single: 'Single',
|
||||
slip_tab_parlay: 'Parlay',
|
||||
slip_parlay_empty_hint: 'Pick a selection, then tap Add to parlay',
|
||||
slip_add_parlay: 'Add to parlay',
|
||||
slip_parlay_same_match: 'Parlay selections cannot be from the same match. Remove the same-match leg first.',
|
||||
slip_parlay_count: '{n} parlay leg(s)',
|
||||
slip_total_stake: 'Total stake',
|
||||
slip_currency: 'Amount',
|
||||
slip_min: 'Min',
|
||||
slip_min_error: 'Minimum stake is {amount}',
|
||||
place_success: 'Bet placed',
|
||||
place_failed: 'Bet failed',
|
||||
},
|
||||
@@ -411,7 +409,7 @@ export default {
|
||||
password_disabled: 'Password change is disabled for this account; contact support',
|
||||
rules_title: 'Betting Rules',
|
||||
rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.',
|
||||
rules_p2: 'Parlays: 2–5 legs from different matches (one per match). Outright and quarter-ball HDP/O-U are excluded.',
|
||||
rules_p2: 'Parlays: 2–5 legs, with at most one selection per match. Outright and quarter-ball HDP/O-U are excluded.',
|
||||
rules_p3: 'Results use admin-entered half-time and full-time scores; payouts apply after settlement preview is confirmed.',
|
||||
rules_p4: 'If this text conflicts with site notices, the latest notice and market rules prevail.',
|
||||
rules_p5: 'How to bet: open any match, tap the ? icon on the top right.',
|
||||
|
||||
@@ -248,7 +248,6 @@ export default {
|
||||
parlay: 'Berganda',
|
||||
tab_matches: 'Perlawanan',
|
||||
tab_outright: 'Juara',
|
||||
tab_parlay: 'Berganda',
|
||||
tab_today: 'Hari Ini',
|
||||
tab_early: 'Awal',
|
||||
show_open_only: 'Buka sahaja',
|
||||
@@ -277,23 +276,10 @@ export default {
|
||||
outright_shown_count: '{shown} / {total} pasukan dipaparkan',
|
||||
outright_load_more: 'Muat lagi',
|
||||
cancel: 'Batal',
|
||||
parlay_title: 'Pertaruhan Berganda',
|
||||
parlay_guide_title: 'Cara parlay',
|
||||
parlay_guide_help: 'Bantuan parlay',
|
||||
parlay_desc: 'Gabung 2–5 perlawanan pra-perlawanan (2 hingga 5 liputan). Tiada live, outright atau suku bola HDP/O-U.',
|
||||
parlay_guide_1: 'Ketik odds dalam senarai; pilihan dipilih ada sempadan emas. Ketik lagi untuk batal',
|
||||
parlay_guide_2: 'Pilih 2–5 pilihan dari perlawanan berbeza. Tiada outright atau suku bola HDP/O-U',
|
||||
parlay_guide_3: 'Ketik Sahkan pesanan di bawah, isi pegangan dalam slip, dan hantar',
|
||||
parlay_max_legs: 'Maksimum 5 pilihan parlay',
|
||||
parlay_block_outright: 'Outright tidak boleh parlay',
|
||||
parlay_block_quarter: 'HDP/O-U suku bola tidak boleh parlay',
|
||||
parlay_block_not_allowed: 'Pasaran ini tidak boleh parlay',
|
||||
parlay_filter_all: 'Semua',
|
||||
parlay_empty: 'Tiada perlawanan untuk pertaruhan berganda',
|
||||
parlay_same_match: 'Perlawanan sama tidak boleh berganda',
|
||||
parlay_same_match_singles: '{n} pilihan → {n} pertaruhan tunggal berasingan',
|
||||
parlay_confirm_singles: 'Sahkan {n} pertaruhan tunggal',
|
||||
parlay_confirm_parlay: 'Sahkan parlay',
|
||||
parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan',
|
||||
back: 'Kembali',
|
||||
refresh: 'Muat semula',
|
||||
@@ -315,10 +301,6 @@ export default {
|
||||
market_ht_handicap: 'Handicap Separuh',
|
||||
market_ht_ou: 'Atas/Bawah Separuh',
|
||||
market_ht_1x2: '1X2 Separuh',
|
||||
parlay_lbl_handicap: 'Handicap',
|
||||
parlay_lbl_ou: 'Atas/Bawah',
|
||||
parlay_lbl_1x2: '1X2',
|
||||
parlay_lbl_oe: 'Ganjil/Genap',
|
||||
parlay_sel_home: 'R',
|
||||
parlay_sel_away: 'P',
|
||||
parlay_sel_draw: 'S',
|
||||
@@ -332,26 +314,32 @@ export default {
|
||||
col_home: 'Home',
|
||||
col_draw: 'Seri',
|
||||
col_away: 'Away',
|
||||
cs_stake_required: 'Masukkan jumlah pada sekurang-kurangnya satu skor',
|
||||
cs_stake_required: 'Pilih sekurang-kurangnya satu skor',
|
||||
cs_confirm_title: 'Sahkan pertaruhan skor tepat',
|
||||
cs_confirm_count: '{n} pertaruhan',
|
||||
cs_confirm_total_stake: 'Jumlah pertaruhan',
|
||||
cs_place_success: 'Pertaruhan berjaya',
|
||||
cs_place_failed: 'Pertaruhan gagal',
|
||||
kickoff_time: 'Masa mula: ',
|
||||
result_stats_title: 'Statistik keputusan',
|
||||
result_ht: 'Separuh',
|
||||
result_ft: 'Penuh',
|
||||
result_corners: 'Sudut',
|
||||
result_yellow_cards: 'Kuning',
|
||||
result_red_cards: 'Merah',
|
||||
guide_title: 'Cara pertaruhan',
|
||||
guide_help_aria: 'Bantuan pertaruhan',
|
||||
guide_got_it: 'Faham',
|
||||
guide_flow_normal: 'Handicap / O-U / 1X2',
|
||||
guide_normal_1: 'Ketik Kembang untuk lihat odds',
|
||||
guide_normal_2: 'Pilih satu odds (sisi emas); ketik lagi untuk batal',
|
||||
guide_normal_3: 'Ketik Sahkan pesanan di bawah pasaran, isi jumlah dan sahkan',
|
||||
guide_normal_1: 'Semua pasaran dipaparkan secara lalai',
|
||||
guide_normal_2: 'Ketik mana-mana odds untuk buka laci pertaruhan',
|
||||
guide_normal_3: 'Isi jumlah dalam laci, kemudian hantar tunggal atau tambah ke parlay',
|
||||
guide_flow_cs: 'Skor tepat',
|
||||
guide_cs_1: 'Kembang dan isi jumlah setiap skor',
|
||||
guide_cs_2: 'Isi jumlah, kemudian Sahkan pesanan di bawah pasaran itu',
|
||||
guide_cs_3: 'Beberapa skor = beberapa pertaruhan',
|
||||
guide_cs_1: 'Ketik skor yang mahu dipertaruh dalam jadual skor',
|
||||
guide_cs_2: 'Laci pertaruhan akan dibuka untuk isi jumlah dan hantar',
|
||||
guide_cs_3: 'Pilihan skor ialah tunggal dan boleh ditambah ke parlay',
|
||||
guide_flow_parlay: 'Parlay (2–5 perlawanan)',
|
||||
guide_parlay_1: 'Halaman ini untuk tunggal dan skor tepat. Parlay: ketik Pertaruhan di nav bawah, kemudian tab Parlay di bahagian atas halaman, pilih 2–5 perlawanan berbeza, hantar dari slip.',
|
||||
guide_parlay_1: 'Ketik mana-mana odds untuk buka laci, kemudian ketik Tambah ke parlay untuk masukkan pilihan ini ke slip parlay.',
|
||||
guide_rules_link: 'Peraturan penuh: Profil → Peraturan Pertaruhan.',
|
||||
mode_cs_tag: 'Pertaruhan di sini',
|
||||
mode_slip_tag: 'Tambah ke slip',
|
||||
@@ -366,12 +354,22 @@ export default {
|
||||
slip_bar_ready: '1 pilihan',
|
||||
slip_bar_go: 'Buka slip',
|
||||
cs_top_hint: '① Isi jumlah ② Ketik Sahkan di atas',
|
||||
slip_empty_hint: 'Ketik odds untuk tambah ke slip',
|
||||
slip_empty_hint: 'Ketik odds untuk buka laci pertaruhan',
|
||||
slip_remove: 'Buang',
|
||||
slip_singles_hint: '{n} pertaruhan tunggal. Parlay: halaman Pertaruhan → tab Berganda di atas.',
|
||||
slip_stake_per_bet: 'Jumlah setiap pertaruhan',
|
||||
slip_est_return: 'Anggaran pulangan',
|
||||
slip_est_return: 'Anggaran bayaran maksimum',
|
||||
slip_parlay_odds: 'Odds gabungan {odds}',
|
||||
slip_review_title: 'Semak pilihan',
|
||||
slip_balance: 'Baki akaun',
|
||||
slip_tab_single: 'Tunggal',
|
||||
slip_tab_parlay: 'Parlay',
|
||||
slip_parlay_empty_hint: 'Pilih satu odds, kemudian ketik Tambah ke parlay',
|
||||
slip_add_parlay: 'Tambah ke parlay',
|
||||
slip_parlay_same_match: 'Pilihan parlay tidak boleh daripada perlawanan sama. Buang pilihan perlawanan sama dahulu.',
|
||||
slip_parlay_count: '{n} pilihan parlay',
|
||||
slip_total_stake: 'Jumlah pertaruhan',
|
||||
slip_currency: 'Amaun',
|
||||
slip_min: 'Min',
|
||||
slip_min_error: 'Jumlah minimum ialah {amount}',
|
||||
place_success: 'Pertaruhan berjaya',
|
||||
place_failed: 'Pertaruhan gagal',
|
||||
},
|
||||
|
||||
@@ -242,7 +242,6 @@ export default {
|
||||
parlay: '串关',
|
||||
tab_matches: '球赛',
|
||||
tab_outright: '优胜冠军',
|
||||
tab_parlay: '串关投注',
|
||||
tab_today: '今日',
|
||||
tab_early: '早盘',
|
||||
show_open_only: '仅显示待开赛',
|
||||
@@ -271,23 +270,10 @@ export default {
|
||||
outright_shown_count: '已显示 {shown} / {total} 队',
|
||||
outright_load_more: '加载更多',
|
||||
cancel: '取消',
|
||||
parlay_title: '串关投注',
|
||||
parlay_guide_title: '串关怎么投?',
|
||||
parlay_guide_help: '查看串关说明',
|
||||
parlay_desc: '选择 2–5 场赛前赛事组合串关(2 串 1 至 5 串 1)。赔率相乘,不含滚球、冠军盘与四分盘让球/大小。',
|
||||
parlay_guide_1: '在列表中点击各场赔率,选中项显示金边;再点同一项可取消',
|
||||
parlay_guide_2: '须选 2–5 项,且须为不同赛事;冠军盘与四分盘让球/大小不可选',
|
||||
parlay_guide_3: '选好后点底部「确认下单」打开投注单,填写金额并提交',
|
||||
parlay_max_legs: '串关最多 5 项',
|
||||
parlay_block_outright: '冠军盘不可串关',
|
||||
parlay_block_quarter: '四分盘让球/大小不可串关',
|
||||
parlay_block_not_allowed: '该玩法不可串关',
|
||||
parlay_filter_all: '全部',
|
||||
parlay_empty: '暂无可用串关赛事',
|
||||
parlay_same_match: '同一场比赛不能串关',
|
||||
parlay_same_match_singles: '已选 {n} 项,将分 {n} 笔单关下单',
|
||||
parlay_confirm_singles: '确认下单({n}笔单关)',
|
||||
parlay_confirm_parlay: '确认串关下单',
|
||||
parlay_need_more: '请至少选择 2 项进行串关',
|
||||
back: '返回',
|
||||
refresh: '刷新',
|
||||
@@ -309,10 +295,6 @@ export default {
|
||||
market_ht_handicap: '半场 让球',
|
||||
market_ht_ou: '半场 大小',
|
||||
market_ht_1x2: '半场 独赢盘',
|
||||
parlay_lbl_handicap: '让球',
|
||||
parlay_lbl_ou: '大小',
|
||||
parlay_lbl_1x2: '独赢盘',
|
||||
parlay_lbl_oe: '单/双',
|
||||
parlay_sel_home: '主',
|
||||
parlay_sel_away: '客',
|
||||
parlay_sel_draw: '和',
|
||||
@@ -326,26 +308,32 @@ export default {
|
||||
col_home: '主场',
|
||||
col_draw: '平',
|
||||
col_away: '客场',
|
||||
cs_stake_required: '请至少在一个比分输入投注金额',
|
||||
cs_stake_required: '请至少选择一个比分',
|
||||
cs_confirm_title: '确认波胆下注',
|
||||
cs_confirm_count: '共 {n} 注',
|
||||
cs_confirm_total_stake: '总投注额',
|
||||
cs_place_success: '下注成功',
|
||||
cs_place_failed: '下注失败',
|
||||
kickoff_time: '开赛时间:',
|
||||
result_stats_title: '赛果统计',
|
||||
result_ht: '半场',
|
||||
result_ft: '全场',
|
||||
result_corners: '角球',
|
||||
result_yellow_cards: '黄牌',
|
||||
result_red_cards: '红牌',
|
||||
guide_title: '怎么下注?',
|
||||
guide_help_aria: '查看下注说明',
|
||||
guide_got_it: '知道了',
|
||||
guide_flow_normal: '让球 / 大小 / 独赢等',
|
||||
guide_normal_1: '点「展开玩法」打开赔率',
|
||||
guide_normal_2: '点一项赔率选中(金边),再点同一项可取消',
|
||||
guide_normal_3: '选中后在当前玩法底部点「确认下单」填金额并提交',
|
||||
guide_normal_1: '页面会默认展示全部盘口',
|
||||
guide_normal_2: '点击任意赔率会直接打开投注抽屉',
|
||||
guide_normal_3: '在抽屉里填写金额,可单注下注或加入串关',
|
||||
guide_flow_cs: '波胆(猜比分)',
|
||||
guide_cs_1: '点「展开玩法」在表格里填各比分金额',
|
||||
guide_cs_2: '填好金额后点该玩法底部「确认下单」,核对后提交',
|
||||
guide_cs_3: '可一次填多个比分,会拆成多笔注单',
|
||||
guide_flow_parlay: '串关(2–5 场)',
|
||||
guide_parlay_1: '本页为单关/波胆。串关:底部导航点「投注」,在页面顶部切换到「串关投注」,选 2–5 场不同赛事后在投注单提交。',
|
||||
guide_cs_1: '在比分表中点击要下注的比分',
|
||||
guide_cs_2: '系统会打开投注抽屉,填写金额后提交',
|
||||
guide_cs_3: '比分项按单注下注,也可以加入串关',
|
||||
guide_flow_parlay: '串关(2–5 项)',
|
||||
guide_parlay_1: '点击赔率打开投注抽屉,再点「加入串关」即可把当前选项放入串关单。',
|
||||
guide_rules_link: '完整规则见「我的」→ 投注规则。',
|
||||
mode_cs_tag: '本页直接下注',
|
||||
mode_slip_tag: '加入投注单',
|
||||
@@ -360,12 +348,22 @@ export default {
|
||||
slip_bar_ready: '已选一项',
|
||||
slip_bar_go: '投注单',
|
||||
cs_top_hint: '① 在比分格填金额 ② 点上方「确认下注」',
|
||||
slip_empty_hint: '点击赔率加入投注单',
|
||||
slip_empty_hint: '点击赔率打开投注抽屉',
|
||||
slip_remove: '移除',
|
||||
slip_singles_hint: '共 {n} 笔单关(串关请到「投注」页顶部「串关投注」)',
|
||||
slip_stake_per_bet: '每笔投注金额',
|
||||
slip_est_return: '预计总返还',
|
||||
slip_est_return: '预计最大领取金额',
|
||||
slip_parlay_odds: '组合赔率 {odds}',
|
||||
slip_review_title: '确认下注内容',
|
||||
slip_balance: '账户余额',
|
||||
slip_tab_single: '单注',
|
||||
slip_tab_parlay: '串关',
|
||||
slip_parlay_empty_hint: '先选择一个下注项,再点「加入串关」',
|
||||
slip_add_parlay: '加入串关',
|
||||
slip_parlay_same_match: '串关选项不得为同一场比赛,请先移除同场选项后再加入。',
|
||||
slip_parlay_count: '{n} 项串关',
|
||||
slip_total_stake: '总投注金额',
|
||||
slip_currency: '金额',
|
||||
slip_min: '最低',
|
||||
slip_min_error: '最低投注金额为 {amount}',
|
||||
place_success: '下注成功',
|
||||
place_failed: '下注失败',
|
||||
},
|
||||
@@ -411,7 +409,7 @@ export default {
|
||||
password_disabled: '当前账号不允许自行修改密码,请联系客服',
|
||||
rules_title: '投注规则',
|
||||
rules_p1: '本平台第一版仅支持足球赛前盘,不含滚球、Cash Out、改单及系统串关。',
|
||||
rules_p2: '串关为 2 串 1 至 5 串 1,每场最多选 1 项;冠军盘、四分盘让球/大小不可进入串关。',
|
||||
rules_p2: '串关为 2 串 1 至 5 串 1,每场比赛最多选择一项;冠军盘、四分盘让球/大小不可进入串关。',
|
||||
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
|
||||
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
|
||||
rules_p5: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',
|
||||
|
||||
@@ -41,7 +41,14 @@ const showAnnouncement = computed(() => !isDetailPage.value && !route.path.start
|
||||
|
||||
const showBottomNav = computed(() => {
|
||||
const p = route.path;
|
||||
if (p === '/' || p === '/bet' || p === '/bets' || p === '/wallet' || p === '/profile') return true;
|
||||
if (
|
||||
p === '/' ||
|
||||
p === '/bet' ||
|
||||
p.startsWith('/match/') ||
|
||||
p === '/bets' ||
|
||||
p === '/wallet' ||
|
||||
p === '/profile'
|
||||
) return true;
|
||||
return false;
|
||||
});
|
||||
const { announcements, load: loadPlayerHome } = usePlayerHome();
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface SlipItem {
|
||||
oddsVersion: string;
|
||||
matchId: string;
|
||||
matchName: string;
|
||||
marketId?: string;
|
||||
marketName?: string;
|
||||
selectionName: string;
|
||||
odds: number;
|
||||
marketType: string;
|
||||
@@ -19,14 +21,28 @@ export interface SlipItem {
|
||||
allowParlay?: boolean;
|
||||
}
|
||||
|
||||
export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
const items = ref<SlipItem[]>([]);
|
||||
const stake = ref<number>(10);
|
||||
const mode = ref<'single' | 'parlay'>('single');
|
||||
const lastParlayError = ref<ParlayRejectReason | 'MAX_LEGS' | null>(null);
|
||||
export type SlipMode = 'single' | 'parlay';
|
||||
export type ParlaySlipError = ParlayRejectReason | 'MAX_LEGS' | 'SAME_MATCH';
|
||||
|
||||
export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
const singleItem = ref<SlipItem | null>(null);
|
||||
const parlayItems = ref<SlipItem[]>([]);
|
||||
const stake = ref<number>(5);
|
||||
const mode = ref<SlipMode>('single');
|
||||
const lastParlayError = ref<ParlaySlipError | null>(null);
|
||||
|
||||
const items = computed(() =>
|
||||
mode.value === 'parlay'
|
||||
? parlayItems.value
|
||||
: singleItem.value
|
||||
? [singleItem.value]
|
||||
: [],
|
||||
);
|
||||
const count = computed(() => items.value.length);
|
||||
const isParlay = computed(() => mode.value === 'parlay' && items.value.length >= PARLAY_MIN_LEGS);
|
||||
const parlayCount = computed(() => parlayItems.value.length);
|
||||
const singleCount = computed(() => (singleItem.value ? 1 : 0));
|
||||
const totalCount = computed(() => singleCount.value + parlayCount.value);
|
||||
const isParlay = computed(() => mode.value === 'parlay' && parlayItems.value.length >= PARLAY_MIN_LEGS);
|
||||
|
||||
function parseLineValue(v: number | string | null | undefined): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
@@ -34,35 +50,33 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** 球赛/详情页:同场可多选,分笔单关;再点同一项可取消 */
|
||||
function addItem(item: SlipItem) {
|
||||
if (mode.value === 'parlay') items.value = [];
|
||||
mode.value = 'single';
|
||||
function setMode(nextMode: SlipMode) {
|
||||
mode.value = nextMode;
|
||||
lastParlayError.value = null;
|
||||
|
||||
const existing = items.value.findIndex(
|
||||
(i) => i.selectionId === item.selectionId,
|
||||
);
|
||||
if (existing >= 0) {
|
||||
items.value.splice(existing, 1);
|
||||
return;
|
||||
}
|
||||
items.value.push(item);
|
||||
}
|
||||
|
||||
/** 串关页:须为不同赛事,每场最多 1 项 */
|
||||
function addParlayLeg(item: SlipItem): ParlayRejectReason | 'MAX_LEGS' | null {
|
||||
if (mode.value === 'single') items.value = [];
|
||||
/** 球赛/详情页:点击任意选项后作为当前单注,打开投注抽屉 */
|
||||
function setSingleItem(item: SlipItem) {
|
||||
mode.value = 'single';
|
||||
singleItem.value = item;
|
||||
lastParlayError.value = null;
|
||||
}
|
||||
|
||||
function addItem(item: SlipItem) {
|
||||
setSingleItem(item);
|
||||
}
|
||||
|
||||
/** 串关:必须来自不同赛事,仍按盘口规则过滤不可串关项 */
|
||||
function addParlayLeg(item: SlipItem): ParlaySlipError | null {
|
||||
mode.value = 'parlay';
|
||||
|
||||
const samePick = items.value.findIndex((i) => i.selectionId === item.selectionId);
|
||||
const samePick = parlayItems.value.findIndex((i) => i.selectionId === item.selectionId);
|
||||
if (samePick >= 0) {
|
||||
items.value.splice(samePick, 1);
|
||||
lastParlayError.value = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (items.value.some((i) => i.matchId === item.matchId)) {
|
||||
if (parlayItems.value.some((i) => i.matchId === item.matchId)) {
|
||||
lastParlayError.value = 'SAME_MATCH';
|
||||
return 'SAME_MATCH';
|
||||
}
|
||||
@@ -77,24 +91,52 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
return check.reason;
|
||||
}
|
||||
|
||||
if (items.value.length >= PARLAY_MAX_LEGS) {
|
||||
if (parlayItems.value.length >= PARLAY_MAX_LEGS) {
|
||||
lastParlayError.value = 'MAX_LEGS';
|
||||
return 'MAX_LEGS';
|
||||
}
|
||||
|
||||
items.value.push(item);
|
||||
parlayItems.value.push(item);
|
||||
lastParlayError.value = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function addSingleToParlay(): ParlaySlipError | null {
|
||||
if (!singleItem.value) return null;
|
||||
return addParlayLeg(singleItem.value);
|
||||
}
|
||||
|
||||
function removeItem(selectionId: string) {
|
||||
items.value = items.value.filter((i) => i.selectionId !== selectionId);
|
||||
if (!items.value.length) mode.value = 'single';
|
||||
if (singleItem.value?.selectionId === selectionId) {
|
||||
singleItem.value = null;
|
||||
}
|
||||
parlayItems.value = parlayItems.value.filter((i) => i.selectionId !== selectionId);
|
||||
if (mode.value === 'parlay' && !parlayItems.value.length) mode.value = 'single';
|
||||
lastParlayError.value = null;
|
||||
}
|
||||
|
||||
function clearSingle() {
|
||||
singleItem.value = null;
|
||||
if (mode.value === 'single') lastParlayError.value = null;
|
||||
}
|
||||
|
||||
function clearParlay() {
|
||||
parlayItems.value = [];
|
||||
mode.value = 'single';
|
||||
lastParlayError.value = null;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
items.value = [];
|
||||
if (mode.value === 'parlay') {
|
||||
clearParlay();
|
||||
return;
|
||||
}
|
||||
clearSingle();
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
singleItem.value = null;
|
||||
parlayItems.value = [];
|
||||
mode.value = 'single';
|
||||
lastParlayError.value = null;
|
||||
}
|
||||
@@ -111,22 +153,15 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
return items.value.reduce((acc, i) => acc + stake.value * i.odds, 0);
|
||||
});
|
||||
|
||||
const hasSameMatch = computed(() => {
|
||||
const matchIds = items.value.map((i) => i.matchId);
|
||||
return new Set(matchIds).size !== matchIds.length;
|
||||
});
|
||||
|
||||
const canPlaceParlay = computed(
|
||||
() =>
|
||||
mode.value === 'parlay' &&
|
||||
items.value.length >= PARLAY_MIN_LEGS &&
|
||||
items.value.length <= PARLAY_MAX_LEGS &&
|
||||
!hasSameMatch.value,
|
||||
parlayItems.value.length >= PARLAY_MIN_LEGS &&
|
||||
parlayItems.value.length <= PARLAY_MAX_LEGS,
|
||||
);
|
||||
|
||||
/** 详情页等同场多笔单关(串关模式不走此路径) */
|
||||
const canPlaceBatchSingles = computed(
|
||||
() => mode.value === 'single' && items.value.length >= 1,
|
||||
() => mode.value === 'single' && singleItem.value != null,
|
||||
);
|
||||
|
||||
const canSubmit = computed(
|
||||
@@ -144,23 +179,33 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
singleItem,
|
||||
parlayItems,
|
||||
items,
|
||||
stake,
|
||||
mode,
|
||||
singleCount,
|
||||
parlayCount,
|
||||
totalCount,
|
||||
count,
|
||||
isParlay,
|
||||
totalOdds,
|
||||
potentialReturn,
|
||||
hasSameMatch,
|
||||
canPlaceParlay,
|
||||
canPlaceBatchSingles,
|
||||
canSubmit,
|
||||
lastParlayError,
|
||||
drawerOpen,
|
||||
setMode,
|
||||
setSingleItem,
|
||||
addItem,
|
||||
addParlayLeg,
|
||||
addSingleToParlay,
|
||||
removeItem,
|
||||
clearSingle,
|
||||
clearParlay,
|
||||
clear,
|
||||
clearAll,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
};
|
||||
|
||||
@@ -1,45 +1,7 @@
|
||||
/** 详情页展示顺序(与产品设计稿一致) */
|
||||
export const FEATURED_MARKET_TYPES = [
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
] as const;
|
||||
|
||||
/** 详情页统一列表顺序:波胆在前,其余玩法在后 */
|
||||
export const DETAIL_MARKET_TYPES = [
|
||||
...FEATURED_MARKET_TYPES,
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_1X2',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'HT_1X2',
|
||||
] as const;
|
||||
|
||||
export const GRID_MARKET_TYPES = [
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_1X2',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'HT_1X2',
|
||||
] as const;
|
||||
|
||||
export type CatalogMarketType =
|
||||
| (typeof FEATURED_MARKET_TYPES)[number]
|
||||
| (typeof GRID_MARKET_TYPES)[number];
|
||||
|
||||
export const MARKET_I18N_KEY: Record<string, string> = {
|
||||
FT_CORRECT_SCORE: 'bet.market_cs',
|
||||
HT_CORRECT_SCORE: 'bet.market_ht_cs',
|
||||
SH_CORRECT_SCORE: 'bet.market_sh_cs',
|
||||
FT_HANDICAP: 'bet.market_ft_handicap',
|
||||
FT_OVER_UNDER: 'bet.market_ft_ou',
|
||||
FT_1X2: 'bet.market_ft_1x2',
|
||||
FT_ODD_EVEN: 'bet.market_ft_oe',
|
||||
HT_HANDICAP: 'bet.market_ht_handicap',
|
||||
HT_OVER_UNDER: 'bet.market_ht_ou',
|
||||
HT_1X2: 'bet.market_ht_1x2',
|
||||
};
|
||||
export {
|
||||
DETAIL_MARKET_TYPES,
|
||||
FEATURED_MARKET_TYPES,
|
||||
GRID_MARKET_TYPES,
|
||||
MARKET_I18N_KEY,
|
||||
type FootballMarketType as CatalogMarketType,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
5
apps/player/src/utils/matchDay.ts
Normal file
5
apps/player/src/utils/matchDay.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
isAfterLocalToday as isAfterTodayMatchWindow,
|
||||
isInLocalToday as isInTodayMatchWindow,
|
||||
isSameLocalCalendarDay as isSameCalendarDay,
|
||||
} from '@thebet365/shared';
|
||||
@@ -1,44 +0,0 @@
|
||||
/** 串关列表表头与玩法类型(labelKey 对应 main.ts bet.*) */
|
||||
export const PARLAY_MARKET_TYPES = [
|
||||
{ key: 'FT_HANDICAP', labelKey: 'market_ft_handicap' },
|
||||
{ key: 'FT_OVER_UNDER', labelKey: 'market_ft_ou' },
|
||||
{ key: 'FT_1X2', labelKey: 'market_ft_1x2' },
|
||||
{ key: 'FT_ODD_EVEN', labelKey: 'market_ft_oe' },
|
||||
{ key: 'HT_HANDICAP', labelKey: 'market_ht_handicap' },
|
||||
{ key: 'HT_OVER_UNDER', labelKey: 'market_ht_ou' },
|
||||
{ key: 'HT_1X2', labelKey: 'market_ht_1x2' },
|
||||
] as const;
|
||||
|
||||
export type ParlayMarketType = (typeof PARLAY_MARKET_TYPES)[number]['key'];
|
||||
|
||||
/** 按全场 / 半场分组(headerKey 对应 bet.ft / bet.ht) */
|
||||
export const PARLAY_MARKET_GROUPS = [
|
||||
{
|
||||
headerKey: 'ft',
|
||||
columns: [
|
||||
{ key: 'FT_HANDICAP', labelKey: 'parlay_lbl_handicap' },
|
||||
{ key: 'FT_OVER_UNDER', labelKey: 'parlay_lbl_ou' },
|
||||
{ key: 'FT_1X2', labelKey: 'parlay_lbl_1x2' },
|
||||
{ key: 'FT_ODD_EVEN', labelKey: 'parlay_lbl_oe' },
|
||||
],
|
||||
},
|
||||
{
|
||||
headerKey: 'ht',
|
||||
columns: [
|
||||
{ key: 'HT_HANDICAP', labelKey: 'parlay_lbl_handicap' },
|
||||
{ key: 'HT_OVER_UNDER', labelKey: 'parlay_lbl_ou' },
|
||||
{ key: 'HT_1X2', labelKey: 'parlay_lbl_1x2' },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** 选项简称 i18n key(串关格内展示) */
|
||||
export const PARLAY_SELECTION_KEYS: Record<string, string> = {
|
||||
HOME: 'parlay_sel_home',
|
||||
AWAY: 'parlay_sel_away',
|
||||
DRAW: 'parlay_sel_draw',
|
||||
OVER: 'parlay_sel_over',
|
||||
UNDER: 'parlay_sel_under',
|
||||
ODD: 'parlay_sel_odd',
|
||||
EVEN: 'parlay_sel_even',
|
||||
};
|
||||
@@ -15,14 +15,41 @@ export function resolveSelectionLabel(
|
||||
t: (key: string) => string,
|
||||
code: string,
|
||||
fallback: string,
|
||||
opts?: { lineValue?: string | number | null },
|
||||
): string {
|
||||
const i18nKey = CODE_I18N[code];
|
||||
if (i18nKey) {
|
||||
const fullKey = `bet.${i18nKey}`;
|
||||
const v = t(fullKey);
|
||||
if (v !== fullKey) return v;
|
||||
if (v !== fullKey) return withLineValue(code, v, opts?.lineValue);
|
||||
}
|
||||
const parsed = parseScoreCode(code, t);
|
||||
if (parsed) return parsed.display;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseLineValue(value: string | number | null | undefined) {
|
||||
if (value == null || value === '') return null;
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function formatLineValue(value: number) {
|
||||
const n = Object.is(value, -0) ? 0 : value;
|
||||
if (Number.isInteger(n)) return String(n);
|
||||
return String(n).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function formatSignedLineValue(value: number) {
|
||||
const n = Object.is(value, -0) ? 0 : value;
|
||||
return n > 0 ? `+${formatLineValue(n)}` : formatLineValue(n);
|
||||
}
|
||||
|
||||
function withLineValue(code: string, label: string, value: string | number | null | undefined) {
|
||||
const line = parseLineValue(value);
|
||||
if (line == null) return label;
|
||||
if (code === 'HOME') return `${label} ${formatSignedLineValue(line)}`;
|
||||
if (code === 'AWAY') return `${label} ${formatSignedLineValue(-line)}`;
|
||||
if (code === 'OVER' || code === 'UNDER') return `${label} ${formatLineValue(line)}`;
|
||||
return label;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed, onActivated, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useBetSlipStore } from '../stores/betSlip';
|
||||
import { usePlayerMatches } from '../composables/usePlayerMatches';
|
||||
import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
|
||||
import OutrightPanel from '../components/outright/OutrightPanel.vue';
|
||||
import ParlayPanel from '../components/parlay/ParlayPanel.vue';
|
||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import type { MatchPhase } from '../utils/matchPhase';
|
||||
import {
|
||||
isAfterLocalToday as isAfterTodayMatchWindow,
|
||||
isInLocalToday as isInTodayMatchWindow,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
type MainTab = 'matches' | 'outright' | 'parlay';
|
||||
type MainTab = 'matches' | 'outright';
|
||||
type TimeTab = 'today' | 'early';
|
||||
|
||||
interface Match {
|
||||
@@ -34,10 +36,18 @@ interface Match {
|
||||
bettingOpen?: boolean;
|
||||
matchPhase?: MatchPhase;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
htHome: number | null;
|
||||
htAway: number | null;
|
||||
ftHome: number | null;
|
||||
ftAway: number | null;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -50,17 +60,18 @@ interface LeagueGroup {
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const slip = useBetSlipStore();
|
||||
|
||||
const mainTab = ref<MainTab>('matches');
|
||||
const timeTab = ref<TimeTab>('early');
|
||||
const timeTab = ref<TimeTab>('today');
|
||||
const showAll = ref(false);
|
||||
const filterNow = ref(new Date());
|
||||
const { summaryMatches, summaryLoading, loadSummary } = usePlayerMatches();
|
||||
const matches = summaryMatches;
|
||||
const loading = summaryLoading;
|
||||
const expandedLeagues = ref<Set<string>>(new Set());
|
||||
|
||||
async function loadMatches() {
|
||||
filterNow.value = new Date();
|
||||
await loadSummary(true);
|
||||
}
|
||||
|
||||
@@ -75,26 +86,14 @@ const pullIndicatorStyle = () => ({
|
||||
opacity: Math.min(pullDistance.value / 48, 1),
|
||||
});
|
||||
|
||||
function dayStart(d: Date) {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function isKickoffToday(startTime: string) {
|
||||
const kick = new Date(startTime);
|
||||
const now = new Date();
|
||||
const start = dayStart(now);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
return kick >= start && kick < end;
|
||||
}
|
||||
|
||||
const filteredMatches = computed(() => {
|
||||
if (mainTab.value !== 'matches') return [];
|
||||
const now = filterNow.value;
|
||||
return matches.value.filter((m) => {
|
||||
const today = isKickoffToday(m.startTime);
|
||||
const timeMatch = timeTab.value === 'today' ? today : !today;
|
||||
const timeMatch =
|
||||
timeTab.value === 'today'
|
||||
? isInTodayMatchWindow(m.startTime, now)
|
||||
: isAfterTodayMatchWindow(m.startTime, now);
|
||||
if (!timeMatch) return false;
|
||||
if (!showAll.value && m.matchPhase !== 'open' && m.matchPhase !== undefined) return false;
|
||||
return true;
|
||||
@@ -157,6 +156,11 @@ function selectMainTab(tab: MainTab) {
|
||||
mainTab.value = tab;
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
filterNow.value = new Date();
|
||||
timeTab.value = 'today';
|
||||
});
|
||||
|
||||
function goMatch(id: string) {
|
||||
router.push(`/match/${id}`);
|
||||
}
|
||||
@@ -190,16 +194,6 @@ function goMatch(id: string) {
|
||||
<span class="tab-icon">🏆</span>
|
||||
{{ t('bet.tab_outright') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="main-tab parlay-tab"
|
||||
:class="{ active: mainTab === 'parlay', 'tab-gold-active': mainTab === 'parlay' }"
|
||||
@click="selectMainTab('parlay')"
|
||||
>
|
||||
<span class="tab-icon">+</span>
|
||||
{{ t('bet.tab_parlay') }}
|
||||
<span v-if="slip.count" class="tab-badge">{{ slip.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="mainTab === 'matches'">
|
||||
@@ -226,11 +220,12 @@ function goMatch(id: string) {
|
||||
<button
|
||||
type="button"
|
||||
class="phase-toggle"
|
||||
:class="{ 'phase-toggle--active': showAll }"
|
||||
:class="{ 'phase-toggle--active': !showAll }"
|
||||
:aria-pressed="!showAll"
|
||||
@click="showAll = !showAll"
|
||||
>
|
||||
<span class="phase-toggle-dot" />
|
||||
{{ showAll ? t('bet.show_all_matches') : t('bet.show_open_only') }}
|
||||
{{ t('bet.show_open_only') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -259,7 +254,6 @@ function goMatch(id: string) {
|
||||
|
||||
<OutrightPanel v-if="mainTab === 'outright'" class="outright-tab" />
|
||||
|
||||
<ParlayPanel v-if="mainTab === 'parlay'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -308,20 +302,6 @@ function goMatch(id: string) {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: #1a1000;
|
||||
color: var(--primary-light);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.time-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -340,7 +320,14 @@ function goMatch(id: string) {
|
||||
}
|
||||
|
||||
.time-tab.active {
|
||||
background: var(--gradient-gold) !important;
|
||||
border-color: #fff1a8 !important;
|
||||
color: #2a1a00 !important;
|
||||
font-weight: 800;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 244, 200, 0.28) inset,
|
||||
0 4px 16px rgba(212, 175, 55, 0.28);
|
||||
text-shadow: 0 1px 0 rgba(255, 252, 235, 0.75);
|
||||
}
|
||||
|
||||
.phase-filter {
|
||||
@@ -403,10 +390,6 @@ function goMatch(id: string) {
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.parlay-tab.tab-gold-active {
|
||||
flex: 1.15;
|
||||
}
|
||||
|
||||
.outright-tab {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import TeamEmblem from '../components/TeamEmblem.vue';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
|
||||
const matchCardBg = `url(${cardBg})`;
|
||||
const { t, locale } = useI18n();
|
||||
@@ -29,14 +30,7 @@ function goMatch(id: string) {
|
||||
}
|
||||
|
||||
function formatKickoff(startTime: string) {
|
||||
return new Date(startTime).toLocaleString(locale.value, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
return formatLocalMatchDateTime(startTime, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -7,21 +7,17 @@ import { formatMoney } from '../utils/localeDisplay';
|
||||
import { useBetSlipStore } from '../stores/betSlip';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import TeamEmblem from '../components/TeamEmblem.vue';
|
||||
import { DETAIL_MARKET_TYPES, MARKET_I18N_KEY } from '../utils/marketCatalog';
|
||||
import MatchBetGuide from '../components/match-detail/MatchBetGuide.vue';
|
||||
import MarketTypeTile from '../components/match-detail/MarketTypeTile.vue';
|
||||
import MarketSelectionsPanel from '../components/match-detail/MarketSelectionsPanel.vue';
|
||||
import CorrectScorePanel from '../components/match-detail/CorrectScorePanel.vue';
|
||||
import CorrectScoreConfirmModal, {
|
||||
type CsConfirmLine,
|
||||
} from '../components/match-detail/CorrectScoreConfirmModal.vue';
|
||||
import { isCorrectScoreMarket, parseScoreCode } from '../utils/correctScoreLayout';
|
||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import vsImg from '../assets/images/vs.png';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import BetSuccessOverlay from '../components/BetSuccessOverlay.vue';
|
||||
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -36,8 +32,11 @@ function goLogin() {
|
||||
interface Market {
|
||||
id: string;
|
||||
marketType: string;
|
||||
marketDisplayName?: string;
|
||||
lineKey?: string | null;
|
||||
period: string;
|
||||
lineValue?: string | number | null;
|
||||
allowSingle?: boolean;
|
||||
allowParlay?: boolean;
|
||||
promoLabel?: string | null;
|
||||
selections: Selection[];
|
||||
@@ -47,6 +46,7 @@ interface Selection {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
selectionDisplayName?: string;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
}
|
||||
@@ -67,25 +67,25 @@ interface MatchDetail {
|
||||
status?: string;
|
||||
bettingOpen?: boolean;
|
||||
matchPhase?: MatchPhase;
|
||||
correctScoreEnabled?: boolean;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
htHome: number | null;
|
||||
htAway: number | null;
|
||||
ftHome: number | null;
|
||||
ftAway: number | null;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
} | null;
|
||||
markets: Market[];
|
||||
}
|
||||
|
||||
const match = ref<MatchDetail | null>(null);
|
||||
const loading = ref(true);
|
||||
const expandedKey = ref<string | null>(null);
|
||||
const correctScoreStakes = ref<Record<string, number>>({});
|
||||
const placingCs = ref(false);
|
||||
const csMessage = ref('');
|
||||
const showCsSuccess = ref(false);
|
||||
const csConfirmOpen = ref(false);
|
||||
const csConfirmMarketType = ref<string | null>(null);
|
||||
|
||||
interface MyBet {
|
||||
betNo: string;
|
||||
@@ -131,37 +131,19 @@ const statusClass = (status: string) => {
|
||||
if (s === 'LOST' || s === 'LOSE') return 'bet-status-lost';
|
||||
return 'bet-status-pending';
|
||||
};
|
||||
const marketsByType = computed(() => {
|
||||
const marketsById = computed(() => {
|
||||
const map = new Map<string, Market>();
|
||||
for (const m of match.value?.markets ?? []) {
|
||||
map.set(m.marketType, m);
|
||||
map.set(m.id, m);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const CS_MARKET_TYPES = new Set(['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE']);
|
||||
|
||||
const visibleMarketTypes = computed(() => {
|
||||
const csEnabled = match.value?.correctScoreEnabled ?? true;
|
||||
if (csEnabled) return DETAIL_MARKET_TYPES;
|
||||
return DETAIL_MARKET_TYPES.filter((t) => !CS_MARKET_TYPES.has(t));
|
||||
});
|
||||
|
||||
function marketPromoLabel(marketType: string) {
|
||||
const m = marketsByType.value.get(marketType);
|
||||
return m?.promoLabel?.trim() || '';
|
||||
}
|
||||
const visibleMarkets = computed(() => match.value?.markets ?? []);
|
||||
|
||||
const kickoff = computed(() => {
|
||||
if (!match.value) return '';
|
||||
return new Date(match.value.startTime).toLocaleString(locale.value, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
return formatLocalMatchDateTime(match.value.startTime, locale.value, { variant: 'full' });
|
||||
});
|
||||
|
||||
const bettingOpen = computed(() => match.value?.bettingOpen !== false);
|
||||
@@ -175,119 +157,44 @@ const phaseLabel = computed(() => matchPhaseLabel(t, matchPhase.value));
|
||||
|
||||
const liveScoreText = computed(() => {
|
||||
const s = match.value?.score;
|
||||
if (!s) return '';
|
||||
if (!s || s.ftHome == null || s.ftAway == null) return '';
|
||||
return `${s.ftHome} - ${s.ftAway}`;
|
||||
});
|
||||
|
||||
function marketLabel(marketType: string) {
|
||||
const key = MARKET_I18N_KEY[marketType];
|
||||
return key ? t(key) : marketType;
|
||||
function statValue(value: number | null | undefined) {
|
||||
return value == null ? '-' : String(value);
|
||||
}
|
||||
|
||||
function expandKey(marketType: string) {
|
||||
return marketType;
|
||||
function statPair(home: number | null | undefined, away: number | null | undefined) {
|
||||
return `${statValue(home)} - ${statValue(away)}`;
|
||||
}
|
||||
|
||||
function isExpanded(marketType: string) {
|
||||
return expandedKey.value === expandKey(marketType);
|
||||
}
|
||||
|
||||
function openMarket(marketType: string) {
|
||||
if (!marketsByType.value.has(marketType)) return;
|
||||
expandedKey.value = expandKey(marketType);
|
||||
}
|
||||
|
||||
function closeMarket() {
|
||||
expandedKey.value = null;
|
||||
}
|
||||
|
||||
function toggleMarket(marketType: string) {
|
||||
if (!marketsByType.value.has(marketType)) return;
|
||||
if (isExpanded(marketType)) closeMarket();
|
||||
else openMarket(marketType);
|
||||
}
|
||||
|
||||
function genRequestId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
const csConfirmLines = computed((): CsConfirmLine[] => {
|
||||
const marketType = csConfirmMarketType.value;
|
||||
if (!marketType) return [];
|
||||
const market = marketsByType.value.get(marketType);
|
||||
if (!market) return [];
|
||||
return market.selections
|
||||
.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0)
|
||||
.map((s) => {
|
||||
const parsed = parseScoreCode(s.selectionCode, t);
|
||||
return {
|
||||
scoreDisplay: parsed?.display ?? s.selectionName,
|
||||
odds: s.odds,
|
||||
stake: correctScoreStakes.value[s.id],
|
||||
};
|
||||
});
|
||||
const resultStats = computed(() => {
|
||||
const s = match.value?.score;
|
||||
if (!s || matchPhase.value !== 'settled') return [];
|
||||
return [
|
||||
{ key: 'ht', label: t('bet.result_ht'), value: statPair(s.htHome, s.htAway) },
|
||||
{ key: 'ft', label: t('bet.result_ft'), value: statPair(s.ftHome, s.ftAway) },
|
||||
{ key: 'corners', label: t('bet.result_corners'), value: statPair(s.homeCorners, s.awayCorners) },
|
||||
{
|
||||
key: 'yellow',
|
||||
label: t('bet.result_yellow_cards'),
|
||||
value: statPair(s.homeYellowCards, s.awayYellowCards),
|
||||
},
|
||||
{ key: 'red', label: t('bet.result_red_cards'), value: statPair(s.homeRedCards, s.awayRedCards) },
|
||||
];
|
||||
});
|
||||
|
||||
function openCorrectScoreConfirm(marketType: string) {
|
||||
const market = marketsByType.value.get(marketType);
|
||||
if (!market || !match.value) return;
|
||||
const hasStake = market.selections.some((s) => (correctScoreStakes.value[s.id] ?? 0) > 0);
|
||||
if (!hasStake) {
|
||||
csMessage.value = t('bet.cs_stake_required');
|
||||
return;
|
||||
}
|
||||
csMessage.value = '';
|
||||
csConfirmMarketType.value = marketType;
|
||||
csConfirmOpen.value = true;
|
||||
const showResultStats = computed(() => resultStats.value.length > 0);
|
||||
|
||||
function marketLabel(market: Market | null | undefined) {
|
||||
return market?.marketDisplayName?.trim() || market?.marketType || '';
|
||||
}
|
||||
|
||||
function closeCorrectScoreConfirm() {
|
||||
csConfirmOpen.value = false;
|
||||
}
|
||||
|
||||
async function confirmCorrectScoreBets() {
|
||||
const marketType = csConfirmMarketType.value;
|
||||
if (!marketType) return;
|
||||
csConfirmOpen.value = false;
|
||||
await placeCorrectScoreBets(marketType);
|
||||
}
|
||||
|
||||
async function placeCorrectScoreBets(marketType: string) {
|
||||
if (!bettingOpen.value) return;
|
||||
if (!auth.token) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
const market = marketsByType.value.get(marketType);
|
||||
if (!market || !match.value) return;
|
||||
const entries = market.selections.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0);
|
||||
if (!entries.length) {
|
||||
csMessage.value = t('bet.cs_stake_required');
|
||||
return;
|
||||
}
|
||||
placingCs.value = true;
|
||||
csMessage.value = '';
|
||||
try {
|
||||
for (const sel of entries) {
|
||||
await api.post('/player/bets/single', {
|
||||
selectionId: sel.id,
|
||||
oddsVersion: String(sel.oddsVersion),
|
||||
stake: correctScoreStakes.value[sel.id],
|
||||
requestId: genRequestId(),
|
||||
});
|
||||
}
|
||||
csMessage.value = t('bet.cs_place_success');
|
||||
const next = { ...correctScoreStakes.value };
|
||||
for (const sel of entries) delete next[sel.id];
|
||||
correctScoreStakes.value = next;
|
||||
showCsSuccess.value = true;
|
||||
} catch (e: unknown) {
|
||||
csMessage.value =
|
||||
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
||||
t('bet.cs_place_failed');
|
||||
} finally {
|
||||
placingCs.value = false;
|
||||
}
|
||||
function selectionLabel(sel: Selection) {
|
||||
const parsedScore = parseScoreCode(sel.selectionCode, t);
|
||||
if (parsedScore) return parsedScore.display;
|
||||
return sel.selectionDisplayName?.trim() || sel.selectionName;
|
||||
}
|
||||
|
||||
async function loadMatch() {
|
||||
@@ -313,17 +220,24 @@ const pullIndicatorStyle = () => ({
|
||||
});
|
||||
|
||||
function isSelected(id: string) {
|
||||
return slip.items.some((i) => i.selectionId === id);
|
||||
return slip.singleItem?.selectionId === id || slip.parlayItems.some((i) => i.selectionId === id);
|
||||
}
|
||||
|
||||
function toggleSelection(sel: Selection, market: Market) {
|
||||
if (!match.value || !bettingOpen.value) return;
|
||||
if (market.allowSingle === false) return;
|
||||
if (!auth.token) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
slip.addItem({
|
||||
selectionId: sel.id,
|
||||
oddsVersion: String(sel.oddsVersion),
|
||||
matchId: match.value.id,
|
||||
matchName: `${match.value.homeTeamName} vs ${match.value.awayTeamName}`,
|
||||
selectionName: sel.selectionName,
|
||||
marketId: market.id,
|
||||
marketName: marketLabel(market),
|
||||
selectionName: selectionLabel(sel),
|
||||
odds: parseFloat(sel.odds),
|
||||
marketType: market.marketType,
|
||||
lineValue:
|
||||
@@ -332,29 +246,14 @@ function toggleSelection(sel: Selection, market: Market) {
|
||||
: null,
|
||||
allowParlay: market.allowParlay,
|
||||
});
|
||||
}
|
||||
|
||||
function onPickSelection(selId: string, marketType: string) {
|
||||
const market = marketsByType.value.get(marketType);
|
||||
const sel = market?.selections.find((s) => s.id === selId);
|
||||
if (!market || !sel) return;
|
||||
toggleSelection(sel, market);
|
||||
}
|
||||
|
||||
function openBetSlipDrawer() {
|
||||
if (!auth.token) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
slip.openDrawer();
|
||||
}
|
||||
|
||||
/** 当前玩法是否已有选项加入投注单 */
|
||||
function hasSlipPickForMarket(marketType: string) {
|
||||
if (!match.value || slip.mode !== 'single') return false;
|
||||
return slip.items.some(
|
||||
(item) => item.matchId === match.value!.id && item.marketType === marketType,
|
||||
);
|
||||
function onPickSelection(selId: string, marketId: string) {
|
||||
const market = marketsById.value.get(marketId);
|
||||
const sel = market?.selections.find((s) => s.id === selId);
|
||||
if (!market || !sel) return;
|
||||
toggleSelection(sel, market);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -446,6 +345,19 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
<p v-if="liveScoreText" class="live-score">{{ liveScoreText }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="showResultStats" class="result-stats-section">
|
||||
<div class="result-stats-head">
|
||||
<h3>{{ t('bet.result_stats_title') }}</h3>
|
||||
<span>{{ match.homeTeamName }} / {{ match.awayTeamName }}</span>
|
||||
</div>
|
||||
<div class="result-stats-grid">
|
||||
<div v-for="item in resultStats" :key="item.key" class="result-stat-item">
|
||||
<span class="result-stat-label">{{ item.label }}</span>
|
||||
<span class="result-stat-value">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 我的投注 -->
|
||||
<section v-if="myBets.length" class="my-bets-section">
|
||||
<h3 class="my-bets-title">{{ t('history.my_bets') || '我的投注' }}</h3>
|
||||
@@ -466,77 +378,45 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
</section>
|
||||
|
||||
<section class="markets-section">
|
||||
<CorrectScoreConfirmModal
|
||||
:open="csConfirmOpen"
|
||||
:market-label="csConfirmMarketType ? marketLabel(csConfirmMarketType) : ''"
|
||||
:home-team-name="match.homeTeamName"
|
||||
:away-team-name="match.awayTeamName"
|
||||
:lines="csConfirmLines"
|
||||
:loading="placingCs"
|
||||
@close="closeCorrectScoreConfirm"
|
||||
@confirm="confirmCorrectScoreBets"
|
||||
/>
|
||||
|
||||
<p v-if="csMessage" class="cs-toast">{{ csMessage }}</p>
|
||||
|
||||
<div class="market-list">
|
||||
<div
|
||||
v-for="marketType in visibleMarketTypes"
|
||||
:key="marketType"
|
||||
v-for="market in visibleMarkets"
|
||||
:key="market.id"
|
||||
class="market-group"
|
||||
:class="{ open: isExpanded(marketType) }"
|
||||
:class="{ open: true }"
|
||||
>
|
||||
<MarketTypeTile
|
||||
:label="marketLabel(marketType)"
|
||||
:promo-label="marketPromoLabel(marketType)"
|
||||
:has-market="marketsByType.has(marketType)"
|
||||
:expanded="isExpanded(marketType)"
|
||||
@toggle="toggleMarket(marketType)"
|
||||
:label="marketLabel(market)"
|
||||
:promo-label="market.promoLabel?.trim() || ''"
|
||||
:has-market="true"
|
||||
:expanded="true"
|
||||
/>
|
||||
<template v-if="isExpanded(marketType) && marketsByType.get(marketType)">
|
||||
<div class="market-panel-wrap" :class="{ locked: !bettingOpen }">
|
||||
<span v-if="!bettingOpen && matchPhase === 'settled'" class="market-status-tag market-status-tag--settled">{{ phaseLabel }}</span>
|
||||
<span v-else-if="!bettingOpen" class="market-status-tag market-status-tag--pending">{{ phaseLabel }}</span>
|
||||
<div class="market-panel-wrap" :class="{ locked: !bettingOpen || market.allowSingle === false }">
|
||||
<span v-if="!bettingOpen && matchPhase === 'settled'" class="market-status-tag market-status-tag--settled">{{ phaseLabel }}</span>
|
||||
<span v-else-if="!bettingOpen" class="market-status-tag market-status-tag--pending">{{ phaseLabel }}</span>
|
||||
<CorrectScorePanel
|
||||
v-if="isCorrectScoreMarket(marketType)"
|
||||
:market-type="marketType"
|
||||
:selections="marketsByType.get(marketType)!.selections"
|
||||
:locked="!bettingOpen"
|
||||
v-model:stakes="correctScoreStakes"
|
||||
v-if="isCorrectScoreMarket(market.marketType)"
|
||||
:market-type="market.marketType"
|
||||
:selections="market.selections"
|
||||
:locked="!bettingOpen || market.allowSingle === false"
|
||||
:is-selected="isSelected"
|
||||
@pick="onPickSelection($event, market.id)"
|
||||
/>
|
||||
<button
|
||||
v-if="isCorrectScoreMarket(marketType) && bettingOpen"
|
||||
type="button"
|
||||
class="market-foot-btn"
|
||||
@click="openCorrectScoreConfirm(marketType)"
|
||||
>
|
||||
{{ t('bet.cs_confirm_cell') }}
|
||||
</button>
|
||||
<MarketSelectionsPanel
|
||||
v-else
|
||||
compact
|
||||
:locked="!bettingOpen"
|
||||
:selections="marketsByType.get(marketType)!.selections"
|
||||
:locked="!bettingOpen || market.allowSingle === false"
|
||||
:line-value="market.lineValue"
|
||||
:selections="market.selections"
|
||||
:is-selected="isSelected"
|
||||
@pick="onPickSelection($event, marketType)"
|
||||
@pick="onPickSelection($event, market.id)"
|
||||
/>
|
||||
<button
|
||||
v-if="!isCorrectScoreMarket(marketType) && bettingOpen && hasSlipPickForMarket(marketType)"
|
||||
type="button"
|
||||
class="market-foot-btn"
|
||||
@click="openBetSlipDrawer"
|
||||
>
|
||||
{{ t('bet.cs_confirm_cell') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<BetSuccessOverlay :show="showCsSuccess" @done="showCsSuccess = false" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -704,6 +584,76 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.result-stats-section {
|
||||
margin: 0 10px 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.22);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #1b1b1b 0%, #121212 100%);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.result-stats-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.result-stats-head h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
font-weight: 900;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.result-stats-head span {
|
||||
min-width: 0;
|
||||
max-width: 58%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.result-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.result-stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 38px;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.result-stat-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.result-stat-value {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hero-teams {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
Reference in New Issue
Block a user