This commit is contained in:
wchino
2026-06-13 17:38:25 +08:00
parent e7e938f261
commit 7b33d9f9fa
190 changed files with 23222 additions and 4336 deletions

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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>

View File

@@ -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',

View File

@@ -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' },

View File

@@ -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' } }],
};
}),
};

View File

@@ -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(

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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"]
}

View File

@@ -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',
},
};

View File

@@ -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");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "show_on_player" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -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");

View File

@@ -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;

View File

@@ -0,0 +1 @@
ALTER TABLE "matches" DROP COLUMN IF EXISTS "correct_score_enabled";

View File

@@ -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;

View File

@@ -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])

View File

@@ -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 球队 logoseed 时写入 teams.logo_url */
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = {
${logoLines}
/** WC2026 默认国家/地区国旗FlagCDNseed 时写入 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);
}

View File

@@ -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,

View File

@@ -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,

View 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,
]);
});
});

View 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');
}
}
}

View 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' }),
});
});
});

View 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;
}
}

View File

@@ -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 {}

View 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);
});
});

View File

@@ -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) {

View 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();
});
});

View File

@@ -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
);
}
}

View File

@@ -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'));
});
});

View File

@@ -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 },

View File

@@ -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 }]]),
);

View File

@@ -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' },

View 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,
});
});
});

View 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;
}
}

View File

@@ -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 {}

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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({

View File

@@ -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],
})

View 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();
});
});

View File

@@ -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;
}
}

View 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',
);
});
});

View 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}`,
);
}
}

View File

@@ -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 {}

View 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']),
}),
}),
}),
);
});
});

View File

@@ -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()

View 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',
]);
});
});

View File

@@ -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);
}
}

View File

@@ -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 };

View File

@@ -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' });
},
},
{

View File

@@ -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, [

View File

@@ -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';

View File

@@ -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 [];
}

View File

@@ -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' }),
}),
);

View File

@@ -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 };
}
}

View File

@@ -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 [];
}

View File

@@ -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,

View File

@@ -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": {

View File

@@ -50,54 +50,61 @@ export const WC2026_ZIBO_ID_TO_CODE: Record<number, string> = {
50470: 'COD',
};
/** zhibo 球队 logoseed 时写入 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 默认国家/地区国旗FlagCDNseed 时写入 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`,
]),
);

View File

@@ -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,

View File

@@ -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', () => {

View File

@@ -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();

View 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);
});
});

View 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 }),
});
}

View File

@@ -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;
});

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;
}[];
}>();

View File

@@ -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);
}

View File

@@ -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),
);

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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">&#9917;</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>

View File

@@ -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,
};
}

View File

@@ -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 25 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 25 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 (25 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 25 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: 25 legs from different matches (one per match). Outright and quarter-ball HDP/O-U are excluded.',
rules_p2: 'Parlays: 25 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.',

View File

@@ -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 25 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 25 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 (25 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 25 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',
},

View File

@@ -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: '选择 25 场赛前赛事组合串关2 串 1 至 5 串 1。赔率相乘不含滚球、冠军盘与四分盘让球/大小。',
parlay_guide_1: '在列表中点击各场赔率,选中项显示金边;再点同一项可取消',
parlay_guide_2: '须选 25 项,且须为不同赛事;冠军盘与四分盘让球/大小不可选',
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: '串关25 ',
guide_parlay_1: '本页为单关/波胆。串关:底部导航点「投注」,在页面顶部切换到「串关投注」,选 25 场不同赛事后在投注单提交。',
guide_cs_1: '在比分表中点击要下注的比分',
guide_cs_2: '系统会打开投注抽屉,填写金额后提交',
guide_cs_3: '比分项按单注下注,也可以加入串关',
guide_flow_parlay: '串关25 ',
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: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',

View File

@@ -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();

View File

@@ -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,
};

View File

@@ -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';

View File

@@ -0,0 +1,5 @@
export {
isAfterLocalToday as isAfterTodayMatchWindow,
isInLocalToday as isInTodayMatchWindow,
isSameLocalCalendarDay as isSameCalendarDay,
} from '@thebet365/shared';

View File

@@ -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',
};

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;