| <template> |
| <BaseDialog |
| :show="show" |
| :title="t('admin.announcements.readStatus')" |
| width="extra-wide" |
| @close="handleClose" |
| > |
| <div class="space-y-4"> |
| <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| <div class="flex-1"> |
| <input |
| v-model="search" |
| type="text" |
| class="input" |
| :placeholder="t('admin.announcements.searchUsers')" |
| @input="handleSearch" |
| /> |
| </div> |
| <button @click="load" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')"> |
| <Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" /> |
| </button> |
| </div> |
| |
| <DataTable :columns="columns" :data="items" :loading="loading"> |
| <template #cell-email="{ value }"> |
| <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> |
| </template> |
| |
| <template #cell-balance="{ value }"> |
| <span class="font-medium text-gray-900 dark:text-white">${{ Number(value ?? 0).toFixed(2) }}</span> |
| </template> |
| |
| <template #cell-eligible="{ value }"> |
| <span :class="['badge', value ? 'badge-success' : 'badge-gray']"> |
| {{ value ? t('admin.announcements.eligible') : t('common.no') }} |
| </span> |
| </template> |
| |
| <template #cell-read_at="{ value }"> |
| <span class="text-sm text-gray-500 dark:text-dark-400"> |
| {{ value ? formatDateTime(value) : t('admin.announcements.unread') }} |
| </span> |
| </template> |
| </DataTable> |
| |
| <Pagination |
| v-if="pagination.total > 0" |
| :page="pagination.page" |
| :total="pagination.total" |
| :page-size="pagination.page_size" |
| @update:page="handlePageChange" |
| @update:pageSize="handlePageSizeChange" |
| /> |
| </div> |
| |
| <template #footer> |
| <div class="flex justify-end"> |
| <button type="button" class="btn btn-secondary" @click="handleClose">{{ t('common.close') }}</button> |
| </div> |
| </template> |
| </BaseDialog> |
| </template> |
| |
| <script setup lang="ts"> |
| import { computed, onMounted, reactive, ref, watch } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import { useAppStore } from '@/stores/app' |
| import { adminAPI } from '@/api/admin' |
| import { formatDateTime } from '@/utils/format' |
| import type { AnnouncementUserReadStatus } from '@/types' |
| import type { Column } from '@/components/common/types' |
| import { getPersistedPageSize } from '@/composables/usePersistedPageSize' |
| |
| import BaseDialog from '@/components/common/BaseDialog.vue' |
| import DataTable from '@/components/common/DataTable.vue' |
| import Pagination from '@/components/common/Pagination.vue' |
| import Icon from '@/components/icons/Icon.vue' |
| |
| const { t } = useI18n() |
| const appStore = useAppStore() |
| |
| const props = defineProps<{ |
| show: boolean |
| announcementId: number | null |
| }>() |
| |
| const emit = defineEmits<{ |
| (e: 'close'): void |
| }>() |
| |
| const loading = ref(false) |
| const search = ref('') |
| |
| const pagination = reactive({ |
| page: 1, |
| page_size: getPersistedPageSize(), |
| total: 0, |
| pages: 0 |
| }) |
| |
| const items = ref<AnnouncementUserReadStatus[]>([]) |
| |
| const columns = computed<Column[]>(() => [ |
| { key: 'email', label: t('common.email') }, |
| { key: 'username', label: t('admin.users.columns.username') }, |
| { key: 'balance', label: t('common.balance') }, |
| { key: 'eligible', label: t('admin.announcements.eligible') }, |
| { key: 'read_at', label: t('admin.announcements.readAt') } |
| ]) |
| |
| let currentController: AbortController | null = null |
| |
| async function load() { |
| if (!props.show || !props.announcementId) return |
| |
| if (currentController) currentController.abort() |
| currentController = new AbortController() |
| |
| try { |
| loading.value = true |
| const res = await adminAPI.announcements.getReadStatus( |
| props.announcementId, |
| pagination.page, |
| pagination.page_size, |
| search.value |
| ) |
| |
| items.value = res.items |
| pagination.total = res.total |
| pagination.pages = res.pages |
| pagination.page = res.page |
| pagination.page_size = res.page_size |
| } catch (error: any) { |
| if (currentController.signal.aborted || error?.name === 'AbortError') return |
| console.error('Failed to load read status:', error) |
| appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus')) |
| } finally { |
| loading.value = false |
| } |
| } |
| |
| function handlePageChange(page: number) { |
| pagination.page = page |
| load() |
| } |
| |
| function handlePageSizeChange(pageSize: number) { |
| pagination.page_size = pageSize |
| pagination.page = 1 |
| load() |
| } |
| |
| let searchDebounceTimer: number | null = null |
| function handleSearch() { |
| if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer) |
| searchDebounceTimer = window.setTimeout(() => { |
| pagination.page = 1 |
| load() |
| }, 300) |
| } |
| |
| function handleClose() { |
| emit('close') |
| } |
| |
| watch( |
| () => props.show, |
| (v) => { |
| if (!v) return |
| pagination.page = 1 |
| load() |
| } |
| ) |
| |
| watch( |
| () => props.announcementId, |
| () => { |
| if (!props.show) return |
| pagination.page = 1 |
| load() |
| } |
| ) |
| |
| onMounted(() => { |
| |
| }) |
| </script> |
| |