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