Compare commits
3 Commits
main
...
fce2333322
| Author | SHA1 | Date | |
|---|---|---|---|
| fce2333322 | |||
| 5c5aa7e55a | |||
| d3ca8498fb |
@@ -1153,6 +1153,12 @@ a {
|
|||||||
color: var(--text) !important;
|
color: var(--text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.el-input,
|
||||||
|
.el-select,
|
||||||
|
.el-date-editor {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
.el-input__wrapper:hover,
|
.el-input__wrapper:hover,
|
||||||
.el-select__wrapper:hover,
|
.el-select__wrapper:hover,
|
||||||
.el-textarea__inner:hover {
|
.el-textarea__inner:hover {
|
||||||
@@ -1426,6 +1432,40 @@ input:-webkit-autofill:focus {
|
|||||||
color: var(--warning-text) !important;
|
color: var(--warning-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Link buttons hover in light theme - override dark legacy */
|
||||||
|
.el-button.is-link:hover,
|
||||||
|
.el-button.is-link:focus,
|
||||||
|
.el-button.is-link.el-button--default:hover,
|
||||||
|
.el-button.is-link.el-button--default:focus,
|
||||||
|
.el-button.is-link.el-button--primary:hover,
|
||||||
|
.el-button.is-link.el-button--primary:focus {
|
||||||
|
color: #155b86 !important; /* Darker blue */
|
||||||
|
background: transparent !important;
|
||||||
|
text-decoration: underline !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button.is-link.el-button--success:hover,
|
||||||
|
.el-button.is-link.el-button--success:focus {
|
||||||
|
color: #224225 !important; /* Darker green */
|
||||||
|
background: transparent !important;
|
||||||
|
text-decoration: underline !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button.is-link.el-button--warning:hover,
|
||||||
|
.el-button.is-link.el-button--warning:focus {
|
||||||
|
color: #704b00 !important; /* Darker warning/gold */
|
||||||
|
background: transparent !important;
|
||||||
|
text-decoration: underline !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button.is-link.el-button--danger:hover,
|
||||||
|
.el-button.is-link.el-button--danger:focus {
|
||||||
|
color: #7d2321 !important; /* Darker danger/red */
|
||||||
|
background: transparent !important;
|
||||||
|
text-decoration: underline !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.el-button.is-disabled,
|
.el-button.is-disabled,
|
||||||
.el-button.is-disabled:hover {
|
.el-button.is-disabled:hover {
|
||||||
background: #f4f0e8 !important;
|
background: #f4f0e8 !important;
|
||||||
|
|||||||
52
apps/admin/src/components/AdminPlayerStatusCell.vue
Normal file
52
apps/admin/src/components/AdminPlayerStatusCell.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
status: string;
|
||||||
|
isOnline?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ isOnline: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { t } = useAdminLocale();
|
||||||
|
|
||||||
|
function statusTagType(s: string) {
|
||||||
|
return s === 'ACTIVE' ? 'success' : s === 'SUSPENDED' ? 'warning' : 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s: string) {
|
||||||
|
const key = `user.status.${s}`;
|
||||||
|
const label = t(key);
|
||||||
|
return label !== key ? label : s;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="player-status-with-presence">
|
||||||
|
<el-tag :type="statusTagType(status)" size="small">{{ statusLabel(status) }}</el-tag>
|
||||||
|
<span :class="isOnline ? 'presence-online' : 'presence-offline'">
|
||||||
|
({{ t(isOnline ? 'user.presence_online' : 'user.presence_offline') }})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.player-status-with-presence {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-online {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #2d8a4e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-offline {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -37,7 +37,7 @@ interface AuditRow {
|
|||||||
const logs = ref<AuditRow[]>([]);
|
const logs = ref<AuditRow[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(20);
|
const pageSize = ref(10);
|
||||||
const filterModule = ref('');
|
const filterModule = ref('');
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
@@ -129,6 +129,7 @@ function operatorDisplay(row: AuditRow): string {
|
|||||||
<template #empty>
|
<template #empty>
|
||||||
<AdminTableEmpty />
|
<AdminTableEmpty />
|
||||||
</template>
|
</template>
|
||||||
|
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" fixed="left" />
|
||||||
<el-table-column :label="t('audit.col.time')" min-width="168" fixed="left">
|
<el-table-column :label="t('audit.col.time')" min-width="168" fixed="left">
|
||||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
316
apps/admin/src/components/ContentImageField.vue
Normal file
316
apps/admin/src/components/ContentImageField.vue
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
category?: string;
|
||||||
|
sizeHintKey?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ category: 'contents', sizeHintKey: 'content.upload.cover_size_hint' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [string] }>();
|
||||||
|
|
||||||
|
const { t } = useAdminLocale();
|
||||||
|
|
||||||
|
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
|
||||||
|
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
const uploading = ref(false);
|
||||||
|
const mediaPickerVisible = ref(false);
|
||||||
|
const mediaFiles = ref<Array<{ id: string; filename: string; url: string; mimeType: string }>>([]);
|
||||||
|
const mediaLoading = ref(false);
|
||||||
|
|
||||||
|
async function uploadImage(file: File) {
|
||||||
|
if (file.size > MAX_UPLOAD_SIZE) {
|
||||||
|
ElMessage.error(t('content.upload.size_error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const { data } = await api.post(`/admin/uploads?category=${props.category}`, fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
const url = data.data?.url as string;
|
||||||
|
if (url) {
|
||||||
|
emit('update:modelValue', url);
|
||||||
|
ElMessage.success(t('content.upload.success'));
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { response?: { data?: { message?: string } } };
|
||||||
|
ElMessage.error(String(e.response?.data?.message || t('content.upload.failed')));
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
if (input.files?.[0]) {
|
||||||
|
void uploadImage(input.files[0]);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage() {
|
||||||
|
emit('update:modelValue', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMediaPicker() {
|
||||||
|
mediaPickerVisible.value = true;
|
||||||
|
mediaLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/files', {
|
||||||
|
params: { category: props.category, pageSize: 200 },
|
||||||
|
});
|
||||||
|
mediaFiles.value = res.data.data.items ?? [];
|
||||||
|
} catch {
|
||||||
|
mediaFiles.value = [];
|
||||||
|
} finally {
|
||||||
|
mediaLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMediaFile(url: string) {
|
||||||
|
emit('update:modelValue', url);
|
||||||
|
mediaPickerVisible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="content-image-field">
|
||||||
|
<div class="field-main">
|
||||||
|
<div v-if="modelValue" class="preview">
|
||||||
|
<img :src="modelValue" alt="" class="preview-img" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="preview-remove"
|
||||||
|
:title="t('content.upload.remove')"
|
||||||
|
:disabled="disabled || uploading"
|
||||||
|
@click="removeImage"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-controls">
|
||||||
|
<p v-if="sizeHintKey" class="size-hint">{{ t(sizeHintKey) }}</p>
|
||||||
|
<div class="actions">
|
||||||
|
<label class="upload-btn" :class="{ 'is-uploading': uploading, 'is-disabled': disabled }">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
:accept="IMAGE_ACCEPT"
|
||||||
|
style="display: none"
|
||||||
|
:disabled="disabled || uploading"
|
||||||
|
@change="onFileChange"
|
||||||
|
/>
|
||||||
|
{{ uploading ? t('content.upload.uploading') : t('content.upload.upload_btn') }}
|
||||||
|
</label>
|
||||||
|
<button type="button" class="pick-btn" :disabled="disabled || uploading" @click="openMediaPicker">
|
||||||
|
{{ t('content.upload.pick_media') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
:model-value="modelValue"
|
||||||
|
:placeholder="t('content.upload.url_placeholder')"
|
||||||
|
size="small"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="mediaPickerVisible"
|
||||||
|
:title="t('content.upload.pick_media_title')"
|
||||||
|
width="680px"
|
||||||
|
destroy-on-close
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<div v-if="mediaLoading" class="media-state">{{ t('common.loading') }}</div>
|
||||||
|
<div v-else-if="mediaFiles.length === 0" class="media-state">{{ t('content.upload.no_media') }}</div>
|
||||||
|
<div v-else class="media-grid">
|
||||||
|
<div
|
||||||
|
v-for="file in mediaFiles"
|
||||||
|
:key="file.id"
|
||||||
|
class="media-card"
|
||||||
|
@click="pickMediaFile(file.url)"
|
||||||
|
>
|
||||||
|
<div class="media-thumb">
|
||||||
|
<img v-if="file.mimeType !== 'image/svg+xml'" :src="file.url" :alt="file.filename" loading="lazy" />
|
||||||
|
<div v-else class="media-svg">SVG</div>
|
||||||
|
</div>
|
||||||
|
<div class="media-name" :title="file.filename">{{ file.filename }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.content-image-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-controls {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-hint {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 120px;
|
||||||
|
max-width: 120px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #f4f0e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 68px;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(31, 35, 32, 0.78);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.36);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-remove:hover {
|
||||||
|
background: rgba(159, 47, 45, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn,
|
||||||
|
.pick-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn.is-uploading,
|
||||||
|
.upload-btn.is-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card:hover {
|
||||||
|
border-color: #d5cfc3;
|
||||||
|
box-shadow: 0 8px 22px rgba(56, 49, 37, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-thumb {
|
||||||
|
height: 80px;
|
||||||
|
background: #f4f0e8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-thumb img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-svg {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-name {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
531
apps/admin/src/components/ContentRichEditor.vue
Normal file
531
apps/admin/src/components/ContentRichEditor.vue
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
placeholder?: string;
|
||||||
|
uploadCategory?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
fill?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ uploadCategory: 'contents', fill: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [string] }>();
|
||||||
|
|
||||||
|
const { t } = useAdminLocale();
|
||||||
|
|
||||||
|
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
|
||||||
|
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
|
||||||
|
const EDITOR_IMAGE_MAX_HEIGHT = '200px';
|
||||||
|
|
||||||
|
const editorRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const mediaPickerVisible = ref(false);
|
||||||
|
/** blob/object URL -> File,保存时由父组件调用 uploadPendingImages 上传 */
|
||||||
|
const pendingFiles = new Map<string, File>();
|
||||||
|
const mediaFiles = ref<Array<{ id: string; filename: string; url: string; mimeType: string }>>([]);
|
||||||
|
const mediaLoading = ref(false);
|
||||||
|
const syncing = ref(false);
|
||||||
|
let savedRange: Range | null = null;
|
||||||
|
|
||||||
|
function isNodeInEditor(node: Node | null): boolean {
|
||||||
|
const el = editorRef.value;
|
||||||
|
if (!el || !node) return false;
|
||||||
|
return el.contains(node.nodeType === Node.TEXT_NODE ? node.parentNode : node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSelection() {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const el = editorRef.value;
|
||||||
|
if (!sel || sel.rangeCount === 0 || !el) return;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
if (isNodeInEditor(range.commonAncestorContainer)) {
|
||||||
|
savedRange = range.cloneRange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSelection(): boolean {
|
||||||
|
const el = editorRef.value;
|
||||||
|
if (!savedRange || !el) return false;
|
||||||
|
try {
|
||||||
|
if (!isNodeInEditor(savedRange.commonAncestorContainer)) return false;
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel) return false;
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(savedRange);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
savedRange = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentSelectionChange() {
|
||||||
|
if (isNodeInEditor(window.getSelection()?.anchorNode ?? null)) {
|
||||||
|
saveSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHtml(html: string) {
|
||||||
|
const trimmed = html.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFromModel() {
|
||||||
|
const el = editorRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
syncing.value = true;
|
||||||
|
el.innerHTML = props.modelValue || '';
|
||||||
|
if (el.innerHTML) normalizeEditorImages(el);
|
||||||
|
syncing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitChange() {
|
||||||
|
if (syncing.value || !editorRef.value) return;
|
||||||
|
const html = normalizeHtml(editorRef.value.innerHTML);
|
||||||
|
cleanupOrphanedBlobs(html);
|
||||||
|
emit('update:modelValue', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupOrphanedBlobs(html: string) {
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const match of html.matchAll(/blob:[^\s"'<>]+/g)) {
|
||||||
|
used.add(match[0]);
|
||||||
|
}
|
||||||
|
for (const blobUrl of pendingFiles.keys()) {
|
||||||
|
if (!used.has(blobUrl)) {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
pendingFiles.delete(blobUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeAllPendingBlobs() {
|
||||||
|
for (const blobUrl of pendingFiles.keys()) {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}
|
||||||
|
pendingFiles.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(cmd: string, value?: string) {
|
||||||
|
editorRef.value?.focus();
|
||||||
|
document.execCommand(cmd, false, value);
|
||||||
|
emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInlineImageStyles(img: HTMLImageElement) {
|
||||||
|
img.removeAttribute('width');
|
||||||
|
img.removeAttribute('height');
|
||||||
|
img.style.maxWidth = '100%';
|
||||||
|
img.style.width = 'auto';
|
||||||
|
img.style.height = 'auto';
|
||||||
|
img.style.maxHeight = EDITOR_IMAGE_MAX_HEIGHT;
|
||||||
|
img.style.objectFit = 'contain';
|
||||||
|
img.style.display = 'block';
|
||||||
|
img.style.margin = '10px 0';
|
||||||
|
img.style.borderRadius = '6px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEditorImage(url: string, pending = false) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = url;
|
||||||
|
if (pending || url.startsWith('blob:')) {
|
||||||
|
img.setAttribute('data-local-blob', '1');
|
||||||
|
}
|
||||||
|
applyInlineImageStyles(img);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditorImages(root: HTMLElement) {
|
||||||
|
root.querySelectorAll('img').forEach((node) => applyInlineImageStyles(node as HTMLImageElement));
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertImageUrl(url: string) {
|
||||||
|
const el = editorRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
el.focus();
|
||||||
|
restoreSelection();
|
||||||
|
|
||||||
|
const img = createEditorImage(url, url.startsWith('blob:'));
|
||||||
|
const sel = window.getSelection();
|
||||||
|
|
||||||
|
if (sel && sel.rangeCount > 0) {
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
if (isNodeInEditor(range.commonAncestorContainer)) {
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(img);
|
||||||
|
const after = document.createRange();
|
||||||
|
after.setStartAfter(img);
|
||||||
|
after.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(after);
|
||||||
|
savedRange = after.cloneRange();
|
||||||
|
normalizeEditorImages(el);
|
||||||
|
emitChange();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.appendChild(img);
|
||||||
|
normalizeEditorImages(el);
|
||||||
|
emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertPendingImage(file: File) {
|
||||||
|
if (file.size > MAX_UPLOAD_SIZE) {
|
||||||
|
ElMessage.error(t('content.upload.size_error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveSelection();
|
||||||
|
const blobUrl = URL.createObjectURL(file);
|
||||||
|
pendingFiles.set(blobUrl, file);
|
||||||
|
insertImageUrl(blobUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadSingleFile(file: File): Promise<string> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const { data } = await api.post(`/admin/uploads?category=${props.uploadCategory}`, fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
const url = data.data?.url as string;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(t('content.upload.failed'));
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扫描 HTML 中的 blob: 图片,上传后替换为 /uploads/... URL */
|
||||||
|
async function uploadPendingImages(html: string): Promise<string> {
|
||||||
|
if (!html.includes('blob:')) return html;
|
||||||
|
|
||||||
|
let result = html;
|
||||||
|
const blobUrls = [...new Set([...html.matchAll(/blob:[^\s"'<>]+/g)].map((m) => m[0]))];
|
||||||
|
|
||||||
|
for (const blobUrl of blobUrls) {
|
||||||
|
const file = pendingFiles.get(blobUrl);
|
||||||
|
if (!file) continue;
|
||||||
|
|
||||||
|
const serverUrl = await uploadSingleFile(file);
|
||||||
|
result = result.replaceAll(blobUrl, serverUrl);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
pendingFiles.delete(blobUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHtml(): string {
|
||||||
|
if (!editorRef.value) return normalizeHtml(props.modelValue);
|
||||||
|
return normalizeHtml(editorRef.value.innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ uploadPendingImages, getHtml });
|
||||||
|
|
||||||
|
function onImageFileChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
if (input.files?.[0]) {
|
||||||
|
insertPendingImage(input.files[0]);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMediaPicker() {
|
||||||
|
saveSelection();
|
||||||
|
mediaPickerVisible.value = true;
|
||||||
|
mediaLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/files', {
|
||||||
|
params: { category: props.uploadCategory, pageSize: 200 },
|
||||||
|
});
|
||||||
|
mediaFiles.value = res.data.data.items ?? [];
|
||||||
|
} catch {
|
||||||
|
mediaFiles.value = [];
|
||||||
|
} finally {
|
||||||
|
mediaLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMediaFile(url: string) {
|
||||||
|
insertImageUrl(url);
|
||||||
|
mediaPickerVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaste(e: ClipboardEvent) {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
saveSelection();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) insertPendingImage(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void nextTick(() => {
|
||||||
|
const el = editorRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
normalizeEditorImages(el);
|
||||||
|
emitChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val, prev) => {
|
||||||
|
if (val === prev) return;
|
||||||
|
const el = editorRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
if (normalizeHtml(el.innerHTML) !== normalizeHtml(val)) {
|
||||||
|
syncFromModel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
syncFromModel();
|
||||||
|
document.addEventListener('selectionchange', onDocumentSelectionChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('selectionchange', onDocumentSelectionChange);
|
||||||
|
revokeAllPendingBlobs();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rich-editor" :class="{ 'is-disabled': disabled, 'rich-editor--fill': fill }">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" title="Bold" :disabled="disabled" @mousedown.prevent @click="exec('bold')">
|
||||||
|
<strong>B</strong>
|
||||||
|
</button>
|
||||||
|
<button type="button" title="Italic" :disabled="disabled" @mousedown.prevent @click="exec('italic')">
|
||||||
|
<em>I</em>
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="disabled" @mousedown.prevent @click="exec('insertUnorderedList')">
|
||||||
|
•
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="disabled" @mousedown.prevent @click="exec('insertOrderedList')">
|
||||||
|
1.
|
||||||
|
</button>
|
||||||
|
<label class="toolbar-upload" @mousedown.prevent="saveSelection">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
:accept="IMAGE_ACCEPT"
|
||||||
|
style="display: none"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="onImageFileChange"
|
||||||
|
/>
|
||||||
|
{{ t('content.editor.insert_image') }}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
@mousedown.prevent="saveSelection"
|
||||||
|
@click="openMediaPicker"
|
||||||
|
>
|
||||||
|
{{ t('content.upload.pick_media') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref="editorRef"
|
||||||
|
class="editor-body"
|
||||||
|
:data-placeholder="placeholder || t('content.editor.placeholder')"
|
||||||
|
:contenteditable="disabled ? 'false' : 'true'"
|
||||||
|
@input="emitChange"
|
||||||
|
@blur="emitChange"
|
||||||
|
@paste="onPaste"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="mediaPickerVisible"
|
||||||
|
:title="t('content.upload.pick_media_title')"
|
||||||
|
width="680px"
|
||||||
|
destroy-on-close
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<div v-if="mediaLoading" class="media-state">{{ t('common.loading') }}</div>
|
||||||
|
<div v-else-if="mediaFiles.length === 0" class="media-state">{{ t('content.upload.no_media') }}</div>
|
||||||
|
<div v-else class="media-grid">
|
||||||
|
<div
|
||||||
|
v-for="file in mediaFiles"
|
||||||
|
:key="file.id"
|
||||||
|
class="media-card"
|
||||||
|
@click="pickMediaFile(file.url)"
|
||||||
|
>
|
||||||
|
<div class="media-thumb">
|
||||||
|
<img v-if="file.mimeType !== 'image/svg+xml'" :src="file.url" :alt="file.filename" loading="lazy" />
|
||||||
|
<div v-else class="media-svg">SVG</div>
|
||||||
|
</div>
|
||||||
|
<div class="media-name" :title="file.filename">{{ file.filename }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rich-editor {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor--fill {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 420px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor.is-disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: #fbfaf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button,
|
||||||
|
.toolbar-upload {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-upload {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-body {
|
||||||
|
min-height: 140px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor--fill .editor-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-body:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-body :deep(img) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor--fill .editor-body :deep(img) {
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-body :deep(ul),
|
||||||
|
.editor-body :deep(ol) {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card:hover {
|
||||||
|
border-color: #d5cfc3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-thumb {
|
||||||
|
height: 80px;
|
||||||
|
background: #f4f0e8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-thumb img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-svg {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-name {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
apps/admin/src/composables/useDepositPendingCount.ts
Normal file
40
apps/admin/src/composables/useDepositPendingCount.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
const pendingCount = ref(0);
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let pollConsumers = 0;
|
||||||
|
|
||||||
|
export async function refreshDepositPendingCount() {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/admin/deposit-orders/pending-count');
|
||||||
|
pendingCount.value = Number(data.data?.count ?? 0);
|
||||||
|
} catch {
|
||||||
|
pendingCount.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDepositPendingCount() {
|
||||||
|
function startDepositPendingPolling() {
|
||||||
|
pollConsumers += 1;
|
||||||
|
if (pollConsumers > 1) return;
|
||||||
|
void refreshDepositPendingCount();
|
||||||
|
pollTimer = setInterval(() => void refreshDepositPendingCount(), 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDepositPendingPolling() {
|
||||||
|
pollConsumers = Math.max(0, pollConsumers - 1);
|
||||||
|
if (pollConsumers > 0) return;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingCount,
|
||||||
|
refreshDepositPendingCount,
|
||||||
|
startDepositPendingPolling,
|
||||||
|
stopDepositPendingPolling,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ const zh: Record<string, string> = {
|
|||||||
'staff.col.last_login': '最近登录',
|
'staff.col.last_login': '最近登录',
|
||||||
'staff.dialog.create': '创建后台账号',
|
'staff.dialog.create': '创建后台账号',
|
||||||
'staff.dialog.edit': '编辑后台账号',
|
'staff.dialog.edit': '编辑后台账号',
|
||||||
|
'staff.field.visible_menus': '菜单权限',
|
||||||
'login.captcha_ph': '验证码',
|
'login.captcha_ph': '验证码',
|
||||||
'login.captcha_refresh': '点击刷新',
|
'login.captcha_refresh': '点击刷新',
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ const zh: Record<string, string> = {
|
|||||||
'deposit.status_approved': '已通过',
|
'deposit.status_approved': '已通过',
|
||||||
'deposit.status_rejected': '已拒绝',
|
'deposit.status_rejected': '已拒绝',
|
||||||
'deposit.search_player_ph': '搜索玩家...',
|
'deposit.search_player_ph': '搜索玩家...',
|
||||||
|
'deposit.pending_badge': '待审核 {n} 笔',
|
||||||
'deposit.order_no': '订单号',
|
'deposit.order_no': '订单号',
|
||||||
'deposit.player': '玩家',
|
'deposit.player': '玩家',
|
||||||
'deposit.amount': '金额',
|
'deposit.amount': '金额',
|
||||||
@@ -114,7 +116,7 @@ const zh: Record<string, string> = {
|
|||||||
'deposit.add_method': '+ 添加',
|
'deposit.add_method': '+ 添加',
|
||||||
'deposit.display_name': '展示名称',
|
'deposit.display_name': '展示名称',
|
||||||
'deposit.details': '详情',
|
'deposit.details': '详情',
|
||||||
'deposit.sort': '排序',
|
'deposit.sort': '排序值',
|
||||||
'deposit.active': '启用',
|
'deposit.active': '启用',
|
||||||
'deposit.show_player': '前台展示',
|
'deposit.show_player': '前台展示',
|
||||||
'deposit.edit_method': '编辑收款方式',
|
'deposit.edit_method': '编辑收款方式',
|
||||||
@@ -124,7 +126,7 @@ const zh: Record<string, string> = {
|
|||||||
'deposit.account_number': '银行账号',
|
'deposit.account_number': '银行账号',
|
||||||
'deposit.usdt_address': 'USDT 地址',
|
'deposit.usdt_address': 'USDT 地址',
|
||||||
'deposit.qr_code': '二维码',
|
'deposit.qr_code': '二维码',
|
||||||
'deposit.sort_order': '排序序号',
|
'deposit.sort_order': '排序值',
|
||||||
'deposit.show_on_player': '展示给玩家',
|
'deposit.show_on_player': '展示给玩家',
|
||||||
'deposit.save': '保存',
|
'deposit.save': '保存',
|
||||||
'deposit.confirm_deactivate': '确认停用此收款方式?',
|
'deposit.confirm_deactivate': '确认停用此收款方式?',
|
||||||
@@ -150,11 +152,12 @@ const zh: Record<string, string> = {
|
|||||||
'role.tier2_agent': '二级代理',
|
'role.tier2_agent': '二级代理',
|
||||||
'logout': '退出',
|
'logout': '退出',
|
||||||
'lang': '语言',
|
'lang': '语言',
|
||||||
'portal.admin': '平台后台',
|
'portal.admin': '平台后台管理',
|
||||||
'portal.agent': '代理后台',
|
'portal.agent': '代理后台管理',
|
||||||
|
|
||||||
'common.all': '全部',
|
'common.all': '全部',
|
||||||
'common.search': '查询',
|
'common.search': '查询',
|
||||||
|
'common.refresh': '刷新',
|
||||||
'common.reset': '重置',
|
'common.reset': '重置',
|
||||||
'common.edit': '编辑',
|
'common.edit': '编辑',
|
||||||
'common.yes': '是',
|
'common.yes': '是',
|
||||||
@@ -164,9 +167,13 @@ const zh: Record<string, string> = {
|
|||||||
'common.confirm': '确定',
|
'common.confirm': '确定',
|
||||||
'common.retry': '重试',
|
'common.retry': '重试',
|
||||||
'common.status': '状态',
|
'common.status': '状态',
|
||||||
|
'user.status.ACTIVE': '正常',
|
||||||
|
'user.status.SUSPENDED': '已冻结',
|
||||||
|
'user.status.DISABLED': '已停用',
|
||||||
'common.type': '类型',
|
'common.type': '类型',
|
||||||
'common.keyword': '关键词',
|
'common.keyword': '关键词',
|
||||||
'common.actions': '操作',
|
'common.actions': '操作',
|
||||||
|
'common.seq': '序号',
|
||||||
'common.more': '更多',
|
'common.more': '更多',
|
||||||
'common.loading': '加载中…',
|
'common.loading': '加载中…',
|
||||||
'common.no_data': '暂无数据',
|
'common.no_data': '暂无数据',
|
||||||
@@ -218,6 +225,9 @@ const zh: Record<string, string> = {
|
|||||||
'dash.match_pending_settle': '待结算',
|
'dash.match_pending_settle': '待结算',
|
||||||
'dash.match_settled': '已结算',
|
'dash.match_settled': '已结算',
|
||||||
'dash.user_active': '正常玩家',
|
'dash.user_active': '正常玩家',
|
||||||
|
'dash.user_online': '当前在线',
|
||||||
|
'dash.players_online': '当前在线',
|
||||||
|
'dash.players_online_hint': '约 2 分钟内活跃',
|
||||||
'dash.user_suspended': '停用',
|
'dash.user_suspended': '停用',
|
||||||
'dash.user_direct': '直属',
|
'dash.user_direct': '直属',
|
||||||
'dash.user_agents': '代理',
|
'dash.user_agents': '代理',
|
||||||
@@ -334,6 +344,7 @@ const en: Record<string, string> = {
|
|||||||
'staff.col.last_login': 'Last login',
|
'staff.col.last_login': 'Last login',
|
||||||
'staff.dialog.create': 'Create staff account',
|
'staff.dialog.create': 'Create staff account',
|
||||||
'staff.dialog.edit': 'Edit staff account',
|
'staff.dialog.edit': 'Edit staff account',
|
||||||
|
'staff.field.visible_menus': 'Menu Permissions',
|
||||||
'login.captcha_ph': 'Captcha',
|
'login.captcha_ph': 'Captcha',
|
||||||
'login.captcha_refresh': 'Click to refresh',
|
'login.captcha_refresh': 'Click to refresh',
|
||||||
|
|
||||||
@@ -370,6 +381,7 @@ const en: Record<string, string> = {
|
|||||||
'deposit.status_approved': 'Approved',
|
'deposit.status_approved': 'Approved',
|
||||||
'deposit.status_rejected': 'Rejected',
|
'deposit.status_rejected': 'Rejected',
|
||||||
'deposit.search_player_ph': 'Search player...',
|
'deposit.search_player_ph': 'Search player...',
|
||||||
|
'deposit.pending_badge': '{n} pending',
|
||||||
'deposit.order_no': 'Order No',
|
'deposit.order_no': 'Order No',
|
||||||
'deposit.player': 'Player',
|
'deposit.player': 'Player',
|
||||||
'deposit.amount': 'Amount',
|
'deposit.amount': 'Amount',
|
||||||
@@ -449,11 +461,12 @@ const en: Record<string, string> = {
|
|||||||
'role.tier2_agent': 'Tier-2 Agent',
|
'role.tier2_agent': 'Tier-2 Agent',
|
||||||
'logout': 'Logout',
|
'logout': 'Logout',
|
||||||
'lang': 'Language',
|
'lang': 'Language',
|
||||||
'portal.admin': 'Platform Admin',
|
'portal.admin': 'Admin Console',
|
||||||
'portal.agent': 'Agent Portal',
|
'portal.agent': 'Agent Console',
|
||||||
|
|
||||||
'common.all': 'All',
|
'common.all': 'All',
|
||||||
'common.search': 'Search',
|
'common.search': 'Search',
|
||||||
|
'common.refresh': 'Refresh',
|
||||||
'common.reset': 'Reset',
|
'common.reset': 'Reset',
|
||||||
'common.edit': 'Edit',
|
'common.edit': 'Edit',
|
||||||
'common.yes': 'Yes',
|
'common.yes': 'Yes',
|
||||||
@@ -463,9 +476,13 @@ const en: Record<string, string> = {
|
|||||||
'common.confirm': 'OK',
|
'common.confirm': 'OK',
|
||||||
'common.retry': 'Retry',
|
'common.retry': 'Retry',
|
||||||
'common.status': 'Status',
|
'common.status': 'Status',
|
||||||
|
'user.status.ACTIVE': 'Active',
|
||||||
|
'user.status.SUSPENDED': 'Suspended',
|
||||||
|
'user.status.DISABLED': 'Disabled',
|
||||||
'common.type': 'Type',
|
'common.type': 'Type',
|
||||||
'common.keyword': 'Keyword',
|
'common.keyword': 'Keyword',
|
||||||
'common.actions': 'Actions',
|
'common.actions': 'Actions',
|
||||||
|
'common.seq': 'No.',
|
||||||
'common.more': 'More',
|
'common.more': 'More',
|
||||||
'common.loading': 'Loading…',
|
'common.loading': 'Loading…',
|
||||||
'common.no_data': 'No data',
|
'common.no_data': 'No data',
|
||||||
@@ -517,6 +534,9 @@ const en: Record<string, string> = {
|
|||||||
'dash.match_pending_settle': 'Pending settlement',
|
'dash.match_pending_settle': 'Pending settlement',
|
||||||
'dash.match_settled': 'Settled',
|
'dash.match_settled': 'Settled',
|
||||||
'dash.user_active': 'Active players',
|
'dash.user_active': 'Active players',
|
||||||
|
'dash.user_online': 'Online now',
|
||||||
|
'dash.players_online': 'Online now',
|
||||||
|
'dash.players_online_hint': 'Active within ~2 min',
|
||||||
'dash.user_suspended': 'Suspended',
|
'dash.user_suspended': 'Suspended',
|
||||||
'dash.user_direct': 'Direct',
|
'dash.user_direct': 'Direct',
|
||||||
'dash.user_agents': 'Agents',
|
'dash.user_agents': 'Agents',
|
||||||
@@ -633,6 +653,7 @@ const ms: Record<string, string> = {
|
|||||||
'staff.col.last_login': 'Log masuk terakhir',
|
'staff.col.last_login': 'Log masuk terakhir',
|
||||||
'staff.dialog.create': 'Cipta akaun kakitangan',
|
'staff.dialog.create': 'Cipta akaun kakitangan',
|
||||||
'staff.dialog.edit': 'Edit akaun kakitangan',
|
'staff.dialog.edit': 'Edit akaun kakitangan',
|
||||||
|
'staff.field.visible_menus': 'Kebenaran Menu',
|
||||||
'login.captcha_ph': 'Captcha',
|
'login.captcha_ph': 'Captcha',
|
||||||
'login.captcha_refresh': 'Klik untuk muat semula',
|
'login.captcha_refresh': 'Klik untuk muat semula',
|
||||||
|
|
||||||
@@ -669,6 +690,7 @@ const ms: Record<string, string> = {
|
|||||||
'deposit.status_approved': 'Diluluskan',
|
'deposit.status_approved': 'Diluluskan',
|
||||||
'deposit.status_rejected': 'Ditolak',
|
'deposit.status_rejected': 'Ditolak',
|
||||||
'deposit.search_player_ph': 'Cari pemain...',
|
'deposit.search_player_ph': 'Cari pemain...',
|
||||||
|
'deposit.pending_badge': '{n} menunggu',
|
||||||
'deposit.order_no': 'No. Pesanan',
|
'deposit.order_no': 'No. Pesanan',
|
||||||
'deposit.player': 'Pemain',
|
'deposit.player': 'Pemain',
|
||||||
'deposit.amount': 'Jumlah',
|
'deposit.amount': 'Jumlah',
|
||||||
@@ -748,11 +770,12 @@ const ms: Record<string, string> = {
|
|||||||
'role.tier2_agent': 'Ejen Peringkat 2',
|
'role.tier2_agent': 'Ejen Peringkat 2',
|
||||||
'logout': 'Log keluar',
|
'logout': 'Log keluar',
|
||||||
'lang': 'Bahasa',
|
'lang': 'Bahasa',
|
||||||
'portal.admin': 'Admin Platform',
|
'portal.admin': 'Pengurusan Admin',
|
||||||
'portal.agent': 'Portal Ejen',
|
'portal.agent': 'Pengurusan Ejen',
|
||||||
|
|
||||||
'common.all': 'Semua',
|
'common.all': 'Semua',
|
||||||
'common.search': 'Cari',
|
'common.search': 'Cari',
|
||||||
|
'common.refresh': 'Muat semula',
|
||||||
'common.reset': 'Set semula',
|
'common.reset': 'Set semula',
|
||||||
'common.edit': 'Edit',
|
'common.edit': 'Edit',
|
||||||
'common.yes': 'Ya',
|
'common.yes': 'Ya',
|
||||||
@@ -762,9 +785,13 @@ const ms: Record<string, string> = {
|
|||||||
'common.confirm': 'OK',
|
'common.confirm': 'OK',
|
||||||
'common.retry': 'Cuba lagi',
|
'common.retry': 'Cuba lagi',
|
||||||
'common.status': 'Status',
|
'common.status': 'Status',
|
||||||
|
'user.status.ACTIVE': 'Aktif',
|
||||||
|
'user.status.SUSPENDED': 'Digantung',
|
||||||
|
'user.status.DISABLED': 'Dinyahaktifkan',
|
||||||
'common.type': 'Jenis',
|
'common.type': 'Jenis',
|
||||||
'common.keyword': 'Kata kunci',
|
'common.keyword': 'Kata kunci',
|
||||||
'common.actions': 'Tindakan',
|
'common.actions': 'Tindakan',
|
||||||
|
'common.seq': 'No.',
|
||||||
'common.more': 'Lagi',
|
'common.more': 'Lagi',
|
||||||
'common.loading': 'Memuatkan…',
|
'common.loading': 'Memuatkan…',
|
||||||
'common.no_data': 'Tiada data',
|
'common.no_data': 'Tiada data',
|
||||||
@@ -816,6 +843,9 @@ const ms: Record<string, string> = {
|
|||||||
'dash.match_pending_settle': 'Menunggu penyelesaian',
|
'dash.match_pending_settle': 'Menunggu penyelesaian',
|
||||||
'dash.match_settled': 'Diselesaikan',
|
'dash.match_settled': 'Diselesaikan',
|
||||||
'dash.user_active': 'Pemain aktif',
|
'dash.user_active': 'Pemain aktif',
|
||||||
|
'dash.user_online': 'Dalam talian',
|
||||||
|
'dash.players_online': 'Dalam talian',
|
||||||
|
'dash.players_online_hint': 'Aktif dalam ~2 min',
|
||||||
'dash.user_suspended': 'Digantung',
|
'dash.user_suspended': 'Digantung',
|
||||||
'dash.user_direct': 'Terus',
|
'dash.user_direct': 'Terus',
|
||||||
'dash.user_agents': 'Ejen',
|
'dash.user_agents': 'Ejen',
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'user.filter.agent': 'Ejen',
|
'user.filter.agent': 'Ejen',
|
||||||
'user.filter.agent_ph': 'Semua',
|
'user.filter.agent_ph': 'Semua',
|
||||||
'user.col.username': 'Nama pengguna',
|
'user.col.username': 'Nama pengguna',
|
||||||
|
'user.col.online': 'Dalam talian',
|
||||||
|
'user.online_yes': 'Sedang dalam talian',
|
||||||
|
'user.presence_online': 'Dalam talian',
|
||||||
|
'user.presence_offline': 'Luar talian',
|
||||||
'user.col.agent': 'Ejen',
|
'user.col.agent': 'Ejen',
|
||||||
'user.col.invite_code': 'Kod jemputan',
|
'user.col.invite_code': 'Kod jemputan',
|
||||||
'user.col.balance': 'Tersedia / Dibekukan',
|
'user.col.balance': 'Tersedia / Dibekukan',
|
||||||
@@ -765,11 +769,39 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'content.btn.enable': 'Aktifkan',
|
'content.btn.enable': 'Aktifkan',
|
||||||
'content.btn.disable': 'Nyahaktif',
|
'content.btn.disable': 'Nyahaktif',
|
||||||
'content.dialog.create': 'Kandungan awam baharu',
|
'content.dialog.create': 'Kandungan awam baharu',
|
||||||
'content.dialog.edit': 'Edit kandungan awam',
|
'content.dialog.create_banner': 'Promosi laman utama baharu',
|
||||||
|
'content.dialog.create_notice': 'Terbitkan notifikasi',
|
||||||
|
'content.dialog.edit': 'Edit kandungan',
|
||||||
|
'content.dialog.edit_banner': 'Edit promosi laman utama',
|
||||||
|
'content.dialog.edit_notice': 'Edit notifikasi',
|
||||||
'content.confirm_delete': 'Padam "{title}"?',
|
'content.confirm_delete': 'Padam "{title}"?',
|
||||||
'content.type.BANNER': 'Banner laman utama',
|
'content.type.BANNER': 'Promosi laman utama',
|
||||||
'content.type.ANNOUNCEMENT': 'Pengumuman',
|
'content.type.ANNOUNCEMENT': 'Notifikasi',
|
||||||
'content.hint.announcement': 'Dipaparkan di ticker atas pemain; isi tajuk atau kandungan',
|
'content.type.INBOX_NOTIFY': 'Notifikasi peti mesej',
|
||||||
|
'content.inbox_notify.inbox_enabled': 'Peti mesej pemain',
|
||||||
|
'content.inbox_notify.inbox_enabled_hint': 'Jika dimatikan, pemain hanya dibawa ke sokongan (tiada tab peti mesej)',
|
||||||
|
'content.inbox_notify.deposit': 'Keputusan deposit',
|
||||||
|
'content.inbox_notify.deposit_hint': 'Hantar mesej apabila diluluskan atau ditolak',
|
||||||
|
'content.inbox_notify.manual_title': 'Tanda "Notifikasi peti mesej" semasa mencipta:',
|
||||||
|
'content.inbox_notify.banner_note': 'Promosi laman utama',
|
||||||
|
'content.inbox_notify.announcement_note': 'Notifikasi / ticker',
|
||||||
|
'content.hint.banner': 'Dipaparkan dalam karusel laman utama; ketik untuk halaman butiran. Muat naik imej muka depan dan kandungan kaya seperti pengumuman laman rasmi.',
|
||||||
|
'content.hint.announcement': 'Dipaparkan sebagai teks bergulir di bahagian atas aplikasi pemain. Isi tajuk dan teks bergulir setiap bahasa — teks biasa sahaja.',
|
||||||
|
'content.section.publish': 'Tetapan terbitan',
|
||||||
|
'content.section.content': 'Kandungan notifikasi',
|
||||||
|
'content.field.publish_kind': 'Jenis terbitan',
|
||||||
|
'content.publish_kind.notice': 'Notifikasi penuh (halaman butiran)',
|
||||||
|
'content.publish_kind.ticker': 'Teks ticker sahaja',
|
||||||
|
'content.publish_kind.hint': 'Notifikasi penuh muncul dalam senarai dan butiran; ticker hanya papar satu baris bergulir.',
|
||||||
|
'content.field.cover_image': 'Imej muka depan',
|
||||||
|
'content.field.ticker_title_ph': 'Tajuk ticker (pilihan)',
|
||||||
|
'content.field.ticker_body_ph': 'Teks dipaparkan dalam ticker',
|
||||||
|
'content.field.ticker_hint': 'Ticker hanya papar teks biasa — tiada imej atau format kaya.',
|
||||||
|
'content.editor.placeholder': 'Tulis kandungan notifikasi; boleh sisip imej dan senarai…',
|
||||||
|
'content.editor.insert_image': 'Sisip imej',
|
||||||
|
'content.upload.cover_size_hint': 'Lebar disyorkan 860px+. Imej muka depan dan dalam kandungan akan menyesuaikan lebar pada pemain.',
|
||||||
|
'content.upload.pick_media_title': 'Pilih imej',
|
||||||
|
'content.upload.no_media': 'Tiada imej dalam pustaka — muat naik dahulu',
|
||||||
'content.status.DRAFT': 'Draf',
|
'content.status.DRAFT': 'Draf',
|
||||||
'content.status.ACTIVE': 'Aktif',
|
'content.status.ACTIVE': 'Aktif',
|
||||||
'content.status.INACTIVE': 'Tidak aktif',
|
'content.status.INACTIVE': 'Tidak aktif',
|
||||||
@@ -783,6 +815,9 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'content.field.link_target': 'Sasaran pautan',
|
'content.field.link_target': 'Sasaran pautan',
|
||||||
'content.field.start_time': 'Masa mula',
|
'content.field.start_time': 'Masa mula',
|
||||||
'content.field.end_time': 'Masa tamat',
|
'content.field.end_time': 'Masa tamat',
|
||||||
|
'content.field.notify_inbox': 'Notifikasi peti mesej',
|
||||||
|
'content.field.notify_inbox_hint': 'Hantar mesej peti kepada semua pemain tentang promosi ini selepas simpan',
|
||||||
|
'content.msg.notify_sent': 'Notifikasi dihantar kepada {count} pemain',
|
||||||
'content.field.title': 'Tajuk',
|
'content.field.title': 'Tajuk',
|
||||||
'content.field.title_ph': 'Pilihan',
|
'content.field.title_ph': 'Pilihan',
|
||||||
'content.field.body': 'Kandungan',
|
'content.field.body': 'Kandungan',
|
||||||
@@ -795,8 +830,6 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'content.upload.size_error': 'Imej mestilah di bawah 5 MB',
|
'content.upload.size_error': 'Imej mestilah di bawah 5 MB',
|
||||||
'content.upload.remove': 'Buang imej',
|
'content.upload.remove': 'Buang imej',
|
||||||
'content.upload.pick_media': 'Pilih dari pustaka',
|
'content.upload.pick_media': 'Pilih dari pustaka',
|
||||||
'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.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.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.link.none': 'Tiada pautan',
|
||||||
|
|||||||
@@ -838,15 +838,33 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'content.btn.enable': '启用',
|
'content.btn.enable': '启用',
|
||||||
'content.btn.disable': '停用',
|
'content.btn.disable': '停用',
|
||||||
'content.dialog.create': '新建公共内容',
|
'content.dialog.create': '新建公共内容',
|
||||||
'content.dialog.edit': '编辑公共内容',
|
'content.dialog.create_banner': '新建首页推广',
|
||||||
|
'content.dialog.create_notice': '发布通知公告',
|
||||||
|
'content.dialog.edit': '编辑内容',
|
||||||
|
'content.dialog.edit_banner': '编辑首页推广',
|
||||||
|
'content.dialog.edit_notice': '编辑通知公告',
|
||||||
'content.confirm_delete': '确定删除「{title}」?',
|
'content.confirm_delete': '确定删除「{title}」?',
|
||||||
'content.type.BANNER': '首页轮播',
|
'content.type.BANNER': '首页推广',
|
||||||
'content.type.ANNOUNCEMENT': '公告滚动',
|
'content.type.ANNOUNCEMENT': '通知公告',
|
||||||
'content.hint.announcement': '显示在玩家端顶部跑马灯;标题与正文填一项即可,建议正文为主',
|
'content.hint.banner': '用于首页轮播展示,点击后进入通知详情页;请填写封面图与正文,像官网发布活动通知一样编辑。',
|
||||||
|
'content.hint.announcement': '在玩家端顶部显示滚动跑马灯文字;填写各语言标题与滚动文案即可,纯文本无富文本。',
|
||||||
|
'content.section.publish': '发布设置',
|
||||||
|
'content.section.content': '通知内容',
|
||||||
|
'content.field.publish_kind': '发布类型',
|
||||||
|
'content.publish_kind.notice': '完整通知(详情页)',
|
||||||
|
'content.publish_kind.ticker': '仅跑马灯文字',
|
||||||
|
'content.publish_kind.hint': '完整通知会出现在公告列表与详情页;跑马灯仅显示顶部滚动一行文字。',
|
||||||
|
'content.field.cover_image': '封面图',
|
||||||
|
'content.field.ticker_title_ph': '跑马灯标题(选填)',
|
||||||
|
'content.field.ticker_body_ph': '跑马灯显示的文字',
|
||||||
|
'content.field.ticker_hint': '跑马灯只显示纯文字,不支持图片与富文本格式。',
|
||||||
|
'content.editor.placeholder': '输入通知正文,可插入图片、列表等…',
|
||||||
|
'content.editor.insert_image': '插入图片',
|
||||||
|
'content.upload.cover_size_hint': '建议宽度 860px 以上;玩家端详情页会完整显示封面,正文内图片也会自适应宽度。',
|
||||||
'content.status.DRAFT': '草稿',
|
'content.status.DRAFT': '草稿',
|
||||||
'content.status.ACTIVE': '已启用',
|
'content.status.ACTIVE': '已启用',
|
||||||
'content.status.INACTIVE': '已停用',
|
'content.status.INACTIVE': '已停用',
|
||||||
'content.col.sort': '排序',
|
'content.col.sort': '排序值',
|
||||||
'content.col.preview': '预览',
|
'content.col.preview': '预览',
|
||||||
'content.col.title': '标题/摘要',
|
'content.col.title': '标题/摘要',
|
||||||
'content.col.player_visible': '玩家可见',
|
'content.col.player_visible': '玩家可见',
|
||||||
@@ -857,7 +875,7 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'content.field.start_time': '开始时间',
|
'content.field.start_time': '开始时间',
|
||||||
'content.field.end_time': '结束时间',
|
'content.field.end_time': '结束时间',
|
||||||
'content.field.title': '标题',
|
'content.field.title': '标题',
|
||||||
'content.field.title_ph': '选填,可与正文相同',
|
'content.field.title_ph': '通知标题,玩家端详情页展示',
|
||||||
'content.field.body': '正文',
|
'content.field.body': '正文',
|
||||||
'content.field.announce_text': '滚动文案',
|
'content.field.announce_text': '滚动文案',
|
||||||
'content.field.image_url': '图片地址',
|
'content.field.image_url': '图片地址',
|
||||||
@@ -868,8 +886,8 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'content.upload.size_error': '图片大小不能超过 5MB',
|
'content.upload.size_error': '图片大小不能超过 5MB',
|
||||||
'content.upload.remove': '移除图片',
|
'content.upload.remove': '移除图片',
|
||||||
'content.upload.pick_media': '从媒体库选择',
|
'content.upload.pick_media': '从媒体库选择',
|
||||||
'content.upload.pick_media_title': '选择 Banner 图片',
|
'content.upload.pick_media_title': '选择图片',
|
||||||
'content.upload.no_media': '媒体库中暂无 Banner 图片,请先上传',
|
'content.upload.no_media': '媒体库中暂无图片,请先上传',
|
||||||
'content.upload.url_placeholder': '或手动粘贴图片 URL',
|
'content.upload.url_placeholder': '或手动粘贴图片 URL',
|
||||||
'content.upload.recommended_size': '建议尺寸:860 x 360 px,或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
|
'content.upload.recommended_size': '建议尺寸:860 x 360 px,或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
|
||||||
'content.link.none': '无跳转',
|
'content.link.none': '无跳转',
|
||||||
@@ -1911,11 +1929,29 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'content.btn.enable': 'Enable',
|
'content.btn.enable': 'Enable',
|
||||||
'content.btn.disable': 'Disable',
|
'content.btn.disable': 'Disable',
|
||||||
'content.dialog.create': 'New public content',
|
'content.dialog.create': 'New public content',
|
||||||
'content.dialog.edit': 'Edit public content',
|
'content.dialog.create_banner': 'New home promotion',
|
||||||
|
'content.dialog.create_notice': 'Publish notification',
|
||||||
|
'content.dialog.edit': 'Edit content',
|
||||||
|
'content.dialog.edit_banner': 'Edit home promotion',
|
||||||
|
'content.dialog.edit_notice': 'Edit notification',
|
||||||
'content.confirm_delete': 'Delete "{title}"?',
|
'content.confirm_delete': 'Delete "{title}"?',
|
||||||
'content.type.BANNER': 'Home banners',
|
'content.type.BANNER': 'Home promotions',
|
||||||
'content.type.ANNOUNCEMENT': 'Announcements',
|
'content.type.ANNOUNCEMENT': 'Notifications',
|
||||||
'content.hint.announcement': 'Shown in the player top marquee; fill title or body (body recommended)',
|
'content.hint.banner': 'Shown in the home carousel; tapping opens the detail page. Add a cover image and rich body like an official site announcement.',
|
||||||
|
'content.hint.announcement': 'Shows as a scrolling marquee at the top of the player app. Enter title and marquee text per language — plain text only.',
|
||||||
|
'content.section.publish': 'Publish settings',
|
||||||
|
'content.section.content': 'Notification content',
|
||||||
|
'content.field.publish_kind': 'Publish type',
|
||||||
|
'content.publish_kind.notice': 'Full notification (detail page)',
|
||||||
|
'content.publish_kind.ticker': 'Ticker text only',
|
||||||
|
'content.publish_kind.hint': 'Full notifications appear in the list and detail page; ticker-only shows one scrolling line at the top.',
|
||||||
|
'content.field.cover_image': 'Cover image',
|
||||||
|
'content.field.ticker_title_ph': 'Ticker title (optional)',
|
||||||
|
'content.field.ticker_body_ph': 'Text shown in the marquee',
|
||||||
|
'content.field.ticker_hint': 'Ticker shows plain text only — no images or rich formatting.',
|
||||||
|
'content.editor.placeholder': 'Write the notification body; you can insert images and lists…',
|
||||||
|
'content.editor.insert_image': 'Insert image',
|
||||||
|
'content.upload.cover_size_hint': 'Recommended width 860px+. Cover and inline images scale to fit on the player detail page.',
|
||||||
'content.status.DRAFT': 'Draft',
|
'content.status.DRAFT': 'Draft',
|
||||||
'content.status.ACTIVE': 'Active',
|
'content.status.ACTIVE': 'Active',
|
||||||
'content.status.INACTIVE': 'Inactive',
|
'content.status.INACTIVE': 'Inactive',
|
||||||
@@ -1930,7 +1966,7 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'content.field.start_time': 'Start time',
|
'content.field.start_time': 'Start time',
|
||||||
'content.field.end_time': 'End time',
|
'content.field.end_time': 'End time',
|
||||||
'content.field.title': 'Title',
|
'content.field.title': 'Title',
|
||||||
'content.field.title_ph': 'Optional; can match body',
|
'content.field.title_ph': 'Title shown on the player detail page',
|
||||||
'content.field.body': 'Body',
|
'content.field.body': 'Body',
|
||||||
'content.field.announce_text': 'Marquee text',
|
'content.field.announce_text': 'Marquee text',
|
||||||
'content.field.image_url': 'Image URL',
|
'content.field.image_url': 'Image URL',
|
||||||
@@ -1941,8 +1977,8 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'content.upload.size_error': 'Image must be under 5 MB',
|
'content.upload.size_error': 'Image must be under 5 MB',
|
||||||
'content.upload.remove': 'Remove image',
|
'content.upload.remove': 'Remove image',
|
||||||
'content.upload.pick_media': 'Pick from library',
|
'content.upload.pick_media': 'Pick from library',
|
||||||
'content.upload.pick_media_title': 'Select Banner Image',
|
'content.upload.pick_media_title': 'Select image',
|
||||||
'content.upload.no_media': 'No banner images in library — upload one first',
|
'content.upload.no_media': 'No images in library — upload one first',
|
||||||
'content.upload.url_placeholder': 'Or paste image URL',
|
'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.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.link.none': 'No link',
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ const loaders: Record<AdminLocale, () => Promise<{ default: Record<string, strin
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inflight = new Map<AdminLocale, Promise<void>>();
|
const inflight = new Map<AdminLocale, Promise<void>>();
|
||||||
|
const loaded = new Set<AdminLocale>();
|
||||||
|
|
||||||
/** 按语言动态加载文案包(仅当前 locale 进入主路径,其余为独立 chunk)。 */
|
/** 按语言动态加载文案包(仅当前 locale 进入主路径,其余为独立 chunk)。 */
|
||||||
export async function ensureAdminLocaleLoaded(locale: AdminLocale): Promise<void> {
|
export async function ensureAdminLocaleLoaded(locale: AdminLocale): Promise<void> {
|
||||||
if (Object.keys(adminMessages[locale]).length > 0) return;
|
if (loaded.has(locale)) return;
|
||||||
const pending = inflight.get(locale);
|
const pending = inflight.get(locale);
|
||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
|
|
||||||
const task = loaders[locale]().then((mod) => {
|
const task = loaders[locale]().then((mod) => {
|
||||||
adminMessages[locale] = mod.default;
|
Object.assign(adminMessages[locale], mod.default);
|
||||||
|
loaded.add(locale);
|
||||||
});
|
});
|
||||||
inflight.set(locale, task);
|
inflight.set(locale, task);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const adminPages: Record<string, string> = {
|
|||||||
'user.filter.agent': 'Agent',
|
'user.filter.agent': 'Agent',
|
||||||
'user.filter.agent_ph': 'All',
|
'user.filter.agent_ph': 'All',
|
||||||
'user.col.username': 'Username',
|
'user.col.username': 'Username',
|
||||||
|
'user.col.online': 'Online',
|
||||||
|
'user.online_yes': 'Online now',
|
||||||
|
'user.presence_online': 'Online',
|
||||||
|
'user.presence_offline': 'Offline',
|
||||||
'user.col.agent': 'Agent',
|
'user.col.agent': 'Agent',
|
||||||
'user.col.agent_cashback': 'Agent cashback',
|
'user.col.agent_cashback': 'Agent cashback',
|
||||||
'user.col.player_cashback': 'Player cashback',
|
'user.col.player_cashback': 'Player cashback',
|
||||||
@@ -786,15 +790,43 @@ const adminPages: Record<string, string> = {
|
|||||||
'msg.outright_odds_saved': 'Outright odds saved',
|
'msg.outright_odds_saved': 'Outright odds saved',
|
||||||
'msg.load_failed': 'Load failed',
|
'msg.load_failed': 'Load failed',
|
||||||
|
|
||||||
|
'content.upload.pick_media_title': 'Select image',
|
||||||
|
'content.upload.no_media': 'No images in library — upload one first',
|
||||||
'content.btn.create': 'New content',
|
'content.btn.create': 'New content',
|
||||||
'content.btn.enable': 'Enable',
|
'content.btn.enable': 'Enable',
|
||||||
'content.btn.disable': 'Disable',
|
'content.btn.disable': 'Disable',
|
||||||
'content.dialog.create': 'New public content',
|
'content.dialog.create': 'New public content',
|
||||||
'content.dialog.edit': 'Edit public content',
|
'content.dialog.create_banner': 'New home promotion',
|
||||||
|
'content.dialog.create_notice': 'Publish notification',
|
||||||
|
'content.dialog.edit': 'Edit content',
|
||||||
|
'content.dialog.edit_banner': 'Edit home promotion',
|
||||||
|
'content.dialog.edit_notice': 'Edit notification',
|
||||||
'content.confirm_delete': 'Delete "{title}"?',
|
'content.confirm_delete': 'Delete "{title}"?',
|
||||||
'content.type.BANNER': 'Home banners',
|
'content.type.BANNER': 'Home promotions',
|
||||||
'content.type.ANNOUNCEMENT': 'Announcements',
|
'content.type.ANNOUNCEMENT': 'Notifications',
|
||||||
'content.hint.announcement': 'Shown in the player top marquee; fill title or body (body recommended)',
|
'content.type.INBOX_NOTIFY': 'Inbox notify',
|
||||||
|
'content.inbox_notify.inbox_enabled': 'Player inbox',
|
||||||
|
'content.inbox_notify.inbox_enabled_hint': 'When off, the player hub opens support only (no mailbox tab)',
|
||||||
|
'content.inbox_notify.deposit': 'Deposit results',
|
||||||
|
'content.inbox_notify.deposit_hint': 'Auto-send inbox messages on approve or reject',
|
||||||
|
'content.inbox_notify.manual_title': 'Check "Inbox notify" when creating:',
|
||||||
|
'content.inbox_notify.banner_note': 'Homepage promo',
|
||||||
|
'content.inbox_notify.announcement_note': 'Announcements / ticker',
|
||||||
|
'content.hint.banner': 'Shown in the home carousel; tapping opens the detail page. Add a cover image and rich body like an official site announcement.',
|
||||||
|
'content.hint.announcement': 'Shows as a scrolling marquee at the top of the player app. Enter title and marquee text per language — plain text only.',
|
||||||
|
'content.section.publish': 'Publish settings',
|
||||||
|
'content.section.content': 'Notification content',
|
||||||
|
'content.field.publish_kind': 'Publish type',
|
||||||
|
'content.publish_kind.notice': 'Full notification (detail page)',
|
||||||
|
'content.publish_kind.ticker': 'Ticker text only',
|
||||||
|
'content.publish_kind.hint': 'Full notifications appear in the list and detail page; ticker-only shows one scrolling line at the top.',
|
||||||
|
'content.field.cover_image': 'Cover image',
|
||||||
|
'content.field.ticker_title_ph': 'Ticker title (optional)',
|
||||||
|
'content.field.ticker_body_ph': 'Text shown in the marquee',
|
||||||
|
'content.field.ticker_hint': 'Ticker shows plain text only — no images or rich formatting.',
|
||||||
|
'content.editor.placeholder': 'Write the notification body; you can insert images and lists…',
|
||||||
|
'content.editor.insert_image': 'Insert image',
|
||||||
|
'content.upload.cover_size_hint': 'Recommended width 860px+. Cover and inline images scale to fit on the player detail page.',
|
||||||
'content.status.DRAFT': 'Draft',
|
'content.status.DRAFT': 'Draft',
|
||||||
'content.status.ACTIVE': 'Active',
|
'content.status.ACTIVE': 'Active',
|
||||||
'content.status.INACTIVE': 'Inactive',
|
'content.status.INACTIVE': 'Inactive',
|
||||||
@@ -808,8 +840,11 @@ const adminPages: Record<string, string> = {
|
|||||||
'content.field.link_target': 'Link target',
|
'content.field.link_target': 'Link target',
|
||||||
'content.field.start_time': 'Start time',
|
'content.field.start_time': 'Start time',
|
||||||
'content.field.end_time': 'End time',
|
'content.field.end_time': 'End time',
|
||||||
|
'content.field.notify_inbox': 'Inbox notification',
|
||||||
|
'content.field.notify_inbox_hint': 'Send an inbox message to all players about this promotion after saving',
|
||||||
|
'content.msg.notify_sent': 'Inbox notification sent to {count} player(s)',
|
||||||
'content.field.title': 'Title',
|
'content.field.title': 'Title',
|
||||||
'content.field.title_ph': 'Optional; can match body',
|
'content.field.title_ph': 'Title shown on the player detail page',
|
||||||
'content.field.body': 'Body',
|
'content.field.body': 'Body',
|
||||||
'content.field.announce_text': 'Marquee text',
|
'content.field.announce_text': 'Marquee text',
|
||||||
'content.field.image_url': 'Image URL',
|
'content.field.image_url': 'Image URL',
|
||||||
@@ -820,8 +855,6 @@ const adminPages: Record<string, string> = {
|
|||||||
'content.upload.size_error': 'Image must be under 5 MB',
|
'content.upload.size_error': 'Image must be under 5 MB',
|
||||||
'content.upload.remove': 'Remove image',
|
'content.upload.remove': 'Remove image',
|
||||||
'content.upload.pick_media': 'Pick from library',
|
'content.upload.pick_media': 'Pick from library',
|
||||||
'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.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.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.link.none': 'No link',
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ const adminPages: Record<string, string> = {
|
|||||||
'user.filter.agent': '所属代理',
|
'user.filter.agent': '所属代理',
|
||||||
'user.filter.agent_ph': '全部',
|
'user.filter.agent_ph': '全部',
|
||||||
'user.col.username': '用户名',
|
'user.col.username': '用户名',
|
||||||
|
'user.col.online': '在线',
|
||||||
|
'user.online_yes': '当前在线',
|
||||||
|
'user.presence_online': '在线',
|
||||||
|
'user.presence_offline': '离线',
|
||||||
'user.col.agent': '所属代理',
|
'user.col.agent': '所属代理',
|
||||||
'user.col.agent_cashback': '代理返水率',
|
'user.col.agent_cashback': '代理返水率',
|
||||||
'user.col.player_cashback': '玩家返水率',
|
'user.col.player_cashback': '玩家返水率',
|
||||||
@@ -790,15 +794,43 @@ const adminPages: Record<string, string> = {
|
|||||||
'content.btn.enable': '启用',
|
'content.btn.enable': '启用',
|
||||||
'content.btn.disable': '停用',
|
'content.btn.disable': '停用',
|
||||||
'content.dialog.create': '新建公共内容',
|
'content.dialog.create': '新建公共内容',
|
||||||
'content.dialog.edit': '编辑公共内容',
|
'content.dialog.create_banner': '新建首页推广',
|
||||||
|
'content.dialog.create_notice': '发布通知公告',
|
||||||
|
'content.dialog.edit': '编辑内容',
|
||||||
|
'content.dialog.edit_banner': '编辑首页推广',
|
||||||
|
'content.dialog.edit_notice': '编辑通知公告',
|
||||||
'content.confirm_delete': '确定删除「{title}」?',
|
'content.confirm_delete': '确定删除「{title}」?',
|
||||||
'content.type.BANNER': '首页轮播',
|
'content.type.BANNER': '首页推广',
|
||||||
'content.type.ANNOUNCEMENT': '公告滚动',
|
'content.type.ANNOUNCEMENT': '通知公告',
|
||||||
'content.hint.announcement': '显示在玩家端顶部跑马灯;标题与正文填一项即可,建议正文为主',
|
'content.type.INBOX_NOTIFY': '邮箱通知',
|
||||||
|
'content.inbox_notify.inbox_enabled': '站内邮箱',
|
||||||
|
'content.inbox_notify.inbox_enabled_hint': '关闭后玩家端入口直达客服,不展示邮箱标签页',
|
||||||
|
'content.inbox_notify.deposit': '充值结果',
|
||||||
|
'content.inbox_notify.deposit_hint': '审核通过或拒绝时自动发送站内信',
|
||||||
|
'content.inbox_notify.manual_title': '以下类型在新建时勾选「邮箱通知」',
|
||||||
|
'content.inbox_notify.banner_note': '首页推广',
|
||||||
|
'content.inbox_notify.announcement_note': '通知公告 / 跑马灯',
|
||||||
|
'content.hint.banner': '用于首页轮播展示,点击后进入通知详情页;请填写封面图与正文,像官网发布活动通知一样编辑。',
|
||||||
|
'content.hint.announcement': '在玩家端顶部显示滚动跑马灯文字;填写各语言标题与滚动文案即可,纯文本无富文本。',
|
||||||
|
'content.section.publish': '发布设置',
|
||||||
|
'content.section.content': '通知内容',
|
||||||
|
'content.field.publish_kind': '发布类型',
|
||||||
|
'content.publish_kind.notice': '完整通知(详情页)',
|
||||||
|
'content.publish_kind.ticker': '仅跑马灯文字',
|
||||||
|
'content.publish_kind.hint': '完整通知会出现在公告列表与详情页;跑马灯仅显示顶部滚动一行文字。',
|
||||||
|
'content.field.cover_image': '封面图',
|
||||||
|
'content.field.ticker_title_ph': '跑马灯标题(选填)',
|
||||||
|
'content.field.ticker_body_ph': '跑马灯显示的文字',
|
||||||
|
'content.field.ticker_hint': '跑马灯只显示纯文字,不支持图片与富文本格式。',
|
||||||
|
'content.editor.placeholder': '输入通知正文,可插入图片、列表等…',
|
||||||
|
'content.editor.insert_image': '插入图片',
|
||||||
|
'content.upload.cover_size_hint': '建议宽度 860px 以上;玩家端详情页会完整显示封面,正文内图片也会自适应宽度。',
|
||||||
|
'content.upload.pick_media_title': '选择图片',
|
||||||
|
'content.upload.no_media': '媒体库中暂无图片,请先上传',
|
||||||
'content.status.DRAFT': '草稿',
|
'content.status.DRAFT': '草稿',
|
||||||
'content.status.ACTIVE': '已启用',
|
'content.status.ACTIVE': '已启用',
|
||||||
'content.status.INACTIVE': '已停用',
|
'content.status.INACTIVE': '已停用',
|
||||||
'content.col.sort': '排序',
|
'content.col.sort': '排序值',
|
||||||
'content.col.preview': '预览',
|
'content.col.preview': '预览',
|
||||||
'content.col.title': '标题/摘要',
|
'content.col.title': '标题/摘要',
|
||||||
'content.col.player_visible': '玩家可见',
|
'content.col.player_visible': '玩家可见',
|
||||||
@@ -808,8 +840,11 @@ const adminPages: Record<string, string> = {
|
|||||||
'content.field.link_target': '链接目标',
|
'content.field.link_target': '链接目标',
|
||||||
'content.field.start_time': '开始时间',
|
'content.field.start_time': '开始时间',
|
||||||
'content.field.end_time': '结束时间',
|
'content.field.end_time': '结束时间',
|
||||||
|
'content.field.notify_inbox': '邮箱通知',
|
||||||
|
'content.field.notify_inbox_hint': '保存后向全部玩家发送站内信,通知查看此推广',
|
||||||
|
'content.msg.notify_sent': '已向 {count} 位玩家发送邮箱通知',
|
||||||
'content.field.title': '标题',
|
'content.field.title': '标题',
|
||||||
'content.field.title_ph': '选填,可与正文相同',
|
'content.field.title_ph': '通知标题,玩家端详情页展示',
|
||||||
'content.field.body': '正文',
|
'content.field.body': '正文',
|
||||||
'content.field.announce_text': '滚动文案',
|
'content.field.announce_text': '滚动文案',
|
||||||
'content.field.image_url': '图片地址',
|
'content.field.image_url': '图片地址',
|
||||||
@@ -820,8 +855,6 @@ const adminPages: Record<string, string> = {
|
|||||||
'content.upload.size_error': '图片大小不能超过 5MB',
|
'content.upload.size_error': '图片大小不能超过 5MB',
|
||||||
'content.upload.remove': '移除图片',
|
'content.upload.remove': '移除图片',
|
||||||
'content.upload.pick_media': '从媒体库选择',
|
'content.upload.pick_media': '从媒体库选择',
|
||||||
'content.upload.pick_media_title': '选择 Banner 图片',
|
|
||||||
'content.upload.no_media': '媒体库中暂无 Banner 图片,请先上传',
|
|
||||||
'content.upload.url_placeholder': '或手动粘贴图片 URL',
|
'content.upload.url_placeholder': '或手动粘贴图片 URL',
|
||||||
'content.upload.recommended_size': '建议尺寸:860 x 360 px,或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
|
'content.upload.recommended_size': '建议尺寸:860 x 360 px,或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
|
||||||
'content.link.none': '无跳转',
|
'content.link.none': '无跳转',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AdminPerm } from '../constants/permissions';
|
|||||||
import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
|
import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
|
||||||
import AdminNavIcon from '../components/AdminNavIcon.vue';
|
import AdminNavIcon from '../components/AdminNavIcon.vue';
|
||||||
import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb';
|
import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb';
|
||||||
|
import { useDepositPendingCount } from '../composables/useDepositPendingCount';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -16,11 +17,18 @@ const auth = useAuthStore();
|
|||||||
const { t } = useAdminLocale();
|
const { t } = useAdminLocale();
|
||||||
const { allowed: smokeTestsAllowed, ensureLoaded: ensureSmokeTestsAllowed } = useSmokeTestsAllowed();
|
const { allowed: smokeTestsAllowed, ensureLoaded: ensureSmokeTestsAllowed } = useSmokeTestsAllowed();
|
||||||
const { hasPermission, role: adminRole } = usePermissions();
|
const { hasPermission, role: adminRole } = usePermissions();
|
||||||
|
const { pendingCount: depositPendingCount, startDepositPendingPolling, stopDepositPendingPolling } =
|
||||||
|
useDepositPendingCount();
|
||||||
|
|
||||||
|
const canSeeDepositPending = computed(
|
||||||
|
() => auth.isAdmin.value && hasPermission(AdminPerm.depositReview, AdminPerm.depositManage),
|
||||||
|
);
|
||||||
|
|
||||||
const sidebarOpen = ref(false);
|
const sidebarOpen = ref(false);
|
||||||
const isMobileNav = ref(false);
|
const isMobileNav = ref(false);
|
||||||
|
|
||||||
type AdminMenuItem = {
|
type AdminMenuItem = {
|
||||||
|
key: string;
|
||||||
path: string;
|
path: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -34,23 +42,30 @@ function menuVisible(item: AdminMenuItem): boolean {
|
|||||||
const code = adminRole.value;
|
const code = adminRole.value;
|
||||||
if (item.path === '/smoke-tests' && smokeTestsAllowed.value === false) return false;
|
if (item.path === '/smoke-tests' && smokeTestsAllowed.value === false) return false;
|
||||||
if (code && code !== 'SUPER_ADMIN' && item.excludeRoles?.includes(code)) return false;
|
if (code && code !== 'SUPER_ADMIN' && item.excludeRoles?.includes(code)) return false;
|
||||||
return hasPermission(...item.permissions);
|
if (!hasPermission(...item.permissions)) return false;
|
||||||
|
|
||||||
|
const visible = auth.user.value?.visibleMenus;
|
||||||
|
if (visible) {
|
||||||
|
const list = visible.split(',');
|
||||||
|
return list.includes(item.key);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminMenus = computed(() => {
|
const adminMenus = computed(() => {
|
||||||
const items: AdminMenuItem[] = [
|
const items: AdminMenuItem[] = [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true, permissions: [AdminPerm.reports], excludeRoles: ['SUPPORT'] },
|
{ key: 'dashboard', path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true, permissions: [AdminPerm.reports], excludeRoles: ['SUPPORT'] },
|
||||||
{ path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true, permissions: [AdminPerm.matches] },
|
{ key: 'matches', path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true, permissions: [AdminPerm.matches] },
|
||||||
{ path: '/users', label: t('nav.agents_players'), icon: 'users', permissions: [AdminPerm.usersView, AdminPerm.agentsView] },
|
{ key: 'users', path: '/users', label: t('nav.agents_players'), icon: 'users', permissions: [AdminPerm.usersView, AdminPerm.agentsView] },
|
||||||
{ path: '/finance-logs', label: t('nav.finance_logs'), icon: 'finance', permissions: [AdminPerm.reports], excludeRoles: ['MATCH_ADMIN'] },
|
{ key: 'finance-logs', path: '/finance-logs', label: t('nav.finance_logs'), icon: 'finance', permissions: [AdminPerm.reports], excludeRoles: ['MATCH_ADMIN'] },
|
||||||
{ path: '/deposit', label: t('nav.deposit_manage'), icon: 'deposit', matchPrefix: true, permissions: [AdminPerm.depositManage, AdminPerm.depositReview] },
|
{ key: 'deposit', path: '/deposit', label: t('nav.deposit_manage'), icon: 'deposit', matchPrefix: true, permissions: [AdminPerm.depositManage, AdminPerm.depositReview] },
|
||||||
{ path: '/cashback', label: t('nav.cashback'), icon: 'cashback', permissions: [AdminPerm.cashback], excludeRoles: ['MATCH_ADMIN', 'SUPPORT'] },
|
{ key: 'cashback', path: '/cashback', label: t('nav.cashback'), icon: 'cashback', permissions: [AdminPerm.cashback], excludeRoles: ['MATCH_ADMIN', 'SUPPORT'] },
|
||||||
{ path: '/bets', label: t('nav.bets'), icon: 'bets', permissions: [AdminPerm.bets] },
|
{ key: 'bets', path: '/bets', label: t('nav.bets'), icon: 'bets', permissions: [AdminPerm.bets] },
|
||||||
{ path: '/contents', label: t('nav.contents'), icon: 'contents', permissions: [AdminPerm.content] },
|
{ key: 'contents', path: '/contents', label: t('nav.contents'), icon: 'contents', permissions: [AdminPerm.content] },
|
||||||
{ path: '/media', label: t('nav.media'), icon: 'media', permissions: [AdminPerm.content, AdminPerm.matches] },
|
{ key: 'media', path: '/media', label: t('nav.media'), icon: 'media', permissions: [AdminPerm.content, AdminPerm.matches] },
|
||||||
{ path: '/audit', label: t('nav.audit'), icon: 'audit', permissions: [AdminPerm.audit], excludeRoles: ['MATCH_ADMIN'] },
|
{ key: 'audit', path: '/audit', label: t('nav.audit'), icon: 'audit', permissions: [AdminPerm.audit], excludeRoles: ['MATCH_ADMIN'] },
|
||||||
{ path: '/staff', label: t('nav.staff'), icon: 'users', permissions: [AdminPerm.settings] },
|
{ key: 'staff', path: '/staff', label: t('nav.staff'), icon: 'users', permissions: [AdminPerm.settings] },
|
||||||
{ path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests', permissions: [AdminPerm.settings] },
|
{ key: 'smoke-tests', path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests', permissions: [AdminPerm.settings] },
|
||||||
];
|
];
|
||||||
return items.filter(menuVisible);
|
return items.filter(menuVisible);
|
||||||
});
|
});
|
||||||
@@ -149,11 +164,13 @@ onMounted(() => {
|
|||||||
window.addEventListener('resize', syncMobileNav);
|
window.addEventListener('resize', syncMobileNav);
|
||||||
if (auth.isAdmin.value) {
|
if (auth.isAdmin.value) {
|
||||||
void ensureSmokeTestsAllowed();
|
void ensureSmokeTestsAllowed();
|
||||||
|
if (canSeeDepositPending.value) startDepositPendingPolling();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', syncMobileNav);
|
window.removeEventListener('resize', syncMobileNav);
|
||||||
|
stopDepositPendingPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => route.path, () => {
|
watch(() => route.path, () => {
|
||||||
@@ -177,6 +194,7 @@ watch(() => route.path, () => {
|
|||||||
<aside class="sidebar" :class="{ open: sidebarOpen }">
|
<aside class="sidebar" :class="{ open: sidebarOpen }">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
|
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
|
||||||
|
<span class="brand-title">{{ isAdminPortal ? t('portal.admin') : t('portal.agent') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
@@ -197,7 +215,16 @@ watch(() => route.path, () => {
|
|||||||
@click="onNavClick"
|
@click="onNavClick"
|
||||||
>
|
>
|
||||||
<AdminNavIcon :name="m.icon" />
|
<AdminNavIcon :name="m.icon" />
|
||||||
<span class="nav-label">{{ m.label }}</span>
|
<span class="nav-label">
|
||||||
|
{{ m.label }}
|
||||||
|
<span
|
||||||
|
v-if="'path' in m && m.path === '/deposit' && depositPendingCount > 0"
|
||||||
|
class="nav-pending-badge"
|
||||||
|
:title="t('deposit.pending_badge', { n: depositPendingCount })"
|
||||||
|
>
|
||||||
|
{{ depositPendingCount > 99 ? '99+' : depositPendingCount }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -245,7 +272,6 @@ watch(() => route.path, () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AdminLocaleSwitcher />
|
<AdminLocaleSwitcher />
|
||||||
<div class="portal-tag">{{ isAdminPortal ? t('portal.admin') : t('portal.agent') }}</div>
|
|
||||||
<button class="btn-logout" @click="logout">{{ t('logout') }}</button>
|
<button class="btn-logout" @click="logout">{{ t('logout') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -285,17 +311,26 @@ watch(() => route.path, () => {
|
|||||||
border-bottom: 1px solid #1a1a1a;
|
border-bottom: 1px solid #1a1a1a;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
max-width: 118px;
|
max-width: 52px;
|
||||||
max-height: 34px;
|
max-height: 32px;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.brand-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: #f0f0f0;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
@@ -324,8 +359,26 @@ watch(() => route.path, () => {
|
|||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
.nav-label {
|
.nav-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.nav-pending-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f56c6c;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.25);
|
||||||
}
|
}
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
@@ -584,14 +637,23 @@ watch(() => route.path, () => {
|
|||||||
.brand {
|
.brand {
|
||||||
height: 64px;
|
height: 64px;
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
padding: 0 16px;
|
padding: 0 12px;
|
||||||
border-bottom: 1px solid var(--border-soft);
|
border-bottom: 1px solid var(--border-soft);
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
max-width: 132px;
|
max-width: 56px;
|
||||||
max-height: 38px;
|
max-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: #2a2824;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface StaffUser {
|
|||||||
maxAgentLevel?: number | null;
|
maxAgentLevel?: number | null;
|
||||||
canManageSubAgents?: boolean;
|
canManageSubAgents?: boolean;
|
||||||
inviteCode?: string | null;
|
inviteCode?: string | null;
|
||||||
|
visibleMenus?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_KEY = 'manage_token';
|
const TOKEN_KEY = 'manage_token';
|
||||||
@@ -98,6 +99,7 @@ export function reconcileStaffSessionFromToken(): boolean {
|
|||||||
role: claims.role ?? user.value?.role,
|
role: claims.role ?? user.value?.role,
|
||||||
permissions: user.value?.permissions,
|
permissions: user.value?.permissions,
|
||||||
inviteCode: user.value?.inviteCode,
|
inviteCode: user.value?.inviteCode,
|
||||||
|
visibleMenus: user.value?.visibleMenus,
|
||||||
};
|
};
|
||||||
user.value = next;
|
user.value = next;
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(next));
|
localStorage.setItem(USER_KEY, JSON.stringify(next));
|
||||||
|
|||||||
12
apps/admin/src/utils/html.ts
Normal file
12
apps/admin/src/utils/html.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/** 去除 HTML 标签,用于列表摘要与跑马灯纯文本 */
|
||||||
|
export function stripHtml(html: string): string {
|
||||||
|
if (!html) return '';
|
||||||
|
if (!/[<>]/.test(html)) return html.trim();
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断富文本是否实质为空 */
|
||||||
|
export function isHtmlEmpty(html: string): boolean {
|
||||||
|
return !stripHtml(html);
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ interface CreditTxRow {
|
|||||||
const items = ref<CreditTxRow[]>([]);
|
const items = ref<CreditTxRow[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(20);
|
const pageSize = ref(10);
|
||||||
const keyword = ref('');
|
const keyword = ref('');
|
||||||
const agentId = ref('');
|
const agentId = ref('');
|
||||||
const transactionType = ref('');
|
const transactionType = ref('');
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import { formatRatePercent, percentToDecimalRate, decimalRateToPercent } from '.
|
|||||||
import InviteCodePanel from '../components/InviteCodePanel.vue';
|
import InviteCodePanel from '../components/InviteCodePanel.vue';
|
||||||
import InviteManageDialog from '../components/InviteManageDialog.vue';
|
import InviteManageDialog from '../components/InviteManageDialog.vue';
|
||||||
import AdminTableWrap from '../components/AdminTableWrap.vue';
|
import AdminTableWrap from '../components/AdminTableWrap.vue';
|
||||||
|
import AdminPlayerStatusCell from '../components/AdminPlayerStatusCell.vue';
|
||||||
import AdminAgentRowActions from '../components/AdminAgentRowActions.vue';
|
import AdminAgentRowActions from '../components/AdminAgentRowActions.vue';
|
||||||
import AdminPlayerRowActions from '../components/AdminPlayerRowActions.vue';
|
import AdminPlayerRowActions from '../components/AdminPlayerRowActions.vue';
|
||||||
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
|
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
|
||||||
@@ -94,7 +95,7 @@ const inviteDialogOpen = ref(false);
|
|||||||
const tier1Agents = ref<AgentRow[]>([]);
|
const tier1Agents = ref<AgentRow[]>([]);
|
||||||
const tier1Total = ref(0);
|
const tier1Total = ref(0);
|
||||||
const tier1Page = ref(1);
|
const tier1Page = ref(1);
|
||||||
const tier1PageSize = ref(20);
|
const tier1PageSize = ref(10);
|
||||||
const tier1Keyword = ref('');
|
const tier1Keyword = ref('');
|
||||||
const tier1FilterStatus = ref('');
|
const tier1FilterStatus = ref('');
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ function ensureSubAgentState(level: number): SubAgentLevelState {
|
|||||||
agents: [],
|
agents: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: 10,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
filterStatus: '',
|
filterStatus: '',
|
||||||
};
|
};
|
||||||
@@ -166,7 +167,7 @@ const activeViewTab = ref('players');
|
|||||||
const allPlayers = ref<PlayerRow[]>([]);
|
const allPlayers = ref<PlayerRow[]>([]);
|
||||||
const playerTotal = ref(0);
|
const playerTotal = ref(0);
|
||||||
const playerPage = ref(1);
|
const playerPage = ref(1);
|
||||||
const playerPageSize = ref(20);
|
const playerPageSize = ref(10);
|
||||||
const playerKeyword = ref('');
|
const playerKeyword = ref('');
|
||||||
const playerFilterStatus = ref('');
|
const playerFilterStatus = ref('');
|
||||||
const playerFilterAgent = ref('');
|
const playerFilterAgent = ref('');
|
||||||
@@ -1557,11 +1558,11 @@ function creditTypeLabel(type: string) {
|
|||||||
<template #empty>
|
<template #empty>
|
||||||
<AdminTableEmpty />
|
<AdminTableEmpty />
|
||||||
</template>
|
</template>
|
||||||
<el-table-column prop="id" label="ID" width="72" />
|
<el-table-column type="index" :index="(i: number) => (playerPage - 1) * playerPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||||
<el-table-column :label="t('common.status')" width="88">
|
<el-table-column :label="t('common.status')" min-width="128">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
<AdminPlayerStatusCell :status="row.status" :is-online="row.isOnline" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('user.col.agent')" min-width="200">
|
<el-table-column :label="t('user.col.agent')" min-width="200">
|
||||||
@@ -1671,19 +1672,19 @@ function creditTypeLabel(type: string) {
|
|||||||
</div>
|
</div>
|
||||||
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
|
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
|
||||||
<template #empty><AdminTableEmpty /></template>
|
<template #empty><AdminTableEmpty /></template>
|
||||||
<el-table-column prop="id" label="ID" width="60" />
|
<el-table-column type="index" :label="t('common.seq')" width="55" align="center" />
|
||||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
||||||
|
<el-table-column :label="t('common.status')" min-width="120">
|
||||||
|
<template #default="{ row: player }">
|
||||||
|
<AdminPlayerStatusCell :status="player.status" :is-online="player.isOnline" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
|
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
|
||||||
<template #default="{ row: player }">
|
<template #default="{ row: player }">
|
||||||
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
|
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
|
||||||
<span v-else>—</span>
|
<span v-else>—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('common.status')" width="80">
|
|
||||||
<template #default="{ row: player }">
|
|
||||||
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
|
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
|
||||||
<template #default="{ row: player }">
|
<template #default="{ row: player }">
|
||||||
<el-tooltip :content="`${formatAmountFull(player.availableBalance)} / ${formatAmountFull(player.frozenBalance)}`" placement="top">
|
<el-tooltip :content="`${formatAmountFull(player.availableBalance)} / ${formatAmountFull(player.frozenBalance)}`" placement="top">
|
||||||
@@ -1717,8 +1718,7 @@ function creditTypeLabel(type: string) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column type="index" :index="(i: number) => (tier1Page - 1) * tier1PageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
<el-table-column prop="userId" label="ID" min-width="64" />
|
|
||||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" show-overflow-tooltip />
|
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" show-overflow-tooltip />
|
||||||
<el-table-column :label="t('common.status')" min-width="72">
|
<el-table-column :label="t('common.status')" min-width="72">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -1837,19 +1837,19 @@ function creditTypeLabel(type: string) {
|
|||||||
</div>
|
</div>
|
||||||
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
|
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
|
||||||
<template #empty><AdminTableEmpty /></template>
|
<template #empty><AdminTableEmpty /></template>
|
||||||
<el-table-column prop="id" label="ID" width="60" />
|
<el-table-column type="index" :label="t('common.seq')" width="55" align="center" />
|
||||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
||||||
|
<el-table-column :label="t('common.status')" min-width="120">
|
||||||
|
<template #default="{ row: player }">
|
||||||
|
<AdminPlayerStatusCell :status="player.status" :is-online="player.isOnline" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
|
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
|
||||||
<template #default="{ row: player }">
|
<template #default="{ row: player }">
|
||||||
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
|
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
|
||||||
<span v-else>—</span>
|
<span v-else>—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('common.status')" width="80">
|
|
||||||
<template #default="{ row: player }">
|
|
||||||
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
|
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
|
||||||
<template #default="{ row: player }">
|
<template #default="{ row: player }">
|
||||||
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
|
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
|
||||||
@@ -1875,7 +1875,7 @@ function creditTypeLabel(type: string) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="userId" label="ID" min-width="64" />
|
<el-table-column type="index" :index="(i: number) => (ensureSubAgentState(agentLevel).page - 1) * ensureSubAgentState(agentLevel).pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" show-overflow-tooltip />
|
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" show-overflow-tooltip />
|
||||||
<el-table-column :label="t('agent.col.parent_chain')" min-width="120" show-overflow-tooltip>
|
<el-table-column :label="t('agent.col.parent_chain')" min-width="120" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ parentChainLabel(row) }}</template>
|
<template #default="{ row }">{{ parentChainLabel(row) }}</template>
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ async function openDetail(row: BetListRow) {
|
|||||||
<template #empty>
|
<template #empty>
|
||||||
<AdminTableEmpty />
|
<AdminTableEmpty />
|
||||||
</template>
|
</template>
|
||||||
|
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
<el-table-column prop="id" :label="t('bet.col.serial')" width="64" align="center" />
|
<el-table-column prop="id" :label="t('bet.col.serial')" width="64" align="center" />
|
||||||
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" min-width="200" show-overflow-tooltip>
|
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" min-width="200" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
|||||||
@@ -453,6 +453,7 @@ onMounted(loadHistory);
|
|||||||
<template #empty>
|
<template #empty>
|
||||||
<AdminTableEmpty :text="t('cashback.history_empty')" />
|
<AdminTableEmpty :text="t('cashback.history_empty')" />
|
||||||
</template>
|
</template>
|
||||||
|
<el-table-column type="index" :index="(i: number) => (historyPage - 1) * historyPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
<el-table-column prop="batchNo" :label="t('cashback.batch_no')" min-width="140" show-overflow-tooltip />
|
<el-table-column prop="batchNo" :label="t('cashback.batch_no')" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column :label="t('cashback.col.period')" min-width="190">
|
<el-table-column :label="t('cashback.col.period')" min-width="190">
|
||||||
<template #default="{ row }">{{ formatPeriodRange(row) }}</template>
|
<template #default="{ row }">{{ formatPeriodRange(row) }}</template>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { usePermissions } from '../composables/usePermissions';
|
|||||||
import { AdminPerm } from '../constants/permissions';
|
import { AdminPerm } from '../constants/permissions';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||||
|
import ContentImageField from '../components/ContentImageField.vue';
|
||||||
|
import ContentRichEditor from '../components/ContentRichEditor.vue';
|
||||||
|
import { stripHtml } from '../utils/html';
|
||||||
import {
|
import {
|
||||||
normalizeStartTimeForApi,
|
normalizeStartTimeForApi,
|
||||||
normalizeStartTimeForPicker,
|
normalizeStartTimeForPicker,
|
||||||
@@ -16,91 +19,8 @@ const { t, localeTag } = useAdminLocale();
|
|||||||
const { hasPermission } = usePermissions();
|
const { hasPermission } = usePermissions();
|
||||||
const canManageContent = computed(() => hasPermission(AdminPerm.content));
|
const canManageContent = computed(() => hasPermission(AdminPerm.content));
|
||||||
|
|
||||||
/* ── Image upload helpers ── */
|
|
||||||
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
|
|
||||||
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
|
|
||||||
|
|
||||||
interface MediaFile {
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
category: string;
|
|
||||||
mimeType: string;
|
|
||||||
size: number;
|
|
||||||
url: string;
|
|
||||||
inUse: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Per-locale uploading state */
|
|
||||||
const uploadingLocale = ref<string | null>(null);
|
|
||||||
|
|
||||||
/** Media picker state */
|
|
||||||
const mediaPickerVisible = ref(false);
|
|
||||||
const mediaPickerLocale = ref('');
|
|
||||||
const mediaFiles = ref<MediaFile[]>([]);
|
|
||||||
const mediaLoading = ref(false);
|
|
||||||
|
|
||||||
async function uploadBannerImage(locale: string, file: File) {
|
|
||||||
if (file.size > MAX_UPLOAD_SIZE) {
|
|
||||||
ElMessage.error(t('content.upload.size_error'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uploadingLocale.value = locale;
|
|
||||||
try {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', file);
|
|
||||||
const { data } = await api.post('/admin/uploads?category=banners', fd, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
});
|
|
||||||
const url = data.data?.url as string;
|
|
||||||
if (url) {
|
|
||||||
const tr = form.value.translations.find((item) => item.locale === locale);
|
|
||||||
if (tr) tr.imageUrl = url;
|
|
||||||
ElMessage.success(t('content.upload.success'));
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
const msg = err?.response?.data?.message || t('content.upload.failed');
|
|
||||||
ElMessage.error(String(msg));
|
|
||||||
} finally {
|
|
||||||
uploadingLocale.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBannerFileChange(e: Event, locale: string) {
|
|
||||||
const input = e.target as HTMLInputElement;
|
|
||||||
if (input.files?.[0]) {
|
|
||||||
void uploadBannerImage(locale, input.files[0]);
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeBannerImage(locale: string) {
|
|
||||||
const tr = form.value.translations.find((item) => item.locale === locale);
|
|
||||||
if (tr) tr.imageUrl = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openMediaPicker(locale: string) {
|
|
||||||
mediaPickerLocale.value = locale;
|
|
||||||
mediaPickerVisible.value = true;
|
|
||||||
mediaLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await api.get('/admin/files', { params: { category: 'banners', pageSize: 200 } });
|
|
||||||
mediaFiles.value = res.data.data.items ?? [];
|
|
||||||
} catch {
|
|
||||||
mediaFiles.value = [];
|
|
||||||
} finally {
|
|
||||||
mediaLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickMediaFile(file: MediaFile) {
|
|
||||||
const tr = form.value.translations.find((item) => item.locale === mediaPickerLocale.value);
|
|
||||||
if (tr) tr.imageUrl = file.url;
|
|
||||||
mediaPickerVisible.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
type StoredContentType = 'BANNER' | 'NOTICE' | 'TICKER';
|
type StoredContentType = 'BANNER' | 'NOTICE' | 'TICKER';
|
||||||
type AdminTab = 'BANNER' | 'ANNOUNCEMENT';
|
type AdminTab = 'BANNER' | 'ANNOUNCEMENT' | 'INBOX_NOTIFY';
|
||||||
type ContentStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE';
|
type ContentStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE';
|
||||||
|
|
||||||
interface TranslationForm {
|
interface TranslationForm {
|
||||||
@@ -126,7 +46,7 @@ interface ContentItem {
|
|||||||
translations: TranslationForm[];
|
translations: TranslationForm[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADMIN_TABS: AdminTab[] = ['BANNER', 'ANNOUNCEMENT'];
|
const ADMIN_TABS: AdminTab[] = ['BANNER', 'ANNOUNCEMENT', 'INBOX_NOTIFY'];
|
||||||
const LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
|
const LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
|
||||||
|
|
||||||
const activeType = ref<AdminTab>('BANNER');
|
const activeType = ref<AdminTab>('BANNER');
|
||||||
@@ -142,9 +62,24 @@ const selectedRows = ref<ContentItem[]>([]);
|
|||||||
|
|
||||||
const hasSelection = computed(() => selectedRows.value.length > 0);
|
const hasSelection = computed(() => selectedRows.value.length > 0);
|
||||||
|
|
||||||
const dialogVisible = ref(false);
|
const bannerDialogVisible = ref(false);
|
||||||
|
const announcementDialogVisible = ref(false);
|
||||||
|
const bannerEditorRef = ref<InstanceType<typeof ContentRichEditor> | null>(null);
|
||||||
const editingId = ref<string | null>(null);
|
const editingId = ref<string | null>(null);
|
||||||
const editingContentType = ref<StoredContentType>('NOTICE');
|
const editingContentType = ref<StoredContentType>('TICKER');
|
||||||
|
const activeLocale = ref<string>('zh-CN');
|
||||||
|
const notifyInbox = ref(false);
|
||||||
|
|
||||||
|
interface InboxNotifySettings {
|
||||||
|
inboxEnabled: boolean;
|
||||||
|
deposit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboxNotifySettings = ref<InboxNotifySettings>({
|
||||||
|
inboxEnabled: true,
|
||||||
|
deposit: true,
|
||||||
|
});
|
||||||
|
const inboxNotifySaving = ref(false);
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
@@ -200,12 +135,63 @@ function formatTime(v: string | null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isBanner = computed(() => activeType.value === 'BANNER');
|
function previewText(row: ContentItem) {
|
||||||
const isAnnouncement = computed(() => activeType.value === 'ANNOUNCEMENT');
|
const raw = row.previewTitle || row.translations.find((tr) => tr.body)?.body || '';
|
||||||
const dialogTitle = computed(() =>
|
return stripHtml(raw) || '—';
|
||||||
editingId.value ? t('content.dialog.edit') : t('content.dialog.create'),
|
}
|
||||||
|
|
||||||
|
const isBannerTab = computed(() => activeType.value === 'BANNER');
|
||||||
|
const isInboxNotifyTab = computed(() => activeType.value === 'INBOX_NOTIFY');
|
||||||
|
|
||||||
|
const activeTranslation = computed(
|
||||||
|
() =>
|
||||||
|
form.value.translations.find((tr) => tr.locale === activeLocale.value) ??
|
||||||
|
form.value.translations[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const bannerDialogTitle = computed(() =>
|
||||||
|
editingId.value ? t('content.dialog.edit_banner') : t('content.dialog.create_banner'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const announcementDialogTitle = computed(() =>
|
||||||
|
editingId.value ? t('content.dialog.edit_notice') : t('content.dialog.create_notice'),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadInboxNotifySettings() {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/admin/contents/inbox-notify-settings');
|
||||||
|
inboxNotifySettings.value = {
|
||||||
|
inboxEnabled: data.data?.inboxEnabled !== false,
|
||||||
|
deposit: Boolean(data.data?.deposit),
|
||||||
|
};
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveInboxNotifySettings() {
|
||||||
|
if (!canManageContent.value) return;
|
||||||
|
inboxNotifySaving.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.put('/admin/contents/inbox-notify-settings', {
|
||||||
|
inboxEnabled: inboxNotifySettings.value.inboxEnabled,
|
||||||
|
deposit: inboxNotifySettings.value.deposit,
|
||||||
|
});
|
||||||
|
inboxNotifySettings.value = {
|
||||||
|
inboxEnabled: data.data?.inboxEnabled !== false,
|
||||||
|
deposit: Boolean(data.data?.deposit),
|
||||||
|
};
|
||||||
|
ElMessage.success(t('msg.saved'));
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||||
|
await loadInboxNotifySettings();
|
||||||
|
} finally {
|
||||||
|
inboxNotifySaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -244,6 +230,10 @@ watch([activeType, filterStatus], () => {
|
|||||||
page.value = 1;
|
page.value = 1;
|
||||||
selectedRows.value = [];
|
selectedRows.value = [];
|
||||||
tableRef.value?.clearSelection();
|
tableRef.value?.clearSelection();
|
||||||
|
if (activeType.value === 'INBOX_NOTIFY') {
|
||||||
|
void loadInboxNotifySettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -311,6 +301,8 @@ function batchDelete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
|
activeLocale.value = 'zh-CN';
|
||||||
|
notifyInbox.value = false;
|
||||||
form.value = {
|
form.value = {
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
@@ -322,15 +314,7 @@ function resetForm() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function loadRowIntoForm(row: ContentItem, plainBody = false) {
|
||||||
editingId.value = null;
|
|
||||||
resetForm();
|
|
||||||
dialogVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(row: ContentItem) {
|
|
||||||
editingId.value = row.id;
|
|
||||||
editingContentType.value = row.contentType;
|
|
||||||
const byLocale = new Map(row.translations.map((tr) => [tr.locale, tr]));
|
const byLocale = new Map(row.translations.map((tr) => [tr.locale, tr]));
|
||||||
form.value = {
|
form.value = {
|
||||||
sortOrder: row.sortOrder,
|
sortOrder: row.sortOrder,
|
||||||
@@ -341,33 +325,57 @@ function openEdit(row: ContentItem) {
|
|||||||
endTime: normalizeStartTimeForPicker(row.endTime ?? undefined),
|
endTime: normalizeStartTimeForPicker(row.endTime ?? undefined),
|
||||||
translations: LOCALES.map((locale) => {
|
translations: LOCALES.map((locale) => {
|
||||||
const tr = byLocale.get(locale);
|
const tr = byLocale.get(locale);
|
||||||
|
const rawBody = tr?.body ?? '';
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
title: tr?.title ?? '',
|
title: tr?.title ?? '',
|
||||||
body: tr?.body ?? '',
|
body: plainBody ? stripHtml(rawBody) : rawBody,
|
||||||
imageUrl: tr?.imageUrl ?? '',
|
imageUrl: tr?.imageUrl ?? '',
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
dialogVisible.value = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPayload() {
|
function openCreate() {
|
||||||
const contentType: StoredContentType = editingId.value
|
editingId.value = null;
|
||||||
? editingContentType.value
|
resetForm();
|
||||||
: isBanner.value
|
if (isBannerTab.value) {
|
||||||
? 'BANNER'
|
editingContentType.value = 'BANNER';
|
||||||
: 'NOTICE';
|
bannerDialogVisible.value = true;
|
||||||
|
} else {
|
||||||
|
editingContentType.value = 'TICKER';
|
||||||
|
announcementDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: ContentItem) {
|
||||||
|
editingId.value = row.id;
|
||||||
|
editingContentType.value = row.contentType;
|
||||||
|
activeLocale.value = 'zh-CN';
|
||||||
|
if (row.contentType === 'BANNER') {
|
||||||
|
loadRowIntoForm(row, false);
|
||||||
|
bannerDialogVisible.value = true;
|
||||||
|
} else {
|
||||||
|
loadRowIntoForm(row, true);
|
||||||
|
announcementDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScheduleFields() {
|
||||||
return {
|
return {
|
||||||
contentType,
|
|
||||||
sortOrder: form.value.sortOrder,
|
sortOrder: form.value.sortOrder,
|
||||||
status: form.value.status,
|
status: form.value.status,
|
||||||
linkType: isBanner.value && form.value.linkType ? form.value.linkType : null,
|
|
||||||
linkTarget:
|
|
||||||
isBanner.value && form.value.linkType ? form.value.linkTarget.trim() : null,
|
|
||||||
startTime: form.value.startTime ? normalizeStartTimeForApi(form.value.startTime) : null,
|
startTime: form.value.startTime ? normalizeStartTimeForApi(form.value.startTime) : null,
|
||||||
endTime: form.value.endTime ? normalizeStartTimeForApi(form.value.endTime) : null,
|
endTime: form.value.endTime ? normalizeStartTimeForApi(form.value.endTime) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBannerPayload() {
|
||||||
|
const payload = {
|
||||||
|
contentType: 'BANNER' as const,
|
||||||
|
...buildScheduleFields(),
|
||||||
|
linkType: form.value.linkType ? form.value.linkType : null,
|
||||||
|
linkTarget: form.value.linkType ? form.value.linkTarget.trim() : null,
|
||||||
translations: form.value.translations.map((tr) => ({
|
translations: form.value.translations.map((tr) => ({
|
||||||
locale: tr.locale,
|
locale: tr.locale,
|
||||||
title: tr.title.trim() || undefined,
|
title: tr.title.trim() || undefined,
|
||||||
@@ -375,25 +383,94 @@ function buildPayload() {
|
|||||||
imageUrl: tr.imageUrl.trim() || undefined,
|
imageUrl: tr.imageUrl.trim() || undefined,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
if (!editingId.value) {
|
||||||
|
return { ...payload, notifyInbox: notifyInbox.value };
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitForm() {
|
function buildAnnouncementPayload() {
|
||||||
if (uploadingLocale.value) {
|
const contentType: StoredContentType = editingId.value ? editingContentType.value : 'TICKER';
|
||||||
ElMessage.warning(t('content.upload.uploading'));
|
const payload = {
|
||||||
return;
|
contentType,
|
||||||
|
...buildScheduleFields(),
|
||||||
|
linkType: null,
|
||||||
|
linkTarget: null,
|
||||||
|
translations: form.value.translations.map((tr) => ({
|
||||||
|
locale: tr.locale,
|
||||||
|
title: tr.title.trim() || undefined,
|
||||||
|
body: tr.body.trim() || undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (!editingId.value) {
|
||||||
|
return { ...payload, notifyInbox: notifyInbox.value };
|
||||||
}
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBannerForm() {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = buildPayload();
|
const editor = bannerEditorRef.value;
|
||||||
|
if (editor) {
|
||||||
|
activeTranslation.value.body = editor.getHtml();
|
||||||
|
for (const tr of form.value.translations) {
|
||||||
|
tr.body = await editor.uploadPendingImages(tr.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildBannerPayload();
|
||||||
const isCreate = !editingId.value;
|
const isCreate = !editingId.value;
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
const { contentType: _type, ...updateBody } = payload;
|
const { contentType: _type, notifyInbox: _notify, ...updateBody } = payload as ReturnType<typeof buildBannerPayload> & { notifyInbox?: boolean };
|
||||||
await api.put(`/admin/contents/${editingId.value}`, updateBody);
|
await api.put(`/admin/contents/${editingId.value}`, updateBody);
|
||||||
} else {
|
|
||||||
await api.post('/admin/contents', payload);
|
|
||||||
}
|
|
||||||
ElMessage.success(t('msg.saved'));
|
ElMessage.success(t('msg.saved'));
|
||||||
dialogVisible.value = false;
|
} else {
|
||||||
|
const { data } = await api.post('/admin/contents', payload);
|
||||||
|
const notifiedCount = Number(data.data?.notifiedCount ?? 0);
|
||||||
|
if (notifyInbox.value && notifiedCount > 0) {
|
||||||
|
ElMessage.success(t('content.msg.notify_sent', { count: notifiedCount }));
|
||||||
|
} else {
|
||||||
|
ElMessage.success(t('msg.saved'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bannerDialogVisible.value = false;
|
||||||
|
if (isCreate) page.value = 1;
|
||||||
|
await load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string; message?: string | string[] } } };
|
||||||
|
const msg = err.response?.data?.error
|
||||||
|
?? (Array.isArray(err.response?.data?.message)
|
||||||
|
? err.response?.data?.message.join(', ')
|
||||||
|
: err.response?.data?.message)
|
||||||
|
?? (e instanceof Error ? e.message : t('msg.save_failed'));
|
||||||
|
ElMessage.error(String(msg));
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAnnouncementForm() {
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = buildAnnouncementPayload();
|
||||||
|
const isCreate = !editingId.value;
|
||||||
|
if (editingId.value) {
|
||||||
|
const { contentType: _type, notifyInbox: _notify, ...updateBody } = payload as ReturnType<
|
||||||
|
typeof buildAnnouncementPayload
|
||||||
|
> & { notifyInbox?: boolean };
|
||||||
|
await api.put(`/admin/contents/${editingId.value}`, updateBody);
|
||||||
|
ElMessage.success(t('msg.saved'));
|
||||||
|
} else {
|
||||||
|
const { data } = await api.post('/admin/contents', payload);
|
||||||
|
const notifiedCount = Number(data.data?.notifiedCount ?? 0);
|
||||||
|
if (notifyInbox.value && notifiedCount > 0) {
|
||||||
|
ElMessage.success(t('content.msg.notify_sent', { count: notifiedCount }));
|
||||||
|
} else {
|
||||||
|
ElMessage.success(t('msg.saved'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
announcementDialogVisible.value = false;
|
||||||
if (isCreate) page.value = 1;
|
if (isCreate) page.value = 1;
|
||||||
await load();
|
await load();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -426,7 +503,7 @@ async function setStatus(row: ContentItem, status: ContentStatus) {
|
|||||||
async function removeItem(row: ContentItem) {
|
async function removeItem(row: ContentItem) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
t('content.confirm_delete', { title: row.previewTitle || row.id }),
|
t('content.confirm_delete', { title: previewText(row) }),
|
||||||
{ type: 'warning' },
|
{ type: 'warning' },
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -459,8 +536,42 @@ void load();
|
|||||||
:name="tp"
|
:name="tp"
|
||||||
/>
|
/>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
<p v-if="isAnnouncement" class="type-hint">{{ t('content.hint.announcement') }}</p>
|
|
||||||
<el-form inline class="filter-row">
|
<template v-if="isInboxNotifyTab">
|
||||||
|
<div class="inbox-notify-panel">
|
||||||
|
<div class="inbox-notify-row">
|
||||||
|
<div class="inbox-notify-row-text">
|
||||||
|
<span class="inbox-notify-label">{{ t('content.inbox_notify.inbox_enabled') }}</span>
|
||||||
|
<span class="inbox-notify-hint">{{ t('content.inbox_notify.inbox_enabled_hint') }}</span>
|
||||||
|
</div>
|
||||||
|
<el-switch
|
||||||
|
v-model="inboxNotifySettings.inboxEnabled"
|
||||||
|
:disabled="!canManageContent || inboxNotifySaving"
|
||||||
|
@change="saveInboxNotifySettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-row">
|
||||||
|
<div class="inbox-notify-row-text">
|
||||||
|
<span class="inbox-notify-label">{{ t('content.inbox_notify.deposit') }}</span>
|
||||||
|
<span class="inbox-notify-hint">{{ t('content.inbox_notify.deposit_hint') }}</span>
|
||||||
|
</div>
|
||||||
|
<el-switch
|
||||||
|
v-model="inboxNotifySettings.deposit"
|
||||||
|
:disabled="!canManageContent || inboxNotifySaving"
|
||||||
|
@change="saveInboxNotifySettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-notes-title">{{ t('content.inbox_notify.manual_title') }}</p>
|
||||||
|
<ul v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-notes">
|
||||||
|
<li>{{ t('content.inbox_notify.banner_note') }}</li>
|
||||||
|
<li>{{ t('content.inbox_notify.announcement_note') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form v-else inline class="filter-row">
|
||||||
<el-form-item :label="t('common.status')">
|
<el-form-item :label="t('common.status')">
|
||||||
<el-select v-model="filterStatus" clearable style="width: 140px">
|
<el-select v-model="filterStatus" clearable style="width: 140px">
|
||||||
<el-option :label="t('common.all')" value="" />
|
<el-option :label="t('common.all')" value="" />
|
||||||
@@ -481,27 +592,13 @@ void load();
|
|||||||
<span v-if="hasSelection" class="batch-hint">
|
<span v-if="hasSelection" class="batch-hint">
|
||||||
{{ t('content.batch.selected', { n: selectedRows.length }) }}
|
{{ t('content.batch.selected', { n: selectedRows.length }) }}
|
||||||
</span>
|
</span>
|
||||||
<el-button
|
<el-button size="small" :disabled="!hasSelection || saving" @click="batchEnable">
|
||||||
size="small"
|
|
||||||
:disabled="!hasSelection || saving"
|
|
||||||
@click="batchEnable"
|
|
||||||
>
|
|
||||||
{{ t('content.batch.enable') }}
|
{{ t('content.batch.enable') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button size="small" :disabled="!hasSelection || saving" @click="batchDisable">
|
||||||
size="small"
|
|
||||||
:disabled="!hasSelection || saving"
|
|
||||||
@click="batchDisable"
|
|
||||||
>
|
|
||||||
{{ t('content.batch.disable') }}
|
{{ t('content.batch.disable') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button type="danger" plain size="small" :disabled="!hasSelection || saving" @click="batchDelete">
|
||||||
type="danger"
|
|
||||||
plain
|
|
||||||
size="small"
|
|
||||||
:disabled="!hasSelection || saving"
|
|
||||||
@click="batchDelete"
|
|
||||||
>
|
|
||||||
{{ t('content.batch.delete') }}
|
{{ t('content.batch.delete') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -509,7 +606,7 @@ void load();
|
|||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card v-loading="loading" class="data-card" shadow="never">
|
<el-card v-if="!isInboxNotifyTab" v-loading="loading" class="data-card" shadow="never">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<el-table
|
<el-table
|
||||||
ref="tableRef"
|
ref="tableRef"
|
||||||
@@ -523,21 +620,17 @@ void load();
|
|||||||
<AdminTableEmpty />
|
<AdminTableEmpty />
|
||||||
</template>
|
</template>
|
||||||
<el-table-column type="selection" width="44" :selectable="() => !saving" />
|
<el-table-column type="selection" width="44" :selectable="() => !saving" />
|
||||||
|
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
<el-table-column prop="sortOrder" :label="t('content.col.sort')" width="64" align="center" />
|
<el-table-column prop="sortOrder" :label="t('content.col.sort')" width="64" align="center" />
|
||||||
<el-table-column v-if="isBanner" :label="t('content.col.preview')" width="88" align="center">
|
<el-table-column :label="t('content.col.preview')" width="88" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<img
|
<img v-if="row.previewImageUrl" :src="row.previewImageUrl" alt="" class="thumb" />
|
||||||
v-if="row.previewImageUrl"
|
|
||||||
:src="row.previewImageUrl"
|
|
||||||
alt=""
|
|
||||||
class="thumb"
|
|
||||||
/>
|
|
||||||
<span v-else class="thumb-empty">—</span>
|
<span v-else class="thumb-empty">—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('content.col.title')" min-width="160">
|
<el-table-column :label="t('content.col.title')" min-width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="preview-title">{{ row.previewTitle || '—' }}</span>
|
<span class="preview-title">{{ previewText(row) }}</span>
|
||||||
<p v-if="!row.playerVisible && row.playerHiddenReason" class="hidden-tip">
|
<p v-if="!row.playerVisible && row.playerHiddenReason" class="hidden-tip">
|
||||||
{{ hiddenTip(row.playerHiddenReason) }}
|
{{ hiddenTip(row.playerHiddenReason) }}
|
||||||
</p>
|
</p>
|
||||||
@@ -564,7 +657,7 @@ void load();
|
|||||||
<span class="schedule-line">{{ formatTime(row.endTime) }}</span>
|
<span class="schedule-line">{{ formatTime(row.endTime) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="isBanner" :label="t('content.col.link')" min-width="120">
|
<el-table-column :label="t('content.col.link')" min-width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<template v-if="row.linkType">
|
<template v-if="row.linkType">
|
||||||
{{ row.linkType }} · {{ row.linkTarget || '—' }}
|
{{ row.linkType }} · {{ row.linkTarget || '—' }}
|
||||||
@@ -586,13 +679,7 @@ void load();
|
|||||||
>
|
>
|
||||||
{{ t('content.btn.enable') }}
|
{{ t('content.btn.enable') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button v-else link type="warning" :disabled="saving" @click="setStatus(row, 'INACTIVE')">
|
||||||
v-else
|
|
||||||
link
|
|
||||||
type="warning"
|
|
||||||
:disabled="saving"
|
|
||||||
@click="setStatus(row, 'INACTIVE')"
|
|
||||||
>
|
|
||||||
{{ t('content.btn.disable') }}
|
{{ t('content.btn.disable') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link type="danger" :disabled="saving" @click="removeItem(row)">
|
<el-button link type="danger" :disabled="saving" @click="removeItem(row)">
|
||||||
@@ -616,13 +703,23 @@ void load();
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" destroy-on-close>
|
<el-dialog
|
||||||
<el-form label-width="96px" size="small">
|
v-model="bannerDialogVisible"
|
||||||
<el-form-item :label="t('content.col.sort')">
|
:title="bannerDialogTitle"
|
||||||
<el-input-number v-model="form.sortOrder" :min="0" :step="1" />
|
width="1000px"
|
||||||
</el-form-item>
|
class="content-publish-dialog"
|
||||||
<el-form-item :label="t('common.status')">
|
destroy-on-close
|
||||||
<el-select v-model="form.status" style="width: 160px">
|
>
|
||||||
|
<el-form label-width="84px" size="small" class="publish-form">
|
||||||
|
<div class="publish-layout">
|
||||||
|
<aside class="publish-meta">
|
||||||
|
<section class="publish-section publish-section--compact">
|
||||||
|
<div class="publish-section-head">{{ t('content.section.publish') }}</div>
|
||||||
|
|
||||||
|
<el-row :gutter="8" class="publish-grid">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="t('common.status')" label-position="top" class="publish-field">
|
||||||
|
<el-select v-model="form.status" style="width: 100%">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
|
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
|
||||||
:key="st"
|
:key="st"
|
||||||
@@ -631,22 +728,23 @@ void load();
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<template v-if="isBanner">
|
</el-col>
|
||||||
<el-form-item :label="t('content.field.link_type')">
|
<el-col :span="12">
|
||||||
<el-select v-model="form.linkType" clearable style="width: 160px">
|
<el-form-item :label="t('content.col.sort')" label-position="top" class="publish-field">
|
||||||
<el-option :label="t('content.link.none')" value="" />
|
<el-input-number
|
||||||
<el-option label="ROUTE" value="ROUTE" />
|
v-model="form.sortOrder"
|
||||||
<el-option label="URL" value="URL" />
|
:min="0"
|
||||||
</el-select>
|
:step="1"
|
||||||
</el-form-item>
|
controls-position="right"
|
||||||
<el-form-item v-if="form.linkType" :label="t('content.field.link_target')">
|
style="width: 100%"
|
||||||
<el-input
|
|
||||||
v-model="form.linkTarget"
|
|
||||||
:placeholder="form.linkType === 'ROUTE' ? '/football' : 'https://'"
|
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</el-col>
|
||||||
<el-form-item :label="t('content.field.start_time')">
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="8" class="publish-grid">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="t('content.field.start_time')" label-position="top" class="publish-field">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="form.startTime"
|
v-model="form.startTime"
|
||||||
type="datetime"
|
type="datetime"
|
||||||
@@ -655,7 +753,9 @@ void load();
|
|||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('content.field.end_time')">
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item :label="t('content.field.end_time')" label-position="top" class="publish-field">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="form.endTime"
|
v-model="form.endTime"
|
||||||
type="datetime"
|
type="datetime"
|
||||||
@@ -664,86 +764,200 @@ void load();
|
|||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
<div v-for="tr in form.translations" :key="tr.locale" class="locale-block">
|
<el-form-item :label="t('content.field.link_type')" label-position="top" class="publish-field">
|
||||||
<div class="locale-head">{{ localeLabel(tr.locale) }}</div>
|
<el-select v-model="form.linkType" clearable style="width: 100%">
|
||||||
<el-form-item :label="t('content.field.title')">
|
<el-option :label="t('content.link.none')" value="" />
|
||||||
<el-input v-model="tr.title" :placeholder="t('content.field.title_ph')" />
|
<el-option label="ROUTE" value="ROUTE" />
|
||||||
|
<el-option label="URL" value="URL" />
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item
|
<el-form-item
|
||||||
v-if="isBanner"
|
v-if="form.linkType"
|
||||||
:label="t('content.field.image_url')"
|
:label="t('content.field.link_target')"
|
||||||
|
label-position="top"
|
||||||
|
class="publish-field"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="form.linkTarget"
|
||||||
|
:placeholder="form.linkType === 'ROUTE' ? '/bet' : 'https://'"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="!editingId" label-position="top" class="publish-field notify-inbox-field">
|
||||||
|
<template #label>{{ t('content.field.notify_inbox') }}</template>
|
||||||
|
<div class="notify-inbox-block">
|
||||||
|
<el-switch v-model="notifyInbox" :disabled="form.status !== 'ACTIVE'" />
|
||||||
|
<p class="notify-inbox-hint">{{ t('content.field.notify_inbox_hint') }}</p>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="publish-section publish-meta-fields">
|
||||||
|
<h3 class="section-title">{{ t('content.section.content') }}</h3>
|
||||||
|
<el-tabs v-model="activeLocale" class="locale-tabs">
|
||||||
|
<el-tab-pane
|
||||||
|
v-for="tr in form.translations"
|
||||||
|
:key="tr.locale"
|
||||||
|
:label="localeLabel(tr.locale)"
|
||||||
|
:name="tr.locale"
|
||||||
|
>
|
||||||
|
<el-form-item
|
||||||
|
:label="t('content.field.title')"
|
||||||
:required="form.status === 'ACTIVE'"
|
:required="form.status === 'ACTIVE'"
|
||||||
>
|
>
|
||||||
<div class="banner-upload-field">
|
<el-input v-model="tr.title" :placeholder="t('content.field.title_ph')" />
|
||||||
<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" />
|
|
||||||
<button type="button" class="banner-preview-remove" :title="t('content.upload.remove')" @click="removeBannerImage(tr.locale)">×</button>
|
|
||||||
</div>
|
|
||||||
<!-- Upload actions -->
|
|
||||||
<div class="banner-upload-actions">
|
|
||||||
<label
|
|
||||||
class="banner-upload-btn"
|
|
||||||
:class="{ 'is-uploading': uploadingLocale === tr.locale }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
:accept="IMAGE_ACCEPT"
|
|
||||||
style="display: none"
|
|
||||||
:disabled="uploadingLocale === tr.locale"
|
|
||||||
@change="onBannerFileChange($event, tr.locale)"
|
|
||||||
/>
|
|
||||||
{{ uploadingLocale === tr.locale ? t('content.upload.uploading') : t('content.upload.upload_btn') }}
|
|
||||||
</label>
|
|
||||||
<button type="button" class="banner-pick-btn" @click="openMediaPicker(tr.locale)">
|
|
||||||
{{ t('content.upload.pick_media') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Manual URL fallback -->
|
|
||||||
<el-input
|
|
||||||
v-model="tr.imageUrl"
|
|
||||||
:placeholder="t('content.upload.url_placeholder')"
|
|
||||||
size="small"
|
|
||||||
class="banner-url-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item
|
<el-form-item
|
||||||
:label="isAnnouncement ? t('content.field.announce_text') : t('content.field.body')"
|
:label="t('content.field.cover_image')"
|
||||||
:required="isAnnouncement && form.status === 'ACTIVE'"
|
:required="form.status === 'ACTIVE'"
|
||||||
>
|
>
|
||||||
<el-input v-model="tr.body" type="textarea" :rows="isAnnouncement ? 2 : 3" />
|
<ContentImageField
|
||||||
|
v-model="tr.imageUrl"
|
||||||
|
category="banners"
|
||||||
|
size-hint-key="content.upload.recommended_size"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="publish-body">
|
||||||
|
<div class="publish-body-head">
|
||||||
|
<span class="publish-body-label">
|
||||||
|
{{ t('content.field.body') }}
|
||||||
|
<span v-if="form.status === 'ACTIVE'" class="required-mark">*</span>
|
||||||
|
</span>
|
||||||
|
<span class="locale-badge">{{ localeLabel(activeLocale) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContentRichEditor
|
||||||
|
ref="bannerEditorRef"
|
||||||
|
v-model="activeTranslation.body"
|
||||||
|
fill
|
||||||
|
upload-category="banners"
|
||||||
|
:placeholder="t('content.editor.placeholder')"
|
||||||
|
class="publish-rich-editor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
|
<el-button @click="bannerDialogVisible = false">{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" :loading="saving" :disabled="!!uploadingLocale" @click="submitForm">
|
<el-button type="primary" :loading="saving" @click="submitBannerForm">
|
||||||
{{ t('common.save') }}
|
{{ t('common.save') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Media picker dialog -->
|
<el-dialog
|
||||||
<el-dialog v-model="mediaPickerVisible" :title="t('content.upload.pick_media_title')" width="680px" destroy-on-close append-to-body>
|
v-model="announcementDialogVisible"
|
||||||
<div v-if="mediaLoading" class="media-picker-loading">{{ t('common.loading') }}</div>
|
:title="announcementDialogTitle"
|
||||||
<div v-else-if="mediaFiles.length === 0" class="media-picker-empty">{{ t('content.upload.no_media') }}</div>
|
width="640px"
|
||||||
<div v-else class="media-picker-grid">
|
class="announcement-dialog"
|
||||||
<div
|
destroy-on-close
|
||||||
v-for="file in mediaFiles"
|
|
||||||
:key="file.id"
|
|
||||||
class="media-picker-card"
|
|
||||||
@click="pickMediaFile(file)"
|
|
||||||
>
|
>
|
||||||
<div class="media-picker-thumb">
|
<p class="announcement-hint">{{ t('content.hint.announcement') }}</p>
|
||||||
<img v-if="file.mimeType !== 'image/svg+xml'" :src="file.url" :alt="file.filename" loading="lazy" />
|
<el-form label-width="96px" size="small" class="announcement-form">
|
||||||
<div v-else class="media-picker-svg">SVG</div>
|
<section class="announcement-publish">
|
||||||
</div>
|
<div class="publish-section-head">{{ t('content.section.publish') }}</div>
|
||||||
<div class="media-picker-name" :title="file.filename">{{ file.filename }}</div>
|
<el-row :gutter="8" class="announcement-publish-row">
|
||||||
</div>
|
<el-col :xs="12" :sm="6">
|
||||||
|
<el-form-item :label="t('common.status')" label-position="top" class="publish-field">
|
||||||
|
<el-select v-model="form.status" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
|
||||||
|
:key="st"
|
||||||
|
:label="statusLabel(st)"
|
||||||
|
:value="st"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="6">
|
||||||
|
<el-form-item :label="t('content.col.sort')" label-position="top" class="publish-field">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.sortOrder"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="6">
|
||||||
|
<el-form-item :label="t('content.field.start_time')" label-position="top" class="publish-field">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.startTime"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="6">
|
||||||
|
<el-form-item :label="t('content.field.end_time')" label-position="top" class="publish-field">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.endTime"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item v-if="!editingId" label-position="top" class="publish-field notify-inbox-field">
|
||||||
|
<template #label>{{ t('content.field.notify_inbox') }}</template>
|
||||||
|
<div class="notify-inbox-block">
|
||||||
|
<el-switch v-model="notifyInbox" :disabled="form.status !== 'ACTIVE'" />
|
||||||
|
<p class="notify-inbox-hint">{{ t('content.field.notify_inbox_hint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="announcement-content">
|
||||||
|
<h3 class="section-title">{{ t('content.section.content') }}</h3>
|
||||||
|
<el-tabs v-model="activeLocale" class="locale-tabs">
|
||||||
|
<el-tab-pane
|
||||||
|
v-for="tr in form.translations"
|
||||||
|
:key="tr.locale"
|
||||||
|
:label="localeLabel(tr.locale)"
|
||||||
|
:name="tr.locale"
|
||||||
|
>
|
||||||
|
<el-form-item :label="t('content.field.title')">
|
||||||
|
<el-input v-model="tr.title" :placeholder="t('content.field.ticker_title_ph')" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
:label="t('content.field.announce_text')"
|
||||||
|
:required="form.status === 'ACTIVE'"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="tr.body"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
:placeholder="t('content.field.ticker_body_ph')"
|
||||||
|
/>
|
||||||
|
<p class="field-hint compact-hint">{{ t('content.field.ticker_hint') }}</p>
|
||||||
|
</el-form-item>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</section>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="announcementDialogVisible = false">{{ t('common.cancel') }}</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="submitAnnouncementForm">
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -753,11 +967,27 @@ void load();
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-hint {
|
.field-hint,
|
||||||
margin: 0 0 8px;
|
.batch-hint,
|
||||||
font-size: 12px;
|
.schedule-line,
|
||||||
color: #666;
|
.schedule-sep,
|
||||||
line-height: 1.5;
|
.thumb-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint.inline-hint,
|
||||||
|
.field-hint.compact-hint {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint.inline-hint {
|
||||||
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-row {
|
.filter-row {
|
||||||
@@ -766,7 +996,6 @@ void load();
|
|||||||
|
|
||||||
.batch-hint {
|
.batch-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #888;
|
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -776,313 +1005,257 @@ void load();
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #222;
|
background: #f4f0e8;
|
||||||
}
|
|
||||||
|
|
||||||
.thumb-empty {
|
|
||||||
color: #555;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-title {
|
.preview-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #ccc;
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden-tip {
|
.hidden-tip {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #c9a227;
|
color: var(--warning-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-line {
|
.schedule-line {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #888;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-sep {
|
.schedule-sep {
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
color: #555;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-block {
|
.content-publish-dialog :deep(.el-dialog__body) {
|
||||||
margin-top: 12px;
|
padding-top: 10px;
|
||||||
padding: 10px 12px;
|
padding-bottom: 12px;
|
||||||
border: 1px solid #252525;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.locale-head {
|
.content-publish-dialog :deep(.el-dialog__footer) {
|
||||||
font-size: 12px;
|
padding-top: 10px;
|
||||||
font-weight: 700;
|
|
||||||
color: #888;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Banner image upload widget ── */
|
.publish-layout {
|
||||||
.banner-upload-field {
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-meta {
|
||||||
|
flex: 0 0 380px;
|
||||||
|
max-width: 400px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-size-hint {
|
.publish-meta-fields {
|
||||||
margin: 0;
|
flex: 1;
|
||||||
font-size: 12px;
|
min-height: 0;
|
||||||
line-height: 1.5;
|
|
||||||
color: #777;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-preview {
|
.publish-body {
|
||||||
position: relative;
|
flex: 1;
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
max-width: 320px;
|
display: flex;
|
||||||
aspect-ratio: 43 / 18;
|
flex-direction: column;
|
||||||
|
min-height: 480px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #252525;
|
|
||||||
background: #111;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-preview-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-preview-remove {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
right: 4px;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
color: #fff;
|
|
||||||
border: 1px solid #333;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-preview-remove:hover {
|
|
||||||
background: rgba(224, 85, 85, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-upload-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-upload-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid rgba(212, 175, 55, 0.5);
|
|
||||||
background: rgba(212, 175, 55, 0.1);
|
|
||||||
color: var(--gold-text);
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-upload-btn:hover {
|
|
||||||
background: rgba(212, 175, 55, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-upload-btn.is-uploading {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-pick-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid #2a2a2a;
|
|
||||||
background: transparent;
|
|
||||||
color: #888;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-pick-btn:hover {
|
|
||||||
border-color: #444;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-url-input {
|
|
||||||
max-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-url-input :deep(.el-input__wrapper) {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Media picker ── */
|
|
||||||
.media-picker-loading,
|
|
||||||
.media-picker-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 0;
|
|
||||||
color: #555;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-picker-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
max-height: 420px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-picker-card {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid #1e1e1e;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-picker-card:hover {
|
|
||||||
border-color: rgba(212, 175, 55, 0.5);
|
|
||||||
box-shadow: 0 2px 12px rgba(212, 175, 55, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-picker-thumb {
|
|
||||||
height: 80px;
|
|
||||||
background: #111;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-picker-thumb img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-picker-svg {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #666;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-picker-name {
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #888;
|
|
||||||
white-space: nowrap;
|
|
||||||
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;
|
background: #fbfaf7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-preview-remove {
|
.publish-body-head {
|
||||||
border-color: rgba(255, 255, 255, 0.36);
|
display: flex;
|
||||||
background: rgba(31, 35, 32, 0.78);
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-preview-remove:hover {
|
.publish-body-label {
|
||||||
background: rgba(159, 47, 45, 0.92);
|
font-size: 13px;
|
||||||
}
|
|
||||||
|
|
||||||
.banner-upload-btn,
|
|
||||||
.banner-pick-btn {
|
|
||||||
border-radius: 7px;
|
|
||||||
font-weight: 700;
|
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);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-pick-btn:hover {
|
.required-mark {
|
||||||
border-color: #d5cfc3;
|
color: var(--el-color-danger);
|
||||||
background: var(--accent-hover);
|
margin-left: 2px;
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-picker-card {
|
.locale-badge {
|
||||||
border-color: var(--border);
|
font-size: 11px;
|
||||||
background: #ffffff;
|
font-weight: 600;
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
color: var(--text-muted);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-rich-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-form :deep(.el-form-item__label) {
|
||||||
|
padding-right: 8px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-section {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfaf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-section--compact {
|
||||||
|
padding: 10px 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-section-head {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-section--compact :deep(.publish-field.el-form-item) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-section--compact :deep(.publish-field.el-form-item:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-section--compact :deep(.publish-field .el-form-item__label) {
|
||||||
|
padding: 0 0 4px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-section--compact :deep(.publish-field .el-form-item__content) {
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-grid {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-grid :deep(.publish-field.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-inbox-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-inbox-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-inbox-field {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-grid--secondary {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-dialog :deep(.el-dialog__body) {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-publish {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfaf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-content {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfaf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.announcement-publish-row {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.publish-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-meta {
|
||||||
|
flex: none;
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-body {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-tabs :deep(.el-tabs__header) {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-tabs :deep(.el-tabs__content) {
|
||||||
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
@@ -1091,10 +1264,56 @@ void load();
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.banner-preview,
|
.inbox-notify-panel {
|
||||||
.banner-url-input {
|
padding: 4px 0 12px;
|
||||||
max-width: 100%;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inbox-notify-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-notify-row-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-notify-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-notify-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-notify-notes-title {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-notify-notes {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-notify-notes li {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { resolveApiError } from '../i18n/form-validation';
|
|||||||
import api from '../api';
|
import api from '../api';
|
||||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||||
import { formatAmount } from '../utils/format-amount';
|
import { formatAmount } from '../utils/format-amount';
|
||||||
|
import { refreshDepositPendingCount } from '../composables/useDepositPendingCount';
|
||||||
|
|
||||||
const { t } = useAdminLocale();
|
const { t } = useAdminLocale();
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ interface DepositAuditLogRow {
|
|||||||
const items = ref<DepositOrderRow[]>([]);
|
const items = ref<DepositOrderRow[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(20);
|
const pageSize = ref(10);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
@@ -85,6 +86,7 @@ async function fetchList() {
|
|||||||
const result = data.data ?? { items: [], total: 0 };
|
const result = data.data ?? { items: [], total: 0 };
|
||||||
items.value = result.items ?? [];
|
items.value = result.items ?? [];
|
||||||
total.value = result.total ?? 0;
|
total.value = result.total ?? 0;
|
||||||
|
void refreshDepositPendingCount();
|
||||||
} catch { /* */ } finally {
|
} catch { /* */ } finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -238,6 +240,10 @@ function statusLabel(s: string) {
|
|||||||
return '● ' + t('deposit.status_pending');
|
return '● ' + t('deposit.status_pending');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshList() {
|
||||||
|
void fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
function prevPage() { if (page.value > 1) { page.value--; fetchList(); } }
|
function prevPage() { if (page.value > 1) { page.value--; fetchList(); } }
|
||||||
function nextPage() { if (page.value * pageSize.value < total.value) { page.value++; fetchList(); } }
|
function nextPage() { if (page.value * pageSize.value < total.value) { page.value++; fetchList(); } }
|
||||||
|
|
||||||
@@ -268,6 +274,9 @@ onMounted(fetchList);
|
|||||||
@keydown.enter="page = 1; fetchList()"
|
@keydown.enter="page = 1; fetchList()"
|
||||||
/>
|
/>
|
||||||
<button class="btn-search" @click="page = 1; fetchList()">{{ t('common.search') }}</button>
|
<button class="btn-search" @click="page = 1; fetchList()">{{ t('common.search') }}</button>
|
||||||
|
<button class="btn-refresh" type="button" :disabled="loading" @click="refreshList">
|
||||||
|
{{ t('common.refresh') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminTableEmpty v-if="!loading && !items.length" />
|
<AdminTableEmpty v-if="!loading && !items.length" />
|
||||||
@@ -275,6 +284,7 @@ onMounted(fetchList);
|
|||||||
<table v-if="items.length" class="data-table">
|
<table v-if="items.length" class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 60px; text-align: center;">{{ t('common.seq') }}</th>
|
||||||
<th>{{ t('deposit.order_no') }}</th>
|
<th>{{ t('deposit.order_no') }}</th>
|
||||||
<th>{{ t('deposit.player') }}</th>
|
<th>{{ t('deposit.player') }}</th>
|
||||||
<th>{{ t('common.type') }}</th>
|
<th>{{ t('common.type') }}</th>
|
||||||
@@ -289,7 +299,8 @@ onMounted(fetchList);
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="row in items" :key="row.id">
|
<tr v-for="(row, idx) in items" :key="row.id">
|
||||||
|
<td style="text-align: center;">{{ (page - 1) * pageSize + idx + 1 }}</td>
|
||||||
<td class="mono">{{ row.orderNo }}</td>
|
<td class="mono">{{ row.orderNo }}</td>
|
||||||
<td>{{ row.playerUsername || row.playerId }}</td>
|
<td>{{ row.playerUsername || row.playerId }}</td>
|
||||||
<td><span :class="['badge', row.methodType === 'BANK' ? 'badge-blue' : 'badge-green']">{{ row.methodType }}</span></td>
|
<td><span :class="['badge', row.methodType === 'BANK' ? 'badge-blue' : 'badge-green']">{{ row.methodType }}</span></td>
|
||||||
@@ -445,6 +456,24 @@ onMounted(fetchList);
|
|||||||
.filters select, .filters input { padding: 6px 10px; border-radius: 4px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 13px; }
|
.filters select, .filters input { padding: 6px 10px; border-radius: 4px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 13px; }
|
||||||
.filters input { min-width: 160px; }
|
.filters input { min-width: 160px; }
|
||||||
.btn-search { background: #409eff; color: #fff; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; font-weight: 600; font-size: 13px; }
|
.btn-search { background: #409eff; color: #fff; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; font-weight: 600; font-size: 13px; }
|
||||||
|
.btn-refresh {
|
||||||
|
background: #fff;
|
||||||
|
color: #606266;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.btn-refresh:hover:not(:disabled) {
|
||||||
|
border-color: #409eff;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
.btn-refresh:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
.data-table th, .data-table td { padding: 10px 6px; border-bottom: 1px solid #333; text-align: left; }
|
.data-table th, .data-table td { padding: 10px 6px; border-bottom: 1px solid #333; text-align: left; }
|
||||||
.data-table th { font-weight: 700; color: #aaa; font-size: 11px; text-transform: uppercase; }
|
.data-table th { font-weight: 700; color: #aaa; font-size: 11px; text-transform: uppercase; }
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ interface TransferTxRow {
|
|||||||
const creditItems = ref<CreditTxRow[]>([]);
|
const creditItems = ref<CreditTxRow[]>([]);
|
||||||
const creditTotal = ref(0);
|
const creditTotal = ref(0);
|
||||||
const creditPage = ref(1);
|
const creditPage = ref(1);
|
||||||
const creditPageSize = ref(20);
|
const creditPageSize = ref(10);
|
||||||
|
|
||||||
const transferItems = ref<TransferTxRow[]>([]);
|
const transferItems = ref<TransferTxRow[]>([]);
|
||||||
const transferTotal = ref(0);
|
const transferTotal = ref(0);
|
||||||
const transferPage = ref(1);
|
const transferPage = ref(1);
|
||||||
const transferPageSize = ref(20);
|
const transferPageSize = ref(10);
|
||||||
|
|
||||||
const keyword = ref('');
|
const keyword = ref('');
|
||||||
const agentId = ref('');
|
const agentId = ref('');
|
||||||
@@ -320,6 +320,7 @@ watch(
|
|||||||
<template #empty>
|
<template #empty>
|
||||||
<AdminTableEmpty />
|
<AdminTableEmpty />
|
||||||
</template>
|
</template>
|
||||||
|
<el-table-column type="index" :index="(i: number) => (creditPage - 1) * creditPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
<el-table-column :label="t('audit.col.time')" min-width="158">
|
<el-table-column :label="t('audit.col.time')" min-width="158">
|
||||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -390,6 +391,7 @@ watch(
|
|||||||
<template #empty>
|
<template #empty>
|
||||||
<AdminTableEmpty />
|
<AdminTableEmpty />
|
||||||
</template>
|
</template>
|
||||||
|
<el-table-column type="index" :index="(i: number) => (transferPage - 1) * transferPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
<el-table-column :label="t('audit.col.time')" min-width="158">
|
<el-table-column :label="t('audit.col.time')" min-width="158">
|
||||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -442,7 +442,7 @@ function onLeagueArchived() {
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="id" label="ID" width="72" />
|
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
<el-table-column :label="t('match.col.league')" width="148" show-overflow-tooltip>
|
<el-table-column :label="t('match.col.league')" width="148" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="league-cell">
|
<div class="league-cell">
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function isLeagueExpanded(id: string) {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="id" label="ID" width="72" />
|
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
<el-table-column :label="t('match.col.league')" width="148" show-overflow-tooltip>
|
<el-table-column :label="t('match.col.league')" width="148" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="league-cell">
|
<div class="league-cell">
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ onMounted(fetchList);
|
|||||||
<table v-if="filteredItems.length" class="data-table">
|
<table v-if="filteredItems.length" class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="text-align: center; width: 60px;">{{ t('common.seq') }}</th>
|
||||||
<th>{{ t('common.type') }}</th>
|
<th>{{ t('common.type') }}</th>
|
||||||
<th>{{ t('deposit.display_name') }}</th>
|
<th>{{ t('deposit.display_name') }}</th>
|
||||||
<th>{{ t('deposit.details') }}</th>
|
<th>{{ t('deposit.details') }}</th>
|
||||||
@@ -205,7 +206,8 @@ onMounted(fetchList);
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="row in filteredItems" :key="row.id">
|
<tr v-for="(row, index) in filteredItems" :key="row.id">
|
||||||
|
<td style="text-align: center;">{{ index + 1 }}</td>
|
||||||
<td><span :class="['badge', row.methodType === 'BANK' ? 'badge-blue' : 'badge-green']">{{ row.methodType }}</span></td>
|
<td><span :class="['badge', row.methodType === 'BANK' ? 'badge-blue' : 'badge-green']">{{ row.methodType }}</span></td>
|
||||||
<td>{{ row.displayName || '-' }}</td>
|
<td>{{ row.displayName || '-' }}</td>
|
||||||
<td class="details-cell">
|
<td class="details-cell">
|
||||||
|
|||||||
@@ -851,6 +851,7 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="previewItemsPage.items" size="small" stripe class="preview-items-table">
|
<el-table :data="previewItemsPage.items" size="small" stripe class="preview-items-table">
|
||||||
|
<el-table-column type="index" :index="(i: number) => (previewItemsPage.page - 1) * previewItemsPage.pageSize + i + 1" :label="t('common.seq')" width="55" align="center" />
|
||||||
<el-table-column :label="t('bet.col.bet_no')" prop="betNo" min-width="140" show-overflow-tooltip />
|
<el-table-column :label="t('bet.col.bet_no')" prop="betNo" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column :label="t('common.type')" width="68">
|
<el-table-column :label="t('common.type')" width="68">
|
||||||
<template #default="{ row }">{{ betTypeLabel(row.betType) }}</template>
|
<template #default="{ row }">{{ betTypeLabel(row.betType) }}</template>
|
||||||
@@ -935,6 +936,7 @@ onMounted(() => {
|
|||||||
stripe
|
stripe
|
||||||
class="stats-table"
|
class="stats-table"
|
||||||
>
|
>
|
||||||
|
<el-table-column type="index" :index="(i: number) => ((stats?.bets.page ?? 1) - 1) * (stats?.bets.pageSize ?? 10) + i + 1" :label="t('common.seq')" width="55" align="center" />
|
||||||
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="140" />
|
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="140" />
|
||||||
<el-table-column prop="username" :label="t('bet.col.player')" width="96" />
|
<el-table-column prop="username" :label="t('bet.col.player')" width="96" />
|
||||||
<el-table-column :label="t('common.type')" width="72">
|
<el-table-column :label="t('common.type')" width="72">
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ onMounted(async () => {
|
|||||||
<span v-else class="case-details-empty">{{ t('smoke.no_steps') }}</span>
|
<span v-else class="case-details-empty">{{ t('smoke.no_steps') }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column type="index" :label="t('common.seq')" width="55" align="center" />
|
||||||
<el-table-column prop="id" :label="t('smoke.col.id')" width="88" />
|
<el-table-column prop="id" :label="t('smoke.col.id')" width="88" />
|
||||||
<el-table-column :label="t('smoke.col.suite')" width="120">
|
<el-table-column :label="t('smoke.col.suite')" width="120">
|
||||||
<template #default="{ row }">{{ suiteName(row.suite) }}</template>
|
<template #default="{ row }">{{ suiteName(row.suite) }}</template>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||||
import AdminTableWrap from '../components/AdminTableWrap.vue';
|
import AdminTableWrap from '../components/AdminTableWrap.vue';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ interface StaffRow {
|
|||||||
roleName: string | null;
|
roleName: string | null;
|
||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
visibleMenus: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoleOption {
|
interface RoleOption {
|
||||||
@@ -23,22 +25,46 @@ interface RoleOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { t, localeTag } = useAdminLocale();
|
const { t, localeTag } = useAdminLocale();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const currentUserId = computed(() => authStore.user.value?.id);
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const rows = ref<StaffRow[]>([]);
|
const rows = ref<StaffRow[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(20);
|
const pageSize = ref(10);
|
||||||
const keyword = ref('');
|
const keyword = ref('');
|
||||||
const roles = ref<RoleOption[]>([]);
|
const roles = ref<RoleOption[]>([]);
|
||||||
|
|
||||||
|
const menuOptions = [
|
||||||
|
{ key: 'dashboard', label: 'nav.dashboard' },
|
||||||
|
{ key: 'matches', label: 'nav.matches' },
|
||||||
|
{ key: 'users', label: 'nav.agents_players' },
|
||||||
|
{ key: 'finance-logs', label: 'nav.finance_logs' },
|
||||||
|
{ key: 'deposit', label: 'nav.deposit_manage' },
|
||||||
|
{ key: 'cashback', label: 'nav.cashback' },
|
||||||
|
{ key: 'bets', label: 'nav.bets' },
|
||||||
|
{ key: 'contents', label: 'nav.contents' },
|
||||||
|
{ key: 'media', label: 'nav.media' },
|
||||||
|
{ key: 'audit', label: 'nav.audit' },
|
||||||
|
{ key: 'staff', label: 'nav.staff' },
|
||||||
|
{ key: 'smoke-tests', label: 'nav.smoke_tests' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROLE_DEFAULT_MENUS: Record<string, string[]> = {
|
||||||
|
SUPER_ADMIN: ['dashboard', 'matches', 'users', 'finance-logs', 'deposit', 'cashback', 'bets', 'contents', 'media', 'audit', 'staff', 'smoke-tests'],
|
||||||
|
MATCH_ADMIN: ['matches', 'bets', 'contents', 'media'],
|
||||||
|
FINANCE_ADMIN: ['dashboard', 'users', 'finance-logs', 'deposit', 'cashback', 'bets'],
|
||||||
|
SUPPORT: ['users', 'bets', 'contents', 'media'],
|
||||||
|
};
|
||||||
|
|
||||||
const createVisible = ref(false);
|
const createVisible = ref(false);
|
||||||
const createLoading = ref(false);
|
const createLoading = ref(false);
|
||||||
const createForm = ref({ username: '', password: '', confirmPassword: '', roleCode: 'MATCH_ADMIN' });
|
const createForm = ref({ username: '', password: '', confirmPassword: '', roleCode: 'MATCH_ADMIN', checkedMenus: [] as string[] });
|
||||||
|
|
||||||
const editVisible = ref(false);
|
const editVisible = ref(false);
|
||||||
const editLoading = ref(false);
|
const editLoading = ref(false);
|
||||||
const editForm = ref({ id: '', username: '', status: 'ACTIVE', roleCode: 'MATCH_ADMIN', password: '' });
|
const editForm = ref({ id: '', username: '', status: 'ACTIVE', roleCode: 'MATCH_ADMIN', password: '', checkedMenus: [] as string[] });
|
||||||
|
|
||||||
const roleLabel = computed(() => {
|
const roleLabel = computed(() => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
@@ -80,8 +106,18 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onCreateRoleChange(newRole: string) {
|
||||||
|
createForm.value.checkedMenus = [...(ROLE_DEFAULT_MENUS[newRole] || [])];
|
||||||
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
createForm.value = { username: '', password: '', confirmPassword: '', roleCode: 'MATCH_ADMIN' };
|
createForm.value = {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
roleCode: 'MATCH_ADMIN',
|
||||||
|
checkedMenus: [...ROLE_DEFAULT_MENUS['MATCH_ADMIN']],
|
||||||
|
};
|
||||||
createVisible.value = true;
|
createVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +141,7 @@ async function submitCreate() {
|
|||||||
username: f.username.trim(),
|
username: f.username.trim(),
|
||||||
password: f.password,
|
password: f.password,
|
||||||
roleCode: f.roleCode,
|
roleCode: f.roleCode,
|
||||||
|
visibleMenus: f.checkedMenus.join(','),
|
||||||
});
|
});
|
||||||
ElMessage.success(t('msg.saved'));
|
ElMessage.success(t('msg.saved'));
|
||||||
createVisible.value = false;
|
createVisible.value = false;
|
||||||
@@ -117,6 +154,10 @@ async function submitCreate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onEditRoleChange(newRole: string) {
|
||||||
|
editForm.value.checkedMenus = [...(ROLE_DEFAULT_MENUS[newRole] || [])];
|
||||||
|
}
|
||||||
|
|
||||||
function openEdit(row: StaffRow) {
|
function openEdit(row: StaffRow) {
|
||||||
editForm.value = {
|
editForm.value = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -124,6 +165,9 @@ function openEdit(row: StaffRow) {
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
roleCode: row.role ?? 'MATCH_ADMIN',
|
roleCode: row.role ?? 'MATCH_ADMIN',
|
||||||
password: '',
|
password: '',
|
||||||
|
checkedMenus: row.visibleMenus
|
||||||
|
? row.visibleMenus.split(',').filter(Boolean)
|
||||||
|
: [...(ROLE_DEFAULT_MENUS[row.role ?? 'MATCH_ADMIN'] || [])],
|
||||||
};
|
};
|
||||||
editVisible.value = true;
|
editVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -139,6 +183,7 @@ async function submitEdit() {
|
|||||||
const payload: Record<string, string> = {
|
const payload: Record<string, string> = {
|
||||||
status: f.status,
|
status: f.status,
|
||||||
roleCode: f.roleCode,
|
roleCode: f.roleCode,
|
||||||
|
visibleMenus: f.checkedMenus.join(','),
|
||||||
};
|
};
|
||||||
if (f.password.trim()) payload.password = f.password.trim();
|
if (f.password.trim()) payload.password = f.password.trim();
|
||||||
const { data } = await api.patch(`/admin/staff/${f.id}`, payload);
|
const { data } = await api.patch(`/admin/staff/${f.id}`, payload);
|
||||||
@@ -157,6 +202,49 @@ async function submitEdit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleFreeze(row: StaffRow, targetStatus: string) {
|
||||||
|
const actionText = targetStatus === 'SUSPENDED' ? t('common.freeze') : t('common.unfreeze');
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要${actionText}管理员「${row.username}」吗?`,
|
||||||
|
t('msg.freeze_confirm_title', { action: actionText }),
|
||||||
|
{ type: 'warning' },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.patch(`/admin/staff/${row.id}`, { status: targetStatus });
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStaff(row: StaffRow) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除管理员「${row.username}」吗?此操作无法撤销。`,
|
||||||
|
t('common.delete'),
|
||||||
|
{ type: 'warning' },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/staff/${row.id}`);
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadRoles();
|
await loadRoles();
|
||||||
await load();
|
await load();
|
||||||
@@ -191,19 +279,61 @@ onMounted(async () => {
|
|||||||
<template #empty>
|
<template #empty>
|
||||||
<AdminTableEmpty />
|
<AdminTableEmpty />
|
||||||
</template>
|
</template>
|
||||||
|
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
<el-table-column prop="username" :label="t('login.username')" min-width="120" />
|
<el-table-column prop="username" :label="t('login.username')" min-width="120" />
|
||||||
<el-table-column :label="t('staff.col.role')" min-width="140">
|
<el-table-column :label="t('staff.col.role')" min-width="140">
|
||||||
<template #default="{ row }">{{ roleLabel(row.role) }}</template>
|
<template #default="{ row }">{{ roleLabel(row.role) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('common.status')" width="100">
|
<el-table-column :label="t('common.status')" width="100" align="center">
|
||||||
<template #default="{ row }">{{ row.status }}</template>
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
size="small"
|
||||||
|
:type="row.status === 'ACTIVE' ? 'success' : row.status === 'SUSPENDED' ? 'warning' : 'danger'"
|
||||||
|
effect="dark"
|
||||||
|
>
|
||||||
|
{{ t(`user.status.${row.status}`) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('staff.col.last_login')" min-width="160">
|
<el-table-column :label="t('staff.col.last_login')" min-width="160">
|
||||||
<template #default="{ row }">{{ formatTime(row.lastLoginAt) }}</template>
|
<template #default="{ row }">{{ formatTime(row.lastLoginAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('common.actions')" width="100" align="center">
|
<el-table-column :label="t('common.actions')" width="220" align="center" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" size="small" @click="openEdit(row)">{{ t('common.edit') }}</el-button>
|
<el-button link type="primary" size="small" @click="openEdit(row)">
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'ACTIVE'"
|
||||||
|
link
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
:disabled="row.id === currentUserId"
|
||||||
|
@click="toggleFreeze(row, 'SUSPENDED')"
|
||||||
|
>
|
||||||
|
{{ t('common.freeze') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
link
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
:disabled="row.id === currentUserId"
|
||||||
|
@click="toggleFreeze(row, 'ACTIVE')"
|
||||||
|
>
|
||||||
|
{{ t('common.unfreeze') }}
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="row.id === currentUserId"
|
||||||
|
@click="deleteStaff(row)"
|
||||||
|
>
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -229,7 +359,7 @@ onMounted(async () => {
|
|||||||
<el-input v-model="createForm.username" autocomplete="off" />
|
<el-input v-model="createForm.username" autocomplete="off" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('staff.col.role')" required>
|
<el-form-item :label="t('staff.col.role')" required>
|
||||||
<el-select v-model="createForm.roleCode" style="width: 100%">
|
<el-select v-model="createForm.roleCode" style="width: 100%" @change="onCreateRoleChange">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="r in roles.filter((x) => x.code !== 'SUPER_ADMIN')"
|
v-for="r in roles.filter((x) => x.code !== 'SUPER_ADMIN')"
|
||||||
:key="r.code"
|
:key="r.code"
|
||||||
@@ -238,6 +368,19 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('staff.field.visible_menus')">
|
||||||
|
<el-checkbox-group v-model="createForm.checkedMenus">
|
||||||
|
<div class="menu-grid">
|
||||||
|
<el-checkbox
|
||||||
|
v-for="item in menuOptions"
|
||||||
|
:key="item.key"
|
||||||
|
:label="item.key"
|
||||||
|
>
|
||||||
|
{{ t(item.label) }}
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item :label="t('login.password')" required>
|
<el-form-item :label="t('login.password')" required>
|
||||||
<el-input v-model="createForm.password" type="password" autocomplete="new-password" />
|
<el-input v-model="createForm.password" type="password" autocomplete="new-password" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -257,7 +400,7 @@ onMounted(async () => {
|
|||||||
<el-input :model-value="editForm.username" disabled />
|
<el-input :model-value="editForm.username" disabled />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('staff.col.role')">
|
<el-form-item :label="t('staff.col.role')">
|
||||||
<el-select v-model="editForm.roleCode" style="width: 100%" :disabled="editForm.roleCode === 'SUPER_ADMIN'">
|
<el-select v-model="editForm.roleCode" style="width: 100%" :disabled="editForm.roleCode === 'SUPER_ADMIN'" @change="onEditRoleChange">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="r in roles"
|
v-for="r in roles"
|
||||||
:key="r.code"
|
:key="r.code"
|
||||||
@@ -266,11 +409,24 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('staff.field.visible_menus')">
|
||||||
|
<el-checkbox-group v-model="editForm.checkedMenus">
|
||||||
|
<div class="menu-grid">
|
||||||
|
<el-checkbox
|
||||||
|
v-for="item in menuOptions"
|
||||||
|
:key="item.key"
|
||||||
|
:label="item.key"
|
||||||
|
>
|
||||||
|
{{ t(item.label) }}
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item :label="t('common.status')">
|
<el-form-item :label="t('common.status')">
|
||||||
<el-select v-model="editForm.status" style="width: 100%">
|
<el-select v-model="editForm.status" style="width: 100%">
|
||||||
<el-option label="ACTIVE" value="ACTIVE" />
|
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||||||
<el-option label="SUSPENDED" value="SUSPENDED" />
|
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||||||
<el-option label="DISABLED" value="DISABLED" />
|
<el-option :label="t('user.status.DISABLED')" value="DISABLED" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.reset_password')">
|
<el-form-item :label="t('user.field.reset_password')">
|
||||||
@@ -291,4 +447,10 @@ onMounted(async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
.menu-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 4px 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
shouldCompactAmount as shouldCompact,
|
shouldCompactAmount as shouldCompact,
|
||||||
} from '../utils/format-amount';
|
} from '../utils/format-amount';
|
||||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||||
|
import AdminPlayerStatusCell from '../components/AdminPlayerStatusCell.vue';
|
||||||
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
|
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
|
||||||
import AdminDetailItem from '../components/AdminDetailItem.vue';
|
import AdminDetailItem from '../components/AdminDetailItem.vue';
|
||||||
import WalletTransferContext from '../components/WalletTransferContext.vue';
|
import WalletTransferContext from '../components/WalletTransferContext.vue';
|
||||||
@@ -511,11 +512,9 @@ function statusLabel(s: string) {
|
|||||||
</template>
|
</template>
|
||||||
<el-table-column prop="id" label="ID" width="72" />
|
<el-table-column prop="id" label="ID" width="72" />
|
||||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||||
<el-table-column :label="t('common.status')" width="88">
|
<el-table-column :label="t('common.status')" min-width="128">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusTagType(row.status)" size="small">
|
<AdminPlayerStatusCell :status="row.status" :is-online="row.isOnline" />
|
||||||
{{ statusLabel(row.status) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('user.col.agent')" min-width="120">
|
<el-table-column :label="t('user.col.agent')" min-width="120">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface AdminDashboard {
|
|||||||
playersActive: number;
|
playersActive: number;
|
||||||
playersSuspended: number;
|
playersSuspended: number;
|
||||||
playersDirect: number;
|
playersDirect: number;
|
||||||
|
playersOnlineNow: number;
|
||||||
agentsTotal: number;
|
agentsTotal: number;
|
||||||
agentsActive: number;
|
agentsActive: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ const kpiPlayer = computed(() => {
|
|||||||
value: `${fmtCount(s.value.users.playersTotal)} / ${fmtCount(s.value.users.agentsTotal)}`,
|
value: `${fmtCount(s.value.users.playersTotal)} / ${fmtCount(s.value.users.agentsTotal)}`,
|
||||||
sub: t('dash.kpi_new_players', { n: fmtCount(s.value.today.newPlayers) }),
|
sub: t('dash.kpi_new_players', { n: fmtCount(s.value.today.newPlayers) }),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t('dash.players_online'),
|
||||||
|
value: fmtCount(s.value.users.playersOnlineNow ?? 0),
|
||||||
|
sub: t('dash.players_online_hint'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t('dash.kpi_agents_active'),
|
label: t('dash.kpi_agents_active'),
|
||||||
value: fmtCount(s.value.users.agentsActive),
|
value: fmtCount(s.value.users.agentsActive),
|
||||||
@@ -55,6 +60,7 @@ const userDistributionOption = computed(() => {
|
|||||||
const userSegs = u
|
const userSegs = u
|
||||||
? [
|
? [
|
||||||
{ label: t('dash.user_active'), value: u.playersActive, color: '#346538' },
|
{ label: t('dash.user_active'), value: u.playersActive, color: '#346538' },
|
||||||
|
{ label: t('dash.user_online'), value: u.playersOnlineNow ?? 0, color: '#2d8a4e' },
|
||||||
{ label: t('dash.user_suspended'), value: u.playersSuspended, color: '#9f2f2d' },
|
{ label: t('dash.user_suspended'), value: u.playersSuspended, color: '#9f2f2d' },
|
||||||
{ label: t('dash.user_direct'), value: u.playersDirect, color: '#1f6c9f' },
|
{ label: t('dash.user_direct'), value: u.playersDirect, color: '#1f6c9f' },
|
||||||
{ label: t('dash.user_agents'), value: u.agentsTotal, color: '#956400' },
|
{ label: t('dash.user_agents'), value: u.agentsTotal, color: '#956400' },
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export interface AdminMarket {
|
|||||||
lineValue: number | null;
|
lineValue: number | null;
|
||||||
paramsJson?: Record<string, unknown> | null;
|
paramsJson?: Record<string, unknown> | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
allowSingle?: boolean;
|
||||||
|
allowParlay?: boolean;
|
||||||
showOnPlayer: boolean;
|
showOnPlayer: boolean;
|
||||||
promoLabel: string;
|
promoLabel: string;
|
||||||
promoLabelI18n?: Record<string, string>;
|
promoLabelI18n?: Record<string, string>;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const archiveTitle = ref('');
|
|||||||
const matches = ref<unknown[]>([]);
|
const matches = ref<unknown[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const matchPage = ref(1);
|
const matchPage = ref(1);
|
||||||
const matchPageSize = ref(20);
|
const matchPageSize = ref(10);
|
||||||
const matchTotal = ref(0);
|
const matchTotal = ref(0);
|
||||||
let loadTimer: ReturnType<typeof setTimeout> | null = null;
|
let loadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -334,7 +334,7 @@ defineExpose({ reload: load });
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table v-loading="loading" :data="matches" stripe row-key="id" class="nested-match-table">
|
<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 type="index" :index="(i: number) => (matchPage - 1) * matchPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||||
<el-table-column :label="t('match.col.matchup')" min-width="180">
|
<el-table-column :label="t('match.col.matchup')" min-width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="matchup-link">{{ matchTitle(row) }}</span>
|
<span class="matchup-link">{{ matchTitle(row) }}</span>
|
||||||
|
|||||||
@@ -198,8 +198,8 @@ function mapMarkets(detail: AdminMatchDetail) {
|
|||||||
lineValue: m.lineValue,
|
lineValue: m.lineValue,
|
||||||
paramsJson: m.paramsJson ?? null,
|
paramsJson: m.paramsJson ?? null,
|
||||||
status: m.status,
|
status: m.status,
|
||||||
allowSingle: true,
|
allowSingle: m.allowSingle ?? true,
|
||||||
allowParlay: true,
|
allowParlay: m.allowParlay ?? true,
|
||||||
showOnPlayer: m.showOnPlayer ?? true,
|
showOnPlayer: m.showOnPlayer ?? true,
|
||||||
sortOrder: m.sortOrder ?? index,
|
sortOrder: m.sortOrder ?? index,
|
||||||
promoLabel: m.promoLabel ?? '',
|
promoLabel: m.promoLabel ?? '',
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface PlayerRow {
|
|||||||
availableBalance: string;
|
availableBalance: string;
|
||||||
frozenBalance: string;
|
frozenBalance: string;
|
||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
|
isOnline?: boolean;
|
||||||
betCount: number;
|
betCount: number;
|
||||||
totalStake: string;
|
totalStake: string;
|
||||||
totalReturn: string;
|
totalReturn: string;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "visible_menus" VARCHAR(1000);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "player_messages" (
|
||||||
|
"id" BIGSERIAL NOT NULL,
|
||||||
|
"user_id" BIGINT NOT NULL,
|
||||||
|
"type" VARCHAR(32) NOT NULL,
|
||||||
|
"title" VARCHAR(256) NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"payload" JSONB,
|
||||||
|
"read_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "player_messages_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "player_messages_user_id_created_at_idx" ON "player_messages"("user_id", "created_at" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "player_messages_user_id_read_at_idx" ON "player_messages"("user_id", "read_at");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "player_messages" ADD CONSTRAINT "player_messages_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -23,6 +23,7 @@ model User {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
visibleMenus String? @map("visible_menus") @db.VarChar(1000)
|
||||||
|
|
||||||
auth UserAuth?
|
auth UserAuth?
|
||||||
wallet Wallet?
|
wallet Wallet?
|
||||||
@@ -31,6 +32,7 @@ model User {
|
|||||||
bets Bet[]
|
bets Bet[]
|
||||||
preferences UserPreference?
|
preferences UserPreference?
|
||||||
depositOrders DepositOrder[] @relation("PlayerDepositOrders")
|
depositOrders DepositOrder[] @relation("PlayerDepositOrders")
|
||||||
|
playerMessages PlayerMessage[]
|
||||||
|
|
||||||
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
|
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
|
||||||
children User[] @relation("UserHierarchy")
|
children User[] @relation("UserHierarchy")
|
||||||
@@ -813,6 +815,23 @@ model DepositOrderAuditLog {
|
|||||||
@@map("deposit_order_audit_logs")
|
@@map("deposit_order_audit_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PlayerMessage {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
userId BigInt @map("user_id")
|
||||||
|
type String @db.VarChar(32)
|
||||||
|
title String @db.VarChar(256)
|
||||||
|
body String @db.Text
|
||||||
|
payload Json?
|
||||||
|
readAt DateTime? @map("read_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, createdAt(sort: Desc)])
|
||||||
|
@@index([userId, readAt])
|
||||||
|
@@map("player_messages")
|
||||||
|
}
|
||||||
|
|
||||||
// ============ System Config & Audit ============
|
// ============ System Config & Audit ============
|
||||||
|
|
||||||
model SystemConfig {
|
model SystemConfig {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
|
import { PresenceService } from '../../domains/presence/presence.service';
|
||||||
import { Decimal } from '@prisma/client/runtime/library';
|
import { Decimal } from '@prisma/client/runtime/library';
|
||||||
|
|
||||||
function dec(v: Decimal | null | undefined) {
|
function dec(v: Decimal | null | undefined) {
|
||||||
@@ -12,7 +13,10 @@ function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminDashboardService {
|
export class AdminDashboardService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private presence: PresenceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async getOverview() {
|
async getOverview() {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -60,6 +64,7 @@ export class AdminDashboardService {
|
|||||||
walletAgg,
|
walletAgg,
|
||||||
recentBets,
|
recentBets,
|
||||||
recentPlayers,
|
recentPlayers,
|
||||||
|
playersOnlineNow,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.prisma.bet.aggregate({
|
this.prisma.bet.aggregate({
|
||||||
where: { placedAt: { gte: today } },
|
where: { placedAt: { gte: today } },
|
||||||
@@ -125,6 +130,7 @@ export class AdminDashboardService {
|
|||||||
parent: { select: { username: true } },
|
parent: { select: { username: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
this.presence.getOnlineCount(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const matchByStatus: Record<string, number> = {};
|
const matchByStatus: Record<string, number> = {};
|
||||||
@@ -164,6 +170,7 @@ export class AdminDashboardService {
|
|||||||
playersActive: playerActive,
|
playersActive: playerActive,
|
||||||
playersSuspended: playerSuspended,
|
playersSuspended: playerSuspended,
|
||||||
playersDirect: playerDirect,
|
playersDirect: playerDirect,
|
||||||
|
playersOnlineNow,
|
||||||
agentsTotal: agentProfiles._count._all,
|
agentsTotal: agentProfiles._count._all,
|
||||||
agentsActive,
|
agentsActive,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import { P } from './admin-permissions';
|
|||||||
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
|
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
|
||||||
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
|
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
|
||||||
import { DepositService } from '../../domains/deposit/deposit.service';
|
import { DepositService } from '../../domains/deposit/deposit.service';
|
||||||
|
import { PlayerMessagesService } from '../../domains/player-messages/player-messages.service';
|
||||||
|
import { PresenceService } from '../../domains/presence/presence.service';
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@@ -272,6 +274,10 @@ class CreateStaffDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
roleCode!: string;
|
roleCode!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
visibleMenus?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UpdateStaffDto {
|
class UpdateStaffDto {
|
||||||
@@ -287,6 +293,10 @@ class UpdateStaffDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
visibleMenus?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResetPlayerPasswordDto {
|
class ResetPlayerPasswordDto {
|
||||||
@@ -1046,6 +1056,10 @@ class CreateContentDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
endTime?: string | null;
|
endTime?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
notifyInbox?: boolean;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
translations!: ContentTranslationDto[];
|
translations!: ContentTranslationDto[];
|
||||||
}
|
}
|
||||||
@@ -1085,6 +1099,16 @@ class ContentStatusDto {
|
|||||||
status!: string;
|
status!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class InboxNotifySettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
inboxEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
deposit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
class CashbackPreviewDto {
|
class CashbackPreviewDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
periodStart!: string;
|
periodStart!: string;
|
||||||
@@ -1209,9 +1233,18 @@ export class AdminController {
|
|||||||
private databaseReset: DatabaseResetService,
|
private databaseReset: DatabaseResetService,
|
||||||
private smokeTests: SmokeTestService,
|
private smokeTests: SmokeTestService,
|
||||||
private depositService: DepositService,
|
private depositService: DepositService,
|
||||||
|
private playerMessages: PlayerMessagesService,
|
||||||
private staff: AdminStaffService,
|
private staff: AdminStaffService,
|
||||||
|
private presence: PresenceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Get('presence/online-count')
|
||||||
|
@RequirePermissions(P.usersView)
|
||||||
|
async getOnlinePlayerCount() {
|
||||||
|
const count = await this.presence.getOnlineCount();
|
||||||
|
return jsonResponse({ count, asOf: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
@RequirePermissions(P.reports)
|
@RequirePermissions(P.reports)
|
||||||
async getDashboard() {
|
async getDashboard() {
|
||||||
@@ -1500,6 +1533,23 @@ export class AdminController {
|
|||||||
return jsonResponse(updated);
|
return jsonResponse(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete('staff/:id')
|
||||||
|
@RequirePermissions(P.settings)
|
||||||
|
async deleteStaff(
|
||||||
|
@CurrentUser('id') operatorId: bigint,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
await this.staff.deleteStaff(BigInt(id), operatorId);
|
||||||
|
await this.audit.log({
|
||||||
|
operatorId,
|
||||||
|
operatorType: 'ADMIN',
|
||||||
|
action: 'DELETE_STAFF',
|
||||||
|
module: 'STAFF',
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
return jsonResponse({ deleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('users/:id')
|
@Delete('users/:id')
|
||||||
@RequirePermissions(P.usersCreate)
|
@RequirePermissions(P.usersCreate)
|
||||||
async deletePlayer(
|
async deletePlayer(
|
||||||
@@ -3104,6 +3154,20 @@ export class AdminController {
|
|||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('contents/inbox-notify-settings')
|
||||||
|
@RequirePermissions(P.content, P.reports)
|
||||||
|
async getInboxNotifySettings() {
|
||||||
|
const settings = await this.systemConfig.getInboxNotifySettings();
|
||||||
|
return jsonResponse(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('contents/inbox-notify-settings')
|
||||||
|
@RequirePermissions(P.content)
|
||||||
|
async updateInboxNotifySettings(@Body() dto: InboxNotifySettingsDto) {
|
||||||
|
const settings = await this.systemConfig.updateInboxNotifySettings(dto);
|
||||||
|
return jsonResponse(settings);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('contents')
|
@Get('contents')
|
||||||
@RequirePermissions(P.content, P.reports)
|
@RequirePermissions(P.content, P.reports)
|
||||||
async listContents(
|
async listContents(
|
||||||
@@ -3128,8 +3192,34 @@ export class AdminController {
|
|||||||
@Post('contents')
|
@Post('contents')
|
||||||
@RequirePermissions(P.content)
|
@RequirePermissions(P.content)
|
||||||
async createContent(@Body() dto: CreateContentDto) {
|
async createContent(@Body() dto: CreateContentDto) {
|
||||||
const item = await this.content.create(dto);
|
const { notifyInbox, ...createDto } = dto;
|
||||||
return jsonResponse(item);
|
const item = await this.content.create(createDto);
|
||||||
|
let notifiedCount: number | undefined;
|
||||||
|
if (notifyInbox && (createDto.status ?? 'DRAFT') === 'ACTIVE') {
|
||||||
|
const inboxEnabled = await this.systemConfig.getInboxFeatureEnabled();
|
||||||
|
if (inboxEnabled) {
|
||||||
|
const translations = createDto.translations.map((tr) => ({
|
||||||
|
locale: tr.locale,
|
||||||
|
title: tr.title,
|
||||||
|
body: tr.body,
|
||||||
|
}));
|
||||||
|
if (createDto.contentType === 'BANNER') {
|
||||||
|
notifiedCount = await this.playerMessages.broadcastBannerPromotion({
|
||||||
|
contentId: BigInt(item.id),
|
||||||
|
translations,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
createDto.contentType === 'NOTICE' ||
|
||||||
|
createDto.contentType === 'TICKER'
|
||||||
|
) {
|
||||||
|
notifiedCount = await this.playerMessages.broadcastAnnouncementPromotion({
|
||||||
|
contentId: BigInt(item.id),
|
||||||
|
translations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jsonResponse({ ...item, notifiedCount });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('contents/:id')
|
@Put('contents/:id')
|
||||||
@@ -3349,6 +3439,13 @@ export class AdminController {
|
|||||||
return jsonResponse(result);
|
return jsonResponse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('deposit-orders/pending-count')
|
||||||
|
@RequirePermissions(P.depositReview)
|
||||||
|
async depositPendingCount() {
|
||||||
|
const count = await this.depositService.countPendingDepositOrders();
|
||||||
|
return jsonResponse({ count });
|
||||||
|
}
|
||||||
|
|
||||||
@Get('deposit-orders/:id/audit-logs')
|
@Get('deposit-orders/:id/audit-logs')
|
||||||
@RequirePermissions(P.depositReview)
|
@RequirePermissions(P.depositReview)
|
||||||
async depositOrderAuditLogs(@Param('id') id: string) {
|
async depositOrderAuditLogs(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { BetsModule } from '../../domains/betting/bets.module';
|
|||||||
import { DatabaseModule } from '../../infrastructure/database/database.module';
|
import { DatabaseModule } from '../../infrastructure/database/database.module';
|
||||||
import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test.module';
|
import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test.module';
|
||||||
import { DepositModule } from '../../domains/deposit/deposit.module';
|
import { DepositModule } from '../../domains/deposit/deposit.module';
|
||||||
|
import { PlayerMessagesModule } from '../../domains/player-messages/player-messages.module';
|
||||||
|
import { PresenceModule } from '../../domains/presence/presence.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -31,6 +33,8 @@ import { DepositModule } from '../../domains/deposit/deposit.module';
|
|||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
SmokeTestModule,
|
SmokeTestModule,
|
||||||
DepositModule,
|
DepositModule,
|
||||||
|
PlayerMessagesModule,
|
||||||
|
PresenceModule,
|
||||||
],
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [AdminDashboardService, PermissionsGuard],
|
providers: [AdminDashboardService, PermissionsGuard],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Patch,
|
Patch,
|
||||||
|
Delete,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
@@ -30,6 +31,8 @@ import { BetsService } from '../../domains/betting/bets.service';
|
|||||||
import { ContentService } from '../../domains/operations/content/content.service';
|
import { ContentService } from '../../domains/operations/content/content.service';
|
||||||
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
|
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
|
||||||
import { DepositService } from '../../domains/deposit/deposit.service';
|
import { DepositService } from '../../domains/deposit/deposit.service';
|
||||||
|
import { PlayerMessagesService } from '../../domains/player-messages/player-messages.service';
|
||||||
|
import { PresenceService } from '../../domains/presence/presence.service';
|
||||||
import { isInLocalTodayMatchWindow } from '@thebet365/shared';
|
import { isInLocalTodayMatchWindow } from '@thebet365/shared';
|
||||||
import { IsString, IsNumber, IsArray, ValidateNested, Min, IsOptional } from 'class-validator';
|
import { IsString, IsNumber, IsArray, ValidateNested, Min, IsOptional } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
@@ -120,8 +123,16 @@ export class PlayerController {
|
|||||||
private cashback: CashbackService,
|
private cashback: CashbackService,
|
||||||
private systemConfig: SystemConfigService,
|
private systemConfig: SystemConfigService,
|
||||||
private deposit: DepositService,
|
private deposit: DepositService,
|
||||||
|
private playerMessages: PlayerMessagesService,
|
||||||
|
private presence: PresenceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Post('presence/ping')
|
||||||
|
async presencePing(@CurrentUser('id') userId: bigint) {
|
||||||
|
await this.presence.touch(userId);
|
||||||
|
return jsonResponse({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
private async formatPlayerProfile(user: NonNullable<Awaited<ReturnType<UsersService['findById']>>>) {
|
private async formatPlayerProfile(user: NonNullable<Awaited<ReturnType<UsersService['findById']>>>) {
|
||||||
const accountSettings = await this.systemConfig.getPlayerAccountSettings();
|
const accountSettings = await this.systemConfig.getPlayerAccountSettings();
|
||||||
const prefs = user.preferences;
|
const prefs = user.preferences;
|
||||||
@@ -175,10 +186,12 @@ export class PlayerController {
|
|||||||
@Headers('x-time-zone') headerTimeZone?: string,
|
@Headers('x-time-zone') headerTimeZone?: string,
|
||||||
) {
|
) {
|
||||||
const locale = userLocale || headerLocale || 'zh-CN';
|
const locale = userLocale || headerLocale || 'zh-CN';
|
||||||
const [banners, announcements, allMatches] = await Promise.all([
|
const [banners, announcements, allMatches, upcomingMatches, inboxEnabled] = await Promise.all([
|
||||||
this.content.listActive('BANNER', locale),
|
this.content.listActive('BANNER', locale),
|
||||||
this.content.listActiveAnnouncements(locale),
|
this.content.listActiveAnnouncements(locale),
|
||||||
this.matches.listPublished(locale, undefined, { includeMarkets: false }),
|
this.matches.listPublished(locale, undefined, { includeMarkets: false }),
|
||||||
|
this.matches.listUpcomingPublished(locale),
|
||||||
|
this.systemConfig.getInboxFeatureEnabled(),
|
||||||
]);
|
]);
|
||||||
const timeZone = safeTimeZone(headerTimeZone);
|
const timeZone = safeTimeZone(headerTimeZone);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -198,6 +211,8 @@ export class PlayerController {
|
|||||||
notices: announcements,
|
notices: announcements,
|
||||||
hotMatches,
|
hotMatches,
|
||||||
todayMatches,
|
todayMatches,
|
||||||
|
upcomingMatches,
|
||||||
|
inboxEnabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +260,20 @@ export class PlayerController {
|
|||||||
return jsonResponse(match);
|
return jsonResponse(match);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('selections/odds')
|
||||||
|
async selectionOdds(@Query('ids') ids?: string) {
|
||||||
|
if (!ids?.trim()) throw appBadRequest('SELECTION_NOT_FOUND');
|
||||||
|
const parsed = ids
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => BigInt(s));
|
||||||
|
if (!parsed.length || parsed.length > 20) throw appBadRequest('PARLAY_LEG_COUNT_INVALID');
|
||||||
|
const items = await this.matches.getSelectionsOdds(parsed);
|
||||||
|
return jsonResponse({ items });
|
||||||
|
}
|
||||||
|
|
||||||
@Post('bets/single')
|
@Post('bets/single')
|
||||||
async singleBet(@CurrentUser('id') userId: bigint, @CurrentUser('parentId') parentId: bigint, @Body() dto: SingleBetDto) {
|
async singleBet(@CurrentUser('id') userId: bigint, @CurrentUser('parentId') parentId: bigint, @Body() dto: SingleBetDto) {
|
||||||
const bet = await this.bets.placeSingleBet(
|
const bet = await this.bets.placeSingleBet(
|
||||||
@@ -449,4 +478,56 @@ export class PlayerController {
|
|||||||
createdAt: order!.createdAt,
|
createdAt: order!.createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Messages / Inbox ============
|
||||||
|
|
||||||
|
@Get('messages')
|
||||||
|
async listMessages(
|
||||||
|
@CurrentUser('id') userId: bigint,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
const result = await this.playerMessages.listForPlayer(
|
||||||
|
userId,
|
||||||
|
page ? parseInt(page, 10) : 1,
|
||||||
|
pageSize ? parseInt(pageSize, 10) : 20,
|
||||||
|
);
|
||||||
|
return jsonResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('messages/unread-count')
|
||||||
|
async messageUnreadCount(@CurrentUser('id') userId: bigint) {
|
||||||
|
const result = await this.playerMessages.getUnreadCount(userId);
|
||||||
|
return jsonResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('messages/:id')
|
||||||
|
async messageDetail(@CurrentUser('id') userId: bigint, @Param('id') id: string) {
|
||||||
|
const message = await this.playerMessages.getForPlayer(userId, BigInt(id));
|
||||||
|
return jsonResponse(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('messages/read-all')
|
||||||
|
async markAllMessagesRead(@CurrentUser('id') userId: bigint) {
|
||||||
|
const result = await this.playerMessages.markAllRead(userId);
|
||||||
|
return jsonResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('messages/:id/read')
|
||||||
|
async markMessageRead(@CurrentUser('id') userId: bigint, @Param('id') id: string) {
|
||||||
|
const message = await this.playerMessages.markRead(userId, BigInt(id));
|
||||||
|
return jsonResponse(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('messages')
|
||||||
|
async deleteAllMessages(@CurrentUser('id') userId: bigint) {
|
||||||
|
const result = await this.playerMessages.deleteAllForPlayer(userId);
|
||||||
|
return jsonResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('messages/:id')
|
||||||
|
async deleteMessage(@CurrentUser('id') userId: bigint, @Param('id') id: string) {
|
||||||
|
const result = await this.playerMessages.deleteForPlayer(userId, BigInt(id));
|
||||||
|
return jsonResponse(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { BetsModule } from '../../domains/betting/bets.module';
|
|||||||
import { ContentModule } from '../../domains/operations/content/content.module';
|
import { ContentModule } from '../../domains/operations/content/content.module';
|
||||||
import { CashbackModule } from '../../domains/operations/cashback/cashback.module';
|
import { CashbackModule } from '../../domains/operations/cashback/cashback.module';
|
||||||
import { DepositModule } from '../../domains/deposit/deposit.module';
|
import { DepositModule } from '../../domains/deposit/deposit.module';
|
||||||
|
import { PlayerMessagesModule } from '../../domains/player-messages/player-messages.module';
|
||||||
|
import { PresenceModule } from '../../domains/presence/presence.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule, DepositModule],
|
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule, DepositModule, PlayerMessagesModule, PresenceModule],
|
||||||
controllers: [PlayerController],
|
controllers: [PlayerController],
|
||||||
})
|
})
|
||||||
export class PlayerModule {}
|
export class PlayerModule {}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
jest.mock('@thebet365/shared', () => ({
|
jest.mock('@thebet365/shared', () => ({
|
||||||
isPreMatchKickoff: jest.fn(() => true),
|
isPreMatchKickoff: jest.fn(() => true),
|
||||||
PARLAY_MARKET_TYPES: [],
|
PARLAY_MARKET_TYPES: [],
|
||||||
resolveTranslationFallback: jest.fn(
|
resolveTranslationFallback: jest.fn((translations: Map<string, string> | Record<string, string>, locale: string) => {
|
||||||
(translations: Map<string, string>, locale: string) =>
|
const get = (key: string) =>
|
||||||
translations.get(locale) ?? translations.get('zh-CN') ?? translations.get('en-US') ?? null,
|
translations instanceof Map ? translations.get(key) : translations[key];
|
||||||
),
|
return get(locale) ?? get('zh-CN') ?? get('en-US') ?? null;
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { MatchesService } from './matches.service';
|
import { MatchesService } from './matches.service';
|
||||||
@@ -18,6 +19,7 @@ describe('MatchesService publish/unpublish', () => {
|
|||||||
match: { findFirst: jest.Mock; update: jest.Mock };
|
match: { findFirst: jest.Mock; update: jest.Mock };
|
||||||
entityTranslation: { findFirst: jest.Mock; upsert: jest.Mock };
|
entityTranslation: { findFirst: jest.Mock; upsert: jest.Mock };
|
||||||
settlementBatch: { deleteMany: jest.Mock };
|
settlementBatch: { deleteMany: jest.Mock };
|
||||||
|
marketSelection: { findMany: jest.Mock };
|
||||||
};
|
};
|
||||||
let outright: { syncWithLeaguePublished: jest.Mock };
|
let outright: { syncWithLeaguePublished: jest.Mock };
|
||||||
let matchBetStats: { betStatsForMatches: jest.Mock };
|
let matchBetStats: { betStatsForMatches: jest.Mock };
|
||||||
@@ -36,6 +38,7 @@ describe('MatchesService publish/unpublish', () => {
|
|||||||
},
|
},
|
||||||
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null), upsert: jest.fn().mockResolvedValue({}) },
|
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null), upsert: jest.fn().mockResolvedValue({}) },
|
||||||
settlementBatch: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
settlementBatch: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
||||||
|
marketSelection: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
};
|
};
|
||||||
outright = { syncWithLeaguePublished: jest.fn().mockResolvedValue(undefined) };
|
outright = { syncWithLeaguePublished: jest.fn().mockResolvedValue(undefined) };
|
||||||
matchBetStats = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
matchBetStats = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
||||||
@@ -124,3 +127,177 @@ describe('MatchesService publish/unpublish', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MatchesService getSelectionsOdds', () => {
|
||||||
|
const selectionId = BigInt(100);
|
||||||
|
const matchId = BigInt(10);
|
||||||
|
|
||||||
|
let prisma: { marketSelection: { findMany: jest.Mock } };
|
||||||
|
let service: MatchesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
marketSelection: { findMany: jest.fn() },
|
||||||
|
};
|
||||||
|
service = new MatchesService(
|
||||||
|
prisma as never,
|
||||||
|
{ syncWithLeaguePublished: jest.fn() } as never,
|
||||||
|
{ betStatsForMatches: jest.fn() } as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns odds snapshot for requested selections', async () => {
|
||||||
|
prisma.marketSelection.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: selectionId,
|
||||||
|
odds: { toString: () => '1.95' },
|
||||||
|
oddsVersion: BigInt(3),
|
||||||
|
status: 'OPEN',
|
||||||
|
market: {
|
||||||
|
status: 'OPEN',
|
||||||
|
showOnPlayer: true,
|
||||||
|
match: { id: matchId, status: 'PUBLISHED' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getSelectionsOdds([selectionId]);
|
||||||
|
|
||||||
|
expect(prisma.marketSelection.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: { in: [selectionId] } },
|
||||||
|
include: { market: { include: { match: true } } },
|
||||||
|
});
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: '100',
|
||||||
|
odds: '1.95',
|
||||||
|
oddsVersion: '3',
|
||||||
|
status: 'OPEN',
|
||||||
|
marketStatus: 'OPEN',
|
||||||
|
marketShowOnPlayer: true,
|
||||||
|
matchStatus: 'PUBLISHED',
|
||||||
|
matchId: '10',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when ids not found', async () => {
|
||||||
|
prisma.marketSelection.findMany.mockResolvedValue([]);
|
||||||
|
const result = await service.getSelectionsOdds([BigInt(999)]);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty id list', async () => {
|
||||||
|
prisma.marketSelection.findMany.mockResolvedValue([]);
|
||||||
|
const result = await service.getSelectionsOdds([]);
|
||||||
|
expect(prisma.marketSelection.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: { in: [] } },
|
||||||
|
include: { market: { include: { match: true } } },
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MatchesService listUpcomingPublished', () => {
|
||||||
|
const leagueId = BigInt(1);
|
||||||
|
const homeTeamId = BigInt(2);
|
||||||
|
const awayTeamId = BigInt(3);
|
||||||
|
const matchId = BigInt(10);
|
||||||
|
|
||||||
|
let prisma: {
|
||||||
|
match: { findMany: jest.Mock };
|
||||||
|
entityTranslation: { findMany: jest.Mock };
|
||||||
|
};
|
||||||
|
let service: MatchesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date('2026-06-17T12:00:00.000Z'));
|
||||||
|
|
||||||
|
prisma = {
|
||||||
|
match: { findMany: jest.fn() },
|
||||||
|
entityTranslation: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([{ locale: 'zh-CN', fieldName: 'name', value: '测试' }]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
service = new MatchesService(
|
||||||
|
prisma as never,
|
||||||
|
{ syncWithLeaguePublished: jest.fn() } as never,
|
||||||
|
{ betStatsForMatches: jest.fn().mockResolvedValue(new Map()) } as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries published matches within the next 3 days sorted by startTime', async () => {
|
||||||
|
const now = new Date('2026-06-17T12:00:00.000Z');
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setDate(end.getDate() + 3);
|
||||||
|
|
||||||
|
prisma.match.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: matchId,
|
||||||
|
leagueId,
|
||||||
|
homeTeamId,
|
||||||
|
awayTeamId,
|
||||||
|
startTime: new Date('2026-06-18T15:00:00.000Z'),
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
isHot: false,
|
||||||
|
displayOrder: 0,
|
||||||
|
matchName: null,
|
||||||
|
stage: null,
|
||||||
|
groupName: null,
|
||||||
|
league: { logoUrl: null },
|
||||||
|
homeTeam: { code: 'HME', logoUrl: null },
|
||||||
|
awayTeam: { code: 'AWY', logoUrl: null },
|
||||||
|
score: null,
|
||||||
|
markets: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.listUpcomingPublished('zh-CN');
|
||||||
|
|
||||||
|
expect(prisma.match.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
|
||||||
|
isOutright: false,
|
||||||
|
sportType: 'FOOTBALL',
|
||||||
|
deletedAt: null,
|
||||||
|
startTime: { gte: now, lte: end },
|
||||||
|
}),
|
||||||
|
orderBy: [{ startTime: 'asc' }, { displayOrder: 'asc' }],
|
||||||
|
take: 50,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: '10',
|
||||||
|
startTime: '2026-06-18T15:00:00.000Z',
|
||||||
|
isHot: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects custom limit and days options', async () => {
|
||||||
|
prisma.match.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.listUpcomingPublished('en-US', { limit: 20, days: 5 });
|
||||||
|
|
||||||
|
const now = new Date('2026-06-17T12:00:00.000Z');
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setDate(end.getDate() + 5);
|
||||||
|
|
||||||
|
expect(prisma.match.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
startTime: { gte: now, lte: end },
|
||||||
|
}),
|
||||||
|
take: 20,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -871,6 +871,8 @@ export class MatchesService {
|
|||||||
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
|
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
|
||||||
paramsJson: m.paramsJson ?? null,
|
paramsJson: m.paramsJson ?? null,
|
||||||
status: m.status,
|
status: m.status,
|
||||||
|
allowSingle: m.allowSingle,
|
||||||
|
allowParlay: m.allowParlay,
|
||||||
showOnPlayer: m.showOnPlayer,
|
showOnPlayer: m.showOnPlayer,
|
||||||
promoLabel: m.promoLabel ?? '',
|
promoLabel: m.promoLabel ?? '',
|
||||||
promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n),
|
promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n),
|
||||||
@@ -1459,6 +1461,42 @@ export class MatchesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 未来 N 天内开赛的已发布赛事(按开赛时间升序,不限 isHot) */
|
||||||
|
async listUpcomingPublished(
|
||||||
|
locale = 'en-US',
|
||||||
|
options?: { limit?: number; days?: number },
|
||||||
|
) {
|
||||||
|
const limit = options?.limit ?? 50;
|
||||||
|
const days = options?.days ?? 3;
|
||||||
|
const now = new Date();
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setDate(end.getDate() + days);
|
||||||
|
|
||||||
|
const matches = await this.prisma.match.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
|
||||||
|
isOutright: false,
|
||||||
|
sportType: 'FOOTBALL',
|
||||||
|
deletedAt: null,
|
||||||
|
startTime: { gte: now, lte: end },
|
||||||
|
league: { isActive: true, deletedAt: null },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
league: true,
|
||||||
|
homeTeam: true,
|
||||||
|
awayTeam: true,
|
||||||
|
score: true,
|
||||||
|
markets: this.playerMarketStatusInclude,
|
||||||
|
},
|
||||||
|
orderBy: [{ startTime: 'asc' }, { displayOrder: 'asc' }],
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: true })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getMatchDetail(matchId: bigint, locale = 'en-US') {
|
async getMatchDetail(matchId: bigint, locale = 'en-US') {
|
||||||
const match = await this.prisma.match.findFirst({
|
const match = await this.prisma.match.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -1481,6 +1519,24 @@ export class MatchesService {
|
|||||||
return this.enrichMatch(match, locale);
|
return this.enrichMatch(match, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSelectionsOdds(ids: bigint[]) {
|
||||||
|
const selections = await this.prisma.marketSelection.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
include: { market: { include: { match: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return selections.map((sel) => ({
|
||||||
|
id: sel.id.toString(),
|
||||||
|
odds: sel.odds.toString(),
|
||||||
|
oddsVersion: sel.oddsVersion.toString(),
|
||||||
|
status: sel.status,
|
||||||
|
marketStatus: sel.market.status,
|
||||||
|
marketShowOnPlayer: sel.market.showOnPlayer,
|
||||||
|
matchStatus: sel.market.match.status,
|
||||||
|
matchId: sel.market.match.id.toString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async listOutrights(locale = 'en-US') {
|
async listOutrights(locale = 'en-US') {
|
||||||
try {
|
try {
|
||||||
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false });
|
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false });
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
|
|||||||
import { DepositService } from './deposit.service';
|
import { DepositService } from './deposit.service';
|
||||||
import { WalletModule } from '../ledger/wallet.module';
|
import { WalletModule } from '../ledger/wallet.module';
|
||||||
import { AgentsModule } from '../agent/agents.module';
|
import { AgentsModule } from '../agent/agents.module';
|
||||||
|
import { PlayerMessagesModule } from '../player-messages/player-messages.module';
|
||||||
|
import { SystemConfigModule } from '../../shared/config/system-config.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WalletModule, AgentsModule],
|
imports: [WalletModule, AgentsModule, PlayerMessagesModule, SystemConfigModule],
|
||||||
providers: [DepositService],
|
providers: [DepositService],
|
||||||
exports: [DepositService],
|
exports: [DepositService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe('DepositService', () => {
|
|||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
findFirst: jest.fn(),
|
findFirst: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
},
|
},
|
||||||
agentProfile: {
|
agentProfile: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
@@ -38,12 +39,28 @@ describe('DepositService', () => {
|
|||||||
const credit = {
|
const credit = {
|
||||||
recalculateUsedCredit: jest.fn(),
|
recalculateUsedCredit: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const playerMessages = {
|
||||||
|
createDepositApprovedMessage: jest.fn(),
|
||||||
|
createDepositRejectedMessage: jest.fn(),
|
||||||
|
};
|
||||||
|
const systemConfig = {
|
||||||
|
getInboxNotifySettings: jest.fn().mockResolvedValue({
|
||||||
|
inboxEnabled: true,
|
||||||
|
deposit: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
let service: DepositService;
|
let service: DepositService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
service = new DepositService(prisma as never, funds as never, credit as never);
|
service = new DepositService(
|
||||||
|
prisma as never,
|
||||||
|
funds as never,
|
||||||
|
credit as never,
|
||||||
|
playerMessages as never,
|
||||||
|
systemConfig as never,
|
||||||
|
);
|
||||||
tx.$queryRaw.mockResolvedValue([{ id: 1n }]);
|
tx.$queryRaw.mockResolvedValue([{ id: 1n }]);
|
||||||
tx.depositOrder.findUnique.mockResolvedValue({
|
tx.depositOrder.findUnique.mockResolvedValue({
|
||||||
id: 1n,
|
id: 1n,
|
||||||
@@ -57,8 +74,11 @@ describe('DepositService', () => {
|
|||||||
});
|
});
|
||||||
tx.bet.findMany.mockResolvedValue([]);
|
tx.bet.findMany.mockResolvedValue([]);
|
||||||
tx.user.findFirst.mockResolvedValue(null);
|
tx.user.findFirst.mockResolvedValue(null);
|
||||||
|
tx.user.findUnique.mockResolvedValue({ locale: 'en-US' });
|
||||||
tx.agentProfile.findUnique.mockResolvedValue(null);
|
tx.agentProfile.findUnique.mockResolvedValue(null);
|
||||||
credit.recalculateUsedCredit.mockResolvedValue(undefined);
|
credit.recalculateUsedCredit.mockResolvedValue(undefined);
|
||||||
|
playerMessages.createDepositApprovedMessage.mockResolvedValue({});
|
||||||
|
playerMessages.createDepositRejectedMessage.mockResolvedValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('posts approved deposits as player wallet transactions and refreshes parent credit', async () => {
|
it('posts approved deposits as player wallet transactions and refreshes parent credit', async () => {
|
||||||
@@ -131,7 +151,7 @@ describe('DepositService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses approval cycle key when revoking a funded deposit for re-review', async () => {
|
it('uses approval cycle key when revoking a funded deposit for re-review', async () => {
|
||||||
const reviewedAt = new Date('2026-06-15T14:08:48.000Z');
|
const reviewedAt = new Date();
|
||||||
tx.depositOrder.findUnique.mockResolvedValue({
|
tx.depositOrder.findUnique.mockResolvedValue({
|
||||||
id: 1n,
|
id: 1n,
|
||||||
orderNo: 'DEP-1',
|
orderNo: 'DEP-1',
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { FundsPostingService } from '../ledger/funds-posting.service';
|
|||||||
import { AgentCreditService } from '../agent/agent-credit.service';
|
import { AgentCreditService } from '../agent/agent-credit.service';
|
||||||
import { appBadRequest } from '../../shared/common/app-error';
|
import { appBadRequest } from '../../shared/common/app-error';
|
||||||
import { deleteUploadFileByUrl } from '../../shared/uploads/delete-upload-file';
|
import { deleteUploadFileByUrl } from '../../shared/uploads/delete-upload-file';
|
||||||
|
import { PlayerMessagesService } from '../player-messages/player-messages.service';
|
||||||
|
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||||
|
|
||||||
function generateOrderNo(): string {
|
function generateOrderNo(): string {
|
||||||
const ts = Date.now().toString(36).toUpperCase();
|
const ts = Date.now().toString(36).toUpperCase();
|
||||||
@@ -49,6 +51,8 @@ export class DepositService {
|
|||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private funds: FundsPostingService,
|
private funds: FundsPostingService,
|
||||||
private credit: AgentCreditService,
|
private credit: AgentCreditService,
|
||||||
|
private playerMessages: PlayerMessagesService,
|
||||||
|
private systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ============ Payment Methods (Admin CRUD) ============
|
// ============ Payment Methods (Admin CRUD) ============
|
||||||
@@ -254,6 +258,14 @@ export class DepositService {
|
|||||||
|
|
||||||
// ============ Deposit Orders ============
|
// ============ Deposit Orders ============
|
||||||
|
|
||||||
|
private async getPlayerLocale(playerId: bigint, tx: Prisma.TransactionClient | PrismaService = this.prisma) {
|
||||||
|
const user = await tx.user.findUnique({
|
||||||
|
where: { id: playerId },
|
||||||
|
select: { locale: true },
|
||||||
|
});
|
||||||
|
return user?.locale ?? 'en-US';
|
||||||
|
}
|
||||||
|
|
||||||
private async recordDepositAudit(
|
private async recordDepositAudit(
|
||||||
client: AuditLogWriter,
|
client: AuditLogWriter,
|
||||||
data: {
|
data: {
|
||||||
@@ -670,6 +682,10 @@ export class DepositService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async countPendingDepositOrders(): Promise<number> {
|
||||||
|
return this.prisma.depositOrder.count({ where: { status: 'PENDING' } });
|
||||||
|
}
|
||||||
|
|
||||||
async approveDepositOrder(
|
async approveDepositOrder(
|
||||||
orderId: bigint,
|
orderId: bigint,
|
||||||
operatorId: bigint,
|
operatorId: bigint,
|
||||||
@@ -722,6 +738,22 @@ export class DepositService {
|
|||||||
await this.credit.recalculateUsedCredit(parentAgentId, tx);
|
await this.credit.recalculateUsedCredit(parentAgentId, tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playerLocale = await this.getPlayerLocale(order.playerId, tx);
|
||||||
|
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
|
||||||
|
if (inboxNotify.inboxEnabled && inboxNotify.deposit) {
|
||||||
|
await this.playerMessages.createDepositApprovedMessage(
|
||||||
|
order.playerId,
|
||||||
|
{
|
||||||
|
depositOrderId: orderId,
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
amount: order.amount.toString(),
|
||||||
|
approvedAmount: creditAmount.toString(),
|
||||||
|
locale: playerLocale,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -753,6 +785,18 @@ export class DepositService {
|
|||||||
remark: reason,
|
remark: reason,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const playerLocale = await this.getPlayerLocale(order.playerId);
|
||||||
|
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
|
||||||
|
if (inboxNotify.inboxEnabled && inboxNotify.deposit) {
|
||||||
|
await this.playerMessages.createDepositRejectedMessage(order.playerId, {
|
||||||
|
depositOrderId: orderId,
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
amount: order.amount.toString(),
|
||||||
|
rejectReason: reason,
|
||||||
|
locale: playerLocale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,11 +61,12 @@ export class AdminStaffService {
|
|||||||
roleName: u.adminRole?.role?.name ?? null,
|
roleName: u.adminRole?.role?.name ?? null,
|
||||||
lastLoginAt: u.auth?.lastLoginAt ?? null,
|
lastLoginAt: u.auth?.lastLoginAt ?? null,
|
||||||
createdAt: u.createdAt,
|
createdAt: u.createdAt,
|
||||||
|
visibleMenus: u.visibleMenus,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createStaff(data: { username: string; password: string; roleCode: string }) {
|
async createStaff(data: { username: string; password: string; roleCode: string; visibleMenus?: string }) {
|
||||||
const username = data.username.trim();
|
const username = data.username.trim();
|
||||||
if (!username) throw appBadRequest('USERNAME_REQUIRED');
|
if (!username) throw appBadRequest('USERNAME_REQUIRED');
|
||||||
if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
|
if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
|
||||||
@@ -86,6 +87,7 @@ export class AdminStaffService {
|
|||||||
userType: 'ADMIN',
|
userType: 'ADMIN',
|
||||||
auth: { create: { passwordHash: hash } },
|
auth: { create: { passwordHash: hash } },
|
||||||
adminRole: { create: { roleId: role.id } },
|
adminRole: { create: { roleId: role.id } },
|
||||||
|
visibleMenus: data.visibleMenus,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
adminRole: { include: { role: { select: { code: true, name: true } } } },
|
adminRole: { include: { role: { select: { code: true, name: true } } } },
|
||||||
@@ -99,12 +101,13 @@ export class AdminStaffService {
|
|||||||
status: user.status,
|
status: user.status,
|
||||||
role: user.adminRole?.role?.code ?? null,
|
role: user.adminRole?.role?.code ?? null,
|
||||||
roleName: user.adminRole?.role?.name ?? null,
|
roleName: user.adminRole?.role?.name ?? null,
|
||||||
|
visibleMenus: user.visibleMenus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStaff(
|
async updateStaff(
|
||||||
staffId: bigint,
|
staffId: bigint,
|
||||||
data: { status?: string; roleCode?: string; password?: string },
|
data: { status?: string; roleCode?: string; password?: string; visibleMenus?: string },
|
||||||
) {
|
) {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { id: staffId, userType: 'ADMIN', deletedAt: null },
|
where: { id: staffId, userType: 'ADMIN', deletedAt: null },
|
||||||
@@ -140,6 +143,13 @@ export class AdminStaffService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.visibleMenus !== undefined) {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: staffId },
|
||||||
|
data: { visibleMenus: data.visibleMenus },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let plainPassword: string | undefined;
|
let plainPassword: string | undefined;
|
||||||
if (data.password !== undefined) {
|
if (data.password !== undefined) {
|
||||||
if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
|
if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
|
||||||
@@ -163,10 +173,40 @@ export class AdminStaffService {
|
|||||||
status: refreshed!.status,
|
status: refreshed!.status,
|
||||||
role: refreshed!.adminRole?.role?.code ?? null,
|
role: refreshed!.adminRole?.role?.code ?? null,
|
||||||
roleName: refreshed!.adminRole?.role?.name ?? null,
|
roleName: refreshed!.adminRole?.role?.name ?? null,
|
||||||
|
visibleMenus: refreshed!.visibleMenus,
|
||||||
...(plainPassword ? { password: plainPassword } : {}),
|
...(plainPassword ? { password: plainPassword } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteStaff(staffId: bigint, operatorId?: bigint) {
|
||||||
|
if (operatorId && staffId === operatorId) {
|
||||||
|
throw appBadRequest('CANNOT_DELETE_SELF');
|
||||||
|
}
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: { id: staffId, userType: 'ADMIN', deletedAt: null },
|
||||||
|
include: { adminRole: { include: { role: true } } },
|
||||||
|
});
|
||||||
|
if (!user) throw appNotFound('STAFF_NOT_FOUND');
|
||||||
|
|
||||||
|
if (user.adminRole?.role?.code === 'SUPER_ADMIN') {
|
||||||
|
const superAdminCount = await this.prisma.user.count({
|
||||||
|
where: {
|
||||||
|
userType: 'ADMIN',
|
||||||
|
deletedAt: null,
|
||||||
|
adminRole: { role: { code: 'SUPER_ADMIN' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (superAdminCount <= 1) {
|
||||||
|
throw appBadRequest('CANNOT_DELETE_LAST_SUPER_ADMIN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.user.update({
|
||||||
|
where: { id: staffId },
|
||||||
|
data: { deletedAt: new Date(), status: 'DISABLED' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async resetPlayerPassword(playerId: bigint, password?: string) {
|
async resetPlayerPassword(playerId: bigint, password?: string) {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { JwtAuthGuard } from './guards';
|
|||||||
import { jsonResponse } from '../../shared/common/filters';
|
import { jsonResponse } from '../../shared/common/filters';
|
||||||
import { getClientIp } from '../../shared/common/client-ip.util';
|
import { getClientIp } from '../../shared/common/client-ip.util';
|
||||||
|
|
||||||
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -17,6 +19,7 @@ export class AuthController {
|
|||||||
private auth: AuthService,
|
private auth: AuthService,
|
||||||
private invites: InvitesService,
|
private invites: InvitesService,
|
||||||
private systemConfig: SystemConfigService,
|
private systemConfig: SystemConfigService,
|
||||||
|
private prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@@ -189,6 +192,15 @@ export class AuthController {
|
|||||||
inviteCode = (await this.auth.getInviteInfo(userId)).inviteCode;
|
inviteCode = (await this.auth.getInviteInfo(userId)).inviteCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let visibleMenus: string | null = null;
|
||||||
|
if (userType === 'ADMIN') {
|
||||||
|
const userDb = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { visibleMenus: true },
|
||||||
|
});
|
||||||
|
visibleMenus = userDb?.visibleMenus ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
id: userId.toString(),
|
id: userId.toString(),
|
||||||
username,
|
username,
|
||||||
@@ -200,6 +212,7 @@ export class AuthController {
|
|||||||
maxAgentLevel,
|
maxAgentLevel,
|
||||||
canManageSubAgents,
|
canManageSubAgents,
|
||||||
inviteCode,
|
inviteCode,
|
||||||
|
visibleMenus,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ export class AuthService {
|
|||||||
locale: user.locale,
|
locale: user.locale,
|
||||||
role: user.adminRole?.role?.code,
|
role: user.adminRole?.role?.code,
|
||||||
agentLevel: user.userType === 'AGENT' ? user.agentLevel : null,
|
agentLevel: user.userType === 'AGENT' ? user.agentLevel : null,
|
||||||
|
visibleMenus: user.visibleMenus,
|
||||||
...(adminPermissions ? { permissions: adminPermissions } : {}),
|
...(adminPermissions ? { permissions: adminPermissions } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { UsersService } from './users.service';
|
|||||||
import { AdminStaffService } from './admin-staff.service';
|
import { AdminStaffService } from './admin-staff.service';
|
||||||
import { AgentsModule } from '../agent/agents.module';
|
import { AgentsModule } from '../agent/agents.module';
|
||||||
import { CashbackModule } from '../operations/cashback/cashback.module';
|
import { CashbackModule } from '../operations/cashback/cashback.module';
|
||||||
|
import { PresenceModule } from '../presence/presence.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AgentsModule, CashbackModule],
|
imports: [AgentsModule, CashbackModule, PresenceModule],
|
||||||
providers: [UsersService, AdminStaffService],
|
providers: [UsersService, AdminStaffService],
|
||||||
exports: [UsersService, AdminStaffService],
|
exports: [UsersService, AdminStaffService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../../shared/prisma/prisma.service';
|
|||||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||||
import { AgentsService } from '../agent/agents.service';
|
import { AgentsService } from '../agent/agents.service';
|
||||||
import { CashbackService } from '../operations/cashback/cashback.service';
|
import { CashbackService } from '../operations/cashback/cashback.service';
|
||||||
|
import { PresenceService } from '../presence/presence.service';
|
||||||
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
||||||
import { Decimal } from '@prisma/client/runtime/library';
|
import { Decimal } from '@prisma/client/runtime/library';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export class UsersService {
|
|||||||
private agents: AgentsService,
|
private agents: AgentsService,
|
||||||
private systemConfig: SystemConfigService,
|
private systemConfig: SystemConfigService,
|
||||||
private cashback: CashbackService,
|
private cashback: CashbackService,
|
||||||
|
private presence: PresenceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private buildAffiliationAgents(
|
private buildAffiliationAgents(
|
||||||
@@ -297,14 +299,19 @@ export class UsersService {
|
|||||||
|
|
||||||
const betMap = await this.loadBetStatsMap(rows.map((r) => r.id));
|
const betMap = await this.loadBetStatsMap(rows.map((r) => r.id));
|
||||||
const affiliationMap = await this.buildAffiliationChainMap(rows.map((r) => r.parentId));
|
const affiliationMap = await this.buildAffiliationChainMap(rows.map((r) => r.parentId));
|
||||||
|
const onlineSet = await this.presence.filterOnlineIds(rows.map((r) => r.id));
|
||||||
return {
|
return {
|
||||||
items: rows.map((u) =>
|
items: rows.map((u) => {
|
||||||
this.formatPlayerRow(
|
const row = this.formatPlayerRow(
|
||||||
u,
|
u,
|
||||||
betMap.get(u.id.toString()),
|
betMap.get(u.id.toString()),
|
||||||
u.parentId ? affiliationMap.get(u.parentId.toString()) : undefined,
|
u.parentId ? affiliationMap.get(u.parentId.toString()) : undefined,
|
||||||
),
|
);
|
||||||
),
|
return {
|
||||||
|
...row,
|
||||||
|
isOnline: onlineSet.has(row.id),
|
||||||
|
};
|
||||||
|
}),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -222,6 +222,9 @@ export class ContentService {
|
|||||||
id: item.id.toString(),
|
id: item.id.toString(),
|
||||||
contentType: item.contentType,
|
contentType: item.contentType,
|
||||||
sortOrder: item.sortOrder,
|
sortOrder: item.sortOrder,
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
linkType: item.linkType,
|
||||||
|
linkTarget: item.linkTarget,
|
||||||
translation: tr,
|
translation: tr,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -252,6 +255,7 @@ export class ContentService {
|
|||||||
id: item.id.toString(),
|
id: item.id.toString(),
|
||||||
contentType: item.contentType,
|
contentType: item.contentType,
|
||||||
sortOrder: item.sortOrder,
|
sortOrder: item.sortOrder,
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
linkType: item.linkType,
|
linkType: item.linkType,
|
||||||
linkTarget: item.linkTarget,
|
linkTarget: item.linkTarget,
|
||||||
translation: t,
|
translation: t,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlayerMessagesService } from './player-messages.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [PlayerMessagesService],
|
||||||
|
exports: [PlayerMessagesService],
|
||||||
|
})
|
||||||
|
export class PlayerMessagesModule {}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { PlayerMessagesService } from './player-messages.service';
|
||||||
|
|
||||||
|
describe('PlayerMessagesService', () => {
|
||||||
|
const prisma = {
|
||||||
|
playerMessage: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
deleteMany: jest.fn(),
|
||||||
|
createMany: jest.fn(),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: PlayerMessagesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
service = new PlayerMessagesService(prisma as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates localized deposit approved messages', async () => {
|
||||||
|
prisma.playerMessage.create.mockResolvedValue({
|
||||||
|
id: 1n,
|
||||||
|
type: 'DEPOSIT_APPROVED',
|
||||||
|
title: '充值已到账',
|
||||||
|
body: 'body',
|
||||||
|
payload: { orderNo: 'DEP-1' },
|
||||||
|
readAt: null,
|
||||||
|
createdAt: new Date('2026-06-17T10:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.createDepositApprovedMessage(7n, {
|
||||||
|
depositOrderId: 10n,
|
||||||
|
orderNo: 'DEP-1',
|
||||||
|
amount: '100',
|
||||||
|
approvedAmount: '100',
|
||||||
|
locale: 'zh-CN',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.playerMessage.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
userId: 7n,
|
||||||
|
type: 'DEPOSIT_APPROVED',
|
||||||
|
title: '充值已到账',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.type).toBe('DEPOSIT_APPROVED');
|
||||||
|
expect(result.isRead).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns paginated inbox with unread count', async () => {
|
||||||
|
prisma.playerMessage.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 2n,
|
||||||
|
type: 'DEPOSIT_REJECTED',
|
||||||
|
title: 'Deposit rejected',
|
||||||
|
body: 'Rejected',
|
||||||
|
payload: null,
|
||||||
|
readAt: null,
|
||||||
|
createdAt: new Date('2026-06-17T11:00:00.000Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
prisma.playerMessage.count.mockResolvedValueOnce(1).mockResolvedValueOnce(1);
|
||||||
|
|
||||||
|
const result = await service.listForPlayer(7n, 1, 20);
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.unreadCount).toBe(1);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a single message for the player', async () => {
|
||||||
|
prisma.playerMessage.findFirst.mockResolvedValue({
|
||||||
|
id: 3n,
|
||||||
|
type: 'DEPOSIT_APPROVED',
|
||||||
|
title: 'Deposit approved',
|
||||||
|
body: 'body',
|
||||||
|
payload: null,
|
||||||
|
readAt: null,
|
||||||
|
createdAt: new Date('2026-06-17T12:00:00.000Z'),
|
||||||
|
});
|
||||||
|
prisma.playerMessage.delete.mockResolvedValue({ id: 3n });
|
||||||
|
|
||||||
|
const result = await service.deleteForPlayer(7n, 3n);
|
||||||
|
|
||||||
|
expect(prisma.playerMessage.delete).toHaveBeenCalledWith({ where: { id: 3n } });
|
||||||
|
expect(result).toEqual({ deleted: true, wasUnread: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes all messages for the player', async () => {
|
||||||
|
prisma.playerMessage.deleteMany.mockResolvedValue({ count: 4 });
|
||||||
|
|
||||||
|
const result = await service.deleteAllForPlayer(7n);
|
||||||
|
|
||||||
|
expect(prisma.playerMessage.deleteMany).toHaveBeenCalledWith({ where: { userId: 7n } });
|
||||||
|
expect(result).toEqual({ deleted: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcasts banner promotion inbox messages to active players', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([
|
||||||
|
{ id: 10n, locale: 'zh-CN', preferences: { locale: 'zh-CN' } },
|
||||||
|
{ id: 11n, locale: 'en-US', preferences: null },
|
||||||
|
]);
|
||||||
|
prisma.playerMessage.createMany.mockResolvedValue({ count: 2 });
|
||||||
|
|
||||||
|
const count = await service.broadcastBannerPromotion({
|
||||||
|
contentId: 99n,
|
||||||
|
translations: [
|
||||||
|
{ locale: 'zh-CN', title: '夏季活动', body: '<p>限时优惠</p>' },
|
||||||
|
{ locale: 'en-US', title: 'Summer promo', body: '' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(count).toBe(2);
|
||||||
|
expect(prisma.playerMessage.createMany).toHaveBeenCalledWith({
|
||||||
|
data: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 10n,
|
||||||
|
type: 'BANNER_PROMO',
|
||||||
|
title: '夏季活动',
|
||||||
|
body: '限时优惠',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 11n,
|
||||||
|
type: 'BANNER_PROMO',
|
||||||
|
title: 'Summer promo',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcasts announcement promotion inbox messages to active players', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([
|
||||||
|
{ id: 10n, locale: 'zh-CN', preferences: { locale: 'zh-CN' } },
|
||||||
|
]);
|
||||||
|
prisma.playerMessage.createMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
const count = await service.broadcastAnnouncementPromotion({
|
||||||
|
contentId: 100n,
|
||||||
|
translations: [{ locale: 'zh-CN', title: '维护通知', body: '系统维护中' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(count).toBe(1);
|
||||||
|
expect(prisma.playerMessage.createMany).toHaveBeenCalledWith({
|
||||||
|
data: [
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 10n,
|
||||||
|
type: 'ANNOUNCEMENT_PROMO',
|
||||||
|
title: '维护通知',
|
||||||
|
body: '系统维护中',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
437
apps/api/src/domains/player-messages/player-messages.service.ts
Normal file
437
apps/api/src/domains/player-messages/player-messages.service.ts
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
|
import { appNotFound } from '../../shared/common/app-error';
|
||||||
|
|
||||||
|
export type PlayerMessageType =
|
||||||
|
| 'DEPOSIT_APPROVED'
|
||||||
|
| 'DEPOSIT_REJECTED'
|
||||||
|
| 'BANNER_PROMO'
|
||||||
|
| 'ANNOUNCEMENT_PROMO';
|
||||||
|
|
||||||
|
export type DepositMessagePayload = {
|
||||||
|
depositOrderId: string;
|
||||||
|
orderNo: string;
|
||||||
|
amount: string;
|
||||||
|
approvedAmount?: string | null;
|
||||||
|
rejectReason?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BannerPromoPayload = {
|
||||||
|
contentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContentTranslationLike = {
|
||||||
|
locale: string;
|
||||||
|
title?: string | null;
|
||||||
|
body?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUPPORTED_LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
|
||||||
|
|
||||||
|
const BANNER_PROMO_DEFAULT_TITLE: Record<string, string> = {
|
||||||
|
'zh-CN': '新推广活动',
|
||||||
|
'en-US': 'New promotion',
|
||||||
|
'ms-MY': 'Promosi baharu',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANNOUNCEMENT_PROMO_DEFAULT_TITLE: Record<string, string> = {
|
||||||
|
'zh-CN': '新公告',
|
||||||
|
'en-US': 'New announcement',
|
||||||
|
'ms-MY': 'Pengumuman baharu',
|
||||||
|
};
|
||||||
|
|
||||||
|
type MessageTemplate = {
|
||||||
|
title: string;
|
||||||
|
body: (payload: DepositMessagePayload) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MESSAGE_TEMPLATES: Record<
|
||||||
|
Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO'>,
|
||||||
|
Record<string, MessageTemplate>
|
||||||
|
> = {
|
||||||
|
DEPOSIT_APPROVED: {
|
||||||
|
'zh-CN': {
|
||||||
|
title: '充值已到账',
|
||||||
|
body: (p) =>
|
||||||
|
`您的充值订单 ${p.orderNo} 已审核通过,申请金额 ${p.amount},到账金额 ${p.approvedAmount ?? p.amount}。`,
|
||||||
|
},
|
||||||
|
'en-US': {
|
||||||
|
title: 'Deposit approved',
|
||||||
|
body: (p) =>
|
||||||
|
`Your deposit order ${p.orderNo} has been approved. Requested ${p.amount}, credited ${p.approvedAmount ?? p.amount}.`,
|
||||||
|
},
|
||||||
|
'ms-MY': {
|
||||||
|
title: 'Deposit diluluskan',
|
||||||
|
body: (p) =>
|
||||||
|
`Pesanan deposit ${p.orderNo} telah diluluskan. Diminta ${p.amount}, dikreditkan ${p.approvedAmount ?? p.amount}.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DEPOSIT_REJECTED: {
|
||||||
|
'zh-CN': {
|
||||||
|
title: '充值未通过',
|
||||||
|
body: (p) => {
|
||||||
|
const reason = p.rejectReason?.trim();
|
||||||
|
return reason
|
||||||
|
? `您的充值订单 ${p.orderNo}(${p.amount})未通过审核。原因:${reason}`
|
||||||
|
: `您的充值订单 ${p.orderNo}(${p.amount})未通过审核。`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'en-US': {
|
||||||
|
title: 'Deposit rejected',
|
||||||
|
body: (p) => {
|
||||||
|
const reason = p.rejectReason?.trim();
|
||||||
|
return reason
|
||||||
|
? `Your deposit order ${p.orderNo} (${p.amount}) was rejected. Reason: ${reason}`
|
||||||
|
: `Your deposit order ${p.orderNo} (${p.amount}) was rejected.`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'ms-MY': {
|
||||||
|
title: 'Deposit ditolak',
|
||||||
|
body: (p) => {
|
||||||
|
const reason = p.rejectReason?.trim();
|
||||||
|
return reason
|
||||||
|
? `Pesanan deposit ${p.orderNo} (${p.amount}) ditolak. Sebab: ${reason}`
|
||||||
|
: `Pesanan deposit ${p.orderNo} (${p.amount}) ditolak.`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveLocale(locale?: string | null): string {
|
||||||
|
const value = locale?.trim();
|
||||||
|
if (value && SUPPORTED_LOCALES.includes(value as (typeof SUPPORTED_LOCALES)[number])) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 'en-US';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickContentTranslation<T extends { locale: string }>(
|
||||||
|
translations: T[],
|
||||||
|
locale: string,
|
||||||
|
): T | undefined {
|
||||||
|
const chain = [locale, 'en-US', 'zh-CN', 'ms-MY'];
|
||||||
|
for (const loc of chain) {
|
||||||
|
const hit = translations.find((tr) => tr.locale === loc);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return translations[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(value: string): string {
|
||||||
|
return value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBannerPromoFallbackBody(locale: string, title: string): string {
|
||||||
|
if (locale === 'zh-CN') return `「${title}」已上线,请到首页查看详情。`;
|
||||||
|
if (locale === 'ms-MY') return `"${title}" kini tersedia. Lihat butiran di halaman utama.`;
|
||||||
|
return `"${title}" is now live. View details on the home page.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnnouncementPromoFallbackBody(locale: string, title: string): string {
|
||||||
|
if (locale === 'zh-CN') return `公告「${title}」已发布,请及时查看。`;
|
||||||
|
if (locale === 'ms-MY') return `Pengumuman "${title}" telah diterbitkan. Sila semak.`;
|
||||||
|
return `Announcement "${title}" is published. Please check it out.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBannerPromoMessage(
|
||||||
|
locale: string | null | undefined,
|
||||||
|
contentId: bigint,
|
||||||
|
translations: ContentTranslationLike[],
|
||||||
|
) {
|
||||||
|
const resolvedLocale = resolveLocale(locale);
|
||||||
|
const tr = pickContentTranslation(translations, resolvedLocale);
|
||||||
|
const title =
|
||||||
|
tr?.title?.trim() ||
|
||||||
|
BANNER_PROMO_DEFAULT_TITLE[resolvedLocale] ||
|
||||||
|
BANNER_PROMO_DEFAULT_TITLE['en-US'];
|
||||||
|
const rawBody = tr?.body?.trim() ? stripHtml(tr.body) : '';
|
||||||
|
const body = rawBody || buildBannerPromoFallbackBody(resolvedLocale, title);
|
||||||
|
const payload: BannerPromoPayload = { contentId: contentId.toString() };
|
||||||
|
return {
|
||||||
|
type: 'BANNER_PROMO' as const,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnnouncementPromoMessage(
|
||||||
|
locale: string | null | undefined,
|
||||||
|
contentId: bigint,
|
||||||
|
translations: ContentTranslationLike[],
|
||||||
|
) {
|
||||||
|
const resolvedLocale = resolveLocale(locale);
|
||||||
|
const tr = pickContentTranslation(translations, resolvedLocale);
|
||||||
|
const title =
|
||||||
|
tr?.title?.trim() ||
|
||||||
|
(tr?.body?.trim() ? tr.body.trim().slice(0, 40) : '') ||
|
||||||
|
ANNOUNCEMENT_PROMO_DEFAULT_TITLE[resolvedLocale] ||
|
||||||
|
ANNOUNCEMENT_PROMO_DEFAULT_TITLE['en-US'];
|
||||||
|
const rawBody = tr?.body?.trim() ?? '';
|
||||||
|
const body = rawBody || buildAnnouncementPromoFallbackBody(resolvedLocale, title);
|
||||||
|
const payload: BannerPromoPayload = { contentId: contentId.toString() };
|
||||||
|
return {
|
||||||
|
type: 'ANNOUNCEMENT_PROMO' as const,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMessageRow(row: {
|
||||||
|
id: bigint;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
payload: Prisma.JsonValue;
|
||||||
|
readAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: row.id.toString(),
|
||||||
|
type: row.type,
|
||||||
|
title: row.title,
|
||||||
|
body: row.body,
|
||||||
|
payload: row.payload ?? null,
|
||||||
|
readAt: row.readAt?.toISOString() ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
isRead: row.readAt != null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlayerMessagesService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
private buildDepositMessage(
|
||||||
|
type: Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO'>,
|
||||||
|
locale: string | null | undefined,
|
||||||
|
payload: DepositMessagePayload,
|
||||||
|
) {
|
||||||
|
const resolvedLocale = resolveLocale(locale);
|
||||||
|
const templates = MESSAGE_TEMPLATES[type];
|
||||||
|
const template = templates[resolvedLocale] ?? templates['en-US'];
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
title: template.title,
|
||||||
|
body: template.body(payload),
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDepositApprovedMessage(
|
||||||
|
userId: bigint,
|
||||||
|
data: {
|
||||||
|
depositOrderId: bigint;
|
||||||
|
orderNo: string;
|
||||||
|
amount: string;
|
||||||
|
approvedAmount: string;
|
||||||
|
locale?: string | null;
|
||||||
|
},
|
||||||
|
client: Prisma.TransactionClient | PrismaService = this.prisma,
|
||||||
|
) {
|
||||||
|
const payload: DepositMessagePayload = {
|
||||||
|
depositOrderId: data.depositOrderId.toString(),
|
||||||
|
orderNo: data.orderNo,
|
||||||
|
amount: data.amount,
|
||||||
|
approvedAmount: data.approvedAmount,
|
||||||
|
};
|
||||||
|
const content = this.buildDepositMessage('DEPOSIT_APPROVED', data.locale, payload);
|
||||||
|
const row = await client.playerMessage.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: content.type,
|
||||||
|
title: content.title,
|
||||||
|
body: content.body,
|
||||||
|
payload: content.payload as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapMessageRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDepositRejectedMessage(
|
||||||
|
userId: bigint,
|
||||||
|
data: {
|
||||||
|
depositOrderId: bigint;
|
||||||
|
orderNo: string;
|
||||||
|
amount: string;
|
||||||
|
rejectReason?: string | null;
|
||||||
|
locale?: string | null;
|
||||||
|
},
|
||||||
|
client: Prisma.TransactionClient | PrismaService = this.prisma,
|
||||||
|
) {
|
||||||
|
const payload: DepositMessagePayload = {
|
||||||
|
depositOrderId: data.depositOrderId.toString(),
|
||||||
|
orderNo: data.orderNo,
|
||||||
|
amount: data.amount,
|
||||||
|
rejectReason: data.rejectReason ?? null,
|
||||||
|
};
|
||||||
|
const content = this.buildDepositMessage('DEPOSIT_REJECTED', data.locale, payload);
|
||||||
|
const row = await client.playerMessage.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: content.type,
|
||||||
|
title: content.title,
|
||||||
|
body: content.body,
|
||||||
|
payload: content.payload as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapMessageRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async broadcastBannerPromotion(data: {
|
||||||
|
contentId: bigint;
|
||||||
|
translations: ContentTranslationLike[];
|
||||||
|
}) {
|
||||||
|
const players = await this.prisma.user.findMany({
|
||||||
|
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
locale: true,
|
||||||
|
preferences: { select: { locale: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!players.length) return 0;
|
||||||
|
|
||||||
|
const rows = players.map((player) => {
|
||||||
|
const playerLocale = player.preferences?.locale ?? player.locale;
|
||||||
|
const content = buildBannerPromoMessage(
|
||||||
|
playerLocale,
|
||||||
|
data.contentId,
|
||||||
|
data.translations,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
userId: player.id,
|
||||||
|
type: content.type,
|
||||||
|
title: content.title,
|
||||||
|
body: content.body,
|
||||||
|
payload: content.payload as Prisma.InputJsonValue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const batchSize = 200;
|
||||||
|
for (let i = 0; i < rows.length; i += batchSize) {
|
||||||
|
await this.prisma.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async broadcastAnnouncementPromotion(data: {
|
||||||
|
contentId: bigint;
|
||||||
|
translations: ContentTranslationLike[];
|
||||||
|
}) {
|
||||||
|
const players = await this.prisma.user.findMany({
|
||||||
|
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
locale: true,
|
||||||
|
preferences: { select: { locale: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!players.length) return 0;
|
||||||
|
|
||||||
|
const rows = players.map((player) => {
|
||||||
|
const playerLocale = player.preferences?.locale ?? player.locale;
|
||||||
|
const content = buildAnnouncementPromoMessage(
|
||||||
|
playerLocale,
|
||||||
|
data.contentId,
|
||||||
|
data.translations,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
userId: player.id,
|
||||||
|
type: content.type,
|
||||||
|
title: content.title,
|
||||||
|
body: content.body,
|
||||||
|
payload: content.payload as Prisma.InputJsonValue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const batchSize = 200;
|
||||||
|
for (let i = 0; i < rows.length; i += batchSize) {
|
||||||
|
await this.prisma.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForPlayer(userId: bigint, page = 1, pageSize = 20) {
|
||||||
|
const safePage = Math.max(1, page);
|
||||||
|
const safePageSize = Math.min(50, Math.max(1, pageSize));
|
||||||
|
const skip = (safePage - 1) * safePageSize;
|
||||||
|
const where = { userId };
|
||||||
|
|
||||||
|
const [rows, total, unreadCount] = await Promise.all([
|
||||||
|
this.prisma.playerMessage.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: safePageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.playerMessage.count({ where }),
|
||||||
|
this.prisma.playerMessage.count({ where: { ...where, readAt: null } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map(mapMessageRow),
|
||||||
|
total,
|
||||||
|
unreadCount,
|
||||||
|
page: safePage,
|
||||||
|
pageSize: safePageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getForPlayer(userId: bigint, messageId: bigint) {
|
||||||
|
const row = await this.prisma.playerMessage.findFirst({
|
||||||
|
where: { id: messageId, userId },
|
||||||
|
});
|
||||||
|
if (!row) throw appNotFound('MESSAGE_NOT_FOUND');
|
||||||
|
return mapMessageRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(userId: bigint, messageId: bigint) {
|
||||||
|
const row = await this.prisma.playerMessage.findFirst({
|
||||||
|
where: { id: messageId, userId },
|
||||||
|
});
|
||||||
|
if (!row) throw appNotFound('MESSAGE_NOT_FOUND');
|
||||||
|
if (row.readAt) return mapMessageRow(row);
|
||||||
|
|
||||||
|
const updated = await this.prisma.playerMessage.update({
|
||||||
|
where: { id: messageId },
|
||||||
|
data: { readAt: new Date() },
|
||||||
|
});
|
||||||
|
return mapMessageRow(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllRead(userId: bigint) {
|
||||||
|
const result = await this.prisma.playerMessage.updateMany({
|
||||||
|
where: { userId, readAt: null },
|
||||||
|
data: { readAt: new Date() },
|
||||||
|
});
|
||||||
|
return { updated: result.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnreadCount(userId: bigint) {
|
||||||
|
const unreadCount = await this.prisma.playerMessage.count({
|
||||||
|
where: { userId, readAt: null },
|
||||||
|
});
|
||||||
|
return { unreadCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForPlayer(userId: bigint, messageId: bigint) {
|
||||||
|
const row = await this.prisma.playerMessage.findFirst({
|
||||||
|
where: { id: messageId, userId },
|
||||||
|
});
|
||||||
|
if (!row) throw appNotFound('MESSAGE_NOT_FOUND');
|
||||||
|
await this.prisma.playerMessage.delete({ where: { id: messageId } });
|
||||||
|
return { deleted: true, wasUnread: row.readAt == null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAllForPlayer(userId: bigint) {
|
||||||
|
const result = await this.prisma.playerMessage.deleteMany({ where: { userId } });
|
||||||
|
return { deleted: result.count };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/domains/presence/presence.module.ts
Normal file
10
apps/api/src/domains/presence/presence.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RedisModule } from '../../shared/redis/redis.module';
|
||||||
|
import { PresenceService } from './presence.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [RedisModule],
|
||||||
|
providers: [PresenceService],
|
||||||
|
exports: [PresenceService],
|
||||||
|
})
|
||||||
|
export class PresenceModule {}
|
||||||
70
apps/api/src/domains/presence/presence.service.spec.ts
Normal file
70
apps/api/src/domains/presence/presence.service.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { PresenceService } from './presence.service';
|
||||||
|
|
||||||
|
describe('PresenceService', () => {
|
||||||
|
const pipeline = {
|
||||||
|
exists: jest.fn().mockReturnThis(),
|
||||||
|
exec: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const redis = {
|
||||||
|
set: jest.fn(),
|
||||||
|
exists: jest.fn(),
|
||||||
|
raw: {
|
||||||
|
scan: jest.fn(),
|
||||||
|
pipeline: jest.fn(() => pipeline),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: PresenceService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
service = new PresenceService(redis as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('touches player key with 120s TTL', async () => {
|
||||||
|
await service.touch(42n);
|
||||||
|
expect(redis.set).toHaveBeenCalledWith('presence:player:42', '1', 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks single player online status', async () => {
|
||||||
|
redis.exists.mockResolvedValue(true);
|
||||||
|
await expect(service.isOnline(7n)).resolves.toBe(true);
|
||||||
|
expect(redis.exists).toHaveBeenCalledWith('presence:player:7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts online keys via SCAN', async () => {
|
||||||
|
redis.raw.scan
|
||||||
|
.mockResolvedValueOnce(['1', ['presence:player:1', 'presence:player:2']])
|
||||||
|
.mockResolvedValueOnce(['0', ['presence:player:3']]);
|
||||||
|
|
||||||
|
await expect(service.getOnlineCount()).resolves.toBe(3);
|
||||||
|
expect(redis.raw.scan).toHaveBeenCalledWith(
|
||||||
|
'0',
|
||||||
|
'MATCH',
|
||||||
|
'presence:player:*',
|
||||||
|
'COUNT',
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters online ids with pipeline exists', async () => {
|
||||||
|
pipeline.exec.mockResolvedValue([
|
||||||
|
[null, 1],
|
||||||
|
[null, 0],
|
||||||
|
[null, 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.filterOnlineIds([10n, 20n, 30n]);
|
||||||
|
expect(result).toEqual(new Set(['10', '30']));
|
||||||
|
expect(pipeline.exists).toHaveBeenCalledTimes(3);
|
||||||
|
expect(pipeline.exists).toHaveBeenNthCalledWith(1, 'presence:player:10');
|
||||||
|
expect(pipeline.exists).toHaveBeenNthCalledWith(2, 'presence:player:20');
|
||||||
|
expect(pipeline.exists).toHaveBeenNthCalledWith(3, 'presence:player:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty set when no ids provided', async () => {
|
||||||
|
await expect(service.filterOnlineIds([])).resolves.toEqual(new Set());
|
||||||
|
expect(redis.raw.pipeline).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
57
apps/api/src/domains/presence/presence.service.ts
Normal file
57
apps/api/src/domains/presence/presence.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { RedisService } from '../../shared/redis/redis.service';
|
||||||
|
|
||||||
|
const TTL_SECONDS = 120;
|
||||||
|
const KEY_PREFIX = 'presence:player:';
|
||||||
|
|
||||||
|
function keyFor(userId: bigint): string {
|
||||||
|
return `${KEY_PREFIX}${userId.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PresenceService {
|
||||||
|
constructor(private readonly redis: RedisService) {}
|
||||||
|
|
||||||
|
async touch(userId: bigint): Promise<void> {
|
||||||
|
await this.redis.set(keyFor(userId), '1', TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isOnline(userId: bigint): Promise<boolean> {
|
||||||
|
return this.redis.exists(keyFor(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOnlineCount(): Promise<number> {
|
||||||
|
let cursor = '0';
|
||||||
|
let count = 0;
|
||||||
|
do {
|
||||||
|
const [next, keys] = await this.redis.raw.scan(
|
||||||
|
cursor,
|
||||||
|
'MATCH',
|
||||||
|
`${KEY_PREFIX}*`,
|
||||||
|
'COUNT',
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
cursor = next;
|
||||||
|
count += keys.length;
|
||||||
|
} while (cursor !== '0');
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async filterOnlineIds(ids: bigint[]): Promise<Set<string>> {
|
||||||
|
const online = new Set<string>();
|
||||||
|
if (ids.length === 0) return online;
|
||||||
|
|
||||||
|
const pipeline = this.redis.raw.pipeline();
|
||||||
|
for (const id of ids) {
|
||||||
|
pipeline.exists(keyFor(id));
|
||||||
|
}
|
||||||
|
const results = await pipeline.exec();
|
||||||
|
ids.forEach((id, index) => {
|
||||||
|
const entry = results?.[index];
|
||||||
|
if (!entry) return;
|
||||||
|
const [err, value] = entry;
|
||||||
|
if (!err && value === 1) online.add(id.toString());
|
||||||
|
});
|
||||||
|
return online;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,17 @@ export const AGENT_MAX_LEVEL = 'agent.max_level';
|
|||||||
export const AGENT_DEFAULT_SUB_CREDIT_RATIO = 'agent.default_sub_credit_ratio';
|
export const AGENT_DEFAULT_SUB_CREDIT_RATIO = 'agent.default_sub_credit_ratio';
|
||||||
export const CASHBACK_PLATFORM_DIRECT_RATE = 'cashback.platform_direct_rate';
|
export const CASHBACK_PLATFORM_DIRECT_RATE = 'cashback.platform_direct_rate';
|
||||||
export const CASHBACK_ADMIN_INVITE_RATE = 'cashback.admin_invite_rate';
|
export const CASHBACK_ADMIN_INVITE_RATE = 'cashback.admin_invite_rate';
|
||||||
|
export const INBOX_NOTIFY_DEPOSIT = 'inbox.notify.deposit';
|
||||||
|
export const INBOX_FEATURE_ENABLED = 'inbox.feature_enabled';
|
||||||
|
export const INBOX_NOTIFY_BANNER = 'inbox.notify.banner';
|
||||||
|
export const INBOX_NOTIFY_ANNOUNCEMENT = 'inbox.notify.announcement';
|
||||||
|
|
||||||
|
export type InboxNotifySettings = {
|
||||||
|
/** 玩家端是否展示站内邮箱(关闭后入口直达客服) */
|
||||||
|
inboxEnabled: boolean;
|
||||||
|
/** 充值审核通过/拒绝时发送站内信 */
|
||||||
|
deposit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type PlatformDirectCashbackSettings = {
|
export type PlatformDirectCashbackSettings = {
|
||||||
/** 平台直属玩家默认返水比例(小数,0.01 = 1%) */
|
/** 平台直属玩家默认返水比例(小数,0.01 = 1%) */
|
||||||
@@ -222,4 +233,34 @@ export class SystemConfigService {
|
|||||||
}
|
}
|
||||||
return this.getPlatformDirectCashbackSettings();
|
return this.getPlatformDirectCashbackSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getInboxFeatureEnabled(): Promise<boolean> {
|
||||||
|
return this.getBoolean(INBOX_FEATURE_ENABLED, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInboxNotifySettings(): Promise<InboxNotifySettings> {
|
||||||
|
const [inboxEnabled, deposit] = await Promise.all([
|
||||||
|
this.getBoolean(INBOX_FEATURE_ENABLED, true),
|
||||||
|
this.getBoolean(INBOX_NOTIFY_DEPOSIT, true),
|
||||||
|
]);
|
||||||
|
return { inboxEnabled, deposit };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInboxNotifySettings(data: Partial<InboxNotifySettings>) {
|
||||||
|
if (data.inboxEnabled !== undefined) {
|
||||||
|
await this.setBoolean(
|
||||||
|
INBOX_FEATURE_ENABLED,
|
||||||
|
data.inboxEnabled,
|
||||||
|
'玩家端是否开启站内邮箱功能',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.deposit !== undefined) {
|
||||||
|
await this.setBoolean(
|
||||||
|
INBOX_NOTIFY_DEPOSIT,
|
||||||
|
data.deposit,
|
||||||
|
'充值审核结果是否通过站内邮箱通知玩家',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.getInboxNotifySettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#001A33" />
|
||||||
<link rel="icon" type="image/png" href="/logo.png" />
|
<link rel="icon" type="image/png" href="/logo.png" />
|
||||||
<link rel="apple-touch-icon" href="/logo.png" />
|
<link rel="apple-touch-icon" href="/logo.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<title>TheBet365</title>
|
<title>TheBet365</title>
|
||||||
<style>
|
<style>
|
||||||
html, body { margin: 0; padding: 0; height: 100%; background: #000; }
|
html, body { margin: 0; padding: 0; height: 100%; background: #001A33; }
|
||||||
#app-loading {
|
#app-loading {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #000;
|
background: #001A33;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
#app-loading svg {
|
#app-loading svg {
|
||||||
@@ -33,37 +33,8 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app-loading">
|
<div id="app-loading">
|
||||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<circle cx="24" cy="24" r="20" stroke="rgba(255,255,255,0.14)" stroke-width="3" fill="none" />
|
||||||
<linearGradient id="gs-track" x1="0%" y1="0%" x2="100%" y2="100%">
|
<circle cx="24" cy="24" r="20" stroke="rgba(255,255,255,0.72)" stroke-width="3" fill="none" stroke-linecap="round" stroke-dasharray="31.4 94.2" />
|
||||||
<stop offset="0%" stop-color="#D4AF37" stop-opacity="0.18" />
|
|
||||||
<stop offset="50%" stop-color="#F0D875" stop-opacity="0.25" />
|
|
||||||
<stop offset="100%" stop-color="#D4AF37" stop-opacity="0.18" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="gs-arc" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stop-color="#FFF4C8" />
|
|
||||||
<stop offset="22%" stop-color="#F0D875" />
|
|
||||||
<stop offset="50%" stop-color="#D4AF37" />
|
|
||||||
<stop offset="78%" stop-color="#B8942B" />
|
|
||||||
<stop offset="100%" stop-color="#8B6914" />
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="gs-glow">
|
|
||||||
<feGaussianBlur stdDeviation="1.2" result="blur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
<radialGradient id="gs-dot" cx="50%" cy="50%" r="50%">
|
|
||||||
<stop offset="0%" stop-color="#FFF4C8" />
|
|
||||||
<stop offset="60%" stop-color="#F0D875" />
|
|
||||||
<stop offset="100%" stop-color="#D4AF37" />
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
<circle cx="24" cy="24" r="20" stroke="url(#gs-track)" stroke-width="3" fill="none" />
|
|
||||||
<circle cx="24" cy="24" r="20" stroke="url(#gs-arc)" stroke-width="3" fill="none" stroke-linecap="round" stroke-dasharray="31.4 94.2" filter="url(#gs-glow)" />
|
|
||||||
<circle cx="24" cy="4" r="2.5" fill="url(#gs-dot)" filter="url(#gs-glow)">
|
|
||||||
<animate attributeName="r" values="2.5;3.2;2.5" dur="1.2s" repeatCount="indefinite" />
|
|
||||||
</circle>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
<stop offset="1" stop-color="#000000"/>
|
<stop offset="1" stop-color="#000000"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1">
|
||||||
<stop stop-color="#E8C96A"/>
|
<stop stop-color="#FFFFFF"/>
|
||||||
<stop offset="1" stop-color="#D4AF37"/>
|
<stop offset="1" stop-color="rgba(255,255,255,0.72)"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="750" height="280" fill="url(#bg)"/>
|
<rect width="750" height="280" fill="url(#bg)"/>
|
||||||
<circle cx="620" cy="80" r="120" fill="#D4AF37" opacity="0.06"/>
|
<circle cx="620" cy="80" r="120" fill="#FFFFFF" opacity="0.06"/>
|
||||||
<circle cx="680" cy="200" r="80" fill="#D4AF37" opacity="0.08"/>
|
<circle cx="680" cy="200" r="80" fill="#FFFFFF" opacity="0.08"/>
|
||||||
<text x="40" y="110" font-family="Arial, sans-serif" font-size="42" font-weight="800" fill="url(#gold)">TheBet365</text>
|
<text x="40" y="110" font-family="Arial, sans-serif" font-size="42" font-weight="800" fill="url(#gold)">TheBet365</text>
|
||||||
<text x="40" y="160" font-family="Arial, sans-serif" font-size="22" fill="#8E8E93">足球赛事火热进行中</text>
|
<text x="40" y="160" font-family="Arial, sans-serif" font-size="22" fill="#8E8E93">足球赛事火热进行中</text>
|
||||||
<rect x="40" y="190" width="140" height="4" fill="#D4AF37" opacity="0.5" rx="2"/>
|
<rect x="40" y="190" width="140" height="4" fill="#FFFFFF" opacity="0.5" rx="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1018 B After Width: | Height: | Size: 1.0 KiB |
@@ -1,8 +1,8 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" fill="none" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" fill="none" aria-hidden="true">
|
||||||
<circle cx="60" cy="60" r="48" stroke="#D4AF37" stroke-width="4" opacity="0.5"/>
|
<circle cx="60" cy="60" r="48" stroke="#FFFFFF" stroke-width="4" opacity="0.5"/>
|
||||||
<circle cx="60" cy="60" r="22" stroke="#F0D875" stroke-width="4"/>
|
<circle cx="60" cy="60" r="22" stroke="rgba(255,255,255,0.72)" stroke-width="4"/>
|
||||||
<path d="M60 8 L68 32 L60 44 L52 32 Z" fill="#D4AF37"/>
|
<path d="M60 8 L68 32 L60 44 L52 32 Z" fill="#FFFFFF"/>
|
||||||
<path d="M60 112 L68 88 L60 76 L52 88 Z" fill="#D4AF37"/>
|
<path d="M60 112 L68 88 L60 76 L52 88 Z" fill="#FFFFFF"/>
|
||||||
<path d="M8 60 L32 52 L44 60 L32 68 Z" fill="#D4AF37"/>
|
<path d="M8 60 L32 52 L44 60 L32 68 Z" fill="#FFFFFF"/>
|
||||||
<path d="M112 60 L88 68 L76 60 L88 52 Z" fill="#D4AF37"/>
|
<path d="M112 60 L88 68 L76 60 L88 52 Z" fill="#FFFFFF"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 489 B After Width: | Height: | Size: 504 B |
@@ -1,23 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{ items: string[]; embedded?: boolean }>(),
|
defineProps<{ items: string[]; targetId?: string; embedded?: boolean }>(),
|
||||||
{ embedded: false },
|
{ embedded: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const detailTo = computed(() =>
|
||||||
|
props.targetId ? `/announcements/${props.targetId}` : '/announcements',
|
||||||
|
);
|
||||||
|
|
||||||
const text = computed(() => {
|
const text = computed(() => {
|
||||||
const list = props.items.filter(Boolean);
|
const list = props.items.filter(Boolean);
|
||||||
if (!list.length) return '';
|
if (!list.length) return '';
|
||||||
return list.join(' ◆ ');
|
return list.join(' ◆ ');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function goDetail() {
|
||||||
|
void router.push(detailTo.value);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="text" class="marquee-bar" :class="{ embedded }">
|
<button
|
||||||
|
v-if="text"
|
||||||
|
type="button"
|
||||||
|
class="marquee-bar"
|
||||||
|
:class="{ embedded }"
|
||||||
|
@click="goDetail"
|
||||||
|
>
|
||||||
<span class="marquee-badge">{{ t('home.announcement_badge') }}</span>
|
<span class="marquee-badge">{{ t('home.announcement_badge') }}</span>
|
||||||
<div class="marquee-viewport">
|
<div class="marquee-viewport">
|
||||||
<div class="marquee-track">
|
<div class="marquee-track">
|
||||||
@@ -25,20 +41,26 @@ const text = computed(() => {
|
|||||||
<span class="marquee-text" aria-hidden="true">{{ text }}</span>
|
<span class="marquee-text" aria-hidden="true">{{ text }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.marquee-bar {
|
.marquee-bar {
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin: -4px -2px 14px;
|
margin: -4px -2px 14px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.marquee-bar.embedded {
|
.marquee-bar.embedded {
|
||||||
@@ -47,7 +69,7 @@ const text = computed(() => {
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: #111;
|
background: var(--bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.marquee-bar.embedded .marquee-badge {
|
.marquee-bar.embedded .marquee-badge {
|
||||||
@@ -65,9 +87,9 @@ const text = computed(() => {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
color: #1A1000;
|
color: var(--text);
|
||||||
background: var(--gradient-gold);
|
background: var(--surface-active);
|
||||||
border: 1px solid var(--border-gold-soft);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +104,7 @@ const text = computed(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
animation: marquee-scroll 18s linear infinite;
|
animation: marquee-scroll 18s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.marquee-text {
|
.marquee-text {
|
||||||
@@ -89,7 +112,7 @@ const text = computed(() => {
|
|||||||
padding-right: 80px;
|
padding-right: 80px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary-light);
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ const { visible, scrollToTop } = useBackToTop(toRef(props, 'scrollEl'));
|
|||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.35));
|
border: 1px solid var(--border-gold-soft);
|
||||||
background: rgba(17, 17, 17, 0.92);
|
background: var(--bg-body);
|
||||||
color: var(--primary-light, #c8a84e);
|
color: var(--primary-light);
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
|
box-shadow: var(--shadow);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s, transform 0.2s;
|
transition: background 0.2s, transform 0.2s;
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ const { visible, scrollToTop } = useBackToTop(toRef(props, 'scrollEl'));
|
|||||||
}
|
}
|
||||||
|
|
||||||
.back-top-btn:active {
|
.back-top-btn:active {
|
||||||
background: rgba(200, 168, 78, 0.16);
|
background: var(--surface-accent);
|
||||||
transform: scale(0.96);
|
transform: scale(0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,23 +123,14 @@ onUnmounted(stopAutoPlay);
|
|||||||
class="slide"
|
class="slide"
|
||||||
@click="onBannerClick(banner)"
|
@click="onBannerClick(banner)"
|
||||||
>
|
>
|
||||||
<template v-if="imageUrl(banner)">
|
|
||||||
<img
|
|
||||||
:src="imageUrl(banner)"
|
|
||||||
alt=""
|
|
||||||
class="slide-backdrop"
|
|
||||||
aria-hidden="true"
|
|
||||||
:loading="i === 0 ? 'eager' : 'lazy'"
|
|
||||||
@error="onImgError"
|
|
||||||
/>
|
|
||||||
<img
|
<img
|
||||||
|
v-if="imageUrl(banner)"
|
||||||
:src="imageUrl(banner)"
|
:src="imageUrl(banner)"
|
||||||
:alt="title(banner)"
|
:alt="title(banner)"
|
||||||
class="slide-img"
|
class="slide-img"
|
||||||
:loading="i === 0 ? 'eager' : 'lazy'"
|
:loading="i === 0 ? 'eager' : 'lazy'"
|
||||||
@error="onImgError"
|
@error="onImgError"
|
||||||
/>
|
/>
|
||||||
</template>
|
|
||||||
<div v-else class="slide-fallback">{{ title(banner) }}</div>
|
<div v-else class="slide-fallback">{{ title(banner) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,81 +157,40 @@ onUnmounted(stopAutoPlay);
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.carousel {
|
.carousel {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: -12px -16px 14px;
|
margin: 0 -16px 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media {
|
.media {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #05080d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewport {
|
.viewport {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: clamp(148px, 41.86vw, 180px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.track {
|
.track {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
|
||||||
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide {
|
.slide {
|
||||||
position: relative;
|
|
||||||
flex: 0 0 100%;
|
flex: 0 0 100%;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
background:
|
|
||||||
linear-gradient(135deg, rgba(7, 18, 31, 0.94), rgba(6, 8, 12, 0.98)),
|
|
||||||
#070d15;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1;
|
|
||||||
background:
|
|
||||||
linear-gradient(90deg, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.03) 48%, rgba(0, 0, 0, 0.18));
|
|
||||||
box-shadow: inset 0 -1px 0 rgba(212, 175, 55, 0.16);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: -18px;
|
|
||||||
width: calc(100% + 36px);
|
|
||||||
height: calc(100% + 36px);
|
|
||||||
object-fit: cover;
|
|
||||||
filter: blur(16px);
|
|
||||||
opacity: 0.42;
|
|
||||||
transform: scale(1.04);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-img {
|
.slide-img {
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 180px;
|
||||||
object-fit: contain;
|
object-fit: cover;
|
||||||
object-position: center;
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-fallback {
|
.slide-fallback {
|
||||||
position: relative;
|
height: 180px;
|
||||||
z-index: 2;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -260,7 +210,6 @@ onUnmounted(stopAutoPlay);
|
|||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding-bottom: 5px;
|
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function close() {
|
|||||||
height: 30px;
|
height: 30px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--border-gold-soft);
|
border: 1px solid var(--border-gold-soft);
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: var(--bg-card);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -93,11 +93,11 @@ function close() {
|
|||||||
max-width: 340px;
|
max-width: 340px;
|
||||||
max-height: 85vh;
|
max-height: 85vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
|
background: var(--gradient-card);
|
||||||
border: 1px solid var(--border-gold-soft);
|
border: 1px solid var(--border-gold-soft);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 16px 14px 14px;
|
padding: 16px 14px 14px;
|
||||||
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
|
box-shadow: var(--shadow), var(--glow-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-x {
|
.close-x {
|
||||||
@@ -107,7 +107,7 @@ function close() {
|
|||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--bg-elevated);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,9 +178,9 @@ function goDetail() {
|
|||||||
.cashback-tag {
|
.cashback-tag {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0b90b;
|
color: var(--primary-light);
|
||||||
background: rgba(240, 185, 11, 0.1);
|
background: var(--surface-accent);
|
||||||
border: 1px solid rgba(240, 185, 11, 0.25);
|
border: 1px solid var(--border-gold-soft);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
}
|
}
|
||||||
@@ -188,9 +188,9 @@ function goDetail() {
|
|||||||
.bet-type-tag {
|
.bet-type-tag {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--primary-light, #c8a84e);
|
color: var(--primary-light);
|
||||||
background: rgba(200, 168, 78, 0.12);
|
background: var(--surface-accent);
|
||||||
border: 1px solid rgba(200, 168, 78, 0.25);
|
border: 1px solid var(--border-gold-soft);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
@@ -198,16 +198,17 @@ function goDetail() {
|
|||||||
|
|
||||||
.card-date {
|
.card-date {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #555;
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #e8e8e8;
|
color: var(--text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -221,7 +222,7 @@ function goDetail() {
|
|||||||
|
|
||||||
.pick-label {
|
.pick-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -233,8 +234,8 @@ function goDetail() {
|
|||||||
.odds-badge {
|
.odds-badge {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #c8a84e;
|
color: var(--primary-light);
|
||||||
background: rgba(200, 168, 78, 0.1);
|
background: var(--surface-accent);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -250,24 +251,24 @@ function goDetail() {
|
|||||||
|
|
||||||
.stake-amt {
|
.stake-amt {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #666;
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.return-amt {
|
.return-amt {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.return-amt.highlight { color: #3db865; }
|
.return-amt.highlight { color: var(--success); }
|
||||||
.return-amt.pending { color: #e8c84a; }
|
.return-amt.pending { color: var(--primary-light); }
|
||||||
.return-amt.lost { color: #e05050; }
|
.return-amt.lost { color: var(--danger); }
|
||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #333;
|
color: var(--neutral);
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
|
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +37,27 @@ const MIN_STAKE = 5;
|
|||||||
const MAX_STAKE_INTEGER_LENGTH = 9;
|
const MAX_STAKE_INTEGER_LENGTH = 9;
|
||||||
const stakeInput = ref('');
|
const stakeInput = ref('');
|
||||||
const keypadKeys = ['1', '2', '3', '4', '5', 'backspace', '6', '7', '8', '9', '0', '00'];
|
const keypadKeys = ['1', '2', '3', '4', '5', 'backspace', '6', '7', '8', '9', '0', '00'];
|
||||||
|
const ODDS_POLL_MS = 5000;
|
||||||
|
|
||||||
|
type OddsDelta = {
|
||||||
|
oldOdds: number;
|
||||||
|
newOdds: number;
|
||||||
|
newVersion: string;
|
||||||
|
suspended: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SelectionOddsRow {
|
||||||
|
id: string;
|
||||||
|
odds: string;
|
||||||
|
oddsVersion: string;
|
||||||
|
status: string;
|
||||||
|
marketStatus: string;
|
||||||
|
marketShowOnPlayer: boolean;
|
||||||
|
matchStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oddsDeltas = ref<Record<string, OddsDelta>>({});
|
||||||
|
let oddsPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const activeItems = computed<SlipItem[]>(() => {
|
const activeItems = computed<SlipItem[]>(() => {
|
||||||
if (activeTab.value === 'parlay') return slip.parlayItems;
|
if (activeTab.value === 'parlay') return slip.parlayItems;
|
||||||
@@ -44,22 +65,57 @@ const activeItems = computed<SlipItem[]>(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activeCount = computed(() => activeItems.value.length);
|
const activeCount = computed(() => activeItems.value.length);
|
||||||
|
|
||||||
|
function effectiveOdds(item: SlipItem) {
|
||||||
|
return oddsDeltas.value[item.selectionId]?.newOdds ?? item.odds;
|
||||||
|
}
|
||||||
|
|
||||||
const activeTotalOdds = computed(() =>
|
const activeTotalOdds = computed(() =>
|
||||||
activeItems.value.reduce((acc, item) => acc * item.odds, 1),
|
activeItems.value.reduce((acc, item) => acc * effectiveOdds(item), 1),
|
||||||
);
|
);
|
||||||
const activeEstimatedReturn = computed(() => {
|
const activeEstimatedReturn = computed(() => {
|
||||||
if (!activeItems.value.length || !Number.isFinite(slip.stake) || slip.stake <= 0) return 0;
|
if (!activeItems.value.length || !Number.isFinite(slip.stake) || slip.stake <= 0) return 0;
|
||||||
if (activeTab.value === 'parlay') return slip.stake * activeTotalOdds.value;
|
if (activeTab.value === 'parlay') return slip.stake * activeTotalOdds.value;
|
||||||
return slip.stake * activeItems.value[0].odds;
|
return slip.stake * effectiveOdds(activeItems.value[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasSuspendedSelections = computed(() =>
|
||||||
|
Object.values(oddsDeltas.value).some((delta) => delta.suspended),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasPendingOddsChanges = computed(() =>
|
||||||
|
Object.values(oddsDeltas.value).some((delta) => !delta.suspended),
|
||||||
|
);
|
||||||
|
|
||||||
|
const oddsWarningText = computed(() => {
|
||||||
|
if (hasSuspendedSelections.value) return t('bet.odds_suspended');
|
||||||
|
if (hasPendingOddsChanges.value) return t('bet.odds_changed');
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitButtonLabel = computed(() => {
|
||||||
|
if (loading.value) return t('bet.placing');
|
||||||
|
if (hasPendingOddsChanges.value) return t('bet.accept_changes_place');
|
||||||
|
return t('bet.place_bet_short');
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSubmitWithOdds = computed(() => canSubmitActive.value && !hasSuspendedSelections.value);
|
||||||
|
|
||||||
const canSubmitActive = computed(() => {
|
const canSubmitActive = computed(() => {
|
||||||
if (activeTab.value === 'parlay') {
|
if (activeTab.value === 'parlay') {
|
||||||
return slip.parlayItems.length >= PARLAY_MIN_LEGS && slip.parlayItems.length <= PARLAY_MAX_LEGS;
|
return slip.parlayItems.length >= PARLAY_MIN_LEGS && slip.parlayItems.length <= PARLAY_MAX_LEGS;
|
||||||
}
|
}
|
||||||
return Boolean(slip.singleItem);
|
return Boolean(slip.singleItem) && slip.singleItem!.allowSingle !== false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const singleParlayOnlyHint = computed(
|
||||||
|
() =>
|
||||||
|
activeTab.value === 'single' &&
|
||||||
|
Boolean(slip.singleItem) &&
|
||||||
|
slip.singleItem!.allowSingle === false &&
|
||||||
|
slip.singleItem!.allowParlay !== false,
|
||||||
|
);
|
||||||
|
|
||||||
const balanceText = computed(() => {
|
const balanceText = computed(() => {
|
||||||
if (balanceLoading.value) return t('bet.loading');
|
if (balanceLoading.value) return t('bet.loading');
|
||||||
if (balance.value == null) return '--';
|
if (balance.value == null) return '--';
|
||||||
@@ -214,6 +270,81 @@ function setMaxStake() {
|
|||||||
if (balance.value != null && balance.value > 0) setStake(balance.value, false);
|
if (balance.value != null && balance.value > 0) setStake(balance.value, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopOddsPolling() {
|
||||||
|
if (oddsPollTimer) {
|
||||||
|
clearInterval(oddsPollTimer);
|
||||||
|
oddsPollTimer = null;
|
||||||
|
}
|
||||||
|
oddsDeltas.value = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptPendingOdds() {
|
||||||
|
for (const [selectionId, delta] of Object.entries(oddsDeltas.value)) {
|
||||||
|
if (!delta.suspended) {
|
||||||
|
slip.updateSelectionOdds(selectionId, delta.newOdds, delta.newVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oddsDeltas.value = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollSelectionsOdds() {
|
||||||
|
const items = activeItems.value;
|
||||||
|
if (!items.length || !show.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ids = items.map((item) => item.selectionId).join(',');
|
||||||
|
const { data } = await api.get('/player/selections/odds', { params: { ids } });
|
||||||
|
const rows: SelectionOddsRow[] = data.data?.items ?? [];
|
||||||
|
const rowMap = new Map(rows.map((row) => [row.id, row]));
|
||||||
|
const next: Record<string, OddsDelta> = {};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const row = rowMap.get(item.selectionId);
|
||||||
|
if (!row) continue;
|
||||||
|
|
||||||
|
const suspended =
|
||||||
|
row.status !== 'OPEN' ||
|
||||||
|
row.marketStatus !== 'OPEN' ||
|
||||||
|
row.marketShowOnPlayer === false ||
|
||||||
|
row.matchStatus !== 'PUBLISHED';
|
||||||
|
const newOdds = parseFloat(row.odds);
|
||||||
|
const versionChanged = row.oddsVersion !== item.oddsVersion;
|
||||||
|
const oddsChanged = Number.isFinite(newOdds) && Math.abs(newOdds - item.odds) > 0.0001;
|
||||||
|
|
||||||
|
if (suspended || versionChanged || oddsChanged) {
|
||||||
|
const existing = oddsDeltas.value[item.selectionId];
|
||||||
|
next[item.selectionId] = {
|
||||||
|
oldOdds: existing?.oldOdds ?? item.odds,
|
||||||
|
newOdds: Number.isFinite(newOdds) ? newOdds : item.odds,
|
||||||
|
newVersion: row.oddsVersion,
|
||||||
|
suspended,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oddsDeltas.value = next;
|
||||||
|
} catch {
|
||||||
|
/* silent retry on next tick */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOddsPolling() {
|
||||||
|
stopOddsPolling();
|
||||||
|
void pollSelectionsOdds();
|
||||||
|
oddsPollTimer = setInterval(() => {
|
||||||
|
void pollSelectionsOdds();
|
||||||
|
}, ODDS_POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oddsDeltaFor(selectionId: string) {
|
||||||
|
return oddsDeltas.value[selectionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function oddsTrendClass(delta: OddsDelta) {
|
||||||
|
if (delta.suspended) return 'odds-suspended';
|
||||||
|
return delta.newOdds >= delta.oldOdds ? 'odds-up' : 'odds-down';
|
||||||
|
}
|
||||||
|
|
||||||
async function placeBet() {
|
async function placeBet() {
|
||||||
if (!activeItems.value.length) return;
|
if (!activeItems.value.length) return;
|
||||||
if (!auth.token) {
|
if (!auth.token) {
|
||||||
@@ -234,6 +365,13 @@ async function placeBet() {
|
|||||||
: t('bet.parlay_need_more');
|
: t('bet.parlay_need_more');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hasSuspendedSelections.value) {
|
||||||
|
error.value = t('bet.odds_suspended');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasPendingOddsChanges.value) {
|
||||||
|
acceptPendingOdds();
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -279,7 +417,10 @@ async function placeBet() {
|
|||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(open) => {
|
(open) => {
|
||||||
if (!open) return;
|
if (!open) {
|
||||||
|
stopOddsPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
activeTab.value = slip.mode;
|
activeTab.value = slip.mode;
|
||||||
if (activeTab.value === 'single' && !slip.singleItem && slip.parlayItems.length) {
|
if (activeTab.value === 'single' && !slip.singleItem && slip.parlayItems.length) {
|
||||||
activeTab.value = 'parlay';
|
activeTab.value = 'parlay';
|
||||||
@@ -289,9 +430,21 @@ watch(
|
|||||||
success.value = '';
|
success.value = '';
|
||||||
syncStakeInputFromSlip();
|
syncStakeInputFromSlip();
|
||||||
loadBalance();
|
loadBalance();
|
||||||
|
startOddsPolling();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => activeItems.value.map((item) => item.selectionId).join(','),
|
||||||
|
() => {
|
||||||
|
if (show.value) void pollSelectionsOdds();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopOddsPolling();
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => slip.mode,
|
() => slip.mode,
|
||||||
(mode) => {
|
(mode) => {
|
||||||
@@ -321,6 +474,8 @@ watch(
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
|
||||||
|
|
||||||
<div class="slip-tabs">
|
<div class="slip-tabs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -354,8 +509,21 @@ watch(
|
|||||||
<div v-if="slip.singleItem.marketName" class="item-market">{{ slip.singleItem.marketName }}</div>
|
<div v-if="slip.singleItem.marketName" class="item-market">{{ slip.singleItem.marketName }}</div>
|
||||||
<div class="item-pick">{{ slip.singleItem.selectionName }}</div>
|
<div class="item-pick">{{ slip.singleItem.selectionName }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-odds">{{ slip.singleItem.odds.toFixed(2) }}</div>
|
<div class="item-odds">
|
||||||
|
<template v-if="oddsDeltaFor(slip.singleItem.selectionId)">
|
||||||
|
<span
|
||||||
|
class="odds-change"
|
||||||
|
:class="oddsTrendClass(oddsDeltaFor(slip.singleItem.selectionId)!)"
|
||||||
|
>
|
||||||
|
{{ oddsDeltaFor(slip.singleItem.selectionId)!.oldOdds.toFixed(2) }}
|
||||||
|
→
|
||||||
|
{{ oddsDeltaFor(slip.singleItem.selectionId)!.newOdds.toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ slip.singleItem.odds.toFixed(2) }}</template>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="singleParlayOnlyHint" class="warning">{{ t('bet.slip_parlay_only_hint') }}</p>
|
||||||
|
|
||||||
<template v-if="activeTab === 'parlay'">
|
<template v-if="activeTab === 'parlay'">
|
||||||
<p v-if="parlayWarning" class="warning">{{ parlayWarning }}</p>
|
<p v-if="parlayWarning" class="warning">{{ parlayWarning }}</p>
|
||||||
@@ -370,7 +538,19 @@ watch(
|
|||||||
<div class="item-pick">{{ item.selectionName }}</div>
|
<div class="item-pick">{{ item.selectionName }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-side">
|
<div class="item-side">
|
||||||
<strong>{{ item.odds.toFixed(2) }}</strong>
|
<strong>
|
||||||
|
<template v-if="oddsDeltaFor(item.selectionId)">
|
||||||
|
<span
|
||||||
|
class="odds-change"
|
||||||
|
:class="oddsTrendClass(oddsDeltaFor(item.selectionId)!)"
|
||||||
|
>
|
||||||
|
{{ oddsDeltaFor(item.selectionId)!.oldOdds.toFixed(2) }}
|
||||||
|
→
|
||||||
|
{{ oddsDeltaFor(item.selectionId)!.newOdds.toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ item.odds.toFixed(2) }}</template>
|
||||||
|
</strong>
|
||||||
<button type="button" class="remove" @click="removeItem(item.selectionId)">
|
<button type="button" class="remove" @click="removeItem(item.selectionId)">
|
||||||
{{ t('bet.slip_remove') }}
|
{{ t('bet.slip_remove') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -441,10 +621,10 @@ watch(
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
:disabled="loading || !canSubmitActive"
|
:disabled="loading || !canSubmitWithOdds"
|
||||||
@click="placeBet"
|
@click="placeBet"
|
||||||
>
|
>
|
||||||
{{ loading ? t('bet.placing') : t('bet.place_bet_short') }}
|
{{ submitButtonLabel }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -466,13 +646,13 @@ watch(
|
|||||||
.drawer {
|
.drawer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 88vh;
|
max-height: 88vh;
|
||||||
background: #101010;
|
background: var(--bg-body);
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 16px 16px 0 0;
|
||||||
border-top: 1px solid var(--border-gold-soft);
|
border-top: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 -20px 44px rgba(0, 0, 0, 0.48);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-head {
|
.drawer-head {
|
||||||
@@ -484,14 +664,14 @@ watch(
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 14px 14px 10px;
|
padding: 14px 14px 10px;
|
||||||
background: linear-gradient(180deg, #1d1d1d 0%, #111 100%);
|
background: var(--bg-card);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-kicker {
|
.drawer-kicker {
|
||||||
margin: 0 0 3px;
|
margin: 0 0 3px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--primary-light);
|
color: var(--primary);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,7 +689,7 @@ watch(
|
|||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--bg-elevated);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -523,15 +703,15 @@ watch(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: rgba(212, 175, 55, 0.08);
|
background: var(--bg-card);
|
||||||
border-bottom: 1px solid rgba(212, 175, 55, 0.18);
|
border-bottom: 1px solid var(--border);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance-bar strong {
|
.balance-bar strong {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
@@ -540,7 +720,7 @@ watch(
|
|||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--bg-elevated);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -550,23 +730,23 @@ watch(
|
|||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
padding: 8px 12px 0;
|
padding: 8px 12px 0;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
background: #101010;
|
background: var(--bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slip-tab {
|
.slip-tab {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border-radius: 6px 6px 0 0;
|
border-radius: 6px 6px 0 0;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #171717;
|
background: var(--bg-input);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slip-tab.active {
|
.slip-tab.active {
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--primary);
|
||||||
background: rgba(212, 175, 55, 0.13);
|
background: var(--surface-active);
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-count {
|
.tab-count {
|
||||||
@@ -578,7 +758,7 @@ watch(
|
|||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: #111;
|
color: var(--on-primary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
@@ -603,15 +783,15 @@ watch(
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 11px 12px;
|
padding: 11px 12px;
|
||||||
background: #151515;
|
background: var(--bg-card);
|
||||||
border: 1px solid #292929;
|
border: 1px solid var(--border);
|
||||||
border-radius: 7px;
|
border-radius: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slip-item--single {
|
.slip-item--single {
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--primary);
|
||||||
background: linear-gradient(180deg, rgba(212, 175, 55, 0.12), rgba(18, 18, 18, 0.94));
|
background: var(--surface-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-main {
|
.item-main {
|
||||||
@@ -631,7 +811,7 @@ watch(
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--primary-light);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-pick {
|
.item-pick {
|
||||||
@@ -643,7 +823,7 @@ watch(
|
|||||||
|
|
||||||
.item-odds,
|
.item-odds,
|
||||||
.item-side strong {
|
.item-side strong {
|
||||||
color: var(--primary-light);
|
color: var(--primary);
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
@@ -672,14 +852,12 @@ watch(
|
|||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: linear-gradient(180deg, #4b370d 0%, #241905 100%);
|
background: transparent;
|
||||||
border: 1px solid var(--primary-light);
|
border: 1px solid var(--border-strong);
|
||||||
color: #ffe892;
|
color: var(--text);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
box-shadow:
|
box-shadow: none;
|
||||||
0 0 0 1px rgba(255, 232, 146, 0.18) inset,
|
|
||||||
0 6px 18px rgba(212, 175, 55, 0.18);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-add-parlay span {
|
.btn-add-parlay span {
|
||||||
@@ -689,27 +867,54 @@ watch(
|
|||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--primary-light);
|
background: var(--primary);
|
||||||
color: #191100;
|
color: var(--on-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.odds-warning {
|
||||||
|
margin: 0 16px 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(244, 162, 97, 0.12);
|
||||||
|
border: 1px solid rgba(244, 162, 97, 0.28);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odds-change {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odds-change.odds-up {
|
||||||
|
color: #6ee7a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odds-change.odds-down {
|
||||||
|
color: #ff8b8b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odds-change.odds-suspended {
|
||||||
|
color: #ffb84d;
|
||||||
|
}
|
||||||
|
|
||||||
.warning,
|
.warning,
|
||||||
.error {
|
.error {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
padding: 10px 11px;
|
padding: 10px 11px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(255, 77, 79, 0.12);
|
background: rgba(239, 68, 68, 0.12);
|
||||||
border: 1px solid rgba(255, 77, 79, 0.25);
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
color: #ff8b8b;
|
color: rgba(239, 68, 68, 0.95);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parlay-meta {
|
.parlay-meta {
|
||||||
margin: 8px 2px 10px;
|
margin: 8px 2px 10px;
|
||||||
color: var(--primary-light);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
@@ -717,8 +922,8 @@ watch(
|
|||||||
.stake-area {
|
.stake-area {
|
||||||
margin: 12px 0 12px;
|
margin: 12px 0 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #151515;
|
background: var(--bg-card);
|
||||||
border: 1px solid #292929;
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,7 +939,7 @@ watch(
|
|||||||
|
|
||||||
.stake-head strong,
|
.stake-head strong,
|
||||||
.return strong {
|
.return strong {
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
@@ -744,17 +949,17 @@ watch(
|
|||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
border: 1px solid var(--border-gold-soft);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #0c0c0c;
|
background: var(--bg-input);
|
||||||
}
|
}
|
||||||
|
|
||||||
.currency {
|
.currency {
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
border-right: 1px solid #2d2d2d;
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stake-input-row input {
|
.stake-input-row input {
|
||||||
@@ -804,10 +1009,10 @@ watch(
|
|||||||
.number-key,
|
.number-key,
|
||||||
.quick-stakes button {
|
.quick-stakes button {
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
border-radius: 5px;
|
border-radius: var(--radius-sm);
|
||||||
background: #f4f1f6;
|
background: var(--bg-elevated);
|
||||||
color: #221f26;
|
color: var(--text);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid var(--border);
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -819,7 +1024,7 @@ watch(
|
|||||||
|
|
||||||
.quick-stakes button {
|
.quick-stakes button {
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
background: #7b787d;
|
background: var(--bg-card);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -830,7 +1035,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.success {
|
.success {
|
||||||
color: var(--primary-light);
|
color: var(--success);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
@@ -841,9 +1046,9 @@ watch(
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
|
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||||
background: rgba(14, 14, 14, 0.98);
|
background: var(--bg-body);
|
||||||
border-top: 1px solid var(--border-gold-soft);
|
border-top: 1px solid var(--border);
|
||||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-foot--split {
|
.drawer-foot--split {
|
||||||
@@ -853,9 +1058,9 @@ watch(
|
|||||||
.btn-primary {
|
.btn-primary {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
border-radius: 7px;
|
border-radius: var(--radius-sm);
|
||||||
background: linear-gradient(180deg, var(--primary-light), var(--primary));
|
background: var(--primary);
|
||||||
color: #111;
|
color: var(--on-primary);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,27 +34,27 @@ const stats = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="stats-panel">
|
<div class="stats-panel">
|
||||||
<div class="stats-row">
|
<div class="stats-row main">
|
||||||
<div class="stat-item">
|
<div class="stat-cell">
|
||||||
<span class="stat-val">{{ stats.total }}</span>
|
<span class="stat-val">{{ stats.total }}</span>
|
||||||
<span class="stat-label">{{ t('history.stats_total') }}</span>
|
<span class="stat-label">{{ t('history.stats_total') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item won">
|
<div class="stat-cell won">
|
||||||
<span class="stat-val">{{ stats.won }}</span>
|
<span class="stat-val">{{ stats.won }}</span>
|
||||||
<span class="stat-label">{{ t('history.stats_won') }}</span>
|
<span class="stat-label">{{ t('history.stats_won') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item lost">
|
<div class="stat-cell lost">
|
||||||
<span class="stat-val">{{ stats.lost }}</span>
|
<span class="stat-val">{{ stats.lost }}</span>
|
||||||
<span class="stat-label">{{ t('history.stats_lost') }}</span>
|
<span class="stat-label">{{ t('history.stats_lost') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item pending">
|
<div class="stat-cell pending">
|
||||||
<span class="stat-val">{{ stats.pending }}</span>
|
<span class="stat-val">{{ stats.pending }}</span>
|
||||||
<span class="stat-label">{{ t('history.stats_pending') }}</span>
|
<span class="stat-label">{{ t('history.stats_pending') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item push">
|
|
||||||
<span class="stat-val">{{ stats.push }}</span>
|
|
||||||
<span class="stat-label">{{ t('history.stats_push') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="stats.push > 0" class="push-row">
|
||||||
|
<span class="stat-label">{{ t('history.stats_push') }}</span>
|
||||||
|
<span class="stat-val push">{{ stats.push }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-bar" :class="{ empty: stats.total === 0 }" aria-hidden="true">
|
<div class="stats-bar" :class="{ empty: stats.total === 0 }" aria-hidden="true">
|
||||||
<template v-if="stats.total > 0">
|
<template v-if="stats.total > 0">
|
||||||
@@ -64,14 +64,14 @@ const stats = computed(() => {
|
|||||||
<div v-if="stats.push > 0" class="bar-seg push" :style="{ flex: stats.push }" />
|
<div v-if="stats.push > 0" class="bar-seg push" :style="{ flex: stats.push }" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-row secondary">
|
<div class="stats-row money">
|
||||||
<div class="stat-item">
|
<div class="money-cell">
|
||||||
<span class="stat-val small">{{ formatMoney(stats.totalStake, locale) }}</span>
|
|
||||||
<span class="stat-label">{{ t('history.stats_stake') }}</span>
|
<span class="stat-label">{{ t('history.stats_stake') }}</span>
|
||||||
|
<span class="stat-val small">{{ formatMoney(stats.totalStake, locale) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="money-cell accent">
|
||||||
<span class="stat-val small gold">{{ formatMoney(stats.totalReturn, locale) }}</span>
|
|
||||||
<span class="stat-label">{{ t('history.stats_return') }}</span>
|
<span class="stat-label">{{ t('history.stats_return') }}</span>
|
||||||
|
<span class="stat-val small gold">{{ formatMoney(stats.totalReturn, locale) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,41 +79,84 @@ const stats = computed(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.stats-panel {
|
.stats-panel {
|
||||||
background: var(--bg-card);
|
position: relative;
|
||||||
border: 1px solid var(--border);
|
background: var(--gradient-card);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 14px 12px 10px;
|
padding: 8px 10px;
|
||||||
margin-bottom: 12px;
|
box-shadow: var(--shadow-card-sm);
|
||||||
backdrop-filter: blur(10px);
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-panel::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 10%;
|
||||||
|
right: 10%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--gradient-card-shine);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-row {
|
.stats-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-row.secondary {
|
.stats-row.main {
|
||||||
margin-bottom: 0;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
margin-top: 8px;
|
|
||||||
justify-content: space-around;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item {
|
.stats-row.money {
|
||||||
flex: 1;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
text-align: center;
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-cell,
|
||||||
|
.money-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.money-cell {
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.money-cell.accent {
|
||||||
|
align-items: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-val {
|
.stat-val {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 18px;
|
font-size: 15px;
|
||||||
font-weight: 900;
|
font-weight: 800;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-val.small {
|
.stat-val.small {
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,26 +164,35 @@ const stats = computed(() => {
|
|||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item.won .stat-val { color: #3db865; }
|
.stat-val.push {
|
||||||
.stat-item.lost .stat-val { color: #e05050; }
|
font-size: 13px;
|
||||||
.stat-item.pending .stat-val { color: #e8c84a; }
|
color: var(--text-muted);
|
||||||
.stat-item.push .stat-val { color: #888; }
|
}
|
||||||
|
|
||||||
|
.stat-cell.won .stat-val { color: var(--success); }
|
||||||
|
.stat-cell.lost .stat-val { color: var(--danger); }
|
||||||
|
.stat-cell.pending .stat-val { color: var(--primary-light); }
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.03em;
|
||||||
margin-top: 2px;
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-bar {
|
.stats-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 4px;
|
height: 3px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-bar.empty {
|
.stats-bar.empty {
|
||||||
@@ -148,12 +200,12 @@ const stats = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bar-seg {
|
.bar-seg {
|
||||||
border-radius: 2px;
|
border-radius: 1px;
|
||||||
transition: flex 0.3s ease;
|
transition: flex 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-seg.won { background: #3db865; }
|
.bar-seg.won { background: var(--success); }
|
||||||
.bar-seg.lost { background: #e05050; }
|
.bar-seg.lost { background: var(--danger); }
|
||||||
.bar-seg.pending { background: #e8c84a; }
|
.bar-seg.pending { background: var(--primary-light); }
|
||||||
.bar-seg.push { background: #444; }
|
.bar-seg.push { background: var(--neutral); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface Particle {
|
|||||||
color: string; shape: number;
|
color: string; shape: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = ['#D4AF37', '#FFD700', '#F0C040', '#C8A02E', '#E6C84C', '#B8960C'];
|
const COLORS = ['#FFFFFF', '#FFD700', '#F0C040', '#C8A02E', '#E6C84C', '#B8960C'];
|
||||||
let w = 0;
|
let w = 0;
|
||||||
let h = 0;
|
let h = 0;
|
||||||
|
|
||||||
@@ -111,8 +111,8 @@ onUnmounted(stop);
|
|||||||
<canvas ref="canvasRef" class="confetti-canvas" />
|
<canvas ref="canvasRef" class="confetti-canvas" />
|
||||||
<div class="success-card">
|
<div class="success-card">
|
||||||
<svg class="check" viewBox="0 0 52 52" xmlns="http://www.w3.org/2000/svg">
|
<svg class="check" viewBox="0 0 52 52" xmlns="http://www.w3.org/2000/svg">
|
||||||
<circle class="check-circle" cx="26" cy="26" r="24" fill="none" stroke="#D4AF37" stroke-width="3" />
|
<circle class="check-circle" cx="26" cy="26" r="24" fill="none" stroke="currentColor" stroke-width="3" />
|
||||||
<path class="check-mark" d="M14 27l7 7 16-16" fill="none" stroke="#D4AF37" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" />
|
<path class="check-mark" d="M14 27l7 7 16-16" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
<p class="success-text">{{ t('bet.place_success') }}</p>
|
<p class="success-text">{{ t('bet.place_success') }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,6 +151,7 @@ onUnmounted(stop);
|
|||||||
.check {
|
.check {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-circle {
|
.check-circle {
|
||||||
|
|||||||
@@ -112,12 +112,12 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
padding: 0 8px 0 8px;
|
padding: 0 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,8 +130,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.chip-label {
|
.chip-label {
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-muted);
|
color: var(--text-dim);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -142,15 +142,16 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.chip-amount {
|
.chip-amount {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--primary-light);
|
color: var(--text-muted);
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
@@ -166,9 +167,11 @@ onUnmounted(() => {
|
|||||||
width: min(260px, 78vw);
|
width: min(260px, 78vw);
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
z-index: 130;
|
z-index: 130;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: #141414;
|
background: var(--dropdown-bg);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,8 +184,9 @@ onUnmounted(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-row.muted {
|
.panel-row.muted {
|
||||||
@@ -190,14 +194,14 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-row.highlight {
|
.panel-row.highlight {
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-divider {
|
.panel-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(90deg, transparent, var(--border-gold-soft), transparent);
|
background: var(--border);
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,18 +209,18 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--primary, #c8a84e);
|
border: none;
|
||||||
background: rgba(200, 168, 78, 0.12);
|
background: var(--primary);
|
||||||
color: var(--primary-light, #c8a84e);
|
color: var(--on-primary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-recharge-btn:active {
|
.panel-recharge-btn:active {
|
||||||
background: rgba(200, 168, 78, 0.24);
|
opacity: 0.88;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backdrop {
|
.backdrop {
|
||||||
|
|||||||
186
apps/player/src/components/ConfirmDialog.vue
Normal file
186
apps/player/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
danger: false,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean];
|
||||||
|
confirm: [];
|
||||||
|
cancel: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const resolvedTitle = computed(() => props.title ?? t('common.confirm'));
|
||||||
|
const resolvedConfirmText = computed(() => props.confirmText ?? t('common.confirm'));
|
||||||
|
const resolvedCancelText = computed(() => props.cancelText ?? t('common.cancel'));
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (props.loading) return;
|
||||||
|
emit('update:visible', false);
|
||||||
|
emit('cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (props.loading) return;
|
||||||
|
emit('confirm');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="confirm-fade">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="confirm-overlay"
|
||||||
|
@click.self="close"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="confirm-modal"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-labelledby="title ? 'confirm-dialog-title' : undefined"
|
||||||
|
:aria-describedby="'confirm-dialog-message'"
|
||||||
|
>
|
||||||
|
<h2 v-if="title" id="confirm-dialog-title" class="confirm-title">{{ resolvedTitle }}</h2>
|
||||||
|
<p id="confirm-dialog-message" class="confirm-message">{{ message }}</p>
|
||||||
|
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="confirm-btn cancel"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
{{ resolvedCancelText }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="confirm-btn confirm"
|
||||||
|
:class="{ danger }"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="onConfirm"
|
||||||
|
>
|
||||||
|
{{ resolvedConfirmText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.confirm-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 340px;
|
||||||
|
background: var(--dropdown-bg, #0A2540);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 22px 18px 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-message {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn.cancel {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn.confirm {
|
||||||
|
border: none;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn.confirm.danger {
|
||||||
|
background: rgba(239, 68, 68, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-fade-enter-active,
|
||||||
|
.confirm-fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-fade-enter-active .confirm-modal,
|
||||||
|
.confirm-fade-leave-active .confirm-modal {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-fade-enter-from,
|
||||||
|
.confirm-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-fade-enter-from .confirm-modal,
|
||||||
|
.confirm-fade-leave-to .confirm-modal {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { buildCustomerServiceUrl } from '../config/customerService';
|
|
||||||
import { useAuthStore } from '../stores/auth';
|
|
||||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>();
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const auth = useAuthStore();
|
|
||||||
const { profileRaw, avatarUrl } = usePlayerProfile();
|
|
||||||
|
|
||||||
const visible = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v) => emit('update:modelValue', v),
|
|
||||||
});
|
|
||||||
|
|
||||||
const iframeSrc = computed(() => {
|
|
||||||
const visitor = auth.user
|
|
||||||
? {
|
|
||||||
name:
|
|
||||||
profileRaw.value?.username ||
|
|
||||||
profileRaw.value?.preferences?.phone ||
|
|
||||||
auth.user.username ||
|
|
||||||
'',
|
|
||||||
avatar: avatarUrl.value
|
|
||||||
? new URL(avatarUrl.value, window.location.origin).href
|
|
||||||
: '',
|
|
||||||
id: String(profileRaw.value?.id ?? auth.user.id ?? ''),
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return buildCustomerServiceUrl(t('support.connecting'), visitor);
|
|
||||||
});
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
visible.value = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="fade">
|
|
||||||
<div v-if="visible" class="cs-overlay" @click.self="close">
|
|
||||||
<div class="cs-modal" role="dialog" :aria-label="t('support.title')">
|
|
||||||
<header class="cs-header">
|
|
||||||
<h2 class="cs-title">{{ t('support.title') }}</h2>
|
|
||||||
<button type="button" class="close-btn" :aria-label="t('support.close')" @click="close">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="cs-body">
|
|
||||||
<iframe
|
|
||||||
v-if="visible"
|
|
||||||
:key="iframeSrc"
|
|
||||||
class="cs-frame"
|
|
||||||
:src="iframeSrc"
|
|
||||||
:title="t('support.title')"
|
|
||||||
allow="microphone; camera; clipboard-read; clipboard-write"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.cs-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 16px;
|
|
||||||
background: rgba(0, 0, 0, 0.72);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
-webkit-backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cs-modal {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: min(100%, 420px);
|
|
||||||
height: min(82vh, 680px);
|
|
||||||
background: #141414;
|
|
||||||
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cs-header {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-bottom: 1px solid var(--border, #2a2a2a);
|
|
||||||
background: rgba(26, 26, 26, 0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cs-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--primary-light, #c8a84e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #666;
|
|
||||||
font-size: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cs-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
background: #0d0d0d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cs-frame {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 0;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cs-empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active {
|
|
||||||
transition: opacity 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-from,
|
|
||||||
.fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
60
apps/player/src/components/CustomerServicePanel.vue
Normal file
60
apps/player/src/components/CustomerServicePanel.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { buildCustomerServiceUrl } from '../config/customerService';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const { profileRaw, avatarUrl } = usePlayerProfile();
|
||||||
|
|
||||||
|
const iframeSrc = computed(() => {
|
||||||
|
const visitor = auth.user
|
||||||
|
? {
|
||||||
|
name:
|
||||||
|
profileRaw.value?.username ||
|
||||||
|
profileRaw.value?.preferences?.phone ||
|
||||||
|
auth.user.username ||
|
||||||
|
'',
|
||||||
|
avatar: avatarUrl.value
|
||||||
|
? new URL(avatarUrl.value, window.location.origin).href
|
||||||
|
: '',
|
||||||
|
id: String(profileRaw.value?.id ?? auth.user.id ?? ''),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return buildCustomerServiceUrl(t('support.connecting'), visitor);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cs-panel">
|
||||||
|
<iframe
|
||||||
|
:key="iframeSrc"
|
||||||
|
class="cs-frame"
|
||||||
|
:src="iframeSrc"
|
||||||
|
:title="t('support.title')"
|
||||||
|
allow="microphone; camera; clipboard-read; clipboard-write"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cs-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0 -16px;
|
||||||
|
background: var(--bg-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-frame {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-height: calc(100dvh - 140px);
|
||||||
|
border: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,102 +1,114 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
||||||
size?: number;
|
size?: number;
|
||||||
|
|
||||||
progress?: number;
|
progress?: number;
|
||||||
|
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const rotation = computed(() => (props.progress ?? 0) * 360);
|
const rotation = computed(() => (props.progress ?? 0) * 360);
|
||||||
|
|
||||||
const wrapperStyle = computed(() => {
|
const wrapperStyle = computed(() => {
|
||||||
|
|
||||||
if (props.active) return {};
|
if (props.active) return {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
||||||
transform: `rotate(${rotation.value}deg)`,
|
transform: `rotate(${rotation.value}deg)`,
|
||||||
|
|
||||||
transition: 'transform 0.05s linear',
|
transition: 'transform 0.05s linear',
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
|
|
||||||
:class="['gold-spinner', { 'is-active': active }]"
|
:class="['gold-spinner', { 'is-active': active }]"
|
||||||
|
|
||||||
:width="size ?? 48"
|
:width="size ?? 48"
|
||||||
|
|
||||||
:height="size ?? 48"
|
:height="size ?? 48"
|
||||||
|
|
||||||
viewBox="0 0 48 48"
|
viewBox="0 0 48 48"
|
||||||
|
|
||||||
fill="none"
|
fill="none"
|
||||||
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|
||||||
:style="!active ? wrapperStyle : undefined"
|
:style="!active ? wrapperStyle : undefined"
|
||||||
|
|
||||||
>
|
>
|
||||||
<defs>
|
|
||||||
<linearGradient id="gs-track" x1="0%" y1="0%" x2="100%" y2="100%">
|
<circle cx="24" cy="24" r="20" stroke="rgba(255,255,255,0.14)" stroke-width="3" fill="none" />
|
||||||
<stop offset="0%" stop-color="#D4AF37" stop-opacity="0.18" />
|
|
||||||
<stop offset="50%" stop-color="#F0D875" stop-opacity="0.25" />
|
|
||||||
<stop offset="100%" stop-color="#D4AF37" stop-opacity="0.18" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="gs-arc" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stop-color="#FFF4C8" />
|
|
||||||
<stop offset="22%" stop-color="#F0D875" />
|
|
||||||
<stop offset="50%" stop-color="#D4AF37" />
|
|
||||||
<stop offset="78%" stop-color="#B8942B" />
|
|
||||||
<stop offset="100%" stop-color="#8B6914" />
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="gs-glow">
|
|
||||||
<feGaussianBlur stdDeviation="1.2" result="blur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
<radialGradient id="gs-dot" cx="50%" cy="50%" r="50%">
|
|
||||||
<stop offset="0%" stop-color="#FFF4C8" />
|
|
||||||
<stop offset="60%" stop-color="#F0D875" />
|
|
||||||
<stop offset="100%" stop-color="#D4AF37" />
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
<circle cx="24" cy="24" r="20" stroke="url(#gs-track)" stroke-width="3" fill="none" />
|
|
||||||
<circle
|
<circle
|
||||||
|
|
||||||
cx="24"
|
cx="24"
|
||||||
|
|
||||||
cy="24"
|
cy="24"
|
||||||
|
|
||||||
r="20"
|
r="20"
|
||||||
stroke="url(#gs-arc)"
|
|
||||||
|
stroke="rgba(255,255,255,0.72)"
|
||||||
|
|
||||||
stroke-width="3"
|
stroke-width="3"
|
||||||
|
|
||||||
fill="none"
|
fill="none"
|
||||||
|
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
|
|
||||||
stroke-dasharray="31.4 94.2"
|
stroke-dasharray="31.4 94.2"
|
||||||
filter="url(#gs-glow)"
|
|
||||||
/>
|
/>
|
||||||
<circle cx="24" cy="4" r="2.5" fill="url(#gs-dot)" filter="url(#gs-glow)">
|
|
||||||
<animate
|
|
||||||
v-if="active"
|
|
||||||
attributeName="r"
|
|
||||||
values="2.5;3.2;2.5"
|
|
||||||
dur="1.2s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</circle>
|
|
||||||
<circle v-if="active" cx="36" cy="13.5" r="1.8" fill="#F0D875" opacity="0.6">
|
|
||||||
<animate attributeName="opacity" values="0.6;0.15;0.6" dur="0.9s" repeatCount="indefinite" begin="0.15s" />
|
|
||||||
</circle>
|
|
||||||
<circle v-if="active" cx="34" cy="34" r="1.5" fill="#D4AF37" opacity="0.4">
|
|
||||||
<animate attributeName="opacity" values="0.4;0.1;0.4" dur="1.1s" repeatCount="indefinite" begin="0.35s" />
|
|
||||||
</circle>
|
|
||||||
<circle v-if="active" cx="14" cy="34" r="1.5" fill="#D4AF37" opacity="0.4">
|
|
||||||
<animate attributeName="opacity" values="0.4;0.1;0.4" dur="1s" repeatCount="indefinite" begin="0.55s" />
|
|
||||||
</circle>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.gold-spinner {
|
.gold-spinner {
|
||||||
|
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.gold-spinner.is-active {
|
.gold-spinner.is-active {
|
||||||
|
|
||||||
animation: gs-spin 0.8s linear infinite;
|
animation: gs-spin 0.8s linear infinite;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@keyframes gs-spin {
|
@keyframes gs-spin {
|
||||||
|
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="league-block">
|
<section class="league-block" :class="{ expanded }">
|
||||||
<button type="button" class="league-row" :aria-expanded="expanded" @click="emit('toggle')">
|
<button type="button" class="league-row" :aria-expanded="expanded" @click="emit('toggle')">
|
||||||
<span class="toggle-icon" :class="{ open: expanded }" aria-hidden="true">
|
<span class="toggle-icon" :class="{ open: expanded }" aria-hidden="true">
|
||||||
<span class="toggle-mark">{{ expanded ? '−' : '+' }}</span>
|
<span class="toggle-mark">{{ expanded ? '−' : '+' }}</span>
|
||||||
@@ -89,12 +89,25 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
|
|||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg-card);
|
background: var(--gradient-card);
|
||||||
|
box-shadow: var(--shadow-card-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.league-block::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 8%;
|
||||||
|
right: 8%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--gradient-card-shine);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.league-row {
|
.league-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -103,7 +116,7 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: none;
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -129,17 +142,17 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
|
|||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-gold-soft);
|
border: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-mark {
|
.toggle-mark {
|
||||||
color: var(--primary-light);
|
color: var(--text-muted);
|
||||||
font-size: 17px;
|
font-size: 16px;
|
||||||
font-weight: 900;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-top: -1px;
|
margin-top: -1px;
|
||||||
}
|
}
|
||||||
@@ -154,8 +167,8 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
|
|||||||
|
|
||||||
.league-title {
|
.league-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
color: #fff;
|
color: var(--text);
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -165,16 +178,16 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #ccc;
|
color: var(--text-muted);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-live {
|
.stat-live {
|
||||||
color: #3db865;
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-open {
|
.stat-open {
|
||||||
color: #c8a84e;
|
color: #6ee78a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.league-saishi {
|
.league-saishi {
|
||||||
@@ -185,17 +198,18 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
border-left: 1px solid #2a2a2a;
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-panel {
|
.match-panel {
|
||||||
padding: 10px 0 4px;
|
background: linear-gradient(180deg, rgba(0, 20, 40, 0.35) 0%, rgba(0, 26, 51, 0.2) 100%);
|
||||||
|
padding: 12px 8px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-grid {
|
.match-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,6 +27,6 @@ const src = computed(() => `/flags/${countryCode.value}.svg`);
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12);
|
box-shadow: 0 0 0 1px var(--border);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ onUnmounted(() => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #0d0d0d;
|
background: var(--bg-body);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -105,8 +105,10 @@ onUnmounted(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
background: #141414;
|
background: var(--dropdown-bg);
|
||||||
border: 1px solid var(--border-gold-soft);
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
@@ -126,7 +128,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.locale-option:hover,
|
.locale-option:hover,
|
||||||
.locale-option.active {
|
.locale-option.active {
|
||||||
background: rgba(212, 175, 55, 0.1);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ function continueBrowsing() {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
background: #1a1a1a;
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
|
border: 1px solid var(--border-gold-soft);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius);
|
||||||
padding: 28px 24px 24px;
|
padding: 28px 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ function continueBrowsing() {
|
|||||||
right: 14px;
|
right: 14px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #666;
|
color: var(--text-muted);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
@@ -84,20 +84,20 @@ function continueBrowsing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
color: #aaa;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-title {
|
.login-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--primary-light, #c8a84e);
|
color: var(--primary-light);
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-hint {
|
.login-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 24px;
|
margin: 0 0 24px;
|
||||||
}
|
}
|
||||||
@@ -110,15 +110,16 @@ function continueBrowsing() {
|
|||||||
|
|
||||||
.login-submit {
|
.login-submit {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
border: none;
|
border: none;
|
||||||
background: linear-gradient(135deg, #d4a017, #e8c84a);
|
background: var(--gradient-accent);
|
||||||
color: #1a1a1a;
|
color: var(--text);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-submit:active {
|
.login-submit:active {
|
||||||
@@ -127,10 +128,10 @@ function continueBrowsing() {
|
|||||||
|
|
||||||
.login-secondary {
|
.login-secondary {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid #333;
|
border: 1px solid var(--border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -139,8 +140,8 @@ function continueBrowsing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-secondary:active {
|
.login-secondary:active {
|
||||||
color: #aaa;
|
color: var(--text);
|
||||||
border-color: #444;
|
border-color: var(--border-gold-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { teamFlagUrl } from '../utils/teamFlag';
|
import { teamFlagUrl } from '../utils/teamFlag';
|
||||||
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
|
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
|
||||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||||
|
import VsBadge from './VsBadge.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
match: {
|
match: {
|
||||||
@@ -78,7 +79,7 @@ const liveScoreText = computed(() => {
|
|||||||
:class="{ 'match-card--phase': phase !== 'open' }"
|
:class="{ 'match-card--phase': phase !== 'open' }"
|
||||||
@click="emit('bet', match.id)"
|
@click="emit('bet', match.id)"
|
||||||
>
|
>
|
||||||
<span v-if="phase === 'open'" class="status-tag status-tag--open">{{ t('bet.status_open') }}</span>
|
<span v-if="phase === 'open'" class="status-ribbon">{{ t('bet.status_open') }}</span>
|
||||||
<span v-else-if="phase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
|
<span v-else-if="phase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
|
||||||
<span v-else class="status-tag status-tag--pending">{{ phaseLabel }}</span>
|
<span v-else class="status-tag status-tag--pending">{{ phaseLabel }}</span>
|
||||||
|
|
||||||
@@ -98,8 +99,8 @@ const liveScoreText = computed(() => {
|
|||||||
|
|
||||||
<div class="center-col">
|
<div class="center-col">
|
||||||
<span class="kickoff">{{ kickoffText }}</span>
|
<span class="kickoff">{{ kickoffText }}</span>
|
||||||
<span v-if="phase !== 'open' && liveScoreText" class="live-score">{{ liveScoreText }}</span>
|
<span v-if="liveScoreText" class="live-score">{{ liveScoreText }}</span>
|
||||||
<span v-else class="vs">VS</span>
|
<VsBadge v-else size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="team">
|
<div class="team">
|
||||||
@@ -130,30 +131,39 @@ const liveScoreText = computed(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.match-card {
|
.match-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
|
background: var(--gradient-card);
|
||||||
border: 1px solid rgba(140, 140, 140, 0.35);
|
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 10px 8px 10px;
|
padding: 12px 10px 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-card-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-card::before {
|
.match-card::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
top: 0;
|
||||||
background: linear-gradient(135deg, rgba(100, 100, 100, 0.08) 0%, transparent 50%, rgba(80, 80, 80, 0.05) 100%);
|
left: 12%;
|
||||||
|
right: 12%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--gradient-card-shine);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-card--phase {
|
.match-card--phase {
|
||||||
/* inherits base dark style */
|
background: linear-gradient(
|
||||||
|
165deg,
|
||||||
|
rgba(255, 255, 255, 0.07) 0%,
|
||||||
|
rgba(255, 255, 255, 0.03) 30%,
|
||||||
|
rgba(0, 20, 40, 0.55) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-row {
|
.teams-row {
|
||||||
@@ -180,19 +190,19 @@ const liveScoreText = computed(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 17px;
|
font-size: 14px;
|
||||||
font-weight: 900;
|
font-weight: 600;
|
||||||
color: #fff;
|
color: var(--text);
|
||||||
line-height: 1.2;
|
line-height: 1.3;
|
||||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.95);
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-card--phase .team-name {
|
.match-card--phase .team-name {
|
||||||
color: #d8d8d8;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-flag {
|
.team-flag {
|
||||||
@@ -215,71 +225,56 @@ const liveScoreText = computed(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 5px;
|
||||||
min-width: 56px;
|
min-width: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kickoff {
|
.kickoff {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
color: #fff;
|
color: var(--text-muted);
|
||||||
line-height: 1.15;
|
line-height: 1.2;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 96px;
|
max-width: 96px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-score {
|
.live-score {
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: var(--text);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.02em;
|
||||||
}
|
|
||||||
|
|
||||||
.vs {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: #fff;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
line-height: 1;
|
|
||||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tag {
|
.status-tag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
left: 0;
|
||||||
|
right: auto;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
padding: 3px 10px;
|
padding: 4px 10px 4px 8px;
|
||||||
border-radius: 0 6px 0 8px;
|
border-radius: 0 0 10px 0;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.status-tag--open {
|
|
||||||
background: linear-gradient(135deg, #e8c84a, #d4a017);
|
|
||||||
color: #1a1a1a;
|
|
||||||
box-shadow: 0 2px 6px rgba(212, 160, 23, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tag--settled {
|
.status-tag--settled {
|
||||||
background: linear-gradient(180deg, #2a2a3a 0%, #1a1a28 100%);
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 20, 40, 0.7) 100%);
|
||||||
color: #8a9ab8;
|
color: var(--text-muted);
|
||||||
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-left: 1px solid rgba(120, 140, 180, 0.3);
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tag--pending {
|
.status-tag--pending {
|
||||||
background: linear-gradient(180deg, #3a3a2a 0%, #28251a 100%);
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(10, 53, 88, 0.55) 100%);
|
||||||
color: #c8a84e;
|
color: var(--text-muted);
|
||||||
border-bottom: 1px solid rgba(200, 168, 78, 0.3);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-left: 1px solid rgba(200, 168, 78, 0.3);
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bet-btn {
|
.bet-btn {
|
||||||
@@ -295,8 +290,8 @@ const liveScoreText = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bet-btn--view {
|
.bet-btn--view {
|
||||||
background: #1a1a1a;
|
background: var(--bg-body);
|
||||||
border: 1px solid #444;
|
border: 1px solid var(--border);
|
||||||
color: #aaa;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
345
apps/player/src/components/MessageListPanel.vue
Normal file
345
apps/player/src/components/MessageListPanel.vue
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onActivated, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||||
|
import GoldSpinner from './GoldSpinner.vue';
|
||||||
|
import ConfirmDialog from './ConfirmDialog.vue';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { usePlayerMessages, type DepositMessagePayload, type PlayerMessage } from '../composables/usePlayerMessages';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
loading,
|
||||||
|
listLoaded,
|
||||||
|
loadMessages,
|
||||||
|
refreshUnreadCount,
|
||||||
|
deleteMessage,
|
||||||
|
} = usePlayerMessages();
|
||||||
|
|
||||||
|
const page = ref(1);
|
||||||
|
const total = ref(0);
|
||||||
|
const deletingId = ref<string | null>(null);
|
||||||
|
const deleteConfirmVisible = ref(false);
|
||||||
|
const pendingDeleteId = ref<string | null>(null);
|
||||||
|
|
||||||
|
const hasMore = computed(() => messages.value.length < total.value);
|
||||||
|
|
||||||
|
function formatDate(createdAt?: string) {
|
||||||
|
if (!createdAt) return '';
|
||||||
|
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageTitle(item: PlayerMessage) {
|
||||||
|
if (item.type === 'DEPOSIT_APPROVED') return t('messages.deposit_approved_title');
|
||||||
|
if (item.type === 'DEPOSIT_REJECTED') return t('messages.deposit_rejected_title');
|
||||||
|
return item.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messagePreview(item: PlayerMessage) {
|
||||||
|
if (item.type === 'DEPOSIT_APPROVED' || item.type === 'DEPOSIT_REJECTED') {
|
||||||
|
const deposit = item.payload as DepositMessagePayload | null;
|
||||||
|
if (deposit?.orderNo) return deposit.orderNo;
|
||||||
|
}
|
||||||
|
return item.body.length > 80 ? `${item.body.slice(0, 80)}…` : item.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(id: string) {
|
||||||
|
router.push(`/messages/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPage(nextPage: number, append = false) {
|
||||||
|
const result = await loadMessages(nextPage, append);
|
||||||
|
if (result) {
|
||||||
|
page.value = result.page;
|
||||||
|
total.value = result.total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryLoad() {
|
||||||
|
if (!auth.token) return;
|
||||||
|
void fetchPage(1);
|
||||||
|
void refreshUnreadCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goLogin() {
|
||||||
|
auth.showLoginPrompt('/messages');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelete(id: string, event: Event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (deletingId.value) return;
|
||||||
|
pendingDeleteId.value = id;
|
||||||
|
deleteConfirmVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteCancel() {
|
||||||
|
pendingDeleteId.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const id = pendingDeleteId.value;
|
||||||
|
if (!id || deletingId.value) return;
|
||||||
|
deletingId.value = id;
|
||||||
|
try {
|
||||||
|
await deleteMessage(id);
|
||||||
|
total.value = Math.max(0, total.value - 1);
|
||||||
|
deleteConfirmVisible.value = false;
|
||||||
|
pendingDeleteId.value = null;
|
||||||
|
} finally {
|
||||||
|
deletingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(tryLoad);
|
||||||
|
onActivated(tryLoad);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="message-list-panel">
|
||||||
|
<div v-if="!auth.token" class="guest-hint">
|
||||||
|
<p>{{ t('auth.login_required') }}</p>
|
||||||
|
<button type="button" class="login-link" @click="goLogin">{{ t('auth.go_login') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="loading && !listLoaded" class="state">
|
||||||
|
<GoldSpinner :size="36" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!messages.length" class="empty">
|
||||||
|
<p>{{ t('messages.empty') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="list">
|
||||||
|
<div
|
||||||
|
v-for="item in messages"
|
||||||
|
:key="item.id"
|
||||||
|
class="list-row"
|
||||||
|
:class="{ unread: !item.isRead }"
|
||||||
|
>
|
||||||
|
<button type="button" class="row-body" @click="openDetail(item.id)">
|
||||||
|
<span class="row-dot" aria-hidden="true" />
|
||||||
|
<span class="row-main">
|
||||||
|
<span class="title-row">
|
||||||
|
<span class="title">{{ messageTitle(item) }}</span>
|
||||||
|
<span class="status-badge" :class="{ unread: !item.isRead }">
|
||||||
|
{{ item.isRead ? t('messages.status_read') : t('messages.status_unread') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="preview">{{ messagePreview(item) }}</span>
|
||||||
|
<span v-if="item.createdAt" class="date">{{ formatDate(item.createdAt) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="delete-btn"
|
||||||
|
:aria-label="t('messages.delete')"
|
||||||
|
:disabled="deletingId === item.id"
|
||||||
|
@click="onDelete(item.id, $event)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M6 7h12M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m2 0v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7h12Z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="hasMore" type="button" class="load-more" :disabled="loading" @click="fetchPage(page + 1, true)">
|
||||||
|
{{ loading ? t('common.loading_more') : t('messages.load_more') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
v-model:visible="deleteConfirmVisible"
|
||||||
|
:title="t('messages.delete')"
|
||||||
|
:message="t('messages.delete_confirm')"
|
||||||
|
:confirm-text="t('messages.delete')"
|
||||||
|
danger
|
||||||
|
:loading="!!deletingId"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
@cancel="onDeleteCancel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-list-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-hint,
|
||||||
|
.state,
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--surface-active);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row.unread .title {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row.unread .row-dot {
|
||||||
|
background: var(--primary);
|
||||||
|
box-shadow: 0 0 8px rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #d8d8d8;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #888;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.unread {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin-left: 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:active:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: rgba(239, 68, 68, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
margin-top: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -131,7 +131,7 @@ onUnmounted(() => {
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #0d0d0d;
|
background: var(--bg-body);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -166,8 +166,10 @@ onUnmounted(() => {
|
|||||||
z-index: 60;
|
z-index: 60;
|
||||||
width: min(260px, calc(100vw - 48px));
|
width: min(260px, calc(100vw - 48px));
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background: #141414;
|
background: var(--dropdown-bg);
|
||||||
border: 1px solid var(--border);
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
@@ -178,19 +180,19 @@ onUnmounted(() => {
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #0d0d0d;
|
background: var(--bg-body);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.country-search::placeholder {
|
.country-search::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.35);
|
color: var(--neutral);
|
||||||
}
|
}
|
||||||
|
|
||||||
.country-search:focus {
|
.country-search:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #555;
|
border-color: var(--border-gold-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.country-menu {
|
.country-menu {
|
||||||
@@ -213,7 +215,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.country-option:hover,
|
.country-option:hover,
|
||||||
.country-option.active {
|
.country-option.active {
|
||||||
background: var(--bg-hover);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.country-option .country-iso {
|
.country-option .country-iso {
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ function confirm() {
|
|||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
max-height: 85vh;
|
max-height: 85vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
|
background: var(--gradient-card);
|
||||||
border: 1px solid var(--border-gold-soft);
|
border: 1px solid var(--border-gold-soft);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 16px 14px 14px;
|
padding: 16px 14px 14px;
|
||||||
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
|
box-shadow: var(--shadow), var(--glow-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-x {
|
.close-x {
|
||||||
@@ -84,7 +84,7 @@ function confirm() {
|
|||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: var(--bg-body);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -115,7 +115,7 @@ function confirm() {
|
|||||||
|
|
||||||
.btn-cancel {
|
.btn-cancel {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #0a0a0a;
|
background: var(--bg-body);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function select(key: string) {
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #0a0a0a;
|
background: var(--bg-body);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -115,15 +115,15 @@ function select(key: string) {
|
|||||||
padding: 8px 4px 6px;
|
padding: 8px 4px 6px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #0a0a0a;
|
background: var(--bg-body);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-item.active {
|
.picker-item.active {
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--border-gold-soft);
|
||||||
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.18);
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||||
background: rgba(212, 175, 55, 0.08);
|
background: var(--surface-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-photo {
|
.picker-photo {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function drawCaptcha() {
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
ctx.fillStyle = '#0d0d0d';
|
ctx.fillStyle = '#002244';
|
||||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
@@ -141,12 +141,12 @@ defineExpose({ validate, refresh });
|
|||||||
height: 36px;
|
height: 36px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #0d0d0d;
|
background: var(--bg-body);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha-row:focus-within {
|
.captcha-row:focus-within {
|
||||||
border-color: #555;
|
border-color: var(--border-gold-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hp-field {
|
.hp-field {
|
||||||
@@ -174,7 +174,7 @@ defineExpose({ validate, refresh });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.captcha-input::placeholder {
|
.captcha-input::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.32);
|
color: var(--neutral);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
|
|||||||
@@ -43,26 +43,26 @@ withDefaults(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-watermark.won {
|
.status-watermark.won {
|
||||||
color: #3db865;
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-watermark.lost {
|
.status-watermark.lost {
|
||||||
color: #e05050;
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-watermark.push {
|
.status-watermark.push {
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-watermark.pending {
|
.status-watermark.pending {
|
||||||
color: #e8c84a;
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-watermark.closed {
|
.status-watermark.closed {
|
||||||
color: #c9a84a;
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-watermark.settled {
|
.status-watermark.settled {
|
||||||
color: #7a9ab8;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function logout() {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--border-gold-soft);
|
border: 1px solid var(--border-gold-soft);
|
||||||
background: linear-gradient(145deg, #2a2210, #141008);
|
background: var(--bg-card);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -115,9 +115,11 @@ function logout() {
|
|||||||
right: 0;
|
right: 0;
|
||||||
min-width: 168px;
|
min-width: 168px;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: #141414;
|
background: var(--dropdown-bg);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
z-index: 140;
|
z-index: 140;
|
||||||
}
|
}
|
||||||
@@ -142,7 +144,7 @@ function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-item:hover {
|
.menu-item:hover {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item.recharge {
|
.menu-item.recharge {
|
||||||
|
|||||||
56
apps/player/src/components/VsBadge.vue
Normal file
56
apps/player/src/components/VsBadge.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
size: 'md',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const sizeClass = computed(() => `vs-badge--${props.size}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="vs-badge"
|
||||||
|
:class="sizeClass"
|
||||||
|
role="img"
|
||||||
|
aria-label="VS"
|
||||||
|
>
|
||||||
|
VS
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vs-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant: small-caps;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs-badge--sm {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.11em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs-badge--md {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs-badge--lg {
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
171
apps/player/src/components/WalletBalanceCard.vue
Normal file
171
apps/player/src/components/WalletBalanceCard.vue
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { formatMoney } from '../utils/localeDisplay';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
availableBalance: unknown;
|
||||||
|
cashbackTotal?: string;
|
||||||
|
frozenBalance?: unknown;
|
||||||
|
compact?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
const balanceDisplay = computed(() => formatMoney(props.availableBalance, locale.value));
|
||||||
|
|
||||||
|
const balanceSizeClass = computed(() => {
|
||||||
|
const len = balanceDisplay.value.length;
|
||||||
|
if (len <= 10) return 'wallet-balance--xl';
|
||||||
|
if (len <= 13) return 'wallet-balance--lg';
|
||||||
|
if (len <= 16) return 'wallet-balance--md';
|
||||||
|
if (len <= 19) return 'wallet-balance--sm';
|
||||||
|
return 'wallet-balance--xs';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="wallet-card" :class="{ 'wallet-card--compact': compact }">
|
||||||
|
<div class="wallet-card-head">
|
||||||
|
<span class="wallet-kicker">{{ t('wallet.cash_balance') }}</span>
|
||||||
|
<RouterLink to="/wallet/recharge" class="wallet-recharge">
|
||||||
|
{{ t('recharge.title') }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="wallet-balance" :class="balanceSizeClass">{{ balanceDisplay }}</p>
|
||||||
|
|
||||||
|
<div class="wallet-metrics">
|
||||||
|
<div class="wallet-metric">
|
||||||
|
<span class="wallet-metric-label">{{ t('wallet.stats_cashback') }}</span>
|
||||||
|
<span class="wallet-metric-value">{{ formatMoney(cashbackTotal ?? '0', locale) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="wallet-metric">
|
||||||
|
<span class="wallet-metric-label">{{ t('wallet.unsettled') }}</span>
|
||||||
|
<span class="wallet-metric-value">{{ formatMoney(frozenBalance, locale) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wallet-card {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: var(--space-section);
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: var(--gradient-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 10%;
|
||||||
|
right: 10%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--gradient-card-shine);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-card--compact {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-kicker {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-recharge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-recharge:active {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-balance {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-balance--xl { font-size: 34px; }
|
||||||
|
.wallet-balance--lg { font-size: 30px; }
|
||||||
|
.wallet-balance--md { font-size: 26px; }
|
||||||
|
.wallet-balance--sm { font-size: 22px; }
|
||||||
|
.wallet-balance--xs { font-size: 19px; }
|
||||||
|
|
||||||
|
.wallet-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-metric:not(:first-child) {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-metric:first-child {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-metric:last-child {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-metric-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-metric-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,126 +1,260 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import { formatMoneyCompact, parseAmount } from '../utils/localeDisplay';
|
import { formatMoneyCompact, parseAmount } from '../utils/localeDisplay';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface Transaction {
|
interface Transaction {
|
||||||
|
|
||||||
transactionType: string;
|
transactionType: string;
|
||||||
|
|
||||||
amount: string;
|
amount: string;
|
||||||
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
transactionId?: string;
|
transactionId?: string;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
|
|
||||||
items: Transaction[];
|
items: Transaction[];
|
||||||
|
|
||||||
cashbackTotal?: string;
|
cashbackTotal?: string;
|
||||||
|
|
||||||
}>(), {
|
}>(), {
|
||||||
|
|
||||||
cashbackTotal: '0',
|
cashbackTotal: '0',
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const CASHBACK_TYPES = new Set(['CASHBACK', 'CASHBACK_DEPOSIT']);
|
const CASHBACK_TYPES = new Set(['CASHBACK', 'CASHBACK_DEPOSIT']);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const stats = computed(() => {
|
const stats = computed(() => {
|
||||||
|
|
||||||
let income = 0;
|
let income = 0;
|
||||||
|
|
||||||
let expense = 0;
|
let expense = 0;
|
||||||
|
|
||||||
let cashback = 0;
|
let cashback = 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
for (const tx of props.items) {
|
for (const tx of props.items) {
|
||||||
|
|
||||||
const amt = parseAmount(tx.amount);
|
const amt = parseAmount(tx.amount);
|
||||||
|
|
||||||
const isCb = CASHBACK_TYPES.has(tx.transactionType.toUpperCase());
|
const isCb = CASHBACK_TYPES.has(tx.transactionType.toUpperCase());
|
||||||
|
|
||||||
if (isCb) {
|
if (isCb) {
|
||||||
|
|
||||||
cashback += Math.abs(amt);
|
cashback += Math.abs(amt);
|
||||||
}
|
|
||||||
if (amt >= 0) income += amt;
|
|
||||||
else expense += Math.abs(amt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use server-side cashback total if provided (more accurate across all pages)
|
if (amt >= 0) income += amt;
|
||||||
if (props.cashbackTotal !== '0') {
|
|
||||||
cashback = Math.abs(parseAmount(props.cashbackTotal));
|
else expense += Math.abs(amt);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (props.cashbackTotal !== '0') {
|
||||||
|
|
||||||
|
cashback = Math.abs(parseAmount(props.cashbackTotal));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const net = income - expense;
|
const net = income - expense;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return { income, expense, net, cashback };
|
return { income, expense, net, cashback };
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="wallet-stats-panel">
|
<div class="wallet-stats-panel">
|
||||||
<div class="stats-row">
|
|
||||||
<div class="stat-item">
|
<div class="stats-grid">
|
||||||
|
|
||||||
|
<div class="stat-cell">
|
||||||
|
|
||||||
<span class="stat-label">{{ t('wallet.stats_income') }}</span>
|
<span class="stat-label">{{ t('wallet.stats_income') }}</span>
|
||||||
|
|
||||||
<span class="stat-val income">{{ formatMoneyCompact(stats.income, locale) }}</span>
|
<span class="stat-val income">{{ formatMoneyCompact(stats.income, locale) }}</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
|
||||||
|
<div class="stat-cell">
|
||||||
|
|
||||||
<span class="stat-label">{{ t('wallet.stats_expense') }}</span>
|
<span class="stat-label">{{ t('wallet.stats_expense') }}</span>
|
||||||
|
|
||||||
<span class="stat-val expense">{{ formatMoneyCompact(stats.expense, locale) }}</span>
|
<span class="stat-val expense">{{ formatMoneyCompact(stats.expense, locale) }}</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="stats-divider" />
|
<div class="stat-cell">
|
||||||
<div class="stats-row">
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-label">{{ t('wallet.stats_net') }}</span>
|
<span class="stat-label">{{ t('wallet.stats_net') }}</span>
|
||||||
<span class="stat-val" :class="stats.net >= 0 ? 'income' : 'expense'">
|
|
||||||
|
<span class="stat-val" :class="stats.net >= 0 ? 'income' : 'muted'">
|
||||||
|
|
||||||
{{ formatMoneyCompact(stats.net, locale) }}
|
{{ formatMoneyCompact(stats.net, locale) }}
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
|
||||||
|
<div class="stat-cell">
|
||||||
|
|
||||||
<span class="stat-label">{{ t('wallet.stats_cashback') }}</span>
|
<span class="stat-label">{{ t('wallet.stats_cashback') }}</span>
|
||||||
<span class="stat-val cashback">{{ formatMoneyCompact(stats.cashback, locale) }}</span>
|
|
||||||
|
<span class="stat-val">{{ formatMoneyCompact(stats.cashback, locale) }}</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.wallet-stats-panel {
|
.wallet-stats-panel {
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
position: relative;
|
||||||
|
|
||||||
|
background: var(--gradient-card);
|
||||||
|
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||||
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 12px;
|
padding: 14px 16px;
|
||||||
|
|
||||||
|
margin-bottom: var(--space-section);
|
||||||
|
|
||||||
|
box-shadow: var(--shadow-card-sm);
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-row {
|
|
||||||
|
|
||||||
|
.wallet-stats-panel::before {
|
||||||
|
|
||||||
|
content: '';
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
left: 10%;
|
||||||
|
|
||||||
|
right: 10%;
|
||||||
|
|
||||||
|
height: 1px;
|
||||||
|
|
||||||
|
background: var(--gradient-card-shine);
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
||||||
|
gap: 12px 16px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.stat-cell {
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-muted);
|
font-weight: 500;
|
||||||
letter-spacing: 0.03em;
|
|
||||||
margin-bottom: 2px;
|
color: var(--text-dim);
|
||||||
|
|
||||||
|
letter-spacing: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.stat-val {
|
.stat-val {
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 800;
|
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
color: var(--text);
|
||||||
|
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-val.income { color: #3db865; }
|
|
||||||
.stat-val.expense { color: #e05050; }
|
|
||||||
.stat-val.cashback { color: #f0b90b; }
|
|
||||||
|
|
||||||
.stats-divider {
|
|
||||||
height: 1px;
|
.stat-val.income { color: var(--success); }
|
||||||
margin: 6px 8px;
|
|
||||||
background: linear-gradient(90deg, transparent, var(--border), transparent);
|
.stat-val.expense { color: var(--text-muted); }
|
||||||
}
|
|
||||||
|
.stat-val.muted { color: var(--text-muted); }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -103,11 +103,11 @@ function formatOdds(odds: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
background: linear-gradient(180deg, #222 0%, #141414 100%);
|
background: var(--gradient-card);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 16px 16px 0 0;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border-gold-soft);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -118,12 +118,13 @@ function formatOdds(odds: string) {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 10px 14px 0;
|
padding: 10px 14px 0;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
background: var(--bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-foot {
|
.drawer-foot {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
|
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||||
background: rgba(14, 14, 14, 0.98);
|
background: var(--bg-card);
|
||||||
border-top: 1px solid var(--border-gold-soft);
|
border-top: 1px solid var(--border-gold-soft);
|
||||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
@@ -134,8 +135,8 @@ function formatOdds(odds: string) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px 14px 12px;
|
padding: 14px 14px 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: rgba(14, 14, 14, 0.98);
|
background: var(--bg-elevated);
|
||||||
border-bottom: 1px solid #2a2a2a;
|
border-bottom: 1px solid var(--border);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,9 +158,9 @@ function formatOdds(odds: string) {
|
|||||||
|
|
||||||
.slip-item {
|
.slip-item {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: #111;
|
background: var(--bg-body);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,25 +116,26 @@ function formatOdds(odds: string) {
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
padding: 6px 3px;
|
padding: 6px 3px;
|
||||||
background: #161616;
|
background: var(--bg-body);
|
||||||
border: 1px solid #282828;
|
border: 1px solid var(--border);
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-sm);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-card.selected {
|
.score-card.selected {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
background: rgba(212, 175, 55, 0.1);
|
background: var(--surface-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-card--locked {
|
.score-card--locked {
|
||||||
background: #111;
|
background: var(--bg-card);
|
||||||
border-color: #333;
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-card--locked .score-line,
|
.score-card--locked .score-line,
|
||||||
.score-card--locked .odds {
|
.score-card--locked .odds {
|
||||||
color: #666;
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-line {
|
.score-line {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function onPick(id: string) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.wrap {
|
.wrap {
|
||||||
padding: 6px 8px 8px;
|
padding: 6px 8px 8px;
|
||||||
background: #0c0c0c;
|
background: var(--bg-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrap.locked {
|
.wrap.locked {
|
||||||
@@ -78,26 +78,27 @@ function onPick(id: string) {
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 6px 4px;
|
padding: 6px 4px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
border: 1px solid #262626;
|
border: 1px solid var(--border);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.odds-btn.selected {
|
.odds-btn.selected {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
background: rgba(212, 175, 55, 0.1);
|
background: var(--surface-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.odds-btn--locked {
|
.odds-btn--locked {
|
||||||
background: #111;
|
background: var(--bg-body);
|
||||||
border-color: #333;
|
border-color: var(--border);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.odds-btn--locked .label,
|
.odds-btn--locked .label,
|
||||||
.odds-btn--locked .odds {
|
.odds-btn--locked .odds {
|
||||||
color: #666;
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
|
|||||||
@@ -1,30 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
label: string;
|
label: string;
|
||||||
promoLabel?: string;
|
promoLabel?: string;
|
||||||
expanded: boolean;
|
statusLabel?: string;
|
||||||
hasMarket: boolean;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ toggle: [] }>();
|
|
||||||
const { t } = useI18n();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<div class="row">
|
||||||
type="button"
|
|
||||||
class="row"
|
|
||||||
:class="{ expanded, disabled: !hasMarket }"
|
|
||||||
:disabled="!hasMarket"
|
|
||||||
@click="emit('toggle')"
|
|
||||||
>
|
|
||||||
<span class="row-label">{{ label }}</span>
|
<span class="row-label">{{ label }}</span>
|
||||||
<span v-if="promoLabel" class="row-promo">{{ promoLabel }}</span>
|
<span v-if="promoLabel" class="row-promo">{{ promoLabel }}</span>
|
||||||
<span v-if="!hasMarket" class="row-muted">{{ t('bet.market_closed') }}</span>
|
<span v-if="statusLabel" class="row-status">{{ statusLabel }}</span>
|
||||||
<span v-else class="row-chevron" :class="{ open: expanded }" aria-hidden="true">▸</span>
|
</div>
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -34,24 +21,14 @@ const { t } = useI18n();
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 13px 12px;
|
padding: 13px 12px;
|
||||||
background: transparent;
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: none;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background 0.15s ease;
|
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:not(.disabled):active {
|
.row::before {
|
||||||
background: rgba(255, 255, 255, 0.025);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row.expanded {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row.expanded::before {
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -62,16 +39,12 @@ const { t } = useI18n();
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row.disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-label {
|
.row-label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text);
|
color: var(--primary-light);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,32 +52,21 @@ const { t } = useI18n();
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ffb800;
|
color: var(--primary-light);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: rgba(255, 184, 0, 0.12);
|
background: var(--surface-accent);
|
||||||
border: 1px solid rgba(255, 184, 0, 0.35);
|
border: 1px solid var(--border-gold-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row.expanded .row-label {
|
.row-status {
|
||||||
color: var(--primary-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-muted {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-chevron {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), color 0.2s;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
.row-chevron.open {
|
color: #f0b429;
|
||||||
transform: rotate(90deg);
|
padding: 2px 6px;
|
||||||
color: var(--primary-light);
|
border-radius: 3px;
|
||||||
|
background: rgba(240, 180, 41, 0.12);
|
||||||
|
border: 1px solid rgba(240, 180, 41, 0.35);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -233,12 +233,12 @@ function formatOdds(odds: string) {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
|
background: var(--gradient-card);
|
||||||
border: 1px solid var(--border-gold-soft);
|
border: 1px solid var(--border-gold-soft);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 18px 16px 16px;
|
padding: 18px 16px 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
|
box-shadow: var(--shadow), var(--glow-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-x {
|
.close-x {
|
||||||
@@ -293,9 +293,9 @@ function formatOdds(odds: string) {
|
|||||||
.odds-badge {
|
.odds-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
background: rgba(198, 40, 40, 0.85);
|
background: rgba(255, 255, 255, 0.85);
|
||||||
border: 1px solid rgba(255, 120, 120, 0.35);
|
border: 1px solid var(--border-gold-soft);
|
||||||
color: #fff;
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -315,9 +315,9 @@ function formatOdds(odds: string) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: var(--bg-body);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance-label {
|
.balance-label {
|
||||||
@@ -345,9 +345,9 @@ function formatOdds(odds: string) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 11px 12px;
|
padding: 11px 12px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #0a0a0a;
|
background: var(--bg-body);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -357,13 +357,14 @@ function formatOdds(odds: string) {
|
|||||||
|
|
||||||
.stake-input:focus {
|
.stake-input:focus {
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--border-gold-soft);
|
||||||
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.15);
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stake-input::placeholder {
|
.stake-input::placeholder {
|
||||||
color: #555;
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-stakes {
|
.quick-stakes {
|
||||||
@@ -375,9 +376,9 @@ function formatOdds(odds: string) {
|
|||||||
.chip {
|
.chip {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 6px 4px;
|
padding: 6px 4px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -386,7 +387,7 @@ function formatOdds(odds: string) {
|
|||||||
.chip:active {
|
.chip:active {
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--border-gold-soft);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
background: rgba(212, 175, 55, 0.08);
|
background: var(--surface-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.est-return {
|
.est-return {
|
||||||
@@ -415,8 +416,8 @@ function formatOdds(odds: string) {
|
|||||||
|
|
||||||
.btn-cancel {
|
.btn-cancel {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
background: #1a1a1a;
|
background: var(--bg-body);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -439,9 +440,9 @@ function formatOdds(odds: string) {
|
|||||||
height: 44px;
|
height: 44px;
|
||||||
margin: 0 auto 8px;
|
margin: 0 auto 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid rgba(46, 158, 94, 0.8);
|
border: 2px solid var(--success);
|
||||||
background: rgba(46, 158, 94, 0.12);
|
background: rgba(46, 204, 113, 0.12);
|
||||||
color: #4ade80;
|
color: var(--success);
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 40px;
|
line-height: 40px;
|
||||||
@@ -457,9 +458,9 @@ function formatOdds(odds: string) {
|
|||||||
.summary {
|
.summary {
|
||||||
margin: 12px 0 14px;
|
margin: 12px 0 14px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: var(--bg-body);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-row {
|
.summary-row {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const headMeta = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="event-block">
|
<section class="event-block" :class="{ expanded }">
|
||||||
<button type="button" class="event-head" :class="{ 'is-expanded': expanded }" :aria-expanded="expanded" @click="emit('toggle')">
|
<button type="button" class="event-head" :class="{ 'is-expanded': expanded }" :aria-expanded="expanded" @click="emit('toggle')">
|
||||||
<span class="toggle-icon" :class="{ open: expanded }">
|
<span class="toggle-icon" :class="{ open: expanded }">
|
||||||
<span class="toggle-mark">{{ expanded ? '−' : '+' }}</span>
|
<span class="toggle-mark">{{ expanded ? '−' : '+' }}</span>
|
||||||
@@ -100,7 +100,7 @@ const headMeta = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: none;
|
background: var(--bg-elevated);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -122,7 +122,7 @@ const headMeta = computed(() => {
|
|||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-gold-soft);
|
border: 1px solid var(--border-gold-soft);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -159,14 +159,15 @@ const headMeta = computed(() => {
|
|||||||
.event-league {
|
.event-league {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-meta {
|
.event-meta {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #666;
|
color: var(--text-muted);
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-saishi {
|
.event-saishi {
|
||||||
@@ -176,16 +177,17 @@ const headMeta = computed(() => {
|
|||||||
max-width: 40px;
|
max-width: 40px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
border-left: 1px solid #2a2a2a;
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.options-scroll {
|
.options-scroll {
|
||||||
max-height: 408px;
|
max-height: 408px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: 8px 2px 2px;
|
padding: 12px 8px 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #333 transparent;
|
scrollbar-color: var(--border) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.options-grid {
|
.options-grid {
|
||||||
|
|||||||
@@ -76,9 +76,9 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 8px 4px 6px;
|
padding: 8px 4px 6px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
|
background: var(--bg-card);
|
||||||
border: 1px solid rgba(140, 140, 140, 0.35);
|
border: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -91,7 +91,7 @@ onUnmounted(() => {
|
|||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(135deg, rgba(100, 100, 100, 0.08) 0%, transparent 50%, rgba(80, 80, 80, 0.05) 100%);
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, transparent 50%, rgba(0, 51, 102, 0.08) 100%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ function closeModal() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.outright-panel {
|
.outright-panel {
|
||||||
padding: 4px 12px 0;
|
padding: 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-summary {
|
.panel-summary {
|
||||||
|
|||||||
174
apps/player/src/composables/useDepositNotifications.ts
Normal file
174
apps/player/src/composables/useDepositNotifications.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import api from '../api';
|
||||||
|
import { usePlayerProfile } from './usePlayerProfile';
|
||||||
|
import { usePlayerMessages } from './usePlayerMessages';
|
||||||
|
|
||||||
|
interface DepositOrderRow {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
amount: string;
|
||||||
|
status: string;
|
||||||
|
rejectReason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_FAST_MS = 8_000;
|
||||||
|
const POLL_SLOW_MS = 30_000;
|
||||||
|
const TRACKED_STORAGE_KEY = 'player_deposit_tracked_pending';
|
||||||
|
|
||||||
|
const lastStatus = new Map<string, string>();
|
||||||
|
const trackedPending = new Set<string>();
|
||||||
|
const notifiedKeys = new Set<string>();
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let pollingActive = false;
|
||||||
|
|
||||||
|
function notifyKey(orderId: string, type: 'approved' | 'rejected') {
|
||||||
|
return `${orderId}:${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTrackedFromStorage() {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(TRACKED_STORAGE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const ids: string[] = JSON.parse(raw);
|
||||||
|
for (const id of ids) {
|
||||||
|
if (id) trackedPending.add(String(id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistTrackedToStorage() {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(TRACKED_STORAGE_KEY, JSON.stringify([...trackedPending]));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTracked(orderId: string) {
|
||||||
|
trackedPending.add(orderId);
|
||||||
|
persistTrackedToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTracked(orderId: string) {
|
||||||
|
if (!trackedPending.delete(orderId)) return;
|
||||||
|
persistTrackedToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPendingInterest() {
|
||||||
|
return trackedPending.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePoll(intervalMs: number) {
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
void pollOnce();
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustPollInterval() {
|
||||||
|
if (!pollingActive) return;
|
||||||
|
schedulePoll(hasPendingInterest() ? POLL_FAST_MS : POLL_SLOW_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackPendingOrder(orderId: string) {
|
||||||
|
const id = String(orderId);
|
||||||
|
if (!id) return;
|
||||||
|
addTracked(id);
|
||||||
|
lastStatus.set(id, 'PENDING');
|
||||||
|
adjustPollInterval();
|
||||||
|
void pollOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldNotify(orderId: string, prev: string | undefined, next: string) {
|
||||||
|
if (next !== 'APPROVED' && next !== 'REJECTED') return false;
|
||||||
|
if (prev === 'PENDING') return true;
|
||||||
|
if (trackedPending.has(orderId)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusChange(order: DepositOrderRow): boolean {
|
||||||
|
const prev = lastStatus.get(order.id);
|
||||||
|
const next = order.status;
|
||||||
|
lastStatus.set(order.id, next);
|
||||||
|
|
||||||
|
if (next === 'PENDING') {
|
||||||
|
addTracked(order.id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTracked(order.id);
|
||||||
|
|
||||||
|
if (!shouldNotify(order.id, prev, next)) return false;
|
||||||
|
|
||||||
|
const type = next === 'APPROVED' ? 'approved' : 'rejected';
|
||||||
|
const key = notifyKey(order.id, type);
|
||||||
|
if (notifiedKeys.has(key)) return false;
|
||||||
|
notifiedKeys.add(key);
|
||||||
|
|
||||||
|
void usePlayerMessages().refreshUnreadCount();
|
||||||
|
return next === 'APPROVED';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollOnce() {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/player/deposit-orders', { params: { page: 1 } });
|
||||||
|
const items: DepositOrderRow[] = data.data?.items ?? [];
|
||||||
|
const { refreshProfile } = usePlayerProfile();
|
||||||
|
|
||||||
|
let needsProfileRefresh = false;
|
||||||
|
for (const order of items) {
|
||||||
|
if (handleStatusChange(order)) needsProfileRefresh = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsProfileRefresh) await refreshProfile();
|
||||||
|
adjustPollInterval();
|
||||||
|
} catch {
|
||||||
|
/* silent retry on next tick */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (!document.hidden && pollingActive) void pollOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollingActive) {
|
||||||
|
void pollOnce();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pollingActive = true;
|
||||||
|
loadTrackedFromStorage();
|
||||||
|
for (const id of trackedPending) {
|
||||||
|
if (!lastStatus.has(id)) lastStatus.set(id, 'PENDING');
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
void pollOnce();
|
||||||
|
schedulePoll(hasPendingInterest() ? POLL_FAST_MS : POLL_SLOW_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
pollingActive = false;
|
||||||
|
trackedPending.clear();
|
||||||
|
lastStatus.clear();
|
||||||
|
notifiedKeys.clear();
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(TRACKED_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDepositNotifications() {
|
||||||
|
return {
|
||||||
|
trackPendingOrder,
|
||||||
|
startPolling,
|
||||||
|
stopPolling,
|
||||||
|
pollOnce,
|
||||||
|
};
|
||||||
|
}
|
||||||
23
apps/player/src/composables/useInboxFeature.ts
Normal file
23
apps/player/src/composables/useInboxFeature.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { usePlayerHome } from './usePlayerHome';
|
||||||
|
|
||||||
|
/** 玩家端站内邮箱功能开关(来自 /player/home) */
|
||||||
|
export function useInboxFeature() {
|
||||||
|
const { homeRaw } = usePlayerHome();
|
||||||
|
|
||||||
|
const inboxEnabled = computed(() => homeRaw.value?.inboxEnabled !== false);
|
||||||
|
|
||||||
|
const hubRoute = computed(() =>
|
||||||
|
inboxEnabled.value ? '/messages' : '/messages?tab=support',
|
||||||
|
);
|
||||||
|
|
||||||
|
const hubOpenLabelKey = computed(() =>
|
||||||
|
inboxEnabled.value ? 'inbox_hub.open' : 'inbox_hub.open_support',
|
||||||
|
);
|
||||||
|
|
||||||
|
const hubTitleKey = computed(() =>
|
||||||
|
inboxEnabled.value ? 'inbox_hub.title' : 'inbox_hub.tab_support',
|
||||||
|
);
|
||||||
|
|
||||||
|
return { inboxEnabled, hubRoute, hubOpenLabelKey, hubTitleKey };
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import api from '../api';
|
|||||||
import type { BannerItem } from '../components/BannerCarousel.vue';
|
import type { BannerItem } from '../components/BannerCarousel.vue';
|
||||||
import { resolveBanners } from '../constants/defaultBanner';
|
import { resolveBanners } from '../constants/defaultBanner';
|
||||||
import { resolveAnnouncements } from '../constants/defaultAnnouncement';
|
import { resolveAnnouncements } from '../constants/defaultAnnouncement';
|
||||||
|
import { stripHtml } from '../utils/html';
|
||||||
|
|
||||||
export interface PlayerHomeMatch {
|
export interface PlayerHomeMatch {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,12 +21,52 @@ export interface PlayerHomeMatch {
|
|||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlayerContentItem {
|
||||||
|
id: string;
|
||||||
|
contentType?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
linkType?: string | null;
|
||||||
|
linkTarget?: string | null;
|
||||||
|
translation?: {
|
||||||
|
title?: string | null;
|
||||||
|
body?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlayerAnnouncementItem = PlayerContentItem;
|
||||||
|
|
||||||
interface HomePayload {
|
interface HomePayload {
|
||||||
banners?: BannerItem[];
|
banners?: PlayerContentItem[];
|
||||||
announcements?: Array<{ translation?: { title?: string; body?: string } }>;
|
announcements?: PlayerAnnouncementItem[];
|
||||||
ticker?: Array<{ translation?: { title?: string; body?: string } }>;
|
ticker?: PlayerAnnouncementItem[];
|
||||||
notices?: Array<{ translation?: { title?: string; body?: string } }>;
|
notices?: PlayerAnnouncementItem[];
|
||||||
hotMatches?: PlayerHomeMatch[];
|
hotMatches?: PlayerHomeMatch[];
|
||||||
|
upcomingMatches?: PlayerHomeMatch[];
|
||||||
|
inboxEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMatchList(
|
||||||
|
existing: PlayerHomeMatch[] | undefined,
|
||||||
|
fresh: PlayerHomeMatch[] | undefined,
|
||||||
|
): PlayerHomeMatch[] | undefined {
|
||||||
|
if (!fresh) return existing;
|
||||||
|
if (!existing) return fresh;
|
||||||
|
|
||||||
|
const freshMap = new Map(fresh.map((m) => [m.id, m]));
|
||||||
|
for (const m of existing) {
|
||||||
|
const f = freshMap.get(m.id);
|
||||||
|
if (f) Object.assign(m, f);
|
||||||
|
}
|
||||||
|
const existingIds = new Set(existing.map((m) => m.id));
|
||||||
|
for (const fm of fresh) {
|
||||||
|
if (!existingIds.has(fm.id)) existing.push(fm);
|
||||||
|
}
|
||||||
|
for (let i = existing.length - 1; i >= 0; i--) {
|
||||||
|
if (!freshMap.has(existing[i].id)) existing.splice(i, 1);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const homeRaw = ref<HomePayload | null>(null);
|
const homeRaw = ref<HomePayload | null>(null);
|
||||||
@@ -40,12 +81,22 @@ function collectAnnouncementLines(data: HomePayload | null): string[] {
|
|||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (const item of source) {
|
for (const item of source) {
|
||||||
const text = item.translation?.title || item.translation?.body;
|
const title = item.translation?.title?.trim();
|
||||||
|
const text = title || stripHtml(item.translation?.body ?? '');
|
||||||
if (text) lines.push(text);
|
if (text) lines.push(text);
|
||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectAnnouncementItems(data: HomePayload | null): PlayerAnnouncementItem[] {
|
||||||
|
if (!data) return [];
|
||||||
|
const source =
|
||||||
|
data.announcements && data.announcements.length > 0
|
||||||
|
? data.announcements
|
||||||
|
: [...(data.ticker ?? []), ...(data.notices ?? [])];
|
||||||
|
return source.filter((item) => item.translation?.title || item.translation?.body);
|
||||||
|
}
|
||||||
|
|
||||||
/** 管理端公共内容 → 玩家端首页/跑马灯(单例,避免重复请求) */
|
/** 管理端公共内容 → 玩家端首页/跑马灯(单例,避免重复请求) */
|
||||||
export function usePlayerHome() {
|
export function usePlayerHome() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -64,26 +115,10 @@ export function usePlayerHome() {
|
|||||||
existing.announcements = fresh.announcements;
|
existing.announcements = fresh.announcements;
|
||||||
existing.ticker = fresh.ticker;
|
existing.ticker = fresh.ticker;
|
||||||
existing.notices = fresh.notices;
|
existing.notices = fresh.notices;
|
||||||
|
existing.inboxEnabled = fresh.inboxEnabled;
|
||||||
|
|
||||||
if (fresh.hotMatches && existing.hotMatches) {
|
existing.hotMatches = mergeMatchList(existing.hotMatches, fresh.hotMatches);
|
||||||
const freshMap = new Map(fresh.hotMatches.map((m) => [m.id, m]));
|
existing.upcomingMatches = mergeMatchList(existing.upcomingMatches, fresh.upcomingMatches);
|
||||||
for (const m of existing.hotMatches) {
|
|
||||||
const f = freshMap.get(m.id);
|
|
||||||
if (f) Object.assign(m, f);
|
|
||||||
}
|
|
||||||
// 处理新增或删除的比赛
|
|
||||||
const existingIds = new Set(existing.hotMatches.map((m) => m.id));
|
|
||||||
for (const fm of fresh.hotMatches) {
|
|
||||||
if (!existingIds.has(fm.id)) existing.hotMatches.push(fm);
|
|
||||||
}
|
|
||||||
for (let i = existing.hotMatches.length - 1; i >= 0; i--) {
|
|
||||||
if (!freshMap.has(existing.hotMatches[i].id)) {
|
|
||||||
existing.hotMatches.splice(i, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
existing.hotMatches = fresh.hotMatches;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
homeRaw.value = fresh;
|
homeRaw.value = fresh;
|
||||||
}
|
}
|
||||||
@@ -94,18 +129,24 @@ export function usePlayerHome() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const banners = computed(() => resolveBanners(homeRaw.value?.banners));
|
const banners = computed(() => resolveBanners(homeRaw.value?.banners as BannerItem[] | undefined));
|
||||||
|
const bannerItems = computed(() => homeRaw.value?.banners ?? []);
|
||||||
const announcements = computed(() =>
|
const announcements = computed(() =>
|
||||||
resolveAnnouncements(collectAnnouncementLines(homeRaw.value), t('home.announcement_default')),
|
resolveAnnouncements(collectAnnouncementLines(homeRaw.value), t('home.announcement_default')),
|
||||||
);
|
);
|
||||||
|
const announcementItems = computed(() => collectAnnouncementItems(homeRaw.value));
|
||||||
const hotMatches = computed(() => homeRaw.value?.hotMatches ?? []);
|
const hotMatches = computed(() => homeRaw.value?.hotMatches ?? []);
|
||||||
|
const upcomingMatches = computed(() => homeRaw.value?.upcomingMatches ?? []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
homeRaw,
|
homeRaw,
|
||||||
loading,
|
loading,
|
||||||
load,
|
load,
|
||||||
banners,
|
banners,
|
||||||
|
bannerItems,
|
||||||
announcements,
|
announcements,
|
||||||
|
announcementItems,
|
||||||
hotMatches,
|
hotMatches,
|
||||||
|
upcomingMatches,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
130
apps/player/src/composables/usePlayerMessages.ts
Normal file
130
apps/player/src/composables/usePlayerMessages.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export type PlayerMessageType =
|
||||||
|
| 'DEPOSIT_APPROVED'
|
||||||
|
| 'DEPOSIT_REJECTED'
|
||||||
|
| 'BANNER_PROMO'
|
||||||
|
| 'ANNOUNCEMENT_PROMO';
|
||||||
|
|
||||||
|
export type DepositMessagePayload = {
|
||||||
|
depositOrderId?: string;
|
||||||
|
orderNo?: string;
|
||||||
|
amount?: string;
|
||||||
|
approvedAmount?: string | null;
|
||||||
|
rejectReason?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BannerPromoPayload = {
|
||||||
|
contentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PlayerMessage {
|
||||||
|
id: string;
|
||||||
|
type: PlayerMessageType | string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
payload: DepositMessagePayload | BannerPromoPayload | null;
|
||||||
|
readAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
isRead: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageListResponse {
|
||||||
|
items: PlayerMessage[];
|
||||||
|
total: number;
|
||||||
|
unreadCount: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadCount = ref(0);
|
||||||
|
const messages = ref<PlayerMessage[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const listLoaded = ref(false);
|
||||||
|
|
||||||
|
async function refreshUnreadCount() {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/player/messages/unread-count');
|
||||||
|
unreadCount.value = Number(data.data?.unreadCount ?? 0);
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages(page = 1, append = false) {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/player/messages', { params: { page, pageSize: 20 } });
|
||||||
|
const payload = data.data as MessageListResponse;
|
||||||
|
const items = payload?.items ?? [];
|
||||||
|
messages.value = append ? [...messages.value, ...items] : items;
|
||||||
|
unreadCount.value = Number(payload?.unreadCount ?? unreadCount.value);
|
||||||
|
listLoaded.value = true;
|
||||||
|
return payload;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessageDetail(id: string) {
|
||||||
|
const { data } = await api.get(`/player/messages/${id}`);
|
||||||
|
return data.data as PlayerMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markMessageRead(id: string) {
|
||||||
|
const { data } = await api.patch(`/player/messages/${id}/read`);
|
||||||
|
const updated = data.data as PlayerMessage;
|
||||||
|
messages.value = messages.value.map((item) =>
|
||||||
|
item.id === id ? { ...item, ...updated, isRead: true } : item,
|
||||||
|
);
|
||||||
|
if (unreadCount.value > 0) unreadCount.value -= 1;
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead() {
|
||||||
|
await api.patch('/player/messages/read-all');
|
||||||
|
messages.value = messages.value.map((item) => ({
|
||||||
|
...item,
|
||||||
|
isRead: true,
|
||||||
|
readAt: item.readAt ?? new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
unreadCount.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMessage(id: string) {
|
||||||
|
const { data } = await api.delete(`/player/messages/${id}`);
|
||||||
|
const wasUnread = Boolean(data.data?.wasUnread);
|
||||||
|
messages.value = messages.value.filter((item) => item.id !== id);
|
||||||
|
if (wasUnread && unreadCount.value > 0) unreadCount.value -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAllMessages() {
|
||||||
|
await api.delete('/player/messages');
|
||||||
|
messages.value = [];
|
||||||
|
unreadCount.value = 0;
|
||||||
|
listLoaded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMessagesState() {
|
||||||
|
unreadCount.value = 0;
|
||||||
|
messages.value = [];
|
||||||
|
listLoaded.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayerMessages() {
|
||||||
|
return {
|
||||||
|
unreadCount,
|
||||||
|
messages,
|
||||||
|
loading,
|
||||||
|
listLoaded,
|
||||||
|
refreshUnreadCount,
|
||||||
|
loadMessages,
|
||||||
|
loadMessageDetail,
|
||||||
|
markMessageRead,
|
||||||
|
markAllRead,
|
||||||
|
deleteMessage,
|
||||||
|
deleteAllMessages,
|
||||||
|
resetMessagesState,
|
||||||
|
};
|
||||||
|
}
|
||||||
37
apps/player/src/composables/usePresencePing.ts
Normal file
37
apps/player/src/composables/usePresencePing.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
const PING_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let active = false;
|
||||||
|
|
||||||
|
async function sendPing() {
|
||||||
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
try {
|
||||||
|
await api.post('/player/presence/ping');
|
||||||
|
} catch {
|
||||||
|
/* ignore transient network errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (!active) return;
|
||||||
|
if (document.visibilityState === 'visible') void sendPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPresencePing() {
|
||||||
|
if (active) return;
|
||||||
|
active = true;
|
||||||
|
void sendPing();
|
||||||
|
pingTimer = setInterval(() => void sendPing(), PING_INTERVAL_MS);
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopPresencePing() {
|
||||||
|
active = false;
|
||||||
|
if (pingTimer) {
|
||||||
|
clearInterval(pingTimer);
|
||||||
|
pingTimer = null;
|
||||||
|
}
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
}
|
||||||
@@ -8,11 +8,16 @@ export default {
|
|||||||
load_failed: 'Failed to load',
|
load_failed: 'Failed to load',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
back_to_top: 'Back to top',
|
back_to_top: 'Back to top',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
confirm: 'Confirm',
|
||||||
},
|
},
|
||||||
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
|
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
|
||||||
home: {
|
home: {
|
||||||
hot_matches: 'Hot matches',
|
hot_matches: 'Hot matches',
|
||||||
|
hot_tab: 'Hot',
|
||||||
|
upcoming_tab: 'Upcoming',
|
||||||
no_matches: 'No matches',
|
no_matches: 'No matches',
|
||||||
|
upcoming_empty: 'No matches kicking off in the next 3 days',
|
||||||
announcement_badge: 'Notice',
|
announcement_badge: 'Notice',
|
||||||
announcement_default:
|
announcement_default:
|
||||||
'Welcome to TheBet365 · Football events are live · Bet responsibly',
|
'Welcome to TheBet365 · Football events are live · Bet responsibly',
|
||||||
@@ -21,6 +26,67 @@ export default {
|
|||||||
banner_slide: 'Slide {n}',
|
banner_slide: 'Slide {n}',
|
||||||
banner_fallback: 'Banner',
|
banner_fallback: 'Banner',
|
||||||
},
|
},
|
||||||
|
announcements: {
|
||||||
|
title: 'Announcements',
|
||||||
|
detail_title: 'Announcement',
|
||||||
|
empty: 'No announcements yet',
|
||||||
|
not_found: 'This announcement is unavailable',
|
||||||
|
view_all: 'View all announcements',
|
||||||
|
type_notice: 'Notice',
|
||||||
|
type_ticker: 'Ticker',
|
||||||
|
type_banner: 'Banner',
|
||||||
|
related_link: 'Related link',
|
||||||
|
go_link: 'Go to page',
|
||||||
|
open_link: 'Open link',
|
||||||
|
back: 'Back',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
placeholder: 'Search teams or leagues',
|
||||||
|
no_results: 'No matches found',
|
||||||
|
results_count: '{count} matches found',
|
||||||
|
hint: 'Enter a team or league to filter on the Bet page',
|
||||||
|
},
|
||||||
|
deposit_notify: {
|
||||||
|
approved_title: 'Deposit approved',
|
||||||
|
rejected_title: 'Deposit rejected',
|
||||||
|
view_history: 'View deposit history',
|
||||||
|
view_messages: 'Open inbox',
|
||||||
|
dismiss: 'Dismiss',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
title: 'Inbox',
|
||||||
|
detail_title: 'Message',
|
||||||
|
empty: 'No messages yet',
|
||||||
|
not_found: 'Message not found',
|
||||||
|
view_all: 'Back to inbox',
|
||||||
|
back: 'Back',
|
||||||
|
mark_all_read: 'Mark all read',
|
||||||
|
load_more: 'Load more',
|
||||||
|
delete: 'Delete',
|
||||||
|
delete_all: 'Delete all',
|
||||||
|
delete_confirm: 'Delete this message?',
|
||||||
|
delete_all_confirm: 'Delete all messages? This cannot be undone.',
|
||||||
|
banner_promo_view: 'View promotion',
|
||||||
|
content_promo_view: 'View details',
|
||||||
|
status_unread: 'Unread',
|
||||||
|
status_read: 'Read',
|
||||||
|
deposit_approved_title: 'Deposit approved',
|
||||||
|
deposit_rejected_title: 'Deposit rejected',
|
||||||
|
deposit_approved_body: 'Order {orderNo} approved. Requested {amount}, credited {approvedAmount}.',
|
||||||
|
deposit_rejected_body: 'Order {orderNo} ({amount}) was rejected. {reason}',
|
||||||
|
reject_reason: 'Rejection reason',
|
||||||
|
no_reason: 'No reason provided',
|
||||||
|
view_recharge_history: 'View recharge history',
|
||||||
|
unread_badge: '{count} unread',
|
||||||
|
open_inbox: 'Open inbox',
|
||||||
|
},
|
||||||
|
inbox_hub: {
|
||||||
|
title: 'Messages & Support',
|
||||||
|
tab_messages: 'Inbox',
|
||||||
|
tab_support: 'Support',
|
||||||
|
open: 'Open messages and support',
|
||||||
|
open_support: 'Open support',
|
||||||
|
},
|
||||||
history: {
|
history: {
|
||||||
league_default: 'Football',
|
league_default: 'Football',
|
||||||
stake: 'Stake',
|
stake: 'Stake',
|
||||||
@@ -304,6 +370,8 @@ export default {
|
|||||||
parlay_block_quarter: 'Quarter-ball HDP/O-U cannot be parlayed',
|
parlay_block_quarter: 'Quarter-ball HDP/O-U cannot be parlayed',
|
||||||
parlay_block_not_allowed: 'This market cannot be parlayed',
|
parlay_block_not_allowed: 'This market cannot be parlayed',
|
||||||
parlay_need_more: 'Select at least 2 legs for parlay',
|
parlay_need_more: 'Select at least 2 legs for parlay',
|
||||||
|
market_status_suspended: 'Suspended',
|
||||||
|
market_status_closed: 'Closed',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
@@ -387,12 +455,18 @@ export default {
|
|||||||
slip_tab_parlay: 'Parlay',
|
slip_tab_parlay: 'Parlay',
|
||||||
slip_parlay_empty_hint: 'Pick a selection, then tap Add to parlay',
|
slip_parlay_empty_hint: 'Pick a selection, then tap Add to parlay',
|
||||||
slip_add_parlay: 'Add to parlay',
|
slip_add_parlay: 'Add to parlay',
|
||||||
|
slip_parlay_only_hint: 'This market is parlay-only. Tap Add to parlay to bet.',
|
||||||
slip_parlay_same_match: 'Parlay selections cannot be from the same match. Remove the same-match leg first.',
|
slip_parlay_same_match: 'Parlay selections cannot be from the same match. Remove the same-match leg first.',
|
||||||
slip_parlay_count: '{n} parlay leg(s)',
|
slip_parlay_count: '{n} parlay leg(s)',
|
||||||
slip_total_stake: 'Total stake',
|
slip_total_stake: 'Total stake',
|
||||||
slip_currency: 'Amount',
|
slip_currency: 'Amount',
|
||||||
slip_min: 'Min',
|
slip_min: 'Min',
|
||||||
slip_min_error: 'Minimum stake is {amount}',
|
slip_min_error: 'Minimum stake is {amount}',
|
||||||
|
odds_changed: 'Odds have changed on some selections. Accept to continue.',
|
||||||
|
odds_suspended: 'Some selections are suspended or closed. Remove them to continue.',
|
||||||
|
accept_changes_place: 'Accept changes & place bet',
|
||||||
|
odds_was: 'Was',
|
||||||
|
odds_now: 'Now',
|
||||||
place_success: 'Bet placed',
|
place_success: 'Bet placed',
|
||||||
place_failed: 'Bet failed',
|
place_failed: 'Bet failed',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export default {
|
|||||||
load_failed: 'Gagal dimuat',
|
load_failed: 'Gagal dimuat',
|
||||||
retry: 'Cuba lagi',
|
retry: 'Cuba lagi',
|
||||||
back_to_top: 'Kembali ke atas',
|
back_to_top: 'Kembali ke atas',
|
||||||
|
cancel: 'Batal',
|
||||||
|
confirm: 'Sahkan',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Laman Utama',
|
home: 'Laman Utama',
|
||||||
@@ -18,7 +20,10 @@ export default {
|
|||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
hot_matches: 'Perlawanan popular',
|
hot_matches: 'Perlawanan popular',
|
||||||
|
hot_tab: 'Popular',
|
||||||
|
upcoming_tab: 'Terdekat',
|
||||||
no_matches: 'Tiada perlawanan',
|
no_matches: 'Tiada perlawanan',
|
||||||
|
upcoming_empty: 'Tiada perlawanan dalam 3 hari akan datang',
|
||||||
announcement_badge: 'Notis',
|
announcement_badge: 'Notis',
|
||||||
announcement_default:
|
announcement_default:
|
||||||
'Selamat datang ke TheBet365 · Perlawanan bola sepak sedang berlangsung · Bertaruh secara bertanggungjawab',
|
'Selamat datang ke TheBet365 · Perlawanan bola sepak sedang berlangsung · Bertaruh secara bertanggungjawab',
|
||||||
@@ -27,6 +32,67 @@ export default {
|
|||||||
banner_slide: 'Slaid {n}',
|
banner_slide: 'Slaid {n}',
|
||||||
banner_fallback: 'Banner',
|
banner_fallback: 'Banner',
|
||||||
},
|
},
|
||||||
|
announcements: {
|
||||||
|
title: 'Pusat Pengumuman',
|
||||||
|
detail_title: 'Butiran Pengumuman',
|
||||||
|
empty: 'Tiada pengumuman',
|
||||||
|
not_found: 'Pengumuman tidak tersedia',
|
||||||
|
view_all: 'Lihat semua pengumuman',
|
||||||
|
type_notice: 'Notis',
|
||||||
|
type_ticker: 'Ticker',
|
||||||
|
type_banner: 'Banner',
|
||||||
|
related_link: 'Pautan berkaitan',
|
||||||
|
go_link: 'Pergi ke halaman',
|
||||||
|
open_link: 'Buka pautan',
|
||||||
|
back: 'Kembali',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
placeholder: 'Cari pasukan atau liga',
|
||||||
|
no_results: 'Tiada perlawanan dijumpai',
|
||||||
|
results_count: '{count} perlawanan dijumpai',
|
||||||
|
hint: 'Masukkan pasukan atau liga untuk tapis di halaman Pertaruhan',
|
||||||
|
},
|
||||||
|
deposit_notify: {
|
||||||
|
approved_title: 'Deposit diluluskan',
|
||||||
|
rejected_title: 'Deposit ditolak',
|
||||||
|
view_history: 'Lihat sejarah deposit',
|
||||||
|
view_messages: 'Buka peti mesej',
|
||||||
|
dismiss: 'Tutup',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
title: 'Peti Mesej',
|
||||||
|
detail_title: 'Butiran Mesej',
|
||||||
|
empty: 'Tiada mesej',
|
||||||
|
not_found: 'Mesej tidak dijumpai',
|
||||||
|
view_all: 'Kembali ke peti mesej',
|
||||||
|
back: 'Kembali',
|
||||||
|
mark_all_read: 'Tanda semua dibaca',
|
||||||
|
load_more: 'Muat lagi',
|
||||||
|
delete: 'Padam',
|
||||||
|
delete_all: 'Padam semua',
|
||||||
|
delete_confirm: 'Padam mesej ini?',
|
||||||
|
delete_all_confirm: 'Padam semua mesej? Tindakan ini tidak boleh dibatalkan.',
|
||||||
|
banner_promo_view: 'Lihat promosi',
|
||||||
|
content_promo_view: 'Lihat butiran',
|
||||||
|
status_unread: 'Belum dibaca',
|
||||||
|
status_read: 'Dibaca',
|
||||||
|
deposit_approved_title: 'Deposit diluluskan',
|
||||||
|
deposit_rejected_title: 'Deposit ditolak',
|
||||||
|
deposit_approved_body: 'Pesanan {orderNo} diluluskan. Diminta {amount}, dikreditkan {approvedAmount}.',
|
||||||
|
deposit_rejected_body: 'Pesanan {orderNo} ({amount}) ditolak. {reason}',
|
||||||
|
reject_reason: 'Sebab penolakan',
|
||||||
|
no_reason: 'Tiada sebab diberikan',
|
||||||
|
view_recharge_history: 'Lihat sejarah deposit',
|
||||||
|
unread_badge: '{count} belum dibaca',
|
||||||
|
open_inbox: 'Buka peti mesej',
|
||||||
|
},
|
||||||
|
inbox_hub: {
|
||||||
|
title: 'Mesej & Sokongan',
|
||||||
|
tab_messages: 'Peti Mesej',
|
||||||
|
tab_support: 'Sokongan',
|
||||||
|
open: 'Buka mesej dan sokongan',
|
||||||
|
open_support: 'Buka sokongan',
|
||||||
|
},
|
||||||
history: {
|
history: {
|
||||||
league_default: 'Bola Sepak',
|
league_default: 'Bola Sepak',
|
||||||
stake: 'Jumlah',
|
stake: 'Jumlah',
|
||||||
@@ -310,6 +376,8 @@ export default {
|
|||||||
parlay_block_quarter: 'HDP/O-U suku bola tidak boleh parlay',
|
parlay_block_quarter: 'HDP/O-U suku bola tidak boleh parlay',
|
||||||
parlay_block_not_allowed: 'Pasaran ini tidak boleh parlay',
|
parlay_block_not_allowed: 'Pasaran ini tidak boleh parlay',
|
||||||
parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan',
|
parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan',
|
||||||
|
market_status_suspended: 'Digantung',
|
||||||
|
market_status_closed: 'Ditutup',
|
||||||
back: 'Kembali',
|
back: 'Kembali',
|
||||||
refresh: 'Muat semula',
|
refresh: 'Muat semula',
|
||||||
download: 'Muat turun',
|
download: 'Muat turun',
|
||||||
@@ -393,12 +461,18 @@ export default {
|
|||||||
slip_tab_parlay: 'Parlay',
|
slip_tab_parlay: 'Parlay',
|
||||||
slip_parlay_empty_hint: 'Pilih satu odds, kemudian ketik Tambah ke parlay',
|
slip_parlay_empty_hint: 'Pilih satu odds, kemudian ketik Tambah ke parlay',
|
||||||
slip_add_parlay: 'Tambah ke parlay',
|
slip_add_parlay: 'Tambah ke parlay',
|
||||||
|
slip_parlay_only_hint: 'Pasaran ini hanya parlay. Ketik Tambah ke parlay untuk bertaruh.',
|
||||||
slip_parlay_same_match: 'Pilihan parlay tidak boleh daripada perlawanan sama. Buang pilihan perlawanan sama dahulu.',
|
slip_parlay_same_match: 'Pilihan parlay tidak boleh daripada perlawanan sama. Buang pilihan perlawanan sama dahulu.',
|
||||||
slip_parlay_count: '{n} pilihan parlay',
|
slip_parlay_count: '{n} pilihan parlay',
|
||||||
slip_total_stake: 'Jumlah pertaruhan',
|
slip_total_stake: 'Jumlah pertaruhan',
|
||||||
slip_currency: 'Amaun',
|
slip_currency: 'Amaun',
|
||||||
slip_min: 'Min',
|
slip_min: 'Min',
|
||||||
slip_min_error: 'Jumlah minimum ialah {amount}',
|
slip_min_error: 'Jumlah minimum ialah {amount}',
|
||||||
|
odds_changed: 'Odds beberapa pilihan telah berubah. Terima untuk teruskan.',
|
||||||
|
odds_suspended: 'Beberapa pilihan digantung atau ditutup. Buang untuk teruskan.',
|
||||||
|
accept_changes_place: 'Terima perubahan & pertaruh',
|
||||||
|
odds_was: 'Asal',
|
||||||
|
odds_now: 'Baharu',
|
||||||
place_success: 'Pertaruhan berjaya',
|
place_success: 'Pertaruhan berjaya',
|
||||||
place_failed: 'Pertaruhan gagal',
|
place_failed: 'Pertaruhan gagal',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ export default {
|
|||||||
load_failed: '加载失败',
|
load_failed: '加载失败',
|
||||||
retry: '重试',
|
retry: '重试',
|
||||||
back_to_top: '回到顶部',
|
back_to_top: '回到顶部',
|
||||||
|
cancel: '取消',
|
||||||
|
confirm: '确定',
|
||||||
},
|
},
|
||||||
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
|
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
|
||||||
home: {
|
home: {
|
||||||
hot_matches: '热门赛事',
|
hot_matches: '热门赛事',
|
||||||
|
hot_tab: '热门',
|
||||||
|
upcoming_tab: '近期',
|
||||||
no_matches: '暂无赛事',
|
no_matches: '暂无赛事',
|
||||||
|
upcoming_empty: '未来 3 天内暂无赛事',
|
||||||
announcement_badge: '公告',
|
announcement_badge: '公告',
|
||||||
announcement_default:
|
announcement_default:
|
||||||
'欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行',
|
'欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行',
|
||||||
@@ -21,6 +26,67 @@ export default {
|
|||||||
banner_slide: '第 {n} 张',
|
banner_slide: '第 {n} 张',
|
||||||
banner_fallback: 'Banner',
|
banner_fallback: 'Banner',
|
||||||
},
|
},
|
||||||
|
announcements: {
|
||||||
|
title: '公告中心',
|
||||||
|
detail_title: '公告详情',
|
||||||
|
empty: '暂无公告',
|
||||||
|
not_found: '公告不存在或已下线',
|
||||||
|
view_all: '查看全部公告',
|
||||||
|
type_notice: '公告',
|
||||||
|
type_ticker: '跑马灯',
|
||||||
|
type_banner: 'Banner',
|
||||||
|
related_link: '相关链接',
|
||||||
|
go_link: '前往页面',
|
||||||
|
open_link: '打开链接',
|
||||||
|
back: '返回',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
placeholder: '搜索球队或联赛',
|
||||||
|
no_results: '未找到相关赛事',
|
||||||
|
results_count: '找到 {count} 场赛事',
|
||||||
|
hint: '输入球队或联赛名称,跳转至投注页筛选',
|
||||||
|
},
|
||||||
|
deposit_notify: {
|
||||||
|
approved_title: '充值已到账',
|
||||||
|
rejected_title: '充值未通过',
|
||||||
|
view_history: '查看充值记录',
|
||||||
|
view_messages: '查看消息中心',
|
||||||
|
dismiss: '关闭',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
title: '消息中心',
|
||||||
|
detail_title: '消息详情',
|
||||||
|
empty: '暂无消息',
|
||||||
|
not_found: '消息不存在',
|
||||||
|
view_all: '返回消息列表',
|
||||||
|
back: '返回',
|
||||||
|
mark_all_read: '全部已读',
|
||||||
|
load_more: '加载更多',
|
||||||
|
delete: '删除',
|
||||||
|
delete_all: '全部删除',
|
||||||
|
delete_confirm: '确定删除这条消息吗?',
|
||||||
|
delete_all_confirm: '确定删除全部消息吗?此操作不可恢复。',
|
||||||
|
banner_promo_view: '查看推广',
|
||||||
|
content_promo_view: '查看详情',
|
||||||
|
status_unread: '未读',
|
||||||
|
status_read: '已读',
|
||||||
|
deposit_approved_title: '充值已到账',
|
||||||
|
deposit_rejected_title: '充值未通过',
|
||||||
|
deposit_approved_body: '订单 {orderNo} 已审核通过,申请 {amount},到账 {approvedAmount}。',
|
||||||
|
deposit_rejected_body: '订单 {orderNo}({amount})未通过审核。{reason}',
|
||||||
|
reject_reason: '拒绝原因',
|
||||||
|
no_reason: '未提供原因',
|
||||||
|
view_recharge_history: '查看充值记录',
|
||||||
|
unread_badge: '{count} 条未读',
|
||||||
|
open_inbox: '打开消息中心',
|
||||||
|
},
|
||||||
|
inbox_hub: {
|
||||||
|
title: '消息与客服',
|
||||||
|
tab_messages: '邮箱',
|
||||||
|
tab_support: '客服',
|
||||||
|
open: '打开消息与客服',
|
||||||
|
open_support: '打开客服',
|
||||||
|
},
|
||||||
history: {
|
history: {
|
||||||
league_default: '足球',
|
league_default: '足球',
|
||||||
stake: '投注',
|
stake: '投注',
|
||||||
@@ -235,7 +301,7 @@ export default {
|
|||||||
audit_amount: '金额',
|
audit_amount: '金额',
|
||||||
audit_credited: '入账金额',
|
audit_credited: '入账金额',
|
||||||
audit_remark_label: '备注',
|
audit_remark_label: '备注',
|
||||||
audit_summary: '审核记录 · {count} 步',
|
audit_summary: '审核记录 · {count} 条',
|
||||||
audit_toggle_show: '查看审核记录',
|
audit_toggle_show: '查看审核记录',
|
||||||
audit_toggle_hide: '收起审核记录',
|
audit_toggle_hide: '收起审核记录',
|
||||||
view_detail: '查看详情',
|
view_detail: '查看详情',
|
||||||
@@ -304,6 +370,8 @@ export default {
|
|||||||
parlay_block_quarter: '四分盘让球/大小不可串关',
|
parlay_block_quarter: '四分盘让球/大小不可串关',
|
||||||
parlay_block_not_allowed: '该玩法不可串关',
|
parlay_block_not_allowed: '该玩法不可串关',
|
||||||
parlay_need_more: '请至少选择 2 项进行串关',
|
parlay_need_more: '请至少选择 2 项进行串关',
|
||||||
|
market_status_suspended: '暂停',
|
||||||
|
market_status_closed: '已关闭',
|
||||||
back: '返回',
|
back: '返回',
|
||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
download: '下载',
|
download: '下载',
|
||||||
@@ -387,12 +455,18 @@ export default {
|
|||||||
slip_tab_parlay: '串关',
|
slip_tab_parlay: '串关',
|
||||||
slip_parlay_empty_hint: '先选择一个下注项,再点「加入串关」',
|
slip_parlay_empty_hint: '先选择一个下注项,再点「加入串关」',
|
||||||
slip_add_parlay: '加入串关',
|
slip_add_parlay: '加入串关',
|
||||||
|
slip_parlay_only_hint: '此盘口不支持单关,请点「加入串关」后再投注',
|
||||||
slip_parlay_same_match: '串关选项不得为同一场比赛,请先移除同场选项后再加入。',
|
slip_parlay_same_match: '串关选项不得为同一场比赛,请先移除同场选项后再加入。',
|
||||||
slip_parlay_count: '{n} 项串关',
|
slip_parlay_count: '{n} 项串关',
|
||||||
slip_total_stake: '总投注金额',
|
slip_total_stake: '总投注金额',
|
||||||
slip_currency: '金额',
|
slip_currency: '金额',
|
||||||
slip_min: '最低',
|
slip_min: '最低',
|
||||||
slip_min_error: '最低投注金额为 {amount}',
|
slip_min_error: '最低投注金额为 {amount}',
|
||||||
|
odds_changed: '部分选项赔率已变更,请确认后下注',
|
||||||
|
odds_suspended: '部分选项已暂停或关闭,请移除后重试',
|
||||||
|
accept_changes_place: '接受变更并下注',
|
||||||
|
odds_was: '原赔率',
|
||||||
|
odds_now: '新赔率',
|
||||||
place_success: '下注成功',
|
place_success: '下注成功',
|
||||||
place_failed: '下注失败',
|
place_failed: '下注失败',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import BackToTopButton from '../components/BackToTopButton.vue';
|
|||||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
const BetSlipDrawer = defineAsyncComponent(() => import('../components/BetSlipDrawer.vue'));
|
const BetSlipDrawer = defineAsyncComponent(() => import('../components/BetSlipDrawer.vue'));
|
||||||
const CustomerServiceModal = defineAsyncComponent(
|
|
||||||
() => import('../components/CustomerServiceModal.vue'),
|
|
||||||
);
|
|
||||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||||
|
import { startPresencePing, stopPresencePing } from '../composables/usePresencePing';
|
||||||
|
import { useDepositNotifications } from '../composables/useDepositNotifications';
|
||||||
|
import { usePlayerMessages } from '../composables/usePlayerMessages';
|
||||||
|
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -32,31 +34,42 @@ const isDetailPage = computed(() => {
|
|||||||
p.startsWith('/bet/') ||
|
p.startsWith('/bet/') ||
|
||||||
p.startsWith('/bets/') ||
|
p.startsWith('/bets/') ||
|
||||||
p.startsWith('/wallet/') ||
|
p.startsWith('/wallet/') ||
|
||||||
|
p.startsWith('/messages') ||
|
||||||
p === '/profile/edit' ||
|
p === '/profile/edit' ||
|
||||||
p === '/profile/cashbacks'
|
p === '/profile/cashbacks'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const showHeader = computed(() => !isDetailPage.value);
|
const showHeader = computed(() => !isDetailPage.value);
|
||||||
const showAnnouncement = computed(() => !isDetailPage.value && !route.path.startsWith('/profile'));
|
const showAnnouncement = computed(
|
||||||
|
() => !isDetailPage.value && !route.path.startsWith('/profile') && !route.path.startsWith('/announcements')
|
||||||
|
);
|
||||||
|
|
||||||
const showBottomNav = computed(() => {
|
const showBottomNav = computed(() => {
|
||||||
const p = route.path;
|
const p = route.path;
|
||||||
if (
|
if (
|
||||||
p === '/' ||
|
p === '/' ||
|
||||||
p === '/bet' ||
|
p === '/bet' ||
|
||||||
|
p === '/announcements' ||
|
||||||
|
p.startsWith('/announcements/') ||
|
||||||
p.startsWith('/match/') ||
|
p.startsWith('/match/') ||
|
||||||
p === '/bets' ||
|
p === '/bets' ||
|
||||||
p === '/wallet' ||
|
p === '/wallet' ||
|
||||||
p === '/profile'
|
p === '/profile'
|
||||||
) return true;
|
) return true;
|
||||||
|
// 邮箱关闭时客服页保留底部导航,避免用户无法离开
|
||||||
|
if (!inboxEnabled.value && p === '/messages') return true;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
const { announcements, load: loadPlayerHome } = usePlayerHome();
|
const { announcements, announcementItems, load: loadPlayerHome } = usePlayerHome();
|
||||||
const { loadProfile, refreshProfile, bindProfileVisibilityRefresh } = usePlayerProfile();
|
const { loadProfile, refreshProfile, bindProfileVisibilityRefresh } = usePlayerProfile();
|
||||||
|
const { startPolling, stopPolling } = useDepositNotifications();
|
||||||
|
const { unreadCount, refreshUnreadCount, resetMessagesState } = usePlayerMessages();
|
||||||
|
const { inboxEnabled, hubRoute, hubOpenLabelKey } = useInboxFeature();
|
||||||
|
const primaryAnnouncementId = computed(() => announcementItems.value[0]?.id ?? '');
|
||||||
|
|
||||||
const mainRef = ref<HTMLElement | null>(null);
|
const mainRef = ref<HTMLElement | null>(null);
|
||||||
const tabScrollTops = new Map<string, number>();
|
const tabScrollTops = new Map<string, number>();
|
||||||
const customerServiceOpen = ref(false);
|
|
||||||
|
|
||||||
watch(locale, (next, prev) => {
|
watch(locale, (next, prev) => {
|
||||||
if (prev && next !== prev) void loadPlayerHome(true);
|
if (prev && next !== prev) void loadPlayerHome(true);
|
||||||
@@ -87,6 +100,13 @@ watch(
|
|||||||
// 个人资料仅登录用户需要
|
// 个人资料仅登录用户需要
|
||||||
if (token) {
|
if (token) {
|
||||||
void loadProfile(true);
|
void loadProfile(true);
|
||||||
|
startPolling();
|
||||||
|
startPresencePing();
|
||||||
|
if (inboxEnabled.value) void refreshUnreadCount();
|
||||||
|
} else {
|
||||||
|
stopPolling();
|
||||||
|
stopPresencePing();
|
||||||
|
resetMessagesState();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -96,6 +116,9 @@ watch(
|
|||||||
() => route.path,
|
() => route.path,
|
||||||
(path) => {
|
(path) => {
|
||||||
if (!auth.token) return;
|
if (!auth.token) return;
|
||||||
|
if (inboxEnabled.value && path.startsWith('/messages')) {
|
||||||
|
void refreshUnreadCount();
|
||||||
|
}
|
||||||
if (balanceRefreshPaths.some((p) => path === p || path.startsWith(`${p}/`))) {
|
if (balanceRefreshPaths.some((p) => path === p || path.startsWith(`${p}/`))) {
|
||||||
void refreshProfile();
|
void refreshProfile();
|
||||||
}
|
}
|
||||||
@@ -108,13 +131,12 @@ watch(
|
|||||||
<header v-if="showHeader" class="header">
|
<header v-if="showHeader" class="header">
|
||||||
<img src="/logo.png" alt="TheBet365" class="logo" />
|
<img src="/logo.png" alt="TheBet365" class="logo" />
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button
|
<RouterLink
|
||||||
type="button"
|
:to="hubRoute"
|
||||||
class="support-btn"
|
class="hub-btn"
|
||||||
:aria-label="t('support.open')"
|
:aria-label="inboxEnabled && unreadCount ? t('messages.unread_badge', { count: unreadCount }) : t(hubOpenLabelKey)"
|
||||||
@click="customerServiceOpen = true"
|
|
||||||
>
|
>
|
||||||
<svg class="support-icon" viewBox="0 0 24 24" aria-hidden="true">
|
<svg class="hub-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M12 3C7.03 3 3 6.58 3 11c0 2.02.9 3.86 2.38 5.24L4 21l4.2-1.02A10.8 10.8 0 0 0 12 19c4.97 0 9-3.58 9-8s-4.03-8-9-8Z"
|
d="M12 3C7.03 3 3 6.58 3 11c0 2.02.9 3.86 2.38 5.24L4 21l4.2-1.02A10.8 10.8 0 0 0 12 19c4.97 0 9-3.58 9-8s-4.03-8-9-8Z"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -126,8 +148,8 @@ watch(
|
|||||||
<circle cx="12" cy="11" r="1" fill="currentColor" />
|
<circle cx="12" cy="11" r="1" fill="currentColor" />
|
||||||
<circle cx="15" cy="11" r="1" fill="currentColor" />
|
<circle cx="15" cy="11" r="1" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="support-label">{{ t('support.short') }}</span>
|
<span v-if="auth.user && inboxEnabled && unreadCount > 0" class="hub-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||||
</button>
|
</RouterLink>
|
||||||
<LocaleSwitcher />
|
<LocaleSwitcher />
|
||||||
<template v-if="auth.user">
|
<template v-if="auth.user">
|
||||||
<CashBalanceChip />
|
<CashBalanceChip />
|
||||||
@@ -140,10 +162,14 @@ watch(
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="showAnnouncement" class="announce-strip">
|
<div v-if="showAnnouncement" class="announce-strip">
|
||||||
<AnnouncementMarquee :items="announcements" embedded />
|
<AnnouncementMarquee
|
||||||
|
:items="announcements"
|
||||||
|
:target-id="primaryAnnouncementId"
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main ref="mainRef" :class="['main', { 'has-nav': showBottomNav }]">
|
<main ref="mainRef" :class="['main', { 'has-nav': showBottomNav, 'main--sub-page': isDetailPage }]">
|
||||||
<RouterView v-slot="{ Component, route: viewRoute }">
|
<RouterView v-slot="{ Component, route: viewRoute }">
|
||||||
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="10">
|
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="10">
|
||||||
<component :is="Component" :key="viewRoute.path" />
|
<component :is="Component" :key="viewRoute.path" />
|
||||||
@@ -182,7 +208,6 @@ watch(
|
|||||||
<BackToTopButton :scroll-el="mainRef" :above-nav="showBottomNav" />
|
<BackToTopButton :scroll-el="mainRef" :above-nav="showBottomNav" />
|
||||||
|
|
||||||
<BetSlipDrawer v-model="slip.drawerOpen" />
|
<BetSlipDrawer v-model="slip.drawerOpen" />
|
||||||
<CustomerServiceModal v-model="customerServiceOpen" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -194,18 +219,21 @@ watch(
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
padding-top: calc(var(--app-top-gap) + env(safe-area-inset-top, 0px));
|
||||||
}
|
}
|
||||||
.header {
|
.header {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
padding: 8px 12px;
|
padding: 6px var(--space-page-x);
|
||||||
background: rgba(17, 17, 17, 0.94);
|
min-height: var(--header-h);
|
||||||
|
background: var(--glass);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
z-index: 110;
|
z-index: 110;
|
||||||
}
|
}
|
||||||
.logo {
|
.logo {
|
||||||
height: 36px; width: auto; display: block;
|
height: 36px; width: auto; display: block;
|
||||||
filter: drop-shadow(0 0 4px rgba(212, 175, 55, 0.2));
|
|
||||||
}
|
}
|
||||||
.header-actions {
|
.header-actions {
|
||||||
--header-chip-h: 36px;
|
--header-chip-h: 36px;
|
||||||
@@ -224,60 +252,67 @@ watch(
|
|||||||
width: var(--header-chip-h);
|
width: var(--header-chip-h);
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-btn {
|
.hub-btn {
|
||||||
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
justify-content: center;
|
||||||
|
width: var(--header-chip-h, 36px);
|
||||||
height: var(--header-chip-h, 36px);
|
height: var(--header-chip-h, 36px);
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
|
border: 1px solid var(--border);
|
||||||
background: rgba(200, 168, 78, 0.08);
|
background: var(--bg-card);
|
||||||
color: var(--primary-light, #c8a84e);
|
color: var(--text);
|
||||||
font-size: 12px;
|
flex-shrink: 0;
|
||||||
font-weight: 700;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
flex-shrink: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-btn:active {
|
.hub-btn:active {
|
||||||
background: rgba(200, 168, 78, 0.16);
|
background: var(--surface-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-icon {
|
.hub-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-label {
|
.hub-badge {
|
||||||
max-width: 48px;
|
position: absolute;
|
||||||
overflow: hidden;
|
top: -4px;
|
||||||
text-overflow: ellipsis;
|
right: -4px;
|
||||||
white-space: nowrap;
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(239, 68, 68, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 0 2px var(--bg-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn {
|
.login-btn {
|
||||||
padding: 6px 16px;
|
padding: 6px 14px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--primary, #c8a84e);
|
border: 1px solid var(--border-strong);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--primary-light, #c8a84e);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: background 0.18s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn:active {
|
.login-btn:active {
|
||||||
background: rgba(200, 168, 78, 0.15);
|
background: var(--surface-active);
|
||||||
}
|
}
|
||||||
.announce-strip {
|
.announce-strip {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
z-index: 105;
|
z-index: 105;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
@@ -286,7 +321,11 @@ watch(
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: 12px 16px 0;
|
padding: var(--space-section) var(--space-page-x) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main.main--sub-page {
|
||||||
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main.has-nav {
|
.main.has-nav {
|
||||||
@@ -294,11 +333,14 @@ watch(
|
|||||||
}
|
}
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: rgba(17, 17, 17, 0.96);
|
background: var(--glass);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.35);
|
box-shadow: none;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
min-height: var(--nav-h);
|
||||||
}
|
}
|
||||||
.nav-item {
|
.nav-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -306,14 +348,14 @@ watch(
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 3px;
|
gap: 2px;
|
||||||
padding: 6px 2px 8px;
|
padding: 6px 2px 8px;
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: color 0.2s;
|
transition: color 0.18s;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -327,22 +369,16 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active :deep(.nav-icon) {
|
.nav-item.active :deep(.nav-icon) {
|
||||||
filter: drop-shadow(0 0 4px rgba(212, 175, 55, 0.35));
|
color: var(--text);
|
||||||
|
filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active::after {
|
.nav-item.active::after {
|
||||||
content: '';
|
display: none;
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 22%;
|
|
||||||
right: 22%;
|
|
||||||
height: 2px;
|
|
||||||
background: var(--primary);
|
|
||||||
border-radius: 0 0 4px 4px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ const router = createRouter({
|
|||||||
{ path: 'profile', component: () => import('../views/ProfileView.vue'), meta: { keepAlive: true, requiresAuth: true } },
|
{ path: 'profile', component: () => import('../views/ProfileView.vue'), meta: { keepAlive: true, requiresAuth: true } },
|
||||||
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
|
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
|
||||||
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue'), meta: { requiresAuth: true } },
|
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue'), meta: { requiresAuth: true } },
|
||||||
|
{ path: 'messages', component: () => import('../views/InboxHubView.vue'), meta: { keepAlive: true, requiresAuth: true } },
|
||||||
|
{ path: 'messages/:id', component: () => import('../views/MessageDetailView.vue'), meta: { requiresAuth: true } },
|
||||||
|
{ path: 'announcements', component: () => import('../views/AnnouncementListView.vue'), meta: { keepAlive: true } },
|
||||||
|
{ path: 'announcements/:id', component: () => import('../views/AnnouncementDetailView.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface SlipItem {
|
|||||||
odds: number;
|
odds: number;
|
||||||
marketType: string;
|
marketType: string;
|
||||||
lineValue?: number | null;
|
lineValue?: number | null;
|
||||||
|
allowSingle?: boolean;
|
||||||
allowParlay?: boolean;
|
allowParlay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +179,16 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
|||||||
drawerOpen.value = false;
|
drawerOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateSelectionOdds(selectionId: string, odds: number, oddsVersion: string) {
|
||||||
|
if (singleItem.value?.selectionId === selectionId) {
|
||||||
|
singleItem.value = { ...singleItem.value, odds, oddsVersion };
|
||||||
|
}
|
||||||
|
const idx = parlayItems.value.findIndex((i) => i.selectionId === selectionId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
parlayItems.value[idx] = { ...parlayItems.value[idx], odds, oddsVersion };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
singleItem,
|
singleItem,
|
||||||
parlayItems,
|
parlayItems,
|
||||||
@@ -208,5 +219,6 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
|||||||
clearAll,
|
clearAll,
|
||||||
openDrawer,
|
openDrawer,
|
||||||
closeDrawer,
|
closeDrawer,
|
||||||
|
updateSelectionOdds,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,187 +1,193 @@
|
|||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary: #D4AF37;
|
/* ── 海军 · 白色强调(替代红色) ── */
|
||||||
--primary-dark: #A8841F;
|
--primary: rgba(255, 255, 255, 0.92);
|
||||||
--primary-light: #F0D875;
|
--primary-dark: rgba(255, 255, 255, 0.78);
|
||||||
--secondary: #1A1A1A;
|
--primary-light: rgba(255, 255, 255, 0.68);
|
||||||
--tertiary: #000000;
|
--primary-muted: rgba(255, 255, 255, 0.52);
|
||||||
--neutral: #8E8E93;
|
--on-primary: #001A33;
|
||||||
--bg-body: #000000;
|
--secondary: #0A3558;
|
||||||
--bg-card: rgba(20, 20, 20, 0.65);
|
--tertiary: #001A33;
|
||||||
--bg-hover: rgba(34, 34, 34, 0.65);
|
--neutral: rgba(255, 255, 255, 0.42);
|
||||||
--bg-elevated: rgba(42, 42, 42, 0.65);
|
--bg-body: #001A33;
|
||||||
--text: #FFFFFF;
|
--dropdown-bg: #0A2540;
|
||||||
--text-muted: #8E8E93;
|
--bg-card: rgba(255, 255, 255, 0.045);
|
||||||
--border: #333333;
|
--bg-hover: rgba(255, 255, 255, 0.06);
|
||||||
--border-gold: #D4AF37;
|
--bg-elevated: rgba(255, 255, 255, 0.08);
|
||||||
--border-gold-soft: rgba(212, 175, 55, 0.28);
|
--bg-input: rgba(255, 255, 255, 0.06);
|
||||||
|
--surface-active: rgba(255, 255, 255, 0.1);
|
||||||
|
--surface-accent: rgba(255, 255, 255, 0.08);
|
||||||
|
--text: rgba(255, 255, 255, 0.94);
|
||||||
|
--text-muted: rgba(255, 255, 255, 0.56);
|
||||||
|
--text-dim: rgba(255, 255, 255, 0.38);
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.16);
|
||||||
|
--border-gold: rgba(255, 255, 255, 0.2);
|
||||||
|
--border-gold-soft: rgba(255, 255, 255, 0.18);
|
||||||
--border-w: 1px;
|
--border-w: 1px;
|
||||||
--border-w-thick: 1px;
|
--border-w-thick: 1px;
|
||||||
--danger: #FF453A;
|
--danger: rgba(255, 255, 255, 0.62);
|
||||||
|
--success: #34C759;
|
||||||
--radius: 12px;
|
--radius: 12px;
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
--shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.2), 0 12px 32px rgba(0, 0, 0, 0.22);
|
||||||
--shadow-gold: 0 2px 12px rgba(212, 175, 55, 0.12);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.16);
|
||||||
--glow-gold: 0 0 8px rgba(212, 175, 55, 0.2);
|
--shadow-card: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 8px 28px rgba(0, 0, 0, 0.22);
|
||||||
--gradient-gold: linear-gradient(
|
--shadow-card-sm: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 4px 18px rgba(0, 0, 0, 0.18);
|
||||||
180deg,
|
--shadow-gold: none;
|
||||||
#FFF4C8 0%,
|
--glow-gold: none;
|
||||||
#F0D875 18%,
|
--gradient-accent: rgba(255, 255, 255, 0.88);
|
||||||
#D4AF37 50%,
|
--gradient-gold: var(--gradient-accent);
|
||||||
#B8942B 78%,
|
--gradient-gold-border: var(--gradient-accent);
|
||||||
#8B6914 100%
|
--gradient-card: linear-gradient(
|
||||||
|
165deg,
|
||||||
|
rgba(255, 255, 255, 0.11) 0%,
|
||||||
|
rgba(255, 255, 255, 0.055) 24%,
|
||||||
|
rgba(10, 53, 88, 0.32) 58%,
|
||||||
|
rgba(0, 20, 40, 0.62) 100%
|
||||||
);
|
);
|
||||||
--gradient-gold-border: linear-gradient(
|
--gradient-card-soft: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
#FFF8DC 0%,
|
rgba(255, 255, 255, 0.08) 0%,
|
||||||
#E8C96A 35%,
|
rgba(255, 255, 255, 0.03) 40%,
|
||||||
#A8841F 70%,
|
rgba(0, 26, 51, 0.45) 100%
|
||||||
#5C4A12 100%
|
|
||||||
);
|
);
|
||||||
--gradient-card: linear-gradient(160deg, #1E1A12 0%, #141414 40%, #0A0A0A 100%);
|
--gradient-card-shine: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.28) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
--glass: rgba(0, 26, 51, 0.78);
|
||||||
|
--space-page-x: 20px;
|
||||||
|
--space-toolbar-top: 8px;
|
||||||
|
--space-section: 16px;
|
||||||
|
--space-card: 16px;
|
||||||
|
--header-h: 48px;
|
||||||
|
--nav-h: 52px;
|
||||||
|
--app-top-gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 金色描边按钮(避免大面积实心填充) */
|
/** 描边按钮 */
|
||||||
.btn-gold-outline {
|
.btn-gold-outline {
|
||||||
background: #141414;
|
background: transparent;
|
||||||
border: 1px solid var(--border-gold-soft);
|
border: 1px solid var(--border-strong);
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
font-weight: 800;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.btn-gold-outline:active:not(:disabled) {
|
.btn-gold-outline:active:not(:disabled) {
|
||||||
background: rgba(212, 175, 55, 0.1);
|
background: var(--surface-active);
|
||||||
border-color: var(--border-gold);
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 选中态:浅底 + 金边,非整块金色 */
|
/** 选中态:中性浅底 */
|
||||||
.tab-gold-active {
|
.tab-gold-active {
|
||||||
background: rgba(212, 175, 55, 0.08) !important;
|
background: var(--bg-elevated) !important;
|
||||||
border-color: var(--border-gold) !important;
|
border-color: var(--border-strong) !important;
|
||||||
color: var(--primary-light) !important;
|
color: var(--text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 登录等关键区域:轻量金边(非厚重 PS 描边) */
|
/* 登录等关键区域 */
|
||||||
.ps-gold-frame {
|
.ps-gold-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid var(--border-gold-soft) !important;
|
border: 1px solid rgba(255, 255, 255, 0.12) !important;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: rgba(14, 14, 14, 0.92) !important;
|
background: var(--gradient-card) !important;
|
||||||
box-shadow: var(--shadow);
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
box-shadow: var(--shadow-card-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-gold-frame::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 10%;
|
||||||
|
right: 10%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--gradient-card-shine);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-gold-frame::before,
|
|
||||||
.ps-gold-frame::after {
|
.ps-gold-frame::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-gold-input {
|
.ps-gold-input {
|
||||||
background: #0d0d0d !important;
|
background: var(--bg-input) !important;
|
||||||
border: 1px solid var(--border) !important;
|
border: 1px solid var(--border) !important;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-gold-input::placeholder {
|
.ps-gold-input::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.35);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-gold-input:focus {
|
.ps-gold-input:focus {
|
||||||
border-color: var(--border-gold-soft) !important;
|
border-color: var(--border-strong) !important;
|
||||||
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.12) !important;
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.08) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 主按钮:斜面浮雕 + 渐变描边 + 外发光 */
|
/* 主按钮 · 扁平 CTA(Stripe 式) */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
padding: 14px 24px;
|
||||||
padding: 17px 24px;
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
background: var(--primary);
|
||||||
color: #3D2800;
|
color: var(--on-primary);
|
||||||
font-weight: 900;
|
font-weight: 600;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: -0.01em;
|
||||||
text-transform: uppercase;
|
text-transform: none;
|
||||||
text-shadow:
|
transition: opacity 0.15s ease, transform 0.12s ease;
|
||||||
0 1px 0 rgba(255, 252, 235, 0.95),
|
box-shadow: none;
|
||||||
0 -1px 0 rgba(120, 85, 15, 0.35);
|
|
||||||
transition: transform 0.12s ease;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: -3px;
|
|
||||||
border-radius: calc(var(--radius-sm) + 3px);
|
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
#FFFCE8 0%,
|
|
||||||
#F7E08A 12%,
|
|
||||||
#D4AF37 35%,
|
|
||||||
#8B6914 55%,
|
|
||||||
#5C4A12 72%,
|
|
||||||
#E8C040 90%,
|
|
||||||
#FFF8DC 100%
|
|
||||||
);
|
|
||||||
z-index: -2;
|
|
||||||
box-shadow:
|
|
||||||
0 0 14px rgba(255, 210, 70, 0.55),
|
|
||||||
0 0 28px rgba(212, 175, 55, 0.28),
|
|
||||||
0 6px 14px rgba(0, 0, 0, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 2px;
|
|
||||||
border-radius: calc(var(--radius-sm) - 1px);
|
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
#FFFBE8 0%,
|
|
||||||
#FFE566 6%,
|
|
||||||
#F0D050 22%,
|
|
||||||
#D4AF37 48%,
|
|
||||||
#B8860B 72%,
|
|
||||||
#8B6914 100%
|
|
||||||
);
|
|
||||||
z-index: -1;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 2px 0 rgba(255, 255, 255, 0.95),
|
|
||||||
inset 0 4px 10px rgba(255, 240, 180, 0.45),
|
|
||||||
inset 0 -2px 0 rgba(90, 65, 12, 0.85),
|
|
||||||
inset 0 -6px 14px rgba(45, 32, 6, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:active:not(:disabled) {
|
.btn-primary:active:not(:disabled) {
|
||||||
transform: translateY(2px) scale(0.985);
|
transform: scale(0.985);
|
||||||
}
|
opacity: 0.88;
|
||||||
|
|
||||||
.btn-primary:active:not(:disabled)::after {
|
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
#E8C040 0%,
|
|
||||||
#C9962A 40%,
|
|
||||||
#8B6914 100%
|
|
||||||
);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 4px 10px rgba(35, 24, 4, 0.65),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:active:not(:disabled)::before {
|
|
||||||
box-shadow:
|
|
||||||
0 0 10px rgba(255, 200, 50, 0.35),
|
|
||||||
0 2px 6px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled {
|
.btn-primary:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
filter: saturate(0.4);
|
}
|
||||||
|
|
||||||
|
/* 筛选 Tab · iOS 分段风格 */
|
||||||
|
.filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 0 var(--space-section);
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.filter-tabs::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.filter-tab {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.18s, border-color 0.18s, color 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab.active {
|
||||||
|
background: var(--surface-active);
|
||||||
|
border-color: var(--border-gold);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -191,20 +197,14 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;
|
||||||
/* 保留 bg 位图;用 scroll 替代 fixed,减轻移动端滚动重绘(后续可换 WebP/AVIF) */
|
background-color: var(--bg-body);
|
||||||
background-color: var(--tertiary);
|
background-image: none;
|
||||||
background-image:
|
|
||||||
radial-gradient(ellipse 120% 60% at 50% -10%, rgba(212, 175, 55, 0.14), transparent 55%),
|
|
||||||
linear-gradient(rgba(0, 0, 0, 0.88), rgba(0, 0, 0, 0.88)),
|
|
||||||
url('./assets/images/bg.webp');
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center center;
|
|
||||||
background-size: cover;
|
|
||||||
background-attachment: scroll;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
a { color: inherit; text-decoration: none; }
|
a { color: inherit; text-decoration: none; }
|
||||||
@@ -217,20 +217,21 @@ button {
|
|||||||
|
|
||||||
input {
|
input {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: #0D0D0D;
|
background: var(--bg-input);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 14px 16px;
|
padding: 13px 16px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.18s, box-shadow 0.18s;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus {
|
input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--border-strong);
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:-webkit-autofill,
|
input:-webkit-autofill,
|
||||||
@@ -239,27 +240,28 @@ input:-webkit-autofill:focus,
|
|||||||
input:-webkit-autofill:active {
|
input:-webkit-autofill:active {
|
||||||
-webkit-text-fill-color: var(--text);
|
-webkit-text-fill-color: var(--text);
|
||||||
caret-color: var(--text);
|
caret-color: var(--text);
|
||||||
box-shadow: 0 0 0 1000px #0a0a0a inset;
|
box-shadow: 0 0 0 1000px var(--bg-input) inset;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
transition: background-color 99999s ease-out 0s;
|
transition: background-color 99999s ease-out 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.odds-btn {
|
.odds-btn {
|
||||||
background: #161616;
|
background: var(--gradient-card-soft);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 12px 14px;
|
padding: 11px 14px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: 78px;
|
min-width: 78px;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
transition: border-color 0.18s, background 0.18s, transform 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.odds-btn:active { transform: scale(0.96); }
|
.odds-btn:active { transform: scale(0.98); }
|
||||||
|
|
||||||
.odds-btn.selected {
|
.odds-btn.selected {
|
||||||
border: 1px solid var(--border-gold-soft);
|
border-color: var(--border-strong);
|
||||||
background: rgba(212, 175, 55, 0.12);
|
background: var(--bg-elevated);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,39 +278,56 @@ input:-webkit-autofill:active {
|
|||||||
|
|
||||||
.odds-btn .label {
|
.odds-btn .label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-dim);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0;
|
||||||
text-transform: uppercase;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.odds-btn .value {
|
.odds-btn .value {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
text-shadow: 0 0 8px rgba(212, 175, 55, 0.5);
|
text-shadow: none;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odds-btn.selected .value {
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-card);
|
position: relative;
|
||||||
border: 1px solid var(--border);
|
background: var(--gradient-card);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 16px;
|
padding: var(--space-card);
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--space-section);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow-card-sm);
|
||||||
background: rgba(26, 26, 26, 0.92);
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 8%;
|
||||||
|
right: 8%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--gradient-card-shine);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 18px;
|
font-size: 15px;
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 12px;
|
||||||
padding: 4px 0 4px 12px;
|
padding: 0;
|
||||||
border-left: 3px solid var(--border-gold);
|
border-left: none;
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: -0.02em;
|
||||||
text-transform: uppercase;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
@@ -320,6 +339,47 @@ input:-webkit-autofill:active {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sub-page toolbar: full-bleed under .main horizontal padding */
|
||||||
|
.sub-toolbar {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-left: calc(-1 * var(--space-page-x));
|
||||||
|
margin-right: calc(-1 * var(--space-page-x));
|
||||||
|
width: calc(100% + 2 * var(--space-page-x));
|
||||||
|
padding: var(--space-toolbar-top) var(--space-page-x) 8px;
|
||||||
|
background: var(--bg-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-toolbar--sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-toolbar--glass {
|
||||||
|
background: var(--bg-body);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top-left diagonal ribbon for open-match status */
|
||||||
|
.status-ribbon {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: -28px;
|
||||||
|
width: 100px;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
background: var(--success);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
z-index: 3;
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|||||||
65
apps/player/src/utils/html.ts
Normal file
65
apps/player/src/utils/html.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
const ALLOWED_TAGS = new Set([
|
||||||
|
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li',
|
||||||
|
'img', 'a', 'h2', 'h3', 'blockquote', 'div', 'span',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sanitizeNode(node: Node): Node | null {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
return node.cloneNode(false);
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return null;
|
||||||
|
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
if (!ALLOWED_TAGS.has(tag)) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const child of Array.from(el.childNodes)) {
|
||||||
|
const safe = sanitizeNode(child);
|
||||||
|
if (safe) frag.appendChild(safe);
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = document.createElement(tag);
|
||||||
|
if (tag === 'img') {
|
||||||
|
const src = el.getAttribute('src')?.trim();
|
||||||
|
if (!src || /^javascript:/i.test(src)) return null;
|
||||||
|
out.setAttribute('src', src);
|
||||||
|
const alt = el.getAttribute('alt');
|
||||||
|
if (alt) out.setAttribute('alt', alt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (tag === 'a') {
|
||||||
|
const href = el.getAttribute('href')?.trim();
|
||||||
|
if (!href || /^javascript:/i.test(href)) return null;
|
||||||
|
out.setAttribute('href', href);
|
||||||
|
out.setAttribute('target', '_blank');
|
||||||
|
out.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
for (const child of Array.from(el.childNodes)) {
|
||||||
|
const safe = sanitizeNode(child);
|
||||||
|
if (safe) out.appendChild(safe);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 玩家端公告正文 HTML 白名单净化 */
|
||||||
|
export function sanitizeAnnouncementHtml(html: string): string {
|
||||||
|
if (!html?.trim()) return '';
|
||||||
|
if (!/[<>]/.test(html)) return html;
|
||||||
|
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const container = document.createElement('div');
|
||||||
|
for (const child of Array.from(doc.body.childNodes)) {
|
||||||
|
const safe = sanitizeNode(child);
|
||||||
|
if (safe) container.appendChild(safe);
|
||||||
|
}
|
||||||
|
return container.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripHtml(html: string): string {
|
||||||
|
if (!html) return '';
|
||||||
|
if (!/[<>]/.test(html)) return html.trim();
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
18
apps/player/src/utils/walletStats.ts
Normal file
18
apps/player/src/utils/walletStats.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export type WalletTransactionStats = {
|
||||||
|
netAmount?: string;
|
||||||
|
byType?: Array<{ transactionType?: string; totalAmount?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CASHBACK_TYPES = new Set(['CASHBACK', 'CASHBACK_DEPOSIT']);
|
||||||
|
|
||||||
|
/** Sum wallet credits from cashback transaction types (matches WalletDetailView fallback). */
|
||||||
|
export function sumCashbackFromStats(stats: WalletTransactionStats | null | undefined): number {
|
||||||
|
let sum = 0;
|
||||||
|
for (const group of stats?.byType ?? []) {
|
||||||
|
const type = group.transactionType?.toUpperCase() ?? '';
|
||||||
|
if (CASHBACK_TYPES.has(type)) {
|
||||||
|
sum += Math.abs(parseFloat(group.totalAmount ?? '0') || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
320
apps/player/src/views/AnnouncementDetailView.vue
Normal file
320
apps/player/src/views/AnnouncementDetailView.vue
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onActivated, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||||
|
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||||
|
import defaultBannerImg from '../assets/images/banner.webp';
|
||||||
|
import { usePlayerHome, type PlayerContentItem } from '../composables/usePlayerHome';
|
||||||
|
import { sanitizeAnnouncementHtml, stripHtml } from '../utils/html';
|
||||||
|
|
||||||
|
const FALLBACK_IMG = '/uploads/banners/welcome.svg';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const { announcementItems, bannerItems, loading, load } = usePlayerHome();
|
||||||
|
|
||||||
|
const announcementId = computed(() => String(route.params.id ?? ''));
|
||||||
|
|
||||||
|
const item = computed<PlayerContentItem | null>(() => {
|
||||||
|
const id = announcementId.value;
|
||||||
|
return (
|
||||||
|
bannerItems.value.find((entry) => entry.id === id) ??
|
||||||
|
announcementItems.value.find((entry) => entry.id === id) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBanner = computed(() => item.value?.contentType === 'BANNER');
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goList() {
|
||||||
|
router.push('/announcements');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(createdAt?: string) {
|
||||||
|
if (!createdAt) return '';
|
||||||
|
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemTitle(entry: PlayerContentItem) {
|
||||||
|
const title = entry.translation?.title?.trim();
|
||||||
|
if (title) return title;
|
||||||
|
const bodyText = stripHtml(entry.translation?.body ?? '');
|
||||||
|
return bodyText || t('home.announcement_badge');
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemBodyHtml(entry: PlayerContentItem) {
|
||||||
|
const title = entry.translation?.title?.trim();
|
||||||
|
const body = entry.translation?.body?.trim();
|
||||||
|
if (!body) return '';
|
||||||
|
if (title && stripHtml(body) === title) return '';
|
||||||
|
return sanitizeAnnouncementHtml(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heroImageUrl = computed(() => {
|
||||||
|
const url = item.value?.translation?.imageUrl?.trim();
|
||||||
|
if (url) return url;
|
||||||
|
if (isBanner.value) return defaultBannerImg || FALLBACK_IMG;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkTarget = computed(() => item.value?.linkTarget?.trim() ?? '');
|
||||||
|
|
||||||
|
const externalLinkUrl = computed(() => {
|
||||||
|
if (item.value?.linkType !== 'URL' || !linkTarget.value) return '';
|
||||||
|
return /^https?:\/\//i.test(linkTarget.value)
|
||||||
|
? linkTarget.value
|
||||||
|
: `https://${linkTarget.value}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onHeroError(e: Event) {
|
||||||
|
const img = e.target as HTMLImageElement;
|
||||||
|
if (img.dataset.fallbackApplied) return;
|
||||||
|
img.dataset.fallbackApplied = '1';
|
||||||
|
img.src = defaultBannerImg || FALLBACK_IMG;
|
||||||
|
}
|
||||||
|
|
||||||
|
function followRouteLink() {
|
||||||
|
if (item.value?.linkType === 'ROUTE' && linkTarget.value) {
|
||||||
|
void router.push(linkTarget.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExternalLink() {
|
||||||
|
if (externalLinkUrl.value) {
|
||||||
|
window.open(externalLinkUrl.value, '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
await load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(announcementId, () => {
|
||||||
|
if (!item.value && !loading.value) void refresh();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="announce-detail">
|
||||||
|
<header class="page-header">
|
||||||
|
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack">‹</button>
|
||||||
|
<h1>{{ t('announcements.detail_title') }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading && !item" class="state">
|
||||||
|
<GoldSpinner :size="36" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!item" class="empty">
|
||||||
|
<p>{{ t('announcements.not_found') }}</p>
|
||||||
|
<button type="button" class="link-btn" @click="goList">{{ t('announcements.view_all') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article v-else class="detail-article">
|
||||||
|
<figure v-if="heroImageUrl" class="detail-hero">
|
||||||
|
<img :src="heroImageUrl" :alt="itemTitle(item)" loading="lazy" @error="onHeroError" />
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<div class="detail-body-wrap">
|
||||||
|
<p v-if="item.createdAt" class="detail-date">{{ formatDate(item.createdAt) }}</p>
|
||||||
|
<h2 class="detail-title">{{ itemTitle(item) }}</h2>
|
||||||
|
<div v-if="itemBodyHtml(item)" class="detail-body" v-html="itemBodyHtml(item)" />
|
||||||
|
|
||||||
|
<div v-if="item.linkType && linkTarget" class="detail-link">
|
||||||
|
<p class="link-label">{{ t('announcements.related_link') }}</p>
|
||||||
|
<p class="link-address">{{ linkTarget }}</p>
|
||||||
|
<button
|
||||||
|
v-if="item.linkType === 'ROUTE'"
|
||||||
|
type="button"
|
||||||
|
class="link-action"
|
||||||
|
@click="followRouteLink"
|
||||||
|
>
|
||||||
|
{{ t('announcements.go_link') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="item.linkType === 'URL'"
|
||||||
|
type="button"
|
||||||
|
class="link-action link-action--outline"
|
||||||
|
@click="openExternalLink"
|
||||||
|
>
|
||||||
|
{{ t('announcements.open_link') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.announce-detail {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 0 14px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state,
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--surface-active);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-article {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-hero {
|
||||||
|
margin: 16px -16px 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg-body);
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-hero img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body-wrap {
|
||||||
|
padding: 18px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-date {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body :deep(p) {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 14px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body :deep(ul),
|
||||||
|
.detail-body :deep(ol) {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body :deep(a) {
|
||||||
|
color: var(--primary-light);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-link {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-label {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-address {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--primary-light);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-action {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-action--outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
178
apps/player/src/views/AnnouncementListView.vue
Normal file
178
apps/player/src/views/AnnouncementListView.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onActivated } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||||
|
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||||
|
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||||
|
import { stripHtml } from '../utils/html';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const { announcementItems, loading, load } = usePlayerHome();
|
||||||
|
|
||||||
|
const items = computed(() => announcementItems.value);
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(id: string) {
|
||||||
|
router.push(`/announcements/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(createdAt?: string) {
|
||||||
|
if (!createdAt) return '';
|
||||||
|
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemTitle(item: (typeof items.value)[number]) {
|
||||||
|
const title = item.translation?.title?.trim();
|
||||||
|
if (title) return title;
|
||||||
|
return stripHtml(item.translation?.body ?? '') || t('home.announcement_badge');
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemPreview(item: (typeof items.value)[number]) {
|
||||||
|
const title = item.translation?.title?.trim();
|
||||||
|
const bodyText = stripHtml(item.translation?.body ?? '');
|
||||||
|
if (bodyText && bodyText !== title) return bodyText;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
void load(true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="announce-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack">‹</button>
|
||||||
|
<h1>{{ t('announcements.title') }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading && !items.length" class="state">
|
||||||
|
<GoldSpinner :size="36" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!items.length" class="empty">
|
||||||
|
<p>{{ t('announcements.empty') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="list">
|
||||||
|
<button
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="list-row"
|
||||||
|
@click="openDetail(item.id)"
|
||||||
|
>
|
||||||
|
<span class="row-main">
|
||||||
|
<span class="title">{{ itemTitle(item) }}</span>
|
||||||
|
<span v-if="itemPreview(item)" class="preview">{{ itemPreview(item) }}</span>
|
||||||
|
<span v-if="item.createdAt" class="date">{{ formatDate(item.createdAt) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.announce-page {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 0 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state,
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 0;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -142,9 +142,9 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- back bar -->
|
<!-- back bar -->
|
||||||
<div class="top-bar">
|
<header class="top-bar sub-toolbar">
|
||||||
<button class="back-btn" @click="router.back()">‹ {{ t('history.back') }}</button>
|
<button class="back-btn" @click="router.back()">‹ {{ t('history.back') }}</button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div v-if="loading" class="state">
|
<div v-if="loading" class="state">
|
||||||
<GoldSpinner :size="36" />
|
<GoldSpinner :size="36" />
|
||||||
@@ -275,7 +275,7 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
.back-btn {
|
.back-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--primary-light, #d4af37);
|
color: var(--primary-light);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 4px 0 8px;
|
padding: 4px 0 8px;
|
||||||
@@ -288,14 +288,14 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
.state {
|
.state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
color: #666;
|
color: var(--text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── hero ── */
|
/* ── hero ── */
|
||||||
.hero {
|
.hero {
|
||||||
border-radius: 14px;
|
border-radius: var(--radius);
|
||||||
padding: 20px 20px 18px;
|
padding: 20px 20px 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -305,48 +305,60 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero.won { background: linear-gradient(135deg, rgba(61,184,101,0.15), rgba(61,184,101,0.06)); border: 1px solid rgba(61,184,101,0.25); }
|
.hero.won {
|
||||||
.hero.lost { background: linear-gradient(135deg, rgba(224,80,80,0.12), rgba(224,80,80,0.05)); border: 1px solid rgba(224,80,80,0.2); }
|
background: linear-gradient(135deg, rgba(46, 204, 113, 0.18), rgba(46, 204, 113, 0.06));
|
||||||
.hero.pending { background: linear-gradient(135deg, rgba(232,200,74,0.1), rgba(232,200,74,0.04)); border: 1px solid rgba(232,200,74,0.2); }
|
border: 1px solid rgba(46, 204, 113, 0.35);
|
||||||
.hero.push { background: #181818; border: 1px solid #2a2a2a; }
|
}
|
||||||
|
.hero.lost {
|
||||||
|
background: linear-gradient(135deg, var(--surface-accent), rgba(255, 255, 255, 0.06));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
.hero.pending {
|
||||||
|
background: var(--gradient-card);
|
||||||
|
border: 1px solid var(--border-gold-soft);
|
||||||
|
}
|
||||||
|
.hero.push {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
.hero-status {
|
.hero-status {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: none;
|
||||||
opacity: 0.7;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
.hero.won .hero-status { color: #3db865; }
|
.hero.won .hero-status { color: var(--success); }
|
||||||
.hero.lost .hero-status { color: #e05050; }
|
.hero.lost .hero-status { color: var(--primary); }
|
||||||
.hero.pending .hero-status { color: #e8c84a; }
|
.hero.pending .hero-status { color: var(--primary-light); }
|
||||||
.hero.push .hero-status { color: #888; }
|
.hero.push .hero-status { color: var(--text-muted); }
|
||||||
|
|
||||||
.hero-return {
|
.hero-return {
|
||||||
font-size: 36px;
|
font-size: 32px;
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
.hero.won .hero-return { color: #3db865; text-shadow: 0 0 24px rgba(61,184,101,0.3); }
|
.hero.won .hero-return { color: var(--success); text-shadow: none; }
|
||||||
.hero.lost .hero-return { color: #e05050; }
|
.hero.lost .hero-return { color: var(--primary); }
|
||||||
.hero.pending .hero-return { color: #e8c84a; }
|
.hero.pending .hero-return { color: var(--primary-light); }
|
||||||
.hero.push .hero-return { color: #777; }
|
.hero.push .hero-return { color: var(--text-muted); }
|
||||||
|
|
||||||
.hero-return-label {
|
.hero-return-label {
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
color: #555;
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── sections ── */
|
/* ── sections ── */
|
||||||
.section {
|
.section {
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
border: 1px solid #222;
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius);
|
||||||
padding: 14px 16px;
|
padding: var(--space-card) 16px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: var(--space-section);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-head {
|
.section-head {
|
||||||
@@ -358,21 +370,21 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
|
|
||||||
.league-tag {
|
.league-tag {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placed-time {
|
.placed-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #555;
|
color: var(--neutral);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-name {
|
.match-name {
|
||||||
font-size: 17px;
|
font-size: 16px;
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
color: #f0f0f0;
|
color: var(--text);
|
||||||
line-height: 1.3;
|
line-height: 1.35;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,18 +410,18 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
|
|
||||||
.row-label {
|
.row-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #666;
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-val {
|
.row-val {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #c8c8c8;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-val.pick {
|
.row-val.pick {
|
||||||
color: #d4af37;
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-chips {
|
.score-chips {
|
||||||
@@ -421,44 +433,44 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
background: #1e1e1e;
|
background: var(--bg-body);
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid var(--border);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-period {
|
.score-period {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #666;
|
color: var(--text-muted);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.02em;
|
||||||
text-transform: uppercase;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-value {
|
.score-value {
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
color: #e8e8e8;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-value.muted {
|
.score-value.muted {
|
||||||
color: #666;
|
color: var(--neutral);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-score {
|
.no-score {
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
color: #555;
|
color: var(--neutral);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* section title */
|
/* section title */
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 10.5px;
|
font-size: 11px;
|
||||||
color: #555;
|
color: var(--text-muted);
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.02em;
|
||||||
text-transform: uppercase;
|
text-transform: none;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,14 +486,14 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
background: #1a1a1a;
|
background: var(--bg-body);
|
||||||
border: 1px solid #252525;
|
border: 1px solid var(--border);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leg-row.won { border-color: rgba(61,184,101,0.2); }
|
.leg-row.won { border-color: rgba(46, 204, 113, 0.35); }
|
||||||
.leg-row.lost { border-color: rgba(224,80,80,0.18); }
|
.leg-row.lost { border-color: rgba(255, 255, 255, 0.35); }
|
||||||
|
|
||||||
.leg-left {
|
.leg-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -496,8 +508,8 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #2a2a2a;
|
background: var(--bg-card);
|
||||||
color: #666;
|
color: var(--text-muted);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -506,8 +518,8 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leg-num.won { background: rgba(61,184,101,0.18); color: #3db865; }
|
.leg-num.won { background: rgba(46, 204, 113, 0.18); color: var(--success); }
|
||||||
.leg-num.lost { background: rgba(224,80,80,0.18); color: #e05050; }
|
.leg-num.lost { background: rgba(255, 255, 255, 0.18); color: var(--primary); }
|
||||||
|
|
||||||
.leg-body {
|
.leg-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -519,12 +531,12 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
.leg-match {
|
.leg-match {
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #d0d0d0;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leg-pick-line {
|
.leg-pick-line {
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,11 +549,11 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
.leg-scores span {
|
.leg-scores span {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #c8c8c8;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leg-scores .muted-score {
|
.leg-scores .muted-score {
|
||||||
color: #555;
|
color: var(--neutral);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leg-right {
|
.leg-right {
|
||||||
@@ -554,23 +566,23 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
|
|
||||||
.leg-odds-val {
|
.leg-odds-val {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
color: #b8a04a;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leg-result-badge {
|
.leg-result-badge {
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.02em;
|
||||||
text-transform: uppercase;
|
text-transform: none;
|
||||||
padding: 2px 6px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leg-result-badge.won { color: #3db865; background: rgba(61,184,101,0.12); }
|
.leg-result-badge.won { color: var(--success); background: rgba(46, 204, 113, 0.12); }
|
||||||
.leg-result-badge.lost { color: #e05050; background: rgba(224,80,80,0.1); }
|
.leg-result-badge.lost { color: var(--primary); background: rgba(255, 255, 255, 0.1); }
|
||||||
.leg-result-badge.push { color: #888; background: #222; }
|
.leg-result-badge.push { color: var(--text-muted); background: var(--bg-card); }
|
||||||
.leg-result-badge.pending { color: #666; background: #1e1e1e; }
|
.leg-result-badge.pending { color: var(--neutral); background: var(--bg-body); }
|
||||||
|
|
||||||
/* summary */
|
/* summary */
|
||||||
.summary-rows {
|
.summary-rows {
|
||||||
@@ -584,9 +596,9 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
border-bottom: 1px solid #1c1c1c;
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #b0b0b0;
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,29 +607,29 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sum-row.muted {
|
.sum-row.muted {
|
||||||
color: #444;
|
color: var(--neutral);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.odds-val {
|
.odds-val {
|
||||||
color: #b8a04a;
|
color: var(--text);
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amt-won {
|
.amt-won {
|
||||||
color: #3db865;
|
color: var(--success);
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amt-pending {
|
.amt-pending {
|
||||||
color: #e8c84a;
|
color: var(--text-muted);
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amt-lost {
|
.amt-lost {
|
||||||
color: #e05050;
|
color: var(--primary);
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,11 +117,11 @@ const pullIndicatorStyle = () => ({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="cashback-page">
|
<div class="cashback-page">
|
||||||
<div class="top-bar">
|
<header class="top-bar sub-toolbar">
|
||||||
<button type="button" class="back-btn" @click="router.back()">
|
<button type="button" class="back-btn" @click="router.back()">
|
||||||
‹ {{ t('history.back') }}
|
‹ {{ t('history.back') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div class="pull-indicator" :style="pullIndicatorStyle()">
|
<div class="pull-indicator" :style="pullIndicatorStyle()">
|
||||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||||
@@ -202,7 +202,7 @@ const pullIndicatorStyle = () => ({
|
|||||||
.back-btn {
|
.back-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--primary-light, #d4af37);
|
color: var(--primary-light);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 4px 0 8px;
|
padding: 4px 0 8px;
|
||||||
@@ -243,18 +243,18 @@ const pullIndicatorStyle = () => ({
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 18px 16px 16px;
|
padding: 18px 16px 16px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
border-radius: var(--radius, 12px);
|
border-radius: var(--radius);
|
||||||
background: linear-gradient(135deg, rgba(212, 175, 55, 0.14), rgba(26, 26, 26, 0.92));
|
background: var(--gradient-card);
|
||||||
border: 1px solid rgba(212, 175, 55, 0.28);
|
border: 1px solid var(--border-gold-soft);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-amount {
|
.hero-amount {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #f0b90b;
|
color: var(--primary-light);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
text-shadow: 0 0 20px rgba(240, 185, 11, 0.2);
|
text-shadow: 0 0 20px var(--surface-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-sub {
|
.hero-sub {
|
||||||
@@ -265,7 +265,7 @@ const pullIndicatorStyle = () => ({
|
|||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
color: #555;
|
color: var(--text-muted);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -290,15 +290,15 @@ const pullIndicatorStyle = () => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.record-row--highlight {
|
.record-row--highlight {
|
||||||
background: rgba(212, 175, 55, 0.08);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
box-shadow: inset 3px 0 0 #d4af37;
|
box-shadow: inset 3px 0 0 var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ledger-hint {
|
.ledger-hint {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #666;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-main {
|
.row-main {
|
||||||
@@ -331,9 +331,9 @@ const pullIndicatorStyle = () => ({
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f0b90b;
|
color: var(--primary-light);
|
||||||
background: rgba(240, 185, 11, 0.1);
|
background: var(--surface-accent);
|
||||||
border: 1px solid rgba(240, 185, 11, 0.22);
|
border: 1px solid var(--border-gold-soft);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
}
|
}
|
||||||
@@ -354,9 +354,9 @@ const pullIndicatorStyle = () => ({
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px dashed rgba(255, 255, 255, 0.06);
|
border-top: 1px dashed var(--border);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #666;
|
color: var(--neutral);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-batch {
|
.row-batch {
|
||||||
@@ -387,7 +387,7 @@ const pullIndicatorStyle = () => ({
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #666;
|
color: var(--neutral);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sentinel {
|
.sentinel {
|
||||||
@@ -403,7 +403,7 @@ const pullIndicatorStyle = () => ({
|
|||||||
.end-hint {
|
.end-hint {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #555;
|
color: var(--neutral);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 16px 0 4px;
|
padding: 16px 0 4px;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ function goMatch(id: string) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="main-tab"
|
class="main-tab"
|
||||||
:class="{ active: mainTab === 'matches', 'tab-gold-active': mainTab === 'matches' }"
|
:class="{ active: mainTab === 'matches' }"
|
||||||
@click="selectMainTab('matches')"
|
@click="selectMainTab('matches')"
|
||||||
>
|
>
|
||||||
<span class="tab-icon">⚽</span>
|
<span class="tab-icon">⚽</span>
|
||||||
@@ -256,7 +256,7 @@ function goMatch(id: string) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="main-tab"
|
class="main-tab"
|
||||||
:class="{ active: mainTab === 'outright', 'tab-gold-active': mainTab === 'outright' }"
|
:class="{ active: mainTab === 'outright' }"
|
||||||
@click="selectMainTab('outright')"
|
@click="selectMainTab('outright')"
|
||||||
>
|
>
|
||||||
<span class="tab-icon">🏆</span>
|
<span class="tab-icon">🏆</span>
|
||||||
@@ -382,7 +382,6 @@ function goMatch(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bet-page {
|
.bet-page {
|
||||||
margin: 0 -16px;
|
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
background: var(--bg-body);
|
background: var(--bg-body);
|
||||||
}
|
}
|
||||||
@@ -407,8 +406,13 @@ function goMatch(id: string) {
|
|||||||
|
|
||||||
.main-tabs {
|
.main-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 2px;
|
||||||
padding: 0 12px 12px;
|
margin: 0 0 var(--space-section);
|
||||||
|
padding: 3px;
|
||||||
|
background: var(--gradient-card-soft);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-tab {
|
.main-tab {
|
||||||
@@ -417,23 +421,23 @@ function goMatch(id: string) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 10px 8px;
|
padding: 9px 8px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: calc(var(--radius-sm) - 3px);
|
||||||
background: var(--bg-card);
|
background: transparent;
|
||||||
border: 1px solid var(--border);
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: none;
|
||||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
transition: background 0.18s, color 0.18s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-tab.active {
|
.main-tab.active {
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
background: rgba(244, 162, 97, 0.1);
|
background: var(--bg-elevated);
|
||||||
border-color: var(--primary);
|
color: var(--text);
|
||||||
color: var(--primary-light);
|
box-shadow: inset 0 0 0 1px var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-icon {
|
.tab-icon {
|
||||||
@@ -446,7 +450,19 @@ function goMatch(id: string) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 12px 12px;
|
padding: 0 0 var(--space-section);
|
||||||
|
background: var(--bg-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-tab.active,
|
||||||
|
.status-toggle-btn.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-active);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-tab.active {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--border-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.phase-filter {
|
.phase-filter {
|
||||||
@@ -469,9 +485,9 @@ function goMatch(id: string) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -484,9 +500,9 @@ function goMatch(id: string) {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: calc(var(--radius-sm) - 2px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -494,30 +510,24 @@ function goMatch(id: string) {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-tab.active {
|
|
||||||
color: var(--primary-light);
|
|
||||||
background: rgba(244, 162, 97, 0.15);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-filters .divider {
|
.right-filters .divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 12px;
|
height: 14px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-toggle-btn {
|
.status-toggle-btn {
|
||||||
padding: 0 8px;
|
padding: 0 10px;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--bg-card);
|
background: transparent;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -526,10 +536,8 @@ function goMatch(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-toggle-btn.active {
|
.status-toggle-btn.active {
|
||||||
color: var(--primary-light);
|
border-color: var(--border-gold);
|
||||||
background: rgba(244, 162, 97, 0.15);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||||
border-color: var(--primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* League Dropdown Selector */
|
/* League Dropdown Selector */
|
||||||
@@ -545,16 +553,17 @@ function goMatch(id: string) {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-text {
|
.dropdown-text {
|
||||||
@@ -592,23 +601,25 @@ function goMatch(id: string) {
|
|||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #0f1217;
|
background: var(--dropdown-bg);
|
||||||
border: 1px solid #2a3240;
|
backdrop-filter: none;
|
||||||
border-radius: 8px;
|
-webkit-backdrop-filter: none;
|
||||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.65);
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #e5e7eb;
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
border-bottom: 1px solid #1f2937;
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item:last-child {
|
.dropdown-item:last-child {
|
||||||
@@ -617,17 +628,17 @@ function goMatch(id: string) {
|
|||||||
|
|
||||||
.dropdown-item:hover,
|
.dropdown-item:hover,
|
||||||
.dropdown-item:active {
|
.dropdown-item:active {
|
||||||
background: #1b2330;
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item.active {
|
.dropdown-item.active {
|
||||||
color: #f4d06f;
|
color: var(--text);
|
||||||
background: #242f42;
|
background: rgba(255, 255, 255, 0.08);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.league-list {
|
.league-list {
|
||||||
padding: 4px 12px 0;
|
padding: 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state,
|
.state,
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ function goLogin() {
|
|||||||
<PhoneCountrySelect v-model="countryIso" />
|
<PhoneCountrySelect v-model="countryIso" />
|
||||||
<input
|
<input
|
||||||
v-model="phone"
|
v-model="phone"
|
||||||
class="field-input"
|
class="field-input ps-gold-input"
|
||||||
type="tel"
|
type="tel"
|
||||||
required
|
required
|
||||||
autocomplete="tel-national"
|
autocomplete="tel-national"
|
||||||
@@ -125,7 +125,7 @@ function goLogin() {
|
|||||||
<div class="inline-row">
|
<div class="inline-row">
|
||||||
<input
|
<input
|
||||||
v-model="smsCode"
|
v-model="smsCode"
|
||||||
class="field-input"
|
class="field-input ps-gold-input"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
@@ -146,7 +146,7 @@ function goLogin() {
|
|||||||
<label>{{ t('auth.password') }}</label>
|
<label>{{ t('auth.password') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="password"
|
v-model="password"
|
||||||
class="field-input"
|
class="field-input ps-gold-input"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
@@ -159,7 +159,7 @@ function goLogin() {
|
|||||||
<label>{{ t('auth.confirm_password') }}</label>
|
<label>{{ t('auth.confirm_password') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="confirmPassword"
|
v-model="confirmPassword"
|
||||||
class="field-input"
|
class="field-input ps-gold-input"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
@@ -197,19 +197,38 @@ function goLogin() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 30vh 16px calc(10vh + max(20px, env(safe-area-inset-bottom)));
|
padding: 30vh 16px calc(10vh + max(20px, env(safe-area-inset-bottom)));
|
||||||
background-color: var(--tertiary);
|
background-color: var(--bg-body);
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center top;
|
background-position: center top;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-page::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 34, 68, 0.35) 0%,
|
||||||
|
rgba(0, 34, 68, 0.78) 52%,
|
||||||
|
rgba(0, 34, 68, 0.94) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-title {
|
.form-title {
|
||||||
@@ -239,19 +258,19 @@ label {
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #0d0d0d;
|
background: var(--bg-input);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-input::placeholder {
|
.field-input::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.32);
|
color: rgba(240, 230, 211, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-input:focus {
|
.field-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #555;
|
border-color: var(--border-gold-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-row {
|
.inline-row {
|
||||||
@@ -271,7 +290,7 @@ label {
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -279,6 +298,12 @@ label {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-secondary:not(:disabled):active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--surface-accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-secondary:disabled {
|
.btn-secondary:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onActivated } from 'vue';
|
import { onActivated, computed, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||||
@@ -11,11 +11,28 @@ import TeamEmblem from '../components/TeamEmblem.vue';
|
|||||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||||
|
import type { PlayerHomeMatch } from '../composables/usePlayerHome';
|
||||||
|
|
||||||
|
type HotTab = 'hot' | 'upcoming';
|
||||||
|
|
||||||
const matchCardBg = `url(${cardBg})`;
|
const matchCardBg = `url(${cardBg})`;
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { banners, hotMatches, loading, load } = usePlayerHome();
|
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
|
||||||
|
const activeTab = ref<HotTab>('hot');
|
||||||
|
|
||||||
|
const bannerFallbackTo = computed(() => {
|
||||||
|
const id = announcementItems.value[0]?.id;
|
||||||
|
return id ? `/announcements/${id}` : '/announcements';
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayedMatches = computed<PlayerHomeMatch[]>(() =>
|
||||||
|
activeTab.value === 'hot' ? hotMatches.value : upcomingMatches.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const emptyMessage = computed(() =>
|
||||||
|
activeTab.value === 'hot' ? t('home.no_matches') : t('home.upcoming_empty'),
|
||||||
|
);
|
||||||
|
|
||||||
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
|
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
|
||||||
onRefresh: async () => { await load(true); },
|
onRefresh: async () => { await load(true); },
|
||||||
@@ -47,14 +64,35 @@ function formatKickoff(startTime: string) {
|
|||||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BannerCarousel :banners="banners" />
|
<BannerCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
|
||||||
|
|
||||||
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
|
<div class="hot-tabs" role="tablist" :aria-label="t('home.hot_matches')">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="hot-tab"
|
||||||
|
:class="{ active: activeTab === 'hot' }"
|
||||||
|
:aria-selected="activeTab === 'hot'"
|
||||||
|
@click="activeTab = 'hot'"
|
||||||
|
>
|
||||||
|
{{ t('home.hot_tab') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="hot-tab"
|
||||||
|
:class="{ active: activeTab === 'upcoming' }"
|
||||||
|
:aria-selected="activeTab === 'upcoming'"
|
||||||
|
@click="activeTab = 'upcoming'"
|
||||||
|
>
|
||||||
|
{{ t('home.upcoming_tab') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="(match, index) in hotMatches"
|
v-for="(match, index) in displayedMatches"
|
||||||
:key="match.id"
|
:key="match.id"
|
||||||
class="match-card"
|
class="match-card"
|
||||||
:class="{ 'match-card--live-anim': index < 3 }"
|
:class="{ 'match-card--live-anim': activeTab === 'hot' && index < 3 }"
|
||||||
@click="goMatch(match.id)"
|
@click="goMatch(match.id)"
|
||||||
>
|
>
|
||||||
<div class="match-info">
|
<div class="match-info">
|
||||||
@@ -102,9 +140,9 @@ function formatKickoff(startTime: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!loading && !hotMatches.length" class="empty">
|
<div v-if="!loading && !displayedMatches.length" class="empty">
|
||||||
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
|
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
|
||||||
<p>{{ t('home.no_matches') }}</p>
|
<p>{{ emptyMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -118,6 +156,36 @@ function formatKickoff(startTime: string) {
|
|||||||
transition: height 0.15s ease;
|
transition: height 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hot-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-tab {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-tab.active {
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--surface-active);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-tab:active {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
.match-card {
|
.match-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
|
|||||||
315
apps/player/src/views/InboxHubView.vue
Normal file
315
apps/player/src/views/InboxHubView.vue
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onActivated, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import MessageListPanel from '../components/MessageListPanel.vue';
|
||||||
|
import CustomerServicePanel from '../components/CustomerServicePanel.vue';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { usePlayerMessages } from '../composables/usePlayerMessages';
|
||||||
|
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||||
|
|
||||||
|
type HubTab = 'messages' | 'support';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const { inboxEnabled, hubTitleKey } = useInboxFeature();
|
||||||
|
const {
|
||||||
|
unreadCount,
|
||||||
|
messages,
|
||||||
|
listLoaded,
|
||||||
|
refreshUnreadCount,
|
||||||
|
markAllRead,
|
||||||
|
deleteAllMessages,
|
||||||
|
} = usePlayerMessages();
|
||||||
|
|
||||||
|
const markingAll = ref(false);
|
||||||
|
const deletingAll = ref(false);
|
||||||
|
const deleteAllConfirmVisible = ref(false);
|
||||||
|
|
||||||
|
const activeTab = computed<HubTab>(() => {
|
||||||
|
if (!inboxEnabled.value) return 'support';
|
||||||
|
return route.query.tab === 'support' ? 'support' : 'messages';
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadInList = computed(() => messages.value.filter((item) => !item.isRead).length);
|
||||||
|
const hasMessages = computed(() => listLoaded.value && messages.value.length > 0);
|
||||||
|
const showMessageActions = computed(
|
||||||
|
() => inboxEnabled.value && activeTab.value === 'messages' && auth.token && hasMessages.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
function switchTab(tab: HubTab) {
|
||||||
|
if (!inboxEnabled.value || tab === activeTab.value) return;
|
||||||
|
router.replace({ path: '/messages', query: tab === 'support' ? { tab: 'support' } : {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMarkAllRead() {
|
||||||
|
if (!unreadInList.value || markingAll.value) return;
|
||||||
|
markingAll.value = true;
|
||||||
|
try {
|
||||||
|
await markAllRead();
|
||||||
|
await refreshUnreadCount();
|
||||||
|
} finally {
|
||||||
|
markingAll.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteAll() {
|
||||||
|
if (deletingAll.value || !messages.value.length) return;
|
||||||
|
deleteAllConfirmVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteAll() {
|
||||||
|
if (deletingAll.value || !messages.value.length) return;
|
||||||
|
deletingAll.value = true;
|
||||||
|
try {
|
||||||
|
await deleteAllMessages();
|
||||||
|
await refreshUnreadCount();
|
||||||
|
deleteAllConfirmVisible.value = false;
|
||||||
|
} finally {
|
||||||
|
deletingAll.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSupportTabWhenDisabled() {
|
||||||
|
if (inboxEnabled.value || route.path !== '/messages') return;
|
||||||
|
if (route.query.tab === 'support') return;
|
||||||
|
void router.replace({ path: '/messages', query: { tab: 'support' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(inboxEnabled, (enabled, prev) => {
|
||||||
|
if (prev && !enabled) ensureSupportTabWhenDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.tab,
|
||||||
|
() => {
|
||||||
|
if (inboxEnabled.value && activeTab.value === 'messages') void refreshUnreadCount();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(ensureSupportTabWhenDisabled);
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
if (inboxEnabled.value) {
|
||||||
|
void refreshUnreadCount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureSupportTabWhenDisabled();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="inbox-hub" :class="{ 'inbox-hub--tabs': inboxEnabled }">
|
||||||
|
<header v-if="!inboxEnabled" class="page-header">
|
||||||
|
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||||
|
<h1>{{ t(hubTitleKey) }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="inboxEnabled" class="hub-top-bar">
|
||||||
|
<button type="button" class="hub-back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||||
|
<nav class="hub-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="hub-tab"
|
||||||
|
:class="{ active: activeTab === 'messages' }"
|
||||||
|
:aria-selected="activeTab === 'messages'"
|
||||||
|
@click="switchTab('messages')"
|
||||||
|
>
|
||||||
|
<span class="hub-tab-label">{{ t('inbox_hub.tab_messages') }}</span>
|
||||||
|
<span v-if="auth.token && unreadCount > 0" class="hub-tab-badge">
|
||||||
|
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="hub-tab"
|
||||||
|
:class="{ active: activeTab === 'support' }"
|
||||||
|
:aria-selected="activeTab === 'support'"
|
||||||
|
@click="switchTab('support')"
|
||||||
|
>
|
||||||
|
{{ t('inbox_hub.tab_support') }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showMessageActions" class="message-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-btn"
|
||||||
|
:disabled="!unreadInList || markingAll"
|
||||||
|
@click="onMarkAllRead"
|
||||||
|
>
|
||||||
|
{{ t('messages.mark_all_read') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-btn danger"
|
||||||
|
:disabled="deletingAll"
|
||||||
|
@click="onDeleteAll"
|
||||||
|
>
|
||||||
|
{{ t('messages.delete_all') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessageListPanel v-if="inboxEnabled" v-show="activeTab === 'messages'" />
|
||||||
|
<CustomerServicePanel v-if="activeTab === 'support'" />
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
v-model:visible="deleteAllConfirmVisible"
|
||||||
|
:title="t('messages.delete_all')"
|
||||||
|
:message="t('messages.delete_all_confirm')"
|
||||||
|
:confirm-text="t('messages.delete_all')"
|
||||||
|
danger
|
||||||
|
:loading="deletingAll"
|
||||||
|
@confirm="confirmDeleteAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inbox-hub {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-hub--tabs {
|
||||||
|
margin: -12px -16px 0;
|
||||||
|
padding: max(12px, env(safe-area-inset-top, 0px)) 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 0 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-top-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-back-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-tabs {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-tab {
|
||||||
|
flex: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-tab-label {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-tab-badge {
|
||||||
|
min-width: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-active);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.danger {
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
color: rgba(239, 68, 68, 0.95);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -90,11 +90,30 @@ function goRegister() {
|
|||||||
<LocaleSwitcher compact />
|
<LocaleSwitcher compact />
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submit" class="login-form ps-gold-frame">
|
<form @submit.prevent="submit" class="login-form ps-gold-frame">
|
||||||
|
<div class="auth-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="auth-tab"
|
||||||
|
:class="{ active: loginMode === 'account' }"
|
||||||
|
@click="switchLoginMode('account')"
|
||||||
|
>
|
||||||
|
{{ t('auth.login_by_account') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="auth-tab"
|
||||||
|
:class="{ active: loginMode === 'phone' }"
|
||||||
|
@click="switchLoginMode('phone')"
|
||||||
|
>
|
||||||
|
{{ t('auth.login_by_phone') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="loginMode === 'account'" class="field">
|
<div v-if="loginMode === 'account'" class="field">
|
||||||
<label>{{ t('auth.username') }}</label>
|
<label>{{ t('auth.username') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="account"
|
v-model="account"
|
||||||
class="field-input"
|
class="field-input ps-gold-input"
|
||||||
required
|
required
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
maxlength="32"
|
maxlength="32"
|
||||||
@@ -108,7 +127,7 @@ function goRegister() {
|
|||||||
<PhoneCountrySelect v-model="countryIso" />
|
<PhoneCountrySelect v-model="countryIso" />
|
||||||
<input
|
<input
|
||||||
v-model="phone"
|
v-model="phone"
|
||||||
class="field-input"
|
class="field-input ps-gold-input"
|
||||||
type="tel"
|
type="tel"
|
||||||
required
|
required
|
||||||
autocomplete="tel-national"
|
autocomplete="tel-national"
|
||||||
@@ -119,17 +138,13 @@ function goRegister() {
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>{{ t('auth.password') }}</label>
|
<label>{{ t('auth.password') }}</label>
|
||||||
<input v-model="password" class="field-input" type="password" required autocomplete="current-password" />
|
<input v-model="password" class="field-input ps-gold-input" type="password" required autocomplete="current-password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn-forgot" @click="goForgotPassword">
|
<button type="button" class="btn-forgot" @click="goForgotPassword">
|
||||||
{{ t('auth.forgot_password') }}
|
{{ t('auth.forgot_password') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" class="btn-mode-switch" @click="switchLoginMode(loginMode === 'account' ? 'phone' : 'account')">
|
|
||||||
{{ loginMode === 'account' ? t('auth.login_by_phone') : t('auth.login_by_account') }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<RobotVerify ref="captchaRef" />
|
<RobotVerify ref="captchaRef" />
|
||||||
<p v-if="resetSuccess" class="success">{{ t('auth.reset_success') }}</p>
|
<p v-if="resetSuccess" class="success">{{ t('auth.reset_success') }}</p>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
@@ -164,19 +179,68 @@ function goRegister() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 34vh 20px calc(15vh + max(28px, env(safe-area-inset-bottom)));
|
padding: 34vh 20px calc(15vh + max(28px, env(safe-area-inset-bottom)));
|
||||||
background-color: var(--tertiary);
|
background-color: var(--bg-body);
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center top;
|
background-position: center top;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-page::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 34, 68, 0.35) 0%,
|
||||||
|
rgba(0, 34, 68, 0.78) 52%,
|
||||||
|
rgba(0, 34, 68, 0.94) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-body);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@@ -191,7 +255,7 @@ function goRegister() {
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #0d0d0d;
|
background: var(--bg-input);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -199,7 +263,7 @@ function goRegister() {
|
|||||||
|
|
||||||
.field-input:focus {
|
.field-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #555;
|
border-color: var(--border-gold-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-row {
|
.inline-row {
|
||||||
@@ -220,36 +284,20 @@ label {
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-mode-switch {
|
|
||||||
align-self: flex-start;
|
|
||||||
margin: -2px 0 0;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: rgba(240, 216, 117, 0.75);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-forgot {
|
.btn-forgot {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
margin: -4px 0 0;
|
margin: -4px 0 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-forgot:active {
|
.btn-forgot:active {
|
||||||
color: rgba(255, 255, 255, 0.75);
|
color: var(--text);
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mode-switch:active {
|
|
||||||
color: rgba(240, 216, 117, 0.95);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-login {
|
.btn-login {
|
||||||
@@ -308,7 +356,7 @@ label {
|
|||||||
|
|
||||||
.success {
|
.success {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #6ee7a0;
|
color: var(--success);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import CorrectScorePanel from '../components/match-detail/CorrectScorePanel.vue'
|
|||||||
import { isCorrectScoreMarket, parseScoreCode } from '../utils/correctScoreLayout';
|
import { isCorrectScoreMarket, parseScoreCode } from '../utils/correctScoreLayout';
|
||||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||||
import vsImg from '../assets/images/vs.png';
|
|
||||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||||
|
import VsBadge from '../components/VsBadge.vue';
|
||||||
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
|
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
|
||||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ interface Market {
|
|||||||
lineKey?: string | null;
|
lineKey?: string | null;
|
||||||
period: string;
|
period: string;
|
||||||
lineValue?: string | number | null;
|
lineValue?: string | number | null;
|
||||||
|
status?: string;
|
||||||
allowSingle?: boolean;
|
allowSingle?: boolean;
|
||||||
allowParlay?: boolean;
|
allowParlay?: boolean;
|
||||||
promoLabel?: string | null;
|
promoLabel?: string | null;
|
||||||
@@ -47,6 +48,7 @@ interface Selection {
|
|||||||
selectionCode: string;
|
selectionCode: string;
|
||||||
selectionName: string;
|
selectionName: string;
|
||||||
selectionDisplayName?: string;
|
selectionDisplayName?: string;
|
||||||
|
status?: string;
|
||||||
odds: string;
|
odds: string;
|
||||||
oddsVersion: string;
|
oddsVersion: string;
|
||||||
}
|
}
|
||||||
@@ -191,6 +193,28 @@ function marketLabel(market: Market | null | undefined) {
|
|||||||
return market?.marketDisplayName?.trim() || market?.marketType || '';
|
return market?.marketDisplayName?.trim() || market?.marketType || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMarketStatus(status?: string) {
|
||||||
|
return (status ?? 'OPEN').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarketOpen(market: Market) {
|
||||||
|
return normalizeMarketStatus(market.status) === 'OPEN';
|
||||||
|
}
|
||||||
|
|
||||||
|
function marketStatusLabel(status?: string) {
|
||||||
|
const normalized = normalizeMarketStatus(status);
|
||||||
|
if (normalized === 'SUSPENDED') return t('bet.market_status_suspended');
|
||||||
|
if (normalized === 'CLOSED') return t('bet.market_status_closed');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarketLocked(market: Market) {
|
||||||
|
if (!bettingOpen.value || !isMarketOpen(market)) return true;
|
||||||
|
const singleOk = market.allowSingle !== false;
|
||||||
|
const parlayOk = market.allowParlay !== false;
|
||||||
|
return !singleOk && !parlayOk;
|
||||||
|
}
|
||||||
|
|
||||||
function selectionLabel(sel: Selection) {
|
function selectionLabel(sel: Selection) {
|
||||||
const parsedScore = parseScoreCode(sel.selectionCode, t);
|
const parsedScore = parseScoreCode(sel.selectionCode, t);
|
||||||
if (parsedScore) return parsedScore.display;
|
if (parsedScore) return parsedScore.display;
|
||||||
@@ -224,8 +248,7 @@ function isSelected(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelection(sel: Selection, market: Market) {
|
function toggleSelection(sel: Selection, market: Market) {
|
||||||
if (!match.value || !bettingOpen.value) return;
|
if (!match.value || isMarketLocked(market)) return;
|
||||||
if (market.allowSingle === false) return;
|
|
||||||
if (!auth.token) {
|
if (!auth.token) {
|
||||||
goLogin();
|
goLogin();
|
||||||
return;
|
return;
|
||||||
@@ -244,6 +267,7 @@ function toggleSelection(sel: Selection, market: Market) {
|
|||||||
market.lineValue != null && market.lineValue !== ''
|
market.lineValue != null && market.lineValue !== ''
|
||||||
? parseFloat(String(market.lineValue))
|
? parseFloat(String(market.lineValue))
|
||||||
: null,
|
: null,
|
||||||
|
allowSingle: market.allowSingle,
|
||||||
allowParlay: market.allowParlay,
|
allowParlay: market.allowParlay,
|
||||||
});
|
});
|
||||||
slip.openDrawer();
|
slip.openDrawer();
|
||||||
@@ -266,7 +290,7 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header class="toolbar">
|
<header class="toolbar sub-toolbar sub-toolbar--sticky sub-toolbar--glass">
|
||||||
<button type="button" class="icon-btn" :aria-label="t('bet.back')" @click="router.back()">←</button>
|
<button type="button" class="icon-btn" :aria-label="t('bet.back')" @click="router.back()">←</button>
|
||||||
<span class="toolbar-title">{{ match?.leagueName ?? '' }}</span>
|
<span class="toolbar-title">{{ match?.leagueName ?? '' }}</span>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
@@ -288,7 +312,8 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
</div>
|
</div>
|
||||||
<template v-else-if="match">
|
<template v-else-if="match">
|
||||||
<section class="match-hero" :class="{ 'match-hero--phase': matchPhase !== 'open' }">
|
<section class="match-hero" :class="{ 'match-hero--phase': matchPhase !== 'open' }">
|
||||||
<span v-if="matchPhase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
|
<span v-if="matchPhase === 'open'" class="status-ribbon">{{ t('bet.status_open') }}</span>
|
||||||
|
<span v-else-if="matchPhase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
|
||||||
<span v-else-if="matchPhase === 'closed_pending'" class="status-tag status-tag--pending">{{ phaseLabel }}</span>
|
<span v-else-if="matchPhase === 'closed_pending'" class="status-tag status-tag--pending">{{ phaseLabel }}</span>
|
||||||
<div class="hero-teams">
|
<div class="hero-teams">
|
||||||
<!-- home -->
|
<!-- home -->
|
||||||
@@ -302,32 +327,7 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
<span class="hero-name">{{ match.homeTeamName }}</span>
|
<span class="hero-name">{{ match.homeTeamName }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- VS arena with lightning -->
|
<VsBadge size="lg" />
|
||||||
<div class="vs-arena">
|
|
||||||
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="hzDetailGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
||||||
<stop offset="0%" stop-color="#5eb8ff" stop-opacity="0.2" />
|
|
||||||
<stop offset="35%" stop-color="#b8ecff" stop-opacity="1" />
|
|
||||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="1" />
|
|
||||||
<stop offset="65%" stop-color="#ffd080" stop-opacity="1" />
|
|
||||||
<stop offset="100%" stop-color="#ff9040" stop-opacity="0.2" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path
|
|
||||||
class="hz-path hz-path-main"
|
|
||||||
stroke="url(#hzDetailGrad)"
|
|
||||||
d="M1 14 H16 L20 5 L24 23 L28 9 L32 14 H40 L44 6 L48 22 L52 12 L56 14 H71"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
class="hz-path hz-path-sub"
|
|
||||||
stroke="url(#hzDetailGrad)"
|
|
||||||
d="M3 19 H14 L18 15 L22 19 H50 L54 16 L58 19 H69"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="hz-beam" aria-hidden="true" />
|
|
||||||
<img :src="vsImg" alt="" class="vs-img" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- away -->
|
<!-- away -->
|
||||||
<div class="hero-team">
|
<div class="hero-team">
|
||||||
@@ -388,24 +388,30 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
<MarketTypeTile
|
<MarketTypeTile
|
||||||
:label="marketLabel(market)"
|
:label="marketLabel(market)"
|
||||||
:promo-label="market.promoLabel?.trim() || ''"
|
:promo-label="market.promoLabel?.trim() || ''"
|
||||||
:has-market="true"
|
:status-label="bettingOpen && !isMarketOpen(market) ? marketStatusLabel(market.status) : ''"
|
||||||
:expanded="true"
|
|
||||||
/>
|
/>
|
||||||
<div class="market-panel-wrap" :class="{ locked: !bettingOpen || market.allowSingle === false }">
|
<div class="market-panel-wrap" :class="{ locked: isMarketLocked(market) }">
|
||||||
<span v-if="!bettingOpen && matchPhase === 'settled'" class="market-status-tag market-status-tag--settled">{{ phaseLabel }}</span>
|
<span v-if="!bettingOpen && matchPhase === 'settled'" class="market-status-tag market-status-tag--settled">{{ phaseLabel }}</span>
|
||||||
<span v-else-if="!bettingOpen" class="market-status-tag market-status-tag--pending">{{ phaseLabel }}</span>
|
<span v-else-if="!bettingOpen" class="market-status-tag market-status-tag--pending">{{ phaseLabel }}</span>
|
||||||
|
<span
|
||||||
|
v-else-if="!isMarketOpen(market)"
|
||||||
|
class="market-status-tag"
|
||||||
|
:class="normalizeMarketStatus(market.status) === 'CLOSED' ? 'market-status-tag--closed' : 'market-status-tag--suspended'"
|
||||||
|
>
|
||||||
|
{{ marketStatusLabel(market.status) }}
|
||||||
|
</span>
|
||||||
<CorrectScorePanel
|
<CorrectScorePanel
|
||||||
v-if="isCorrectScoreMarket(market.marketType)"
|
v-if="isCorrectScoreMarket(market.marketType)"
|
||||||
:market-type="market.marketType"
|
:market-type="market.marketType"
|
||||||
:selections="market.selections"
|
:selections="market.selections"
|
||||||
:locked="!bettingOpen || market.allowSingle === false"
|
:locked="isMarketLocked(market)"
|
||||||
:is-selected="isSelected"
|
:is-selected="isSelected"
|
||||||
@pick="onPickSelection($event, market.id)"
|
@pick="onPickSelection($event, market.id)"
|
||||||
/>
|
/>
|
||||||
<MarketSelectionsPanel
|
<MarketSelectionsPanel
|
||||||
v-else
|
v-else
|
||||||
compact
|
compact
|
||||||
:locked="!bettingOpen || market.allowSingle === false"
|
:locked="isMarketLocked(market)"
|
||||||
:line-value="market.lineValue"
|
:line-value="market.lineValue"
|
||||||
:selections="market.selections"
|
:selections="market.selections"
|
||||||
:is-selected="isSelected"
|
:is-selected="isSelected"
|
||||||
@@ -421,7 +427,8 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.detail-page {
|
.detail-page {
|
||||||
margin: 0 -16px;
|
--detail-gutter-x: 0;
|
||||||
|
padding-top: var(--space-toolbar-top);
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,14 +444,12 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 4px 12px 8px;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
position: sticky;
|
}
|
||||||
top: -12px;
|
|
||||||
z-index: 50;
|
.detail-page > .sub-toolbar {
|
||||||
margin-top: -12px;
|
padding-top: 0;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
margin-bottom: 8px;
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-title {
|
.toolbar-title {
|
||||||
@@ -487,42 +492,59 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
.match-hero {
|
.match-hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
margin: 0 0 10px;
|
margin: 0 calc(-1 * (var(--space-page-x) - var(--detail-gutter-x))) 12px;
|
||||||
padding: 14px 12px 16px;
|
padding: 14px 12px 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--gradient-card);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 10%;
|
||||||
|
right: 10%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--gradient-card-shine);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-hero--phase {
|
.match-hero--phase {
|
||||||
opacity: 0.98;
|
opacity: 0.98;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── hero status tag (top-right corner) ── */
|
/* ── hero status tag (top-left ribbon) ── */
|
||||||
.status-tag {
|
.status-tag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
left: 0;
|
||||||
|
right: auto;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
padding: 4px 14px;
|
padding: 4px 12px 4px 10px;
|
||||||
border-radius: 0 6px 0 10px;
|
border-radius: 0 0 10px 0;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tag--settled {
|
.status-tag--settled {
|
||||||
background: linear-gradient(180deg, #2a2a3a 0%, #1a1a28 100%);
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 20, 40, 0.75) 100%);
|
||||||
color: #8a9ab8;
|
color: var(--text-muted);
|
||||||
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-left: 1px solid rgba(120, 140, 180, 0.3);
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tag--pending {
|
.status-tag--pending {
|
||||||
background: linear-gradient(180deg, #3a3a2a 0%, #28251a 100%);
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(10, 53, 88, 0.6) 100%);
|
||||||
color: #c8a84e;
|
color: var(--text-muted);
|
||||||
border-bottom: 1px solid rgba(200, 168, 78, 0.3);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-left: 1px solid rgba(200, 168, 78, 0.3);
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── market panel smaller status tag ── */
|
/* ── market panel smaller status tag ── */
|
||||||
@@ -548,10 +570,24 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.market-status-tag--pending {
|
.market-status-tag--pending {
|
||||||
background: linear-gradient(180deg, #3a3a2a 0%, #28251a 100%);
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(0, 51, 102, 0.95) 100%);
|
||||||
color: #c8a84e;
|
color: var(--primary-light);
|
||||||
border-bottom: 1px solid rgba(200, 168, 78, 0.3);
|
border-bottom: 1px solid var(--border-gold-soft);
|
||||||
border-left: 1px solid rgba(200, 168, 78, 0.3);
|
border-left: 1px solid var(--border-gold-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-status-tag--suspended {
|
||||||
|
background: linear-gradient(180deg, rgba(240, 180, 41, 0.28) 0%, rgba(120, 80, 10, 0.92) 100%);
|
||||||
|
color: #ffe08a;
|
||||||
|
border-bottom: 1px solid rgba(240, 180, 41, 0.45);
|
||||||
|
border-left: 1px solid rgba(240, 180, 41, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-status-tag--closed {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(40, 40, 55, 0.95) 100%);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
|
||||||
|
border-left: 1px solid rgba(120, 140, 180, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.market-panel-wrap {
|
.market-panel-wrap {
|
||||||
@@ -577,20 +613,20 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
font-size: 28px;
|
font-size: 24px;
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: var(--text);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-stats-section {
|
.result-stats-section {
|
||||||
margin: 0 10px 12px;
|
margin: 0 calc(-1 * (var(--space-page-x) - var(--detail-gutter-x))) 12px;
|
||||||
padding: 12px;
|
padding: var(--space-card);
|
||||||
border: 1px solid rgba(212, 175, 55, 0.22);
|
border: 1px solid var(--border-gold-soft);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
background: linear-gradient(180deg, #1b1b1b 0%, #121212 100%);
|
background: var(--bg-card);
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-stats-head {
|
.result-stats-head {
|
||||||
@@ -605,8 +641,8 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
font-weight: 900;
|
font-weight: 600;
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-stats-head span {
|
.result-stats-head span {
|
||||||
@@ -633,8 +669,8 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(255, 255, 255, 0.035);
|
background: var(--bg-body);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-stat-label {
|
.result-stat-label {
|
||||||
@@ -684,138 +720,30 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* VS arena — same as HomeView */
|
|
||||||
.vs-arena {
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 140px;
|
|
||||||
height: 126px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hz-lightning {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hz-path {
|
|
||||||
fill: none;
|
|
||||||
stroke-width: 2.2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
filter: drop-shadow(0 0 4px rgba(120, 210, 255, 0.95)) drop-shadow(0 0 8px rgba(255, 180, 80, 0.55));
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hz-path-main {
|
|
||||||
animation: hz-strike-main 2.6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hz-path-sub {
|
|
||||||
stroke-width: 1.6;
|
|
||||||
animation: hz-strike-sub 2.6s ease-in-out infinite;
|
|
||||||
animation-delay: 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hz-beam {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: 50%;
|
|
||||||
height: 2px;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgba(94, 184, 255, 0) 0%,
|
|
||||||
rgba(184, 236, 255, 0.95) 28%,
|
|
||||||
#fff 50%,
|
|
||||||
rgba(255, 208, 128, 0.95) 72%,
|
|
||||||
rgba(255, 144, 64, 0) 100%
|
|
||||||
);
|
|
||||||
opacity: 0;
|
|
||||||
filter: blur(0.4px);
|
|
||||||
animation: hz-beam-flash 2.6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vs-img {
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
width: 126px;
|
|
||||||
height: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes hz-strike-main {
|
|
||||||
0%, 72%, 100% { opacity: 0; }
|
|
||||||
74% { opacity: 1; }
|
|
||||||
75% { opacity: 0.25; }
|
|
||||||
76% { opacity: 0.95; }
|
|
||||||
78% { opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes hz-strike-sub {
|
|
||||||
0%, 74%, 100% { opacity: 0; }
|
|
||||||
76% { opacity: 0.85; }
|
|
||||||
77% { opacity: 0.2; }
|
|
||||||
78% { opacity: 0.7; }
|
|
||||||
80% { opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes hz-beam-flash {
|
|
||||||
0%, 71%, 100% { opacity: 0; transform: translateY(-50%) scaleX(0.6); }
|
|
||||||
73% { opacity: 0.85; transform: translateY(-50%) scaleX(1); }
|
|
||||||
75% { opacity: 0.15; transform: translateY(-50%) scaleX(0.95); }
|
|
||||||
76% { opacity: 0.75; transform: translateY(-50%) scaleX(1); }
|
|
||||||
78% { opacity: 0; transform: translateY(-50%) scaleX(1.05); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes vs-glow {
|
|
||||||
0%, 100% {
|
|
||||||
opacity: 0.82;
|
|
||||||
filter: drop-shadow(0 0 2px rgba(212, 175, 55, 0.3));
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
filter: drop-shadow(0 0 3px rgba(255, 230, 140, 0.7)) drop-shadow(0 0 6px rgba(212, 175, 55, 0.35));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.hz-path, .hz-beam { animation: none; opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.markets-section {
|
.markets-section {
|
||||||
padding: 0 10px 8px;
|
margin: 0 calc(-1 * (var(--space-page-x) - var(--detail-gutter-x)));
|
||||||
|
padding: 0 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.market-list {
|
.market-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 10px;
|
||||||
padding: 4px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.market-group {
|
.market-group {
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid #252525;
|
border: 1px solid var(--border);
|
||||||
background: linear-gradient(180deg, #1a1a1a 0%, #151515 100%);
|
background: var(--bg-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.35);
|
box-shadow: var(--shadow);
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.market-group.open {
|
.market-group.open {
|
||||||
border-color: rgba(212, 175, 55, 0.25);
|
border-color: var(--border-gold-soft);
|
||||||
box-shadow: 0 4px 16px rgba(212, 175, 55, 0.08);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.market-group :deep(.row) {
|
.market-group :deep(.row) {
|
||||||
@@ -833,7 +761,7 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
padding: 9px;
|
padding: 9px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--border-gold-soft);
|
border: 1px solid var(--border-gold-soft);
|
||||||
background: rgba(212, 175, 55, 0.1);
|
background: var(--surface-accent);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -854,14 +782,14 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
|
|
||||||
/* ── 我的投注 ── */
|
/* ── 我的投注 ── */
|
||||||
.my-bets-section {
|
.my-bets-section {
|
||||||
|
margin: 0 calc(-1 * (var(--space-page-x) - var(--detail-gutter-x))) 12px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.my-bets-title {
|
.my-bets-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ccc;
|
color: var(--text-muted);
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,16 +800,16 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.my-bet-card {
|
.my-bet-card {
|
||||||
background: #1c1c1c;
|
background: var(--bg-card);
|
||||||
border: 1px solid #2e2e2e;
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.my-bet-card:active {
|
.my-bet-card:active {
|
||||||
background: #252525;
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bet-header {
|
.bet-header {
|
||||||
@@ -912,7 +840,7 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
|
|
||||||
.bet-status-won {
|
.bet-status-won {
|
||||||
background: rgba(61, 184, 101, 0.15);
|
background: rgba(61, 184, 101, 0.15);
|
||||||
color: #3db865;
|
color: #2ECC71;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bet-status-lost {
|
.bet-status-lost {
|
||||||
@@ -933,7 +861,7 @@ function onPickSelection(selId: string, marketId: string) {
|
|||||||
|
|
||||||
.bet-stake {
|
.bet-stake {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bet-return {
|
.bet-return {
|
||||||
|
|||||||
360
apps/player/src/views/MessageDetailView.vue
Normal file
360
apps/player/src/views/MessageDetailView.vue
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onActivated, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||||
|
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||||
|
import { formatMoney } from '../utils/localeDisplay';
|
||||||
|
import {
|
||||||
|
usePlayerMessages,
|
||||||
|
type DepositMessagePayload,
|
||||||
|
type BannerPromoPayload,
|
||||||
|
type PlayerMessage,
|
||||||
|
} from '../composables/usePlayerMessages';
|
||||||
|
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const { loadMessageDetail, markMessageRead, deleteMessage } = usePlayerMessages();
|
||||||
|
const { hubRoute } = useInboxFeature();
|
||||||
|
|
||||||
|
const messageId = computed(() => String(route.params.id ?? ''));
|
||||||
|
const message = ref<PlayerMessage | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(false);
|
||||||
|
const deleting = ref(false);
|
||||||
|
const deleteConfirmVisible = ref(false);
|
||||||
|
|
||||||
|
const depositPayload = computed(() => {
|
||||||
|
if (
|
||||||
|
!message.value ||
|
||||||
|
message.value.type === 'BANNER_PROMO' ||
|
||||||
|
message.value.type === 'ANNOUNCEMENT_PROMO'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (message.value.payload ?? null) as DepositMessagePayload | null;
|
||||||
|
});
|
||||||
|
const contentPromoId = computed(() => {
|
||||||
|
if (
|
||||||
|
message.value?.type !== 'BANNER_PROMO' &&
|
||||||
|
message.value?.type !== 'ANNOUNCEMENT_PROMO'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return (message.value.payload as BannerPromoPayload | null)?.contentId;
|
||||||
|
});
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goList() {
|
||||||
|
router.push(hubRoute.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewContentPromo() {
|
||||||
|
if (!contentPromoId.value) return;
|
||||||
|
router.push(`/announcements/${contentPromoId.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(createdAt?: string) {
|
||||||
|
if (!createdAt) return '';
|
||||||
|
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageTitle(item: PlayerMessage) {
|
||||||
|
if (item.type === 'DEPOSIT_APPROVED') return t('messages.deposit_approved_title');
|
||||||
|
if (item.type === 'DEPOSIT_REJECTED') return t('messages.deposit_rejected_title');
|
||||||
|
if (item.type === 'BANNER_PROMO' || item.type === 'ANNOUNCEMENT_PROMO') return item.title;
|
||||||
|
return item.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageBody(item: PlayerMessage) {
|
||||||
|
const deposit = depositPayload.value;
|
||||||
|
if (item.type === 'DEPOSIT_APPROVED' && deposit?.orderNo) {
|
||||||
|
return t('messages.deposit_approved_body', {
|
||||||
|
orderNo: deposit.orderNo,
|
||||||
|
amount: formatMoney(deposit.amount ?? '0', locale.value),
|
||||||
|
approvedAmount: formatMoney(
|
||||||
|
deposit.approvedAmount ?? deposit.amount ?? '0',
|
||||||
|
locale.value,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (item.type === 'DEPOSIT_REJECTED' && deposit?.orderNo) {
|
||||||
|
return t('messages.deposit_rejected_body', {
|
||||||
|
orderNo: deposit.orderNo,
|
||||||
|
amount: formatMoney(deposit.amount ?? '0', locale.value),
|
||||||
|
reason: deposit.rejectReason?.trim() || t('messages.no_reason'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return item.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetail() {
|
||||||
|
if (!messageId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = false;
|
||||||
|
try {
|
||||||
|
message.value = await loadMessageDetail(messageId.value);
|
||||||
|
if (message.value && !message.value.isRead) {
|
||||||
|
await markMessageRead(messageId.value);
|
||||||
|
message.value = { ...message.value, isRead: true };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error.value = true;
|
||||||
|
message.value = null;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelete() {
|
||||||
|
if (!messageId.value || deleting.value) return;
|
||||||
|
deleteConfirmVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!messageId.value || deleting.value) return;
|
||||||
|
deleting.value = true;
|
||||||
|
try {
|
||||||
|
await deleteMessage(messageId.value);
|
||||||
|
deleteConfirmVisible.value = false;
|
||||||
|
router.replace(hubRoute.value);
|
||||||
|
} finally {
|
||||||
|
deleting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void fetchDetail();
|
||||||
|
});
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
void fetchDetail();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(messageId, () => {
|
||||||
|
void fetchDetail();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="message-detail">
|
||||||
|
<header class="page-header">
|
||||||
|
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||||
|
<h1>{{ t('messages.detail_title') }}</h1>
|
||||||
|
<button
|
||||||
|
v-if="message"
|
||||||
|
type="button"
|
||||||
|
class="delete-header-btn"
|
||||||
|
:aria-label="t('messages.delete')"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="onDelete"
|
||||||
|
>
|
||||||
|
{{ t('messages.delete') }}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading && !message" class="state">
|
||||||
|
<GoldSpinner :size="36" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error || !message" class="empty">
|
||||||
|
<p>{{ t('messages.not_found') }}</p>
|
||||||
|
<button type="button" class="link-btn" @click="goList">{{ t('messages.view_all') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article v-else class="detail-article">
|
||||||
|
<span class="status-badge" :class="{ unread: !message.isRead }">
|
||||||
|
{{ message.isRead ? t('messages.status_read') : t('messages.status_unread') }}
|
||||||
|
</span>
|
||||||
|
<p v-if="message.createdAt" class="detail-date">{{ formatDate(message.createdAt) }}</p>
|
||||||
|
<h2 class="detail-title">{{ messageTitle(message) }}</h2>
|
||||||
|
<p class="detail-body">{{ messageBody(message) }}</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="message.type === 'DEPOSIT_REJECTED' && depositPayload?.rejectReason?.trim()"
|
||||||
|
class="reason-box"
|
||||||
|
>
|
||||||
|
<p class="reason-label">{{ t('messages.reject_reason') }}</p>
|
||||||
|
<p class="reason-text">{{ depositPayload.rejectReason }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="contentPromoId"
|
||||||
|
type="button"
|
||||||
|
class="action-btn"
|
||||||
|
@click="viewContentPromo"
|
||||||
|
>
|
||||||
|
{{ t('messages.content_promo_view') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="depositPayload?.depositOrderId"
|
||||||
|
type="button"
|
||||||
|
class="action-btn"
|
||||||
|
@click="router.push('/wallet/recharge/history')"
|
||||||
|
>
|
||||||
|
{{ t('messages.view_recharge_history') }}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
v-model:visible="deleteConfirmVisible"
|
||||||
|
:title="t('messages.delete')"
|
||||||
|
:message="t('messages.delete_confirm')"
|
||||||
|
:confirm-text="t('messages.delete')"
|
||||||
|
danger
|
||||||
|
:loading="deleting"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-detail {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 0 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-header-btn {
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
color: rgba(239, 68, 68, 0.95);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-header-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #888;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.unread {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state,
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--surface-active);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-article {
|
||||||
|
padding: 18px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-date {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-box {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-label {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(239, 68, 68, 0.95);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
margin-top: 24px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-active);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -112,7 +112,7 @@ const pullIndicatorStyle = () => ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<BetStatsPanel :items="items" />
|
<BetStatsPanel class="stats-panel" :items="items" />
|
||||||
|
|
||||||
<div class="filter-tabs">
|
<div class="filter-tabs">
|
||||||
<button
|
<button
|
||||||
@@ -128,7 +128,9 @@ const pullIndicatorStyle = () => ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="items.length">
|
<template v-if="items.length">
|
||||||
|
<div class="bet-list">
|
||||||
<BetHistoryCard v-for="bet in items" :key="bet.betNo" :bet="bet" />
|
<BetHistoryCard v-for="bet in items" :key="bet.betNo" :bet="bet" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div ref="sentinel" class="sentinel" />
|
<div ref="sentinel" class="sentinel" />
|
||||||
|
|
||||||
@@ -151,6 +153,16 @@ const pullIndicatorStyle = () => ({
|
|||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-panel {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-section);
|
||||||
|
}
|
||||||
|
|
||||||
.pull-indicator {
|
.pull-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -161,8 +173,8 @@ const pullIndicatorStyle = () => ({
|
|||||||
|
|
||||||
.filter-tabs {
|
.filter-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--space-section);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -172,20 +184,21 @@ const pullIndicatorStyle = () => ({
|
|||||||
|
|
||||||
.filter-tab {
|
.filter-tab {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 7px 14px;
|
padding: 8px 16px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid var(--border);
|
||||||
transition: all 0.2s;
|
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-tab.active {
|
.filter-tab.active {
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
background: rgba(212, 175, 55, 0.1);
|
background: var(--surface-accent);
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
@@ -217,7 +230,7 @@ const pullIndicatorStyle = () => ({
|
|||||||
.end-hint {
|
.end-hint {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #555;
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 16px 0 4px;
|
padding: 16px 0 4px;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ function back() {
|
|||||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header class="page-head">
|
<header class="page-head sub-toolbar">
|
||||||
<button type="button" class="back-btn" @click="back">← {{ t('profile.back') }}</button>
|
<button type="button" class="back-btn" @click="back">← {{ t('profile.back') }}</button>
|
||||||
<h2 class="page-title">{{ t('profile.edit') }}</h2>
|
<h2 class="page-title">{{ t('profile.edit') }}</h2>
|
||||||
</header>
|
</header>
|
||||||
@@ -308,7 +308,7 @@ function back() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.edit-page {
|
.edit-page {
|
||||||
padding: 8px 0 20px;
|
padding: 0 0 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -366,7 +366,7 @@ function back() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(145deg, #2a2210, #141008);
|
background: var(--gradient-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-img {
|
.avatar-img {
|
||||||
@@ -380,14 +380,14 @@ function back() {
|
|||||||
padding: 7px 18px;
|
padding: 7px 18px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--border-gold-soft);
|
border: 1px solid var(--border-gold-soft);
|
||||||
background: rgba(212, 175, 55, 0.08);
|
background: var(--surface-accent);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-card {
|
.form-card {
|
||||||
padding: 16px;
|
padding: var(--space-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
@@ -471,7 +471,7 @@ function back() {
|
|||||||
border: none;
|
border: none;
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
border-radius: 0 6px 6px 0;
|
border-radius: 0 6px 6px 0;
|
||||||
background: rgba(212, 175, 55, 0.06);
|
background: var(--surface-accent);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -505,7 +505,7 @@ label {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #0a0a0a;
|
background: var(--bg-input);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
@@ -513,19 +513,19 @@ label {
|
|||||||
.field-input:focus {
|
.field-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--border-gold-soft);
|
||||||
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.12);
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-input:-webkit-autofill,
|
.field-input:-webkit-autofill,
|
||||||
.field-input:-webkit-autofill:hover,
|
.field-input:-webkit-autofill:hover,
|
||||||
.field-input:-webkit-autofill:focus {
|
.field-input:-webkit-autofill:focus {
|
||||||
-webkit-text-fill-color: var(--text);
|
-webkit-text-fill-color: var(--text);
|
||||||
box-shadow: 0 0 0 1000px #0a0a0a inset;
|
box-shadow: 0 0 0 1000px var(--bg-input) inset;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-input::placeholder {
|
.field-input::placeholder {
|
||||||
color: #555;
|
color: var(--neutral);
|
||||||
}
|
}
|
||||||
|
|
||||||
.readonly {
|
.readonly {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onActivated, computed } from 'vue';
|
import { ref, onMounted, onActivated } from 'vue';
|
||||||
import { useRouter, RouterLink } from 'vue-router';
|
import { useRouter, RouterLink } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { formatMoney } from '../utils/localeDisplay';
|
|
||||||
import LocaleFlag from '../components/LocaleFlag.vue';
|
import LocaleFlag from '../components/LocaleFlag.vue';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useAppLocale } from '../composables/useAppLocale';
|
import { useAppLocale } from '../composables/useAppLocale';
|
||||||
@@ -11,13 +10,16 @@ import { usePlayerProfile } from '../composables/usePlayerProfile';
|
|||||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||||
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
|
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
|
||||||
import walletBg from '../assets/images/wallet-bg.webp';
|
import { sumCashbackFromStats } from '../utils/walletStats';
|
||||||
|
import WalletBalanceCard from '../components/WalletBalanceCard.vue';
|
||||||
|
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const { locales, setLocale, initFromUser } = useAppLocale();
|
const { locales, setLocale, initFromUser } = useAppLocale();
|
||||||
const { profileRaw, refreshProfile } = usePlayerProfile();
|
const { profileRaw, refreshProfile } = usePlayerProfile();
|
||||||
|
const { hubRoute } = useInboxFeature();
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref(false);
|
const error = ref(false);
|
||||||
@@ -49,14 +51,7 @@ async function fetchCashbackTotal() {
|
|||||||
// Fallback: sum wallet cashback credits when batch detail API is unavailable.
|
// Fallback: sum wallet cashback credits when batch detail API is unavailable.
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/player/wallet/transactions/stats');
|
const { data } = await api.get('/player/wallet/transactions/stats');
|
||||||
const byType = data.data?.byType ?? [];
|
cashbackTotal.value = sumCashbackFromStats(data.data).toString();
|
||||||
let sum = 0;
|
|
||||||
for (const g of byType) {
|
|
||||||
if (['CASHBACK', 'CASHBACK_DEPOSIT'].includes(g.transactionType?.toUpperCase())) {
|
|
||||||
sum += Math.abs(parseFloat(g.totalAmount ?? '0'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cashbackTotal.value = sum.toString();
|
|
||||||
} catch {
|
} catch {
|
||||||
// Keep default value
|
// Keep default value
|
||||||
}
|
}
|
||||||
@@ -88,19 +83,6 @@ function logout() {
|
|||||||
auth.logout();
|
auth.logout();
|
||||||
router.push('/');
|
router.push('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
const balanceDisplay = computed(() =>
|
|
||||||
formatMoney(profileRaw.value?.wallet?.availableBalance, locale.value),
|
|
||||||
);
|
|
||||||
|
|
||||||
const balanceAmountClass = computed(() => {
|
|
||||||
const len = balanceDisplay.value.length;
|
|
||||||
if (len <= 10) return 'balance-amount--xl';
|
|
||||||
if (len <= 13) return 'balance-amount--lg';
|
|
||||||
if (len <= 16) return 'balance-amount--md';
|
|
||||||
if (len <= 19) return 'balance-amount--sm';
|
|
||||||
return 'balance-amount--xs';
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -122,49 +104,14 @@ const balanceAmountClass = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="wallet-banner">
|
<WalletBalanceCard
|
||||||
<img class="wallet-banner-img" :src="walletBg" alt="" />
|
:available-balance="profileRaw?.wallet?.availableBalance"
|
||||||
<div class="wallet-banner-scrim" aria-hidden="true" />
|
:cashback-total="cashbackTotal"
|
||||||
|
:frozen-balance="profileRaw?.wallet?.frozenBalance"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="card-overlay">
|
<section class="settings-group settings-group--wallet">
|
||||||
<div class="bank-card-top">
|
<RouterLink to="/wallet/detail" class="settings-cell settings-cell--accent-entry">
|
||||||
<div class="bank-card-top-left">
|
|
||||||
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
|
|
||||||
<div class="bank-card-brand-text">
|
|
||||||
<span class="brand-name">THEBET365</span>
|
|
||||||
<span class="bank-card-type">MEMBER</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<RouterLink to="/wallet/recharge" class="recharge-btn">
|
|
||||||
<span class="recharge-icon">+</span>
|
|
||||||
<span>{{ t('recharge.title') }}</span>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bank-card-balance">
|
|
||||||
<span class="bank-card-label">当前余额</span>
|
|
||||||
<p class="bank-card-number balance-amount" :class="balanceAmountClass">{{ balanceDisplay }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bank-card-footer">
|
|
||||||
<div class="bank-card-field">
|
|
||||||
<span class="bank-card-label">持卡人</span>
|
|
||||||
<span class="bank-card-holder">{{ profileRaw?.username }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="bank-card-field bank-card-field--center">
|
|
||||||
<span class="bank-card-label">累计返水</span>
|
|
||||||
<span class="bank-card-stat">{{ formatMoney(cashbackTotal, locale) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="bank-card-field bank-card-field--right">
|
|
||||||
<span class="bank-card-label">未结算</span>
|
|
||||||
<span class="bank-card-stat">{{ formatMoney(profileRaw?.wallet?.frozenBalance, locale) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="settings-group">
|
|
||||||
<RouterLink to="/wallet/detail" class="settings-cell settings-cell--gold-entry">
|
|
||||||
<span class="cell-main">
|
<span class="cell-main">
|
||||||
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||||
<path d="M9 5H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V9" />
|
<path d="M9 5H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V9" />
|
||||||
@@ -175,7 +122,7 @@ const balanceAmountClass = computed(() => {
|
|||||||
<span class="cell-chevron" aria-hidden="true">›</span>
|
<span class="cell-chevron" aria-hidden="true">›</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink to="/profile/cashbacks" class="settings-cell settings-cell--gold-entry">
|
<RouterLink to="/profile/cashbacks" class="settings-cell settings-cell--accent-entry">
|
||||||
<span class="cell-main">
|
<span class="cell-main">
|
||||||
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="8" />
|
<circle cx="12" cy="12" r="8" />
|
||||||
@@ -186,7 +133,7 @@ const balanceAmountClass = computed(() => {
|
|||||||
<span class="cell-chevron" aria-hidden="true">›</span>
|
<span class="cell-chevron" aria-hidden="true">›</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink to="/wallet/recharge/history" class="settings-cell settings-cell--gold-entry">
|
<RouterLink to="/wallet/recharge/history" class="settings-cell settings-cell--accent-entry">
|
||||||
<span class="cell-main">
|
<span class="cell-main">
|
||||||
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||||
<path d="M12 8v8M8 12h8" />
|
<path d="M12 8v8M8 12h8" />
|
||||||
@@ -197,6 +144,19 @@ const balanceAmountClass = computed(() => {
|
|||||||
<span class="cell-chevron" aria-hidden="true">›</span>
|
<span class="cell-chevron" aria-hidden="true">›</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink :to="hubRoute" class="settings-cell settings-cell--accent-entry">
|
||||||
|
<span class="cell-main">
|
||||||
|
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||||
|
<path d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11A2.5 2.5 0 0 1 17.5 20H6.5A2.5 2.5 0 0 1 4 17.5v-11Z" />
|
||||||
|
<path d="m4 7 8 5.5L20 7" />
|
||||||
|
</svg>
|
||||||
|
<span class="cell-label">{{ t('messages.title') }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="cell-chevron" aria-hidden="true">›</span>
|
||||||
|
</RouterLink>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-group settings-group--profile">
|
||||||
<RouterLink to="/profile/edit" class="settings-cell">
|
<RouterLink to="/profile/edit" class="settings-cell">
|
||||||
<span class="cell-main">
|
<span class="cell-main">
|
||||||
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||||
@@ -311,238 +271,36 @@ const balanceAmountClass = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.retry-btn:active {
|
.retry-btn:active {
|
||||||
background: rgba(200, 168, 78, 0.15);
|
background: var(--surface-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wallet-banner {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
aspect-ratio: 2 / 1;
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow:
|
|
||||||
0 3px 10px rgba(0, 0, 0, 0.28),
|
|
||||||
0 1px 3px rgba(0, 0, 0, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wallet-banner::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1;
|
|
||||||
border-radius: inherit;
|
|
||||||
box-shadow: inset 0 0 14px rgba(0, 0, 0, 0.22);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wallet-banner-img {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: center center;
|
|
||||||
transform: scale(1.12);
|
|
||||||
transform-origin: center center;
|
|
||||||
filter: brightness(0.86);
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wallet-banner-scrim {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 2;
|
|
||||||
pointer-events: none;
|
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgba(0, 0, 0, 0.4) 0%,
|
|
||||||
rgba(0, 0, 0, 0.18) 42%,
|
|
||||||
rgba(0, 0, 0, 0.46) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 3;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 14px 16px 12px;
|
|
||||||
line-height: 1.3;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bank-card-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bank-card-top-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-logo {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bank-card-brand-text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-name {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
line-height: 1;
|
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bank-card-type {
|
|
||||||
font-size: 8px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
color: rgba(212, 175, 55, 0.65);
|
|
||||||
font-style: italic;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bank-card-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(255, 255, 255, 0.82);
|
|
||||||
line-height: 1.3;
|
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recharge-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
border: 1px solid rgba(212, 175, 55, 0.35);
|
|
||||||
color: var(--primary-light);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
line-height: 1;
|
|
||||||
text-decoration: none;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recharge-btn:active {
|
|
||||||
background: rgba(212, 175, 55, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recharge-icon {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bank-card-balance {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 2px 0;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bank-card-number {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--primary-light);
|
|
||||||
line-height: 1.1;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.balance-amount {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.balance-amount--xl { font-size: clamp(24px, 6.2vw, 30px); }
|
|
||||||
.balance-amount--lg { font-size: clamp(21px, 5.6vw, 26px); }
|
|
||||||
.balance-amount--md { font-size: clamp(18px, 4.9vw, 22px); }
|
|
||||||
.balance-amount--sm { font-size: clamp(16px, 4.2vw, 19px); }
|
|
||||||
.balance-amount--xs { font-size: clamp(14px, 3.6vw, 16px); }
|
|
||||||
|
|
||||||
.bank-card-footer {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 8px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bank-card-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bank-card-field--center { text-align: center; }
|
|
||||||
.bank-card-field--right { text-align: right; }
|
|
||||||
|
|
||||||
.bank-card-holder {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
line-height: 1.2;
|
|
||||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bank-card-stat {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 1.2;
|
|
||||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.settings-group {
|
.settings-group {
|
||||||
background: var(--bg-card);
|
position: relative;
|
||||||
border: 1px solid var(--border);
|
background: var(--gradient-card);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-card-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 10%;
|
||||||
|
right: 10%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--gradient-card-shine);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group--wallet {
|
||||||
|
margin-bottom: var(--space-section);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group--profile {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-cell {
|
.settings-cell {
|
||||||
@@ -566,33 +324,29 @@ const balanceAmountClass = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-cell:active {
|
.settings-cell:active {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-cell--gold-entry {
|
.settings-cell--accent-entry {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
border-color: rgba(212, 175, 55, 0.18);
|
border-color: var(--border);
|
||||||
border-bottom-color: rgba(212, 175, 55, 0.18);
|
background: transparent;
|
||||||
background:
|
box-shadow: none;
|
||||||
linear-gradient(90deg, rgba(212, 175, 55, 0.08), rgba(20, 20, 20, 0.65) 46%, rgba(212, 175, 55, 0.04));
|
|
||||||
box-shadow:
|
|
||||||
inset 1px 0 0 rgba(212, 175, 55, 0.22),
|
|
||||||
inset 0 1px 0 rgba(255, 244, 200, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-cell--gold-entry:active {
|
.settings-cell--accent-entry:active {
|
||||||
background:
|
background: var(--surface-active);
|
||||||
linear-gradient(90deg, rgba(212, 175, 55, 0.12), rgba(20, 20, 20, 0.74) 46%, rgba(212, 175, 55, 0.06));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-cell--gold-entry .cell-label {
|
.settings-cell--accent-entry .cell-label {
|
||||||
color: #f4dc8b;
|
color: var(--text);
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-cell--gold-entry .cell-chevron {
|
.settings-cell--accent-entry .cell-chevron {
|
||||||
color: rgba(240, 216, 117, 0.78);
|
color: var(--text-muted);
|
||||||
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-cell--stack {
|
.settings-cell--stack {
|
||||||
@@ -618,7 +372,7 @@ const balanceAmountClass = computed(() => {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-cell--gold-entry .cell-icon {
|
.settings-cell--accent-entry .cell-icon {
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,23 +422,23 @@ const balanceAmountClass = computed(() => {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #0a0a0a;
|
background: var(--bg-body);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-opt.active {
|
.lang-opt.active {
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--primary);
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
background: rgba(212, 175, 55, 0.1);
|
background: var(--surface-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 12px;
|
margin-top: var(--space-section);
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
|||||||
@@ -226,11 +226,11 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="recharge-history-page">
|
<div class="recharge-history-page">
|
||||||
<div class="page-header">
|
<header class="page-header sub-toolbar">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h2>{{ t('recharge.history_title') }}</h2>
|
<h2>{{ t('recharge.history_title') }}</h2>
|
||||||
<button class="recharge-btn" @click="goRecharge">+ {{ t('recharge.title') }}</button>
|
<button class="recharge-btn" @click="goRecharge">+ {{ t('recharge.title') }}</button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="pull-indicator"
|
class="pull-indicator"
|
||||||
@@ -409,25 +409,26 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.recharge-history-page { padding: 0 16px 24px; }
|
.recharge-history-page { padding: 0 0 24px; }
|
||||||
.page-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; }
|
.page-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
.page-header h2 { margin: 0; font-size: 17px; font-weight: 700; }
|
.page-header h2 { margin: 0; font-size: 17px; font-weight: 700; }
|
||||||
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 24px; cursor: pointer; padding: 0 8px; }
|
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 24px; cursor: pointer; padding: 0 8px; }
|
||||||
.recharge-btn { background: none; border: none; color: var(--primary-light); font-size: 13px; cursor: pointer; font-weight: 600; }
|
.recharge-btn { background: none; border: none; color: var(--primary-light); font-size: 13px; cursor: pointer; font-weight: 600; }
|
||||||
.state { display: flex; justify-content: center; padding: 48px; }
|
.state { display: flex; justify-content: center; padding: 48px; }
|
||||||
.empty { text-align: center; color: #666; padding: 48px 16px; font-weight: 600; }
|
.empty { text-align: center; color: var(--text-muted); padding: 48px 16px; font-weight: 600; }
|
||||||
.pull-indicator { display: flex; align-items: center; justify-content: center; overflow: hidden; transition: height 0.15s ease; }
|
.pull-indicator { display: flex; align-items: center; justify-content: center; overflow: hidden; transition: height 0.15s ease; }
|
||||||
|
|
||||||
.order-list { display: flex; flex-direction: column; gap: 12px; }
|
.order-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
.order-card {
|
.order-card {
|
||||||
background: linear-gradient(135deg, #1a1810 0%, #1f1b0e 40%, #16140c 100%);
|
background: var(--gradient-card);
|
||||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
border: 1px solid var(--border-gold-soft);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
.order-card:active {
|
.order-card:active {
|
||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
@@ -437,57 +438,54 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; right: 0;
|
top: 0; left: 0; right: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.6), transparent);
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.45), transparent);
|
||||||
}
|
}
|
||||||
.order-card.rejected {
|
.order-card.rejected {
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
border-color: rgba(245, 108, 108, 0.22);
|
border-color: rgba(255, 255, 255, 0.35);
|
||||||
}
|
}
|
||||||
.order-card.rejected::before {
|
.order-card.rejected::before {
|
||||||
background: linear-gradient(90deg, transparent, rgba(245, 108, 108, 0.35), transparent);
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.35), transparent);
|
||||||
}
|
}
|
||||||
.order-card.rejected .order-amount {
|
.order-card.rejected .order-amount {
|
||||||
background: none;
|
background: none;
|
||||||
-webkit-background-clip: unset;
|
-webkit-background-clip: unset;
|
||||||
background-clip: unset;
|
background-clip: unset;
|
||||||
color: #bbb;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.order-card.rejected .info-label {
|
.order-card.rejected .info-label {
|
||||||
color: #888;
|
color: var(--neutral);
|
||||||
}
|
}
|
||||||
.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||||
.method-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
|
.method-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
|
||||||
.method-badge.bank { background: rgba(30, 58, 95, 0.6); color: #66b1ff; }
|
.method-badge.bank { background: rgba(30, 58, 95, 0.6); color: #66b1ff; }
|
||||||
.method-badge.usdt { background: rgba(26, 58, 42, 0.6); color: #67c23a; }
|
.method-badge.usdt { background: rgba(26, 58, 42, 0.6); color: #67c23a; }
|
||||||
.status-badge { font-size: 12px; font-weight: 700; }
|
.status-badge { font-size: 12px; font-weight: 700; }
|
||||||
.status-pending { color: #e6a23c; }
|
.status-pending { color: var(--primary-light); }
|
||||||
.status-approved { color: #67c23a; }
|
.status-approved { color: var(--success); }
|
||||||
.status-rejected { color: #f56c6c; }
|
.status-rejected { color: var(--primary); }
|
||||||
.order-body { }
|
.order-body { }
|
||||||
.order-amount {
|
.order-amount {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
background: linear-gradient(135deg, #f0d060, #d4a830);
|
color: var(--primary-light);
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
color: transparent;
|
|
||||||
}
|
}
|
||||||
.approved-amount { font-size: 12px; color: #67c23a; margin-bottom: 6px; font-weight: 600; }
|
.approved-amount { font-size: 12px; color: var(--success); margin-bottom: 6px; font-weight: 600; }
|
||||||
.order-info-row { margin-bottom: 8px; }
|
.order-info-row { margin-bottom: 8px; }
|
||||||
.info-label { font-size: 13px; color: rgba(212, 175, 55, 0.7); font-weight: 600; }
|
.info-label { font-size: 13px; color: var(--text-muted); font-weight: 600; }
|
||||||
.order-times { display: flex; flex-direction: column; gap: 4px; margin-bottom: 6px; }
|
.order-times { display: flex; flex-direction: column; gap: 4px; margin-bottom: 6px; }
|
||||||
.time-row { display: flex; justify-content: space-between; align-items: center; }
|
.time-row { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.time-label { font-size: 11px; color: #888; }
|
.time-label { font-size: 11px; color: var(--text-muted); }
|
||||||
.time-value { font-size: 11px; color: #aaa; font-variant-numeric: tabular-nums; }
|
.time-value { font-size: 11px; color: var(--neutral); font-variant-numeric: tabular-nums; }
|
||||||
.order-remark {
|
.order-remark {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(212, 175, 55, 0.8);
|
color: var(--text-muted);
|
||||||
background: rgba(212, 175, 55, 0.06);
|
background: var(--surface-accent);
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
border-left: 2px solid rgba(212, 175, 55, 0.3);
|
border-left: 2px solid var(--border-gold-soft);
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -510,11 +508,11 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
border-top: 1px solid rgba(212, 175, 55, 0.12);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.card-detail-summary {
|
.card-detail-summary {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: rgba(212, 175, 55, 0.65);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.card-detail-link {
|
.card-detail-link {
|
||||||
@@ -550,7 +548,7 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
.end-hint {
|
.end-hint {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #555;
|
color: var(--neutral);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 16px 0 4px;
|
padding: 16px 0 4px;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
@@ -560,7 +558,7 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
background: rgba(0, 0, 0, 0.48);
|
background: rgba(0, 34, 68, 0.72);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -573,12 +571,14 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
max-height: min(88vh, 720px);
|
max-height: min(88vh, 720px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #141414;
|
background: var(--dropdown-bg);
|
||||||
border: 1px solid #2a2a2a;
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
border-radius: 14px 14px 0 0;
|
border-radius: 14px 14px 0 0;
|
||||||
padding: 16px 16px calc(14px + env(safe-area-inset-bottom, 0px));
|
padding: 16px 16px calc(14px + env(safe-area-inset-bottom, 0px));
|
||||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-close {
|
.detail-close {
|
||||||
@@ -590,7 +590,7 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #777;
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -600,15 +600,17 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
margin: 0 28px 12px 0;
|
margin: 0 28px 12px 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e8e8e8;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-summary {
|
.detail-summary {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--bg-body);
|
||||||
border: 1px solid #222;
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-summary-head {
|
.detail-summary-head {
|
||||||
@@ -622,13 +624,13 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
color: var(--primary-light, #d4af37);
|
color: var(--primary-light);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-method-name {
|
.detail-method-name {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
@@ -643,13 +645,13 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
|
|
||||||
.detail-label {
|
.detail-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-value {
|
.detail-value {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #ccc;
|
color: var(--text);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -657,12 +659,12 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
.detail-summary .order-remark {
|
.detail-summary .order-remark {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #999;
|
color: var(--text-muted);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 6px 0 0;
|
padding: 6px 0 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #222;
|
border-top: 1px solid var(--border);
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -670,12 +672,12 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
.detail-summary .reject-reason {
|
.detail-summary .reject-reason {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #b08888;
|
color: var(--primary-light);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 6px 0 0;
|
padding: 6px 0 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid rgba(245, 108, 108, 0.15);
|
border-top: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -688,14 +690,14 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
.detail-audit {
|
.detail-audit {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid #222;
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-audit-title {
|
.detail-audit-title {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,7 +725,7 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
width: 5px;
|
width: 5px;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #555;
|
background: var(--neutral);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,7 +734,7 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
width: 1px;
|
width: 1px;
|
||||||
min-height: 10px;
|
min-height: 10px;
|
||||||
margin: 3px 0;
|
margin: 3px 0;
|
||||||
background: #2a2a2a;
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audit-step-body {
|
.audit-step-body {
|
||||||
@@ -755,13 +757,13 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
.audit-step-title {
|
.audit-step-title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ccc;
|
color: var(--text);
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audit-step-time {
|
.audit-step-time {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #666;
|
color: var(--neutral);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -770,7 +772,7 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
.audit-step-actor {
|
.audit-step-actor {
|
||||||
margin: 2px 0 0;
|
margin: 2px 0 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #999;
|
color: var(--text-muted);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,14 +780,14 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
margin: 3px 0 0;
|
margin: 3px 0 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #7eb87a;
|
color: var(--success);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audit-step-note {
|
.audit-step-note {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -802,36 +804,36 @@ function auditStepCount(order: DepositOrder) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.audit-step-box--reject {
|
.audit-step-box--reject {
|
||||||
background: transparent;
|
background: var(--bg-body);
|
||||||
border: 1px solid rgba(245, 108, 108, 0.22);
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audit-step-box-label {
|
.audit-step-box-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #c07070;
|
color: var(--primary-light);
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audit-step-box-text {
|
.audit-step-box-text {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #b08888;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audit-step--submitted .audit-dot {
|
.audit-step--submitted .audit-dot {
|
||||||
background: #c9a227;
|
background: var(--primary-light);
|
||||||
}
|
}
|
||||||
.audit-step--approved .audit-dot {
|
.audit-step--approved .audit-dot {
|
||||||
background: #5fad5a;
|
background: var(--success);
|
||||||
}
|
}
|
||||||
.audit-step--rejected .audit-dot {
|
.audit-step--rejected .audit-dot {
|
||||||
background: #d06060;
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
.audit-step--revoked .audit-dot {
|
.audit-step--revoked .audit-dot {
|
||||||
background: #777;
|
background: var(--neutral);
|
||||||
}
|
}
|
||||||
.audit-step--reopened .audit-dot {
|
.audit-step--reopened .audit-dot {
|
||||||
background: #c9a227;
|
background: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-reapply-btn {
|
.modal-reapply-btn {
|
||||||
|
|||||||
@@ -233,11 +233,11 @@ onMounted(fetchMethods);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="recharge-page">
|
<div class="recharge-page">
|
||||||
<div class="page-header">
|
<header class="page-header sub-toolbar">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h2>{{ t('recharge.title') }}</h2>
|
<h2>{{ t('recharge.title') }}</h2>
|
||||||
<button class="history-btn" @click="goHistory">{{ t('recharge.history') }}</button>
|
<button class="history-btn" @click="goHistory">{{ t('recharge.history') }}</button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div v-if="loading" class="state">
|
<div v-if="loading" class="state">
|
||||||
<GoldSpinner :size="36" />
|
<GoldSpinner :size="36" />
|
||||||
@@ -362,11 +362,10 @@ onMounted(fetchMethods);
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.recharge-page { padding: 0 12px 24px; }
|
.recharge-page { padding: 0 0 24px; }
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 8px 0 12px;
|
|
||||||
}
|
}
|
||||||
.page-header h2 { margin: 0; font-size: 16px; font-weight: 700; }
|
.page-header h2 { margin: 0; font-size: 16px; font-weight: 700; }
|
||||||
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 22px; cursor: pointer; padding: 0 6px; }
|
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 22px; cursor: pointer; padding: 0 6px; }
|
||||||
@@ -386,18 +385,28 @@ onMounted(fetchMethods);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.type-tabs {
|
.type-tabs {
|
||||||
display: flex; margin-bottom: 12px;
|
display: flex;
|
||||||
border-radius: 6px; overflow: hidden;
|
margin-bottom: 12px;
|
||||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-body);
|
||||||
}
|
}
|
||||||
.tab {
|
.tab {
|
||||||
flex: 1; padding: 8px; border: none;
|
flex: 1;
|
||||||
background: rgba(20, 20, 20, 0.8);
|
padding: 8px;
|
||||||
color: var(--text-muted); font-weight: 700; font-size: 13px;
|
border: none;
|
||||||
cursor: pointer; transition: all 0.2s;
|
background: var(--bg-card);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
.tab.active {
|
.tab.active {
|
||||||
background: var(--primary-light); color: #000;
|
background: var(--surface-accent);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.methods-list {
|
.methods-list {
|
||||||
@@ -405,15 +414,15 @@ onMounted(fetchMethods);
|
|||||||
}
|
}
|
||||||
.method-pill {
|
.method-pill {
|
||||||
display: flex; flex-direction: column; gap: 1px;
|
display: flex; flex-direction: column; gap: 1px;
|
||||||
background: rgba(20, 20, 20, 0.8);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px; padding: 6px 12px;
|
border-radius: 6px; padding: 6px 12px;
|
||||||
text-align: left; cursor: pointer;
|
text-align: left; cursor: pointer;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.method-pill.selected {
|
.method-pill.selected {
|
||||||
border-color: var(--primary-light);
|
border-color: var(--primary);
|
||||||
background: rgba(212, 175, 55, 0.06);
|
background: var(--surface-accent);
|
||||||
}
|
}
|
||||||
.pill-name { font-weight: 700; font-size: 12px; color: var(--text); }
|
.pill-name { font-weight: 700; font-size: 12px; color: var(--text); }
|
||||||
.pill-sub { font-size: 10px; color: var(--text-muted); }
|
.pill-sub { font-size: 10px; color: var(--text-muted); }
|
||||||
@@ -434,13 +443,13 @@ onMounted(fetchMethods);
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 60% at 100% 0%, rgba(212, 175, 55, 0.22), transparent 55%),
|
radial-gradient(ellipse 80% 60% at 100% 0%, rgba(255, 255, 255, 0.18), transparent 55%),
|
||||||
radial-gradient(ellipse 50% 50% at 0% 100%, rgba(212, 175, 55, 0.1), transparent 50%),
|
radial-gradient(ellipse 50% 50% at 0% 100%, var(--surface-accent), transparent 50%),
|
||||||
linear-gradient(135deg, #2a2218 0%, #1a1610 35%, #12100c 70%, #0a0908 100%);
|
var(--gradient-card);
|
||||||
border: 1px solid rgba(212, 175, 55, 0.35);
|
border: 1px solid var(--border-gold-soft);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 8px 24px rgba(0, 0, 0, 0.55),
|
0 8px 24px rgba(0, 0, 0, 0.35),
|
||||||
0 2px 8px rgba(212, 175, 55, 0.12),
|
0 2px 8px var(--surface-accent),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +470,7 @@ onMounted(fetchMethods);
|
|||||||
.bank-card-deco {
|
.bank-card-deco {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid rgba(212, 175, 55, 0.12);
|
border: 1px solid var(--border-gold-soft);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +479,7 @@ onMounted(fetchMethods);
|
|||||||
height: 180px;
|
height: 180px;
|
||||||
right: -60px;
|
right: -60px;
|
||||||
bottom: -80px;
|
bottom: -80px;
|
||||||
background: radial-gradient(circle, rgba(212, 175, 55, 0.08), transparent 70%);
|
background: radial-gradient(circle, var(--surface-accent), transparent 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-card-deco--2 {
|
.bank-card-deco--2 {
|
||||||
@@ -478,7 +487,7 @@ onMounted(fetchMethods);
|
|||||||
height: 100px;
|
height: 100px;
|
||||||
right: 40px;
|
right: 40px;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
border-color: rgba(212, 175, 55, 0.08);
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-card-top,
|
.bank-card-top,
|
||||||
@@ -531,8 +540,8 @@ onMounted(fetchMethods);
|
|||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(212, 175, 55, 0.35);
|
border: 1px solid var(--border-gold-soft);
|
||||||
background: rgba(0, 0, 0, 0.25);
|
background: rgba(0, 34, 68, 0.45);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -548,7 +557,7 @@ onMounted(fetchMethods);
|
|||||||
|
|
||||||
.bank-card-copy:active {
|
.bank-card-copy:active {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
background: rgba(212, 175, 55, 0.12);
|
background: var(--surface-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-card-bottom {
|
.bank-card-bottom {
|
||||||
@@ -587,13 +596,14 @@ onMounted(fetchMethods);
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.18em;
|
letter-spacing: 0.18em;
|
||||||
color: rgba(212, 175, 55, 0.55);
|
color: var(--primary-light);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.method-info {
|
.method-info {
|
||||||
background: rgba(17, 17, 17, 0.9);
|
background: var(--bg-body);
|
||||||
border-radius: 8px; padding: 10px 12px;
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -624,19 +634,20 @@ onMounted(fetchMethods);
|
|||||||
color: var(--text-muted); margin-bottom: 4px;
|
color: var(--text-muted); margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.amount-input {
|
.amount-input {
|
||||||
width: 100%; padding: 10px; background: #111;
|
width: 100%; padding: 10px; background: var(--bg-input);
|
||||||
border: 1px solid var(--border); border-radius: 6px;
|
border: 1px solid var(--border); border-radius: 6px;
|
||||||
color: #fff; font-size: 16px; font-weight: 700;
|
color: var(--text); font-size: 16px; font-weight: 700;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.amount-input:focus { border-color: var(--primary-light); outline: none; }
|
.amount-input:focus { border-color: var(--border-gold-soft); outline: none; }
|
||||||
|
|
||||||
.upload-area {
|
.upload-area {
|
||||||
border: 1px dashed rgba(212, 175, 55, 0.3);
|
border: 1px dashed var(--border-gold-soft);
|
||||||
border-radius: 6px; padding: 16px;
|
border-radius: 6px; padding: 16px;
|
||||||
text-align: center; position: relative;
|
text-align: center; position: relative;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
background: var(--bg-body);
|
||||||
}
|
}
|
||||||
.upload-area input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
.upload-area input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||||
.upload-hint { font-size: 12px; color: var(--text-muted); }
|
.upload-hint { font-size: 12px; color: var(--text-muted); }
|
||||||
@@ -652,22 +663,22 @@ onMounted(fetchMethods);
|
|||||||
|
|
||||||
.btn-submit {
|
.btn-submit {
|
||||||
width: 100%; padding: 12px;
|
width: 100%; padding: 12px;
|
||||||
background: linear-gradient(135deg, #f0d060, #d4a830);
|
background: var(--gradient-accent);
|
||||||
color: #000; border: none; border-radius: 6px;
|
color: #fff; border: none; border-radius: 6px;
|
||||||
font-size: 14px; font-weight: 800;
|
font-size: 14px; font-weight: 800;
|
||||||
cursor: pointer; margin-top: 8px;
|
cursor: pointer; margin-top: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(212, 175, 55, 0.2);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.btn-submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
.btn-submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
.success-state { text-align: center; padding: 40px 16px; }
|
.success-state { text-align: center; padding: 40px 16px; }
|
||||||
.success-icon { font-size: 40px; color: #67c23a; margin-bottom: 10px; }
|
.success-icon { font-size: 40px; color: var(--success); margin-bottom: 10px; }
|
||||||
.success-state h3 { margin: 0 0 6px; font-size: 16px; }
|
.success-state h3 { margin: 0 0 6px; font-size: 16px; color: var(--text); }
|
||||||
.order-no { font-family: monospace; color: var(--primary-light); font-size: 13px; margin: 4px 0; }
|
.order-no { font-family: monospace; color: var(--primary-light); font-size: 13px; margin: 4px 0; }
|
||||||
.success-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 20px; }
|
.success-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 20px; }
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, #f0d060, #d4a830);
|
background: var(--gradient-accent);
|
||||||
color: #000; border: none; border-radius: 6px;
|
color: #fff; border: none; border-radius: 6px;
|
||||||
padding: 10px 20px; font-weight: 700; font-size: 13px; cursor: pointer;
|
padding: 10px 20px; font-weight: 700; font-size: 13px; cursor: pointer;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const fieldError = () => {
|
|||||||
<label>{{ t('auth.username') }}</label>
|
<label>{{ t('auth.username') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="account"
|
v-model="account"
|
||||||
class="field-input"
|
class="field-input ps-gold-input"
|
||||||
required
|
required
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
maxlength="32"
|
maxlength="32"
|
||||||
@@ -132,7 +132,7 @@ const fieldError = () => {
|
|||||||
<PhoneCountrySelect v-model="countryIso" />
|
<PhoneCountrySelect v-model="countryIso" />
|
||||||
<input
|
<input
|
||||||
v-model="phone"
|
v-model="phone"
|
||||||
class="field-input"
|
class="field-input ps-gold-input"
|
||||||
type="tel"
|
type="tel"
|
||||||
required
|
required
|
||||||
autocomplete="tel-national"
|
autocomplete="tel-national"
|
||||||
@@ -147,7 +147,7 @@ const fieldError = () => {
|
|||||||
<div class="inline-row">
|
<div class="inline-row">
|
||||||
<input
|
<input
|
||||||
v-model="smsCode"
|
v-model="smsCode"
|
||||||
class="field-input"
|
class="field-input ps-gold-input"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
@@ -171,17 +171,17 @@ const fieldError = () => {
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>{{ t('auth.password') }}</label>
|
<label>{{ t('auth.password') }}</label>
|
||||||
<input v-model="password" class="field-input" type="password" required autocomplete="new-password" minlength="8" :disabled="loading" />
|
<input v-model="password" class="field-input ps-gold-input" type="password" required autocomplete="new-password" minlength="8" :disabled="loading" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>{{ t('auth.confirm_password') }}</label>
|
<label>{{ t('auth.confirm_password') }}</label>
|
||||||
<input v-model="confirmPassword" class="field-input" type="password" required autocomplete="new-password" minlength="8" :disabled="loading" />
|
<input v-model="confirmPassword" class="field-input ps-gold-input" type="password" required autocomplete="new-password" minlength="8" :disabled="loading" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field field-optional">
|
<div class="field field-optional">
|
||||||
<label>{{ t('auth.invite_code') }} <span class="optional-tag">{{ t('auth.optional') }}</span></label>
|
<label>{{ t('auth.invite_code') }} <span class="optional-tag">{{ t('auth.optional') }}</span></label>
|
||||||
<input v-model="inviteCode" class="field-input" autocomplete="off" :disabled="loading" />
|
<input v-model="inviteCode" class="field-input ps-gold-input" autocomplete="off" :disabled="loading" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="fieldError()" class="error">{{ fieldError() }}</p>
|
<p v-if="fieldError()" class="error">{{ fieldError() }}</p>
|
||||||
@@ -227,12 +227,31 @@ const fieldError = () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 30vh 16px calc(10vh + max(20px, env(safe-area-inset-bottom)));
|
padding: 30vh 16px calc(10vh + max(20px, env(safe-area-inset-bottom)));
|
||||||
background-color: var(--tertiary);
|
background-color: var(--bg-body);
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center top;
|
background-position: center top;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-page::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 34, 68, 0.35) 0%,
|
||||||
|
rgba(0, 34, 68, 0.78) 52%,
|
||||||
|
rgba(0, 34, 68, 0.94) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -240,7 +259,7 @@ const fieldError = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form.is-busy {
|
.login-form.is-busy {
|
||||||
@@ -257,7 +276,7 @@ const fieldError = () => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: rgba(8, 8, 8, 0.72);
|
background: rgba(0, 34, 68, 0.82);
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
@@ -266,7 +285,7 @@ const fieldError = () => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: rgba(240, 216, 117, 0.95);
|
color: var(--primary-light);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,19 +333,19 @@ label {
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #0d0d0d;
|
background: var(--bg-input);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-input::placeholder {
|
.field-input::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.32);
|
color: rgba(240, 230, 211, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-input:focus {
|
.field-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #555;
|
border-color: var(--border-gold-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-row {
|
.inline-row {
|
||||||
@@ -346,7 +365,7 @@ label {
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -354,6 +373,13 @@ label {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-secondary:not(:disabled).active,
|
||||||
|
.btn-secondary:active:not(:disabled) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--surface-accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-secondary:disabled {
|
.btn-secondary:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -153,9 +153,9 @@ const pullIndicatorStyle = () => ({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="wallet-detail-page">
|
<div class="wallet-detail-page">
|
||||||
<div class="top-bar">
|
<header class="top-bar sub-toolbar">
|
||||||
<button class="back-btn" type="button" @click="router.back()">‹ {{ t('history.back') }}</button>
|
<button class="back-btn" type="button" @click="router.back()">‹ {{ t('history.back') }}</button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="pull-indicator"
|
class="pull-indicator"
|
||||||
@@ -170,7 +170,7 @@ const pullIndicatorStyle = () => ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<WalletStatsPanel :items="items" :cashback-total="cashbackTotal" />
|
<WalletStatsPanel class="stats-panel" :items="items" :cashback-total="cashbackTotal" />
|
||||||
|
|
||||||
<div class="filter-tabs">
|
<div class="filter-tabs">
|
||||||
<button
|
<button
|
||||||
@@ -193,17 +193,15 @@ const pullIndicatorStyle = () => ({
|
|||||||
class="tx-row"
|
class="tx-row"
|
||||||
@click="goDetail(tx)"
|
@click="goDetail(tx)"
|
||||||
>
|
>
|
||||||
<div class="tx-main">
|
<div class="tx-col-left">
|
||||||
<div class="tx-text">
|
|
||||||
<span class="tx-type">{{ txLabel(tx) }}</span>
|
<span class="tx-type">{{ txLabel(tx) }}</span>
|
||||||
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
|
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
|
||||||
|
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tx-col-right">
|
||||||
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
|
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
|
||||||
{{ formatMoney(tx.amount, locale) }}
|
{{ formatMoney(tx.amount, locale) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<div class="tx-meta">
|
|
||||||
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
|
|
||||||
<span class="tx-arrow">›</span>
|
<span class="tx-arrow">›</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -231,6 +229,10 @@ const pullIndicatorStyle = () => ({
|
|||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-panel {
|
||||||
|
margin-bottom: var(--space-section);
|
||||||
|
}
|
||||||
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
@@ -238,9 +240,9 @@ const pullIndicatorStyle = () => ({
|
|||||||
.back-btn {
|
.back-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--primary-light, #d4af37);
|
color: var(--text-muted);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
padding: 4px 0 8px;
|
padding: 4px 0 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -258,8 +260,8 @@ const pullIndicatorStyle = () => ({
|
|||||||
|
|
||||||
.filter-tabs {
|
.filter-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--space-section);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -269,20 +271,21 @@ const pullIndicatorStyle = () => ({
|
|||||||
|
|
||||||
.filter-tab {
|
.filter-tab {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 7px 14px;
|
padding: 8px 16px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid var(--border);
|
||||||
transition: all 0.2s;
|
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-tab.active {
|
.filter-tab.active {
|
||||||
color: var(--primary-light);
|
color: var(--text);
|
||||||
background: rgba(212, 175, 55, 0.1);
|
background: var(--bg-elevated);
|
||||||
border-color: var(--border-gold-soft);
|
border-color: var(--border-strong);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
@@ -303,14 +306,21 @@ const pullIndicatorStyle = () => ({
|
|||||||
|
|
||||||
.tx-list {
|
.tx-list {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tx-row {
|
.tx-row {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 12px 16px;
|
padding: 12px 14px;
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -322,21 +332,22 @@ const pullIndicatorStyle = () => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tx-row:active {
|
.tx-row:active {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tx-main {
|
.tx-col-left {
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tx-text {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 3px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-col-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tx-summary {
|
.tx-summary {
|
||||||
@@ -348,18 +359,11 @@ const pullIndicatorStyle = () => ({
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tx-meta {
|
.tx-type { font-weight: 600; color: var(--text); letter-spacing: -0.01em; }
|
||||||
display: flex;
|
.pos { color: var(--success); font-weight: 600; font-size: 14px; font-variant-numeric: tabular-nums; }
|
||||||
justify-content: space-between;
|
.neg { color: var(--text); font-weight: 600; font-size: 14px; font-variant-numeric: tabular-nums; }
|
||||||
align-items: center;
|
.tx-time { font-size: 11px; color: var(--text-dim); }
|
||||||
margin-top: 4px;
|
.tx-arrow { font-size: 15px; color: var(--text-dim); font-weight: 400; line-height: 1; }
|
||||||
}
|
|
||||||
|
|
||||||
.tx-type { font-weight: 700; color: var(--text); }
|
|
||||||
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
|
|
||||||
.neg { color: var(--danger); font-weight: 700; }
|
|
||||||
.tx-time { font-size: 11px; color: var(--text-muted); }
|
|
||||||
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }
|
|
||||||
|
|
||||||
.sentinel {
|
.sentinel {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
@@ -374,7 +378,7 @@ const pullIndicatorStyle = () => ({
|
|||||||
.end-hint {
|
.end-hint {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #555;
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 16px 0 4px;
|
padding: 16px 0 4px;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
|
|||||||
@@ -141,9 +141,9 @@ function goCashbackDetail() {
|
|||||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="top-bar">
|
<header class="top-bar sub-toolbar">
|
||||||
<button class="back-btn" type="button" @click="router.back()">‹ {{ t('history.back') }}</button>
|
<button class="back-btn" type="button" @click="router.back()">‹ {{ t('history.back') }}</button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div v-if="loading" class="state">
|
<div v-if="loading" class="state">
|
||||||
<GoldSpinner :size="36" />
|
<GoldSpinner :size="36" />
|
||||||
@@ -238,7 +238,7 @@ function goCashbackDetail() {
|
|||||||
.back-btn {
|
.back-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--primary-light, #d4af37);
|
color: var(--primary-light);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 4px 0 8px;
|
padding: 4px 0 8px;
|
||||||
@@ -251,13 +251,13 @@ function goCashbackDetail() {
|
|||||||
.state {
|
.state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
color: #666;
|
color: var(--text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
border-radius: 14px;
|
border-radius: var(--radius);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -265,18 +265,18 @@ function goCashbackDetail() {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
border: 1px solid #222;
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero.pos {
|
.hero.pos {
|
||||||
border-color: rgba(212, 175, 55, 0.25);
|
border-color: rgba(46, 204, 113, 0.35);
|
||||||
background: linear-gradient(135deg, rgba(212, 175, 55, 0.1), rgba(212, 175, 55, 0.03));
|
background: linear-gradient(135deg, rgba(46, 204, 113, 0.16), rgba(46, 204, 113, 0.05));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero.neg {
|
.hero.neg {
|
||||||
border-color: rgba(224, 80, 80, 0.2);
|
border-color: rgba(255, 255, 255, 0.35);
|
||||||
background: linear-gradient(135deg, rgba(224, 80, 80, 0.1), rgba(224, 80, 80, 0.03));
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.05));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-type {
|
.hero-type {
|
||||||
@@ -284,13 +284,13 @@ function goCashbackDetail() {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-summary {
|
.hero-summary {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #bbb;
|
color: var(--text);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
@@ -301,27 +301,27 @@ function goCashbackDetail() {
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero.pos .hero-amount { color: var(--primary-light); }
|
.hero.pos .hero-amount { color: var(--success); }
|
||||||
.hero.neg .hero-amount { color: var(--danger); }
|
.hero.neg .hero-amount { color: var(--primary); }
|
||||||
|
|
||||||
.hero-time {
|
.hero-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #555;
|
color: var(--neutral);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
background: #141414;
|
background: var(--bg-card);
|
||||||
border: 1px solid #222;
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius);
|
||||||
padding: 14px 16px;
|
padding: var(--space-card) 16px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: var(--space-section);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #888;
|
color: var(--text-muted);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
@@ -338,18 +338,18 @@ function goCashbackDetail() {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #aaa;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sum-row span:last-child {
|
.sum-row span:last-child {
|
||||||
color: #f0f0f0;
|
color: var(--text);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pos { color: var(--primary-light) !important; }
|
.pos { color: var(--success) !important; }
|
||||||
.neg { color: var(--danger) !important; }
|
.neg { color: var(--primary) !important; }
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
@@ -366,8 +366,8 @@ function goCashbackDetail() {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--border-gold-soft, rgba(212, 175, 55, 0.35));
|
border: 1px solid var(--border-gold-soft);
|
||||||
background: rgba(212, 175, 55, 0.08);
|
background: var(--surface-accent);
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -377,7 +377,7 @@ function goCashbackDetail() {
|
|||||||
.tx-id {
|
.tx-id {
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #ccc;
|
color: var(--text-muted);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,260 +1,570 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
import { ref, onActivated, onMounted } from 'vue';
|
import { ref, onActivated, onMounted } from 'vue';
|
||||||
|
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { formatMoney, formatMoneyCompact } from '../utils/localeDisplay';
|
|
||||||
|
import { formatMoney } from '../utils/localeDisplay';
|
||||||
|
|
||||||
import { txTypeKey, txDisplayType, txSummaryLabel } from '../utils/walletTx';
|
import { txTypeKey, txDisplayType, txSummaryLabel } from '../utils/walletTx';
|
||||||
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
|
|
||||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||||
|
|
||||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type Transaction = {
|
type Transaction = {
|
||||||
|
|
||||||
transactionType: string;
|
transactionType: string;
|
||||||
|
|
||||||
displayType?: string;
|
displayType?: string;
|
||||||
|
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
|
|
||||||
summaryKind?: 'opening_bonus' | null;
|
summaryKind?: 'opening_bonus' | null;
|
||||||
|
|
||||||
referenceType?: string | null;
|
referenceType?: string | null;
|
||||||
|
|
||||||
amount: string;
|
amount: string;
|
||||||
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const items = ref<Transaction[]>([]);
|
const items = ref<Transaction[]>([]);
|
||||||
const cashbackTotal = ref<string>('0');
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
const PREVIEW_COUNT = 15;
|
const PREVIEW_COUNT = 15;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function txLabel(tx: Transaction): string {
|
function txLabel(tx: Transaction): string {
|
||||||
|
|
||||||
const key = txTypeKey(txDisplayType(tx));
|
const key = txTypeKey(txDisplayType(tx));
|
||||||
|
|
||||||
if (key) {
|
if (key) {
|
||||||
|
|
||||||
const translated = t(key);
|
const translated = t(key);
|
||||||
|
|
||||||
if (translated !== key) return translated;
|
if (translated !== key) return translated;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.transactionType;
|
return tx.transactionType;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function txSubtitle(tx: Transaction): string {
|
function txSubtitle(tx: Transaction): string {
|
||||||
|
|
||||||
return txSummaryLabel(tx, t);
|
return txSummaryLabel(tx, t);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function goDetail(tx: Transaction) {
|
function goDetail(tx: Transaction) {
|
||||||
|
|
||||||
if (!tx.transactionId) return;
|
if (!tx.transactionId) return;
|
||||||
|
|
||||||
router.push(`/wallet/transactions/${tx.transactionId}`);
|
router.push(`/wallet/transactions/${tx.transactionId}`);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function goCashbacks() {
|
function goCashbacks() {
|
||||||
|
|
||||||
router.push('/wallet/cashbacks');
|
router.push('/wallet/cashbacks');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [txRes, cbRes] = await Promise.all([
|
|
||||||
api.get('/player/wallet/transactions', { params: { page: 1 } }),
|
const { data } = await api.get('/player/wallet/transactions', { params: { page: 1 } });
|
||||||
api.get('/player/cashbacks').catch(() => ({ data: { data: [] } })),
|
|
||||||
]);
|
const result = data.data ?? { items: [] };
|
||||||
const result = txRes.data.data ?? { items: [] };
|
|
||||||
items.value = (result.items ?? []).slice(0, PREVIEW_COUNT);
|
items.value = (result.items ?? []).slice(0, PREVIEW_COUNT);
|
||||||
const cbRows = parseCashbackApiData(cbRes.data?.data);
|
|
||||||
cashbackTotal.value = sumCashbackAmount(cbRows).toString();
|
|
||||||
} catch {
|
} catch {
|
||||||
|
|
||||||
/* ignore */
|
/* ignore */
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { pullDistance, spinning, progress } = usePullToRefresh({
|
const { pullDistance, spinning, progress } = usePullToRefresh({
|
||||||
|
|
||||||
onRefresh: fetchData,
|
onRefresh: fetchData,
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(fetchData);
|
onMounted(fetchData);
|
||||||
|
|
||||||
onActivated(fetchData);
|
onActivated(fetchData);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const pullIndicatorStyle = () => ({
|
const pullIndicatorStyle = () => ({
|
||||||
|
|
||||||
height: `${pullDistance.value}px`,
|
height: `${pullDistance.value}px`,
|
||||||
|
|
||||||
opacity: Math.min(pullDistance.value / 48, 1),
|
opacity: Math.min(pullDistance.value / 48, 1),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="wallet-page">
|
<div class="wallet-page">
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
||||||
class="pull-indicator"
|
class="pull-indicator"
|
||||||
|
|
||||||
:style="pullIndicatorStyle()"
|
:style="pullIndicatorStyle()"
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div v-if="loading" class="state">
|
<div v-if="loading" class="state">
|
||||||
|
|
||||||
<GoldSpinner :size="36" />
|
<GoldSpinner :size="36" />
|
||||||
|
|
||||||
<span class="loading-text">{{ t('bet.loading') }}</span>
|
<span class="loading-text">{{ t('bet.loading') }}</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="top-bar">
|
|
||||||
<button type="button" class="more-link secondary" @click="goCashbacks">
|
<div class="wallet-links">
|
||||||
{{ t('wallet.view_cashbacks_detail') }} ›
|
|
||||||
|
<button type="button" class="wallet-link" @click="goCashbacks">
|
||||||
|
|
||||||
|
{{ t('wallet.view_cashbacks') }}
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
v-if="items.length"
|
v-if="items.length"
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
class="more-link"
|
|
||||||
|
class="wallet-link wallet-link--primary"
|
||||||
|
|
||||||
@click="router.push('/wallet/detail')"
|
@click="router.push('/wallet/detail')"
|
||||||
>{{ t('wallet.view_all') }} ›</button>
|
|
||||||
|
>
|
||||||
|
|
||||||
|
{{ t('wallet.view_all') }}
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div v-if="items.length" class="tx-list">
|
<div v-if="items.length" class="tx-list">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
v-for="tx in items"
|
v-for="tx in items"
|
||||||
|
|
||||||
:key="tx.transactionId"
|
:key="tx.transactionId"
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
class="tx-row"
|
class="tx-row"
|
||||||
|
|
||||||
@click="goDetail(tx)"
|
@click="goDetail(tx)"
|
||||||
|
|
||||||
>
|
>
|
||||||
<div class="tx-main">
|
|
||||||
<div class="tx-text">
|
<div class="tx-col-left">
|
||||||
|
|
||||||
<span class="tx-type">{{ txLabel(tx) }}</span>
|
<span class="tx-type">{{ txLabel(tx) }}</span>
|
||||||
|
|
||||||
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
|
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
|
||||||
</div>
|
|
||||||
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
|
|
||||||
{{ formatMoney(tx.amount, locale) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="tx-meta">
|
|
||||||
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
|
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
|
||||||
<span class="tx-arrow">›</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tx-col-right">
|
||||||
|
|
||||||
|
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
|
||||||
|
|
||||||
|
{{ formatMoney(tx.amount, locale) }}
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="tx-arrow" aria-hidden="true">›</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div v-if="!items.length" class="empty">
|
<div v-if="!items.length" class="empty">
|
||||||
|
|
||||||
{{ t('wallet.no_records') }}
|
{{ t('wallet.no_records') }}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.wallet-page {
|
.wallet-page {
|
||||||
|
|
||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.pull-indicator {
|
.pull-indicator {
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
transition: height 0.15s ease;
|
transition: height 0.15s ease;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
||||||
padding: 56px 20px;
|
padding: 56px 20px;
|
||||||
font-weight: 600;
|
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.loading-text {
|
.loading-text {
|
||||||
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.wallet-links {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
margin: 0 0 12px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.wallet-link {
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.wallet-link--primary {
|
||||||
|
|
||||||
|
color: var(--text);
|
||||||
|
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.wallet-link:active {
|
||||||
|
|
||||||
|
opacity: 0.72;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.tx-list {
|
.tx-list {
|
||||||
|
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
background: var(--gradient-card);
|
||||||
|
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||||
|
|
||||||
|
border-radius: var(--radius);
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
box-shadow: var(--shadow-card-sm);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.tx-row {
|
.tx-row {
|
||||||
display: block;
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 8px 12px;
|
|
||||||
|
padding: 14px 16px;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.tx-row:last-child {
|
.tx-row:last-child {
|
||||||
|
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.tx-row:active {
|
.tx-row:active {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
|
background: var(--bg-hover);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tx-main {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tx-text {
|
|
||||||
|
.tx-col-left {
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.tx-col-right {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.tx-summary {
|
.tx-summary {
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tx-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tx-type { font-weight: 700; color: var(--text); }
|
|
||||||
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
|
|
||||||
.neg { color: var(--danger); font-weight: 700; }
|
|
||||||
.tx-time { font-size: 11px; color: var(--text-muted); }
|
|
||||||
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }
|
|
||||||
|
|
||||||
.top-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 4px 2px;
|
|
||||||
margin-top: -8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-bar .more-link.secondary {
|
|
||||||
color: #f0b90b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.more-link {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--primary-light);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
||||||
|
color: var(--text-dim);
|
||||||
|
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.tx-type {
|
||||||
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 6px;
|
|
||||||
cursor: pointer;
|
color: var(--text);
|
||||||
opacity: 0.8;
|
|
||||||
transition: opacity 0.2s;
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-link:active {
|
|
||||||
opacity: 1;
|
|
||||||
|
.pos {
|
||||||
|
|
||||||
|
color: var(--success);
|
||||||
|
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.neg {
|
||||||
|
|
||||||
|
color: var(--text);
|
||||||
|
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.tx-time {
|
||||||
|
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
color: var(--text-dim);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.tx-arrow {
|
||||||
|
|
||||||
|
font-size: 15px;
|
||||||
|
|
||||||
|
color: var(--text-dim);
|
||||||
|
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
padding: 40px 16px;
|
||||||
|
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty { text-align: center; color: var(--text-muted); padding: 40px 16px; font-weight: 600; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ export const API_ERROR_MESSAGES = {
|
|||||||
'en-US': 'User not found',
|
'en-US': 'User not found',
|
||||||
'ms-MY': 'Pengguna tidak dijumpai',
|
'ms-MY': 'Pengguna tidak dijumpai',
|
||||||
},
|
},
|
||||||
|
CANNOT_DELETE_SELF: {
|
||||||
|
'zh-CN': '不能删除自己',
|
||||||
|
'en-US': 'Cannot delete yourself',
|
||||||
|
'ms-MY': 'Tidak boleh memadam diri sendiri',
|
||||||
|
},
|
||||||
|
STAFF_NOT_FOUND: {
|
||||||
|
'zh-CN': '管理员不存在',
|
||||||
|
'en-US': 'Staff member not found',
|
||||||
|
'ms-MY': 'Ahli kakitangan tidak dijumpai',
|
||||||
|
},
|
||||||
|
CANNOT_DELETE_LAST_SUPER_ADMIN: {
|
||||||
|
'zh-CN': '不能删除唯一的超级管理员',
|
||||||
|
'en-US': 'Cannot delete the last super admin',
|
||||||
|
'ms-MY': 'Tidak boleh memadam pentadbir super terakhir',
|
||||||
|
},
|
||||||
PASSWORD_CHANGE_DISABLED: {
|
PASSWORD_CHANGE_DISABLED: {
|
||||||
'zh-CN': '当前平台未开放玩家自行修改密码',
|
'zh-CN': '当前平台未开放玩家自行修改密码',
|
||||||
'en-US': 'Password change is disabled for players',
|
'en-US': 'Password change is disabled for players',
|
||||||
@@ -842,6 +857,11 @@ export const API_ERROR_MESSAGES = {
|
|||||||
'en-US': 'Content not found',
|
'en-US': 'Content not found',
|
||||||
'ms-MY': 'Kandungan tidak dijumpai',
|
'ms-MY': 'Kandungan tidak dijumpai',
|
||||||
},
|
},
|
||||||
|
MESSAGE_NOT_FOUND: {
|
||||||
|
'zh-CN': '消息不存在',
|
||||||
|
'en-US': 'Message not found',
|
||||||
|
'ms-MY': 'Mesej tidak dijumpai',
|
||||||
|
},
|
||||||
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
|
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
|
||||||
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
|
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
|
||||||
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',
|
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ export const API_ERROR_MESSAGES = {
|
|||||||
'en-US': 'User not found',
|
'en-US': 'User not found',
|
||||||
'ms-MY': 'Pengguna tidak dijumpai',
|
'ms-MY': 'Pengguna tidak dijumpai',
|
||||||
},
|
},
|
||||||
|
CANNOT_DELETE_SELF: {
|
||||||
|
'zh-CN': '不能删除自己',
|
||||||
|
'en-US': 'Cannot delete yourself',
|
||||||
|
'ms-MY': 'Tidak boleh memadam diri sendiri',
|
||||||
|
},
|
||||||
|
STAFF_NOT_FOUND: {
|
||||||
|
'zh-CN': '管理员不存在',
|
||||||
|
'en-US': 'Staff member not found',
|
||||||
|
'ms-MY': 'Ahli kakitangan tidak dijumpai',
|
||||||
|
},
|
||||||
|
CANNOT_DELETE_LAST_SUPER_ADMIN: {
|
||||||
|
'zh-CN': '不能删除唯一的超级管理员',
|
||||||
|
'en-US': 'Cannot delete the last super admin',
|
||||||
|
'ms-MY': 'Tidak boleh memadam pentadbir super terakhir',
|
||||||
|
},
|
||||||
PASSWORD_CHANGE_DISABLED: {
|
PASSWORD_CHANGE_DISABLED: {
|
||||||
'zh-CN': '当前平台未开放玩家自行修改密码',
|
'zh-CN': '当前平台未开放玩家自行修改密码',
|
||||||
'en-US': 'Password change is disabled for players',
|
'en-US': 'Password change is disabled for players',
|
||||||
@@ -844,6 +859,11 @@ export const API_ERROR_MESSAGES = {
|
|||||||
'en-US': 'Content not found',
|
'en-US': 'Content not found',
|
||||||
'ms-MY': 'Kandungan tidak dijumpai',
|
'ms-MY': 'Kandungan tidak dijumpai',
|
||||||
},
|
},
|
||||||
|
MESSAGE_NOT_FOUND: {
|
||||||
|
'zh-CN': '消息不存在',
|
||||||
|
'en-US': 'Message not found',
|
||||||
|
'ms-MY': 'Mesej tidak dijumpai',
|
||||||
|
},
|
||||||
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
|
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
|
||||||
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
|
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
|
||||||
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',
|
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',
|
||||||
|
|||||||
Reference in New Issue
Block a user