feat(theme-2): sync inbox/announcements/presence/deposit from main

This commit is contained in:
2026-06-18 09:38:33 +08:00
parent ec51b675e3
commit e56029225e
86 changed files with 6828 additions and 928 deletions

View 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-active);
border-radius: 8px;
padding: 8px 14px;
background: rgba(0, 61, 107, 0.06);
color: var(--primary);
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: var(--primary-dark, #002847);
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 6px rgba(0, 61, 107, 0.35);
}
.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: #374151;
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: var(--text-muted);
background: rgba(0, 0, 0, 0.05);
}
.status-badge.unread {
color: var(--primary);
background: rgba(0, 61, 107, 0.08);
}
.preview {
font-size: 13px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
font-size: 12px;
color: var(--text-muted);
}
.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: #9ca3af;
cursor: pointer;
}
.delete-btn:active:not(:disabled) {
background: rgba(220, 38, 38, 0.08);
color: #dc2626;
}
.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(--primary);
font-size: 13px;
}
</style>