feat(announcements): add admin/user announcement system

Implements announcements end-to-end (admin CRUD + read status, user list + mark read) with OR-of-AND targeting. Also breaks the ent<->service import cycle by moving schema-facing constants/targeting into a new domain package.
This commit is contained in:
ducky
2026-01-30 16:45:04 +08:00
parent cadca752c4
commit b7f69844e1
70 changed files with 12366 additions and 71 deletions

View File

@@ -0,0 +1,71 @@
/**
* Admin Announcements API endpoints
*/
import { apiClient } from '../client'
import type {
Announcement,
AnnouncementUserReadStatus,
BasePaginationResponse,
CreateAnnouncementRequest,
UpdateAnnouncementRequest
} from '@/types'
export async function list(
page: number = 1,
pageSize: number = 20,
filters?: {
status?: string
search?: string
}
): Promise<BasePaginationResponse<Announcement>> {
const { data } = await apiClient.get<BasePaginationResponse<Announcement>>('/admin/announcements', {
params: { page, page_size: pageSize, ...filters }
})
return data
}
export async function getById(id: number): Promise<Announcement> {
const { data } = await apiClient.get<Announcement>(`/admin/announcements/${id}`)
return data
}
export async function create(request: CreateAnnouncementRequest): Promise<Announcement> {
const { data } = await apiClient.post<Announcement>('/admin/announcements', request)
return data
}
export async function update(id: number, request: UpdateAnnouncementRequest): Promise<Announcement> {
const { data } = await apiClient.put<Announcement>(`/admin/announcements/${id}`, request)
return data
}
export async function deleteAnnouncement(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/announcements/${id}`)
return data
}
export async function getReadStatus(
id: number,
page: number = 1,
pageSize: number = 20,
search: string = ''
): Promise<BasePaginationResponse<AnnouncementUserReadStatus>> {
const { data } = await apiClient.get<BasePaginationResponse<AnnouncementUserReadStatus>>(
`/admin/announcements/${id}/read-status`,
{ params: { page, page_size: pageSize, search } }
)
return data
}
const announcementsAPI = {
list,
getById,
create,
update,
delete: deleteAnnouncement,
getReadStatus
}
export default announcementsAPI

View File

@@ -10,6 +10,7 @@ import accountsAPI from './accounts'
import proxiesAPI from './proxies'
import redeemAPI from './redeem'
import promoAPI from './promo'
import announcementsAPI from './announcements'
import settingsAPI from './settings'
import systemAPI from './system'
import subscriptionsAPI from './subscriptions'
@@ -30,6 +31,7 @@ export const adminAPI = {
proxies: proxiesAPI,
redeem: redeemAPI,
promo: promoAPI,
announcements: announcementsAPI,
settings: settingsAPI,
system: systemAPI,
subscriptions: subscriptionsAPI,
@@ -48,6 +50,7 @@ export {
proxiesAPI,
redeemAPI,
promoAPI,
announcementsAPI,
settingsAPI,
systemAPI,
subscriptionsAPI,

View File

@@ -0,0 +1,26 @@
/**
* User Announcements API endpoints
*/
import { apiClient } from './client'
import type { UserAnnouncement } from '@/types'
export async function list(unreadOnly: boolean = false): Promise<UserAnnouncement[]> {
const { data } = await apiClient.get<UserAnnouncement[]>('/announcements', {
params: unreadOnly ? { unread_only: 1 } : {}
})
return data
}
export async function markRead(id: number): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>(`/announcements/${id}/read`)
return data
}
const announcementsAPI = {
list,
markRead
}
export default announcementsAPI

View File

@@ -16,6 +16,7 @@ export { userAPI } from './user'
export { redeemAPI, type RedeemHistoryItem } from './redeem'
export { userGroupsAPI } from './groups'
export { totpAPI } from './totp'
export { default as announcementsAPI } from './announcements'
// Admin APIs
export { adminAPI } from './admin'

View File

@@ -0,0 +1,186 @@
<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 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: 20,
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(() => {
// noop
})
</script>

View File

@@ -0,0 +1,388 @@
<template>
<div class="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/50">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.announcements.form.targetingMode') }}
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-dark-400">
{{ mode === 'all' ? t('admin.announcements.form.targetingAll') : t('admin.announcements.form.targetingCustom') }}
</div>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="radio"
name="announcement-targeting-mode"
value="all"
:checked="mode === 'all'"
@change="setMode('all')"
class="h-4 w-4"
/>
{{ t('admin.announcements.form.targetingAll') }}
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="radio"
name="announcement-targeting-mode"
value="custom"
:checked="mode === 'custom'"
@change="setMode('custom')"
class="h-4 w-4"
/>
{{ t('admin.announcements.form.targetingCustom') }}
</label>
</div>
</div>
<div v-if="mode === 'custom'" class="mt-4 space-y-4">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-white">
OR
<span class="ml-1 text-xs font-normal text-gray-500 dark:text-dark-400">
({{ anyOf.length }}/50)
</span>
</div>
<button
type="button"
class="btn btn-secondary"
:disabled="anyOf.length >= 50"
@click="addOrGroup"
>
<Icon name="plus" size="sm" class="mr-1" />
{{ t('admin.announcements.form.addOrGroup') }}
</button>
</div>
<div v-if="anyOf.length === 0" class="rounded-xl border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-dark-600 dark:text-dark-400">
{{ t('admin.announcements.form.targetingCustom') }}: {{ t('admin.announcements.form.addOrGroup') }}
</div>
<div
v-for="(group, groupIndex) in anyOf"
:key="groupIndex"
class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.announcements.form.targetingCustom') }} #{{ groupIndex + 1 }}
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-dark-400">AND ({{ (group.all_of?.length || 0) }}/50)</span>
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-dark-400">
{{ t('admin.announcements.form.addAndCondition') }}
</div>
</div>
<button
type="button"
class="btn btn-secondary"
@click="removeOrGroup(groupIndex)"
>
<Icon name="trash" size="sm" class="mr-1" />
{{ t('common.delete') }}
</button>
</div>
<div class="mt-4 space-y-3">
<div
v-for="(cond, condIndex) in (group.all_of || [])"
:key="condIndex"
class="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900/30"
>
<div class="flex flex-col gap-3 md:flex-row md:items-end">
<div class="w-full md:w-52">
<label class="input-label">{{ t('admin.announcements.form.conditionType') }}</label>
<Select
:model-value="cond.type"
:options="conditionTypeOptions"
@update:model-value="(v) => setConditionType(groupIndex, condIndex, v as any)"
/>
</div>
<div v-if="cond.type === 'subscription'" class="flex-1">
<label class="input-label">{{ t('admin.announcements.form.selectPackages') }}</label>
<GroupSelector
v-model="subscriptionSelections[groupIndex][condIndex]"
:groups="groups"
/>
</div>
<div v-else class="flex flex-1 flex-col gap-3 sm:flex-row">
<div class="w-full sm:w-44">
<label class="input-label">{{ t('admin.announcements.form.operator') }}</label>
<Select
:model-value="cond.operator"
:options="balanceOperatorOptions"
@update:model-value="(v) => setOperator(groupIndex, condIndex, v as any)"
/>
</div>
<div class="w-full sm:flex-1">
<label class="input-label">{{ t('admin.announcements.form.balanceValue') }}</label>
<input
:value="String(cond.value ?? '')"
type="number"
step="any"
class="input"
@input="(e) => setBalanceValue(groupIndex, condIndex, (e.target as HTMLInputElement).value)"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn btn-secondary"
@click="removeAndCondition(groupIndex, condIndex)"
>
<Icon name="trash" size="sm" class="mr-1" />
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn btn-secondary"
:disabled="(group.all_of?.length || 0) >= 50"
@click="addAndCondition(groupIndex)"
>
<Icon name="plus" size="sm" class="mr-1" />
{{ t('admin.announcements.form.addAndCondition') }}
</button>
</div>
</div>
</div>
<div v-if="validationError" class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/30 dark:bg-red-900/10 dark:text-red-300">
{{ validationError }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
AdminGroup,
AnnouncementTargeting,
AnnouncementCondition,
AnnouncementConditionGroup,
AnnouncementConditionType,
AnnouncementOperator
} from '@/types'
import Select from '@/components/common/Select.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const props = defineProps<{
modelValue: AnnouncementTargeting
groups: AdminGroup[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: AnnouncementTargeting): void
}>()
const anyOf = computed(() => props.modelValue?.any_of ?? [])
type Mode = 'all' | 'custom'
const mode = computed<Mode>(() => (anyOf.value.length === 0 ? 'all' : 'custom'))
const conditionTypeOptions = computed(() => [
{ value: 'subscription', label: t('admin.announcements.form.conditionSubscription') },
{ value: 'balance', label: t('admin.announcements.form.conditionBalance') }
])
const balanceOperatorOptions = computed(() => [
{ value: 'gt', label: t('admin.announcements.operators.gt') },
{ value: 'gte', label: t('admin.announcements.operators.gte') },
{ value: 'lt', label: t('admin.announcements.operators.lt') },
{ value: 'lte', label: t('admin.announcements.operators.lte') },
{ value: 'eq', label: t('admin.announcements.operators.eq') }
])
function setMode(next: Mode) {
if (next === 'all') {
emit('update:modelValue', { any_of: [] })
return
}
if (anyOf.value.length === 0) {
emit('update:modelValue', { any_of: [{ all_of: [defaultSubscriptionCondition()] }] })
}
}
function defaultSubscriptionCondition(): AnnouncementCondition {
return {
type: 'subscription' as AnnouncementConditionType,
operator: 'in' as AnnouncementOperator,
group_ids: []
}
}
function defaultBalanceCondition(): AnnouncementCondition {
return {
type: 'balance' as AnnouncementConditionType,
operator: 'gte' as AnnouncementOperator,
value: 0
}
}
type TargetingDraft = {
any_of: AnnouncementConditionGroup[]
}
function updateTargeting(mutator: (draft: TargetingDraft) => void) {
const draft: TargetingDraft = JSON.parse(JSON.stringify(props.modelValue ?? { any_of: [] }))
if (!draft.any_of) draft.any_of = []
mutator(draft)
emit('update:modelValue', draft)
}
function addOrGroup() {
updateTargeting((draft) => {
if (draft.any_of.length >= 50) return
draft.any_of.push({ all_of: [defaultSubscriptionCondition()] })
})
}
function removeOrGroup(groupIndex: number) {
updateTargeting((draft) => {
draft.any_of.splice(groupIndex, 1)
})
}
function addAndCondition(groupIndex: number) {
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group.all_of) group.all_of = []
if (group.all_of.length >= 50) return
group.all_of.push(defaultSubscriptionCondition())
})
}
function removeAndCondition(groupIndex: number, condIndex: number) {
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group?.all_of) return
group.all_of.splice(condIndex, 1)
})
}
function setConditionType(groupIndex: number, condIndex: number, nextType: AnnouncementConditionType) {
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group?.all_of) return
if (nextType === 'subscription') {
group.all_of[condIndex] = defaultSubscriptionCondition()
} else {
group.all_of[condIndex] = defaultBalanceCondition()
}
})
}
function setOperator(groupIndex: number, condIndex: number, op: AnnouncementOperator) {
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group?.all_of) return
const cond = group.all_of[condIndex]
if (!cond) return
cond.operator = op
})
}
function setBalanceValue(groupIndex: number, condIndex: number, raw: string) {
const n = raw === '' ? 0 : Number(raw)
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group?.all_of) return
const cond = group.all_of[condIndex]
if (!cond) return
cond.value = Number.isFinite(n) ? n : 0
})
}
// We keep group_ids selection in a parallel reactive map because GroupSelector is numeric list.
// Then we mirror it back to targeting.group_ids via a watcher.
const subscriptionSelections = reactive<Record<number, Record<number, number[]>>>({})
function ensureSelectionPath(groupIndex: number, condIndex: number) {
if (!subscriptionSelections[groupIndex]) subscriptionSelections[groupIndex] = {}
if (!subscriptionSelections[groupIndex][condIndex]) subscriptionSelections[groupIndex][condIndex] = []
}
watch(
() => props.modelValue,
(v) => {
const groups = v?.any_of ?? []
for (let gi = 0; gi < groups.length; gi++) {
const allOf = groups[gi]?.all_of ?? []
for (let ci = 0; ci < allOf.length; ci++) {
const c = allOf[ci]
if (c?.type === 'subscription') {
ensureSelectionPath(gi, ci)
subscriptionSelections[gi][ci] = (c.group_ids ?? []).slice()
}
}
}
},
{ immediate: true, deep: true }
)
watch(
() => subscriptionSelections,
() => {
// sync back to targeting
updateTargeting((draft) => {
const groups = draft.any_of ?? []
for (let gi = 0; gi < groups.length; gi++) {
const allOf = groups[gi]?.all_of ?? []
for (let ci = 0; ci < allOf.length; ci++) {
const c = allOf[ci]
if (c?.type === 'subscription') {
ensureSelectionPath(gi, ci)
c.operator = 'in' as AnnouncementOperator
c.group_ids = (subscriptionSelections[gi]?.[ci] ?? []).slice()
}
}
}
})
},
{ deep: true }
)
const validationError = computed(() => {
if (mode.value !== 'custom') return ''
const groups = anyOf.value
if (groups.length === 0) return t('admin.announcements.form.addOrGroup')
if (groups.length > 50) return 'any_of > 50'
for (const g of groups) {
const allOf = g?.all_of ?? []
if (allOf.length === 0) return t('admin.announcements.form.addAndCondition')
if (allOf.length > 50) return 'all_of > 50'
for (const c of allOf) {
if (c.type === 'subscription') {
if (!c.group_ids || c.group_ids.length === 0) return t('admin.announcements.form.selectPackages')
}
}
}
return ''
})
</script>

View File

@@ -319,6 +319,21 @@ const ServerIcon = {
)
}
const BellIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9a6 6 0 10-12 0v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0'
})
]
)
}
const TicketIcon = {
render: () =>
h(
@@ -418,6 +433,7 @@ const ChevronDoubleRightIcon = {
const userNavItems = computed(() => {
const items = [
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/announcements', label: t('nav.announcements'), icon: BellIcon },
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
@@ -440,6 +456,7 @@ const userNavItems = computed(() => {
// Personal navigation items (for admin's "My Account" section, without Dashboard)
const personalNavItems = computed(() => {
const items = [
{ path: '/announcements', label: t('nav.announcements'), icon: BellIcon },
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
@@ -470,6 +487,7 @@ const adminNavItems = computed(() => {
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },

View File

@@ -185,6 +185,7 @@ export default {
// Navigation
nav: {
dashboard: 'Dashboard',
announcements: 'Announcements',
apiKeys: 'API Keys',
usage: 'Usage',
redeem: 'Redeem',
@@ -1951,6 +1952,73 @@ export default {
}
},
// Announcements
announcements: {
title: 'Announcements',
description: 'Create announcements and target by conditions',
createAnnouncement: 'Create Announcement',
editAnnouncement: 'Edit Announcement',
deleteAnnouncement: 'Delete Announcement',
searchAnnouncements: 'Search announcements...',
status: 'Status',
allStatus: 'All Status',
columns: {
title: 'Title',
status: 'Status',
targeting: 'Targeting',
timeRange: 'Schedule',
createdAt: 'Created At',
actions: 'Actions'
},
statusLabels: {
draft: 'Draft',
active: 'Active',
archived: 'Archived'
},
form: {
title: 'Title',
content: 'Content (Markdown supported)',
status: 'Status',
startsAt: 'Starts At',
endsAt: 'Ends At',
startsAtHint: 'Leave empty to start immediately',
endsAtHint: 'Leave empty to never expire',
targetingMode: 'Targeting',
targetingAll: 'All users',
targetingCustom: 'Custom rules',
addOrGroup: 'Add OR group',
addAndCondition: 'Add AND condition',
conditionType: 'Condition type',
conditionSubscription: 'Subscription',
conditionBalance: 'Balance',
operator: 'Operator',
balanceValue: 'Balance threshold',
selectPackages: 'Select packages'
},
operators: {
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
eq: '='
},
targetingSummaryAll: 'All users',
targetingSummaryCustom: 'Custom ({groups} groups)',
timeImmediate: 'Immediate',
timeNever: 'Never',
readStatus: 'Read Status',
eligible: 'Eligible',
readAt: 'Read at',
unread: 'Unread',
searchUsers: 'Search users...',
failedToLoad: 'Failed to load announcements',
failedToCreate: 'Failed to create announcement',
failedToUpdate: 'Failed to update announcement',
failedToDelete: 'Failed to delete announcement',
failedToLoadReadStatus: 'Failed to load read status',
deleteConfirm: 'Are you sure you want to delete this announcement? This action cannot be undone.'
},
// Promo Codes
promo: {
title: 'Promo Code Management',
@@ -3063,6 +3131,21 @@ export default {
'The administrator enabled the entry but has not configured a purchase URL. Please contact admin.'
},
// Announcements Page
announcements: {
title: 'Announcements',
description: 'View system announcements',
unreadOnly: 'Show unread only',
markRead: 'Mark as read',
readAt: 'Read at',
read: 'Read',
unread: 'Unread',
startsAt: 'Starts at',
endsAt: 'Ends at',
empty: 'No announcements',
emptyUnread: 'No unread announcements'
},
// User Subscriptions Page
userSubscriptions: {
title: 'My Subscriptions',

View File

@@ -182,6 +182,7 @@ export default {
// Navigation
nav: {
dashboard: '仪表盘',
announcements: '公告',
apiKeys: 'API 密钥',
usage: '使用记录',
redeem: '兑换',
@@ -2098,6 +2099,73 @@ export default {
failedToDelete: '删除兑换码失败'
},
// Announcements
announcements: {
title: '公告管理',
description: '创建公告并按条件投放',
createAnnouncement: '创建公告',
editAnnouncement: '编辑公告',
deleteAnnouncement: '删除公告',
searchAnnouncements: '搜索公告...',
status: '状态',
allStatus: '全部状态',
columns: {
title: '标题',
status: '状态',
targeting: '展示条件',
timeRange: '有效期',
createdAt: '创建时间',
actions: '操作'
},
statusLabels: {
draft: '草稿',
active: '展示中',
archived: '已归档'
},
form: {
title: '标题',
content: '内容(支持 Markdown',
status: '状态',
startsAt: '开始时间',
endsAt: '结束时间',
startsAtHint: '留空表示立即生效',
endsAtHint: '留空表示永久生效',
targetingMode: '展示条件',
targetingAll: '所有用户',
targetingCustom: '按条件',
addOrGroup: '添加 OR 条件组',
addAndCondition: '添加 AND 条件',
conditionType: '条件类型',
conditionSubscription: '订阅套餐',
conditionBalance: '余额',
operator: '运算符',
balanceValue: '余额阈值',
selectPackages: '选择套餐'
},
operators: {
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
eq: '='
},
targetingSummaryAll: '全部用户',
targetingSummaryCustom: '自定义({groups} 组)',
timeImmediate: '立即',
timeNever: '永久',
readStatus: '已读情况',
eligible: '符合条件',
readAt: '已读时间',
unread: '未读',
searchUsers: '搜索用户...',
failedToLoad: '加载公告失败',
failedToCreate: '创建公告失败',
failedToUpdate: '更新公告失败',
failedToDelete: '删除公告失败',
failedToLoadReadStatus: '加载已读情况失败',
deleteConfirm: '确定要删除该公告吗?此操作无法撤销。'
},
// Promo Codes
promo: {
title: '优惠码管理',
@@ -3212,6 +3280,21 @@ export default {
notConfiguredDesc: '管理员已开启入口,但尚未配置购买订阅链接,请联系管理员。'
},
// Announcements Page
announcements: {
title: '公告',
description: '查看系统公告',
unreadOnly: '仅显示未读',
markRead: '标记已读',
readAt: '已读时间',
read: '已读',
unread: '未读',
startsAt: '开始时间',
endsAt: '结束时间',
empty: '暂无公告',
emptyUnread: '暂无未读公告'
},
// User Subscriptions Page
userSubscriptions: {
title: '我的订阅',

View File

@@ -187,6 +187,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'purchase.description'
}
},
{
path: '/announcements',
name: 'Announcements',
component: () => import('@/views/user/AnnouncementsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Announcements',
titleKey: 'announcements.title',
descriptionKey: 'announcements.description'
}
},
// ==================== Admin Routes ====================
{
@@ -265,6 +277,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.accounts.description'
}
},
{
path: '/admin/announcements',
name: 'AdminAnnouncements',
component: () => import('@/views/admin/AnnouncementsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Announcements',
titleKey: 'admin.announcements.title',
descriptionKey: 'admin.announcements.description'
}
},
{
path: '/admin/proxies',
name: 'AdminProxies',

View File

@@ -129,6 +129,81 @@ export interface UpdateSubscriptionRequest {
is_active?: boolean
}
// ==================== Announcement Types ====================
export type AnnouncementStatus = 'draft' | 'active' | 'archived'
export type AnnouncementConditionType = 'subscription' | 'balance'
export type AnnouncementOperator = 'in' | 'gt' | 'gte' | 'lt' | 'lte' | 'eq'
export interface AnnouncementCondition {
type: AnnouncementConditionType
operator: AnnouncementOperator
group_ids?: number[]
value?: number
}
export interface AnnouncementConditionGroup {
all_of?: AnnouncementCondition[]
}
export interface AnnouncementTargeting {
any_of?: AnnouncementConditionGroup[]
}
export interface Announcement {
id: number
title: string
content: string
status: AnnouncementStatus
targeting: AnnouncementTargeting
starts_at?: string
ends_at?: string
created_by?: number
updated_by?: number
created_at: string
updated_at: string
}
export interface UserAnnouncement {
id: number
title: string
content: string
starts_at?: string
ends_at?: string
read_at?: string
created_at: string
updated_at: string
}
export interface CreateAnnouncementRequest {
title: string
content: string
status?: AnnouncementStatus
targeting: AnnouncementTargeting
starts_at?: number
ends_at?: number
}
export interface UpdateAnnouncementRequest {
title?: string
content?: string
status?: AnnouncementStatus
targeting?: AnnouncementTargeting
starts_at?: number
ends_at?: number
}
export interface AnnouncementUserReadStatus {
user_id: number
email: string
username: string
balance: number
eligible: boolean
read_at?: string
}
// ==================== Proxy Node Types ====================
export interface ProxyNode {

View File

@@ -0,0 +1,538 @@
<template>
<AppLayout>
<TablePageLayout>
<template #actions>
<div class="flex justify-end gap-3">
<button
@click="loadAnnouncements"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button @click="openCreateDialog" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-1" />
{{ t('admin.announcements.createAnnouncement') }}
</button>
</div>
</template>
<template #filters>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="max-w-md flex-1">
<input
v-model="searchQuery"
type="text"
:placeholder="t('admin.announcements.searchAnnouncements')"
class="input"
@input="handleSearch"
/>
</div>
<div class="flex gap-2">
<Select
v-model="filters.status"
:options="statusFilterOptions"
class="w-40"
@change="handleStatusChange"
/>
</div>
</div>
</template>
<template #table>
<DataTable :columns="columns" :data="announcements" :loading="loading">
<template #cell-title="{ value, row }">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-gray-900 dark:text-white">{{ value }}</span>
</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400">
<span>#{{ row.id }}</span>
<span class="text-gray-300 dark:text-dark-700">·</span>
<span>{{ formatDateTime(row.created_at) }}</span>
</div>
</div>
</template>
<template #cell-status="{ value }">
<span
:class="[
'badge',
value === 'active'
? 'badge-success'
: value === 'draft'
? 'badge-gray'
: 'badge-warning'
]"
>
{{ statusLabel(value) }}
</span>
</template>
<template #cell-targeting="{ row }">
<span class="text-sm text-gray-600 dark:text-gray-300">
{{ targetingSummary(row.targeting) }}
</span>
</template>
<template #cell-timeRange="{ row }">
<div class="text-sm text-gray-600 dark:text-gray-300">
<div>
<span class="font-medium">{{ t('admin.announcements.form.startsAt') }}:</span>
<span class="ml-1">{{ row.starts_at ? formatDateTime(row.starts_at) : t('admin.announcements.timeImmediate') }}</span>
</div>
<div class="mt-0.5">
<span class="font-medium">{{ t('admin.announcements.form.endsAt') }}:</span>
<span class="ml-1">{{ row.ends_at ? formatDateTime(row.ends_at) : t('admin.announcements.timeNever') }}</span>
</div>
</div>
</template>
<template #cell-createdAt="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center space-x-1">
<button
@click="openReadStatus(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
:title="t('admin.announcements.readStatus')"
>
<Icon name="eye" size="sm" />
</button>
<button
@click="openEditDialog(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dark-600 dark:hover:text-gray-300"
:title="t('common.edit')"
>
<Icon name="edit" size="sm" />
</button>
<button
@click="handleDelete(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="t('common.delete')"
>
<Icon name="trash" size="sm" />
</button>
</div>
</template>
<template #empty>
<EmptyState
:title="t('empty.noData')"
:description="t('admin.announcements.failedToLoad')"
:action-text="t('admin.announcements.createAnnouncement')"
@action="openCreateDialog"
/>
</template>
</DataTable>
</template>
<template #pagination>
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</template>
</TablePageLayout>
<!-- Create/Edit Dialog -->
<BaseDialog
:show="showEditDialog"
:title="isEditing ? t('admin.announcements.editAnnouncement') : t('admin.announcements.createAnnouncement')"
width="wide"
@close="closeEdit"
>
<form id="announcement-form" @submit.prevent="handleSave" class="space-y-4">
<div>
<label class="input-label">{{ t('admin.announcements.form.title') }}</label>
<input v-model="form.title" type="text" class="input" required />
</div>
<div>
<label class="input-label">{{ t('admin.announcements.form.content') }}</label>
<textarea v-model="form.content" rows="6" class="input" required></textarea>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.announcements.form.status') }}</label>
<Select v-model="form.status" :options="statusOptions" />
</div>
<div></div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.announcements.form.startsAt') }}</label>
<input v-model="form.starts_at_str" type="datetime-local" class="input" />
<p class="input-hint">{{ t('admin.announcements.form.startsAtHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.announcements.form.endsAt') }}</label>
<input v-model="form.ends_at_str" type="datetime-local" class="input" />
<p class="input-hint">{{ t('admin.announcements.form.endsAtHint') }}</p>
</div>
</div>
<AnnouncementTargetingEditor
v-model="form.targeting"
:groups="subscriptionGroups"
/>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" @click="closeEdit" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button type="submit" form="announcement-form" :disabled="saving" class="btn btn-primary">
{{ saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</BaseDialog>
<!-- Delete Confirmation -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.announcements.deleteAnnouncement')"
:message="t('admin.announcements.deleteConfirm')"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
danger
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
<!-- Read Status Dialog -->
<AnnouncementReadStatusDialog
:show="showReadStatusDialog"
:announcement-id="readStatusAnnouncementId"
@close="showReadStatusDialog = false"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import type { AdminGroup, Announcement, AnnouncementTargeting } from '@/types'
import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import AnnouncementTargetingEditor from '@/components/admin/announcements/AnnouncementTargetingEditor.vue'
import AnnouncementReadStatusDialog from '@/components/admin/announcements/AnnouncementReadStatusDialog.vue'
const { t } = useI18n()
const appStore = useAppStore()
const announcements = ref<Announcement[]>([])
const loading = ref(false)
const filters = reactive({
status: '',
})
const searchQuery = ref('')
const pagination = reactive({
page: 1,
page_size: 20,
total: 0,
pages: 0
})
const statusFilterOptions = computed(() => [
{ value: '', label: t('admin.announcements.allStatus') },
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') },
{ value: 'active', label: t('admin.announcements.statusLabels.active') },
{ value: 'archived', label: t('admin.announcements.statusLabels.archived') }
])
const statusOptions = computed(() => [
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') },
{ value: 'active', label: t('admin.announcements.statusLabels.active') },
{ value: 'archived', label: t('admin.announcements.statusLabels.archived') }
])
const columns = computed<Column[]>(() => [
{ key: 'title', label: t('admin.announcements.columns.title') },
{ key: 'status', label: t('admin.announcements.columns.status') },
{ key: 'targeting', label: t('admin.announcements.columns.targeting') },
{ key: 'timeRange', label: t('admin.announcements.columns.timeRange') },
{ key: 'createdAt', label: t('admin.announcements.columns.createdAt') },
{ key: 'actions', label: t('admin.announcements.columns.actions') }
])
const statusLabel = (status: string) => {
if (status === 'draft') return t('admin.announcements.statusLabels.draft')
if (status === 'active') return t('admin.announcements.statusLabels.active')
if (status === 'archived') return t('admin.announcements.statusLabels.archived')
return status
}
const targetingSummary = (targeting: AnnouncementTargeting) => {
const anyOf = targeting?.any_of ?? []
if (!anyOf || anyOf.length === 0) return t('admin.announcements.targetingSummaryAll')
return t('admin.announcements.targetingSummaryCustom', { groups: anyOf.length })
}
// ===== CRUD / list =====
let currentController: AbortController | null = null
async function loadAnnouncements() {
if (currentController) currentController.abort()
currentController = new AbortController()
try {
loading.value = true
const res = await adminAPI.announcements.list(pagination.page, pagination.page_size, {
status: filters.status || undefined,
search: searchQuery.value || undefined
})
announcements.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('Error loading announcements:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoad'))
} finally {
loading.value = false
}
}
function handlePageChange(page: number) {
pagination.page = page
loadAnnouncements()
}
function handlePageSizeChange(pageSize: number) {
pagination.page_size = pageSize
pagination.page = 1
loadAnnouncements()
}
function handleStatusChange() {
pagination.page = 1
loadAnnouncements()
}
let searchDebounceTimer: number | null = null
function handleSearch() {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => {
pagination.page = 1
loadAnnouncements()
}, 300)
}
// ===== Create/Edit dialog =====
const showEditDialog = ref(false)
const saving = ref(false)
const editingAnnouncement = ref<Announcement | null>(null)
const isEditing = computed(() => !!editingAnnouncement.value)
const form = reactive({
title: '',
content: '',
status: 'draft',
starts_at_str: '',
ends_at_str: '',
targeting: { any_of: [] } as AnnouncementTargeting
})
const subscriptionGroups = ref<AdminGroup[]>([])
async function loadSubscriptionGroups() {
try {
const all = await adminAPI.groups.getAll()
subscriptionGroups.value = (all || []).filter((g) => g.subscription_type === 'subscription')
} catch (error: any) {
console.error('Error loading groups:', error)
// not fatal
}
}
function resetForm() {
form.title = ''
form.content = ''
form.status = 'draft'
form.starts_at_str = ''
form.ends_at_str = ''
form.targeting = { any_of: [] }
}
function fillFormFromAnnouncement(a: Announcement) {
form.title = a.title
form.content = a.content
form.status = a.status
// Backend returns RFC3339 strings
form.starts_at_str = a.starts_at ? formatDateTimeLocalInput(Math.floor(new Date(a.starts_at).getTime() / 1000)) : ''
form.ends_at_str = a.ends_at ? formatDateTimeLocalInput(Math.floor(new Date(a.ends_at).getTime() / 1000)) : ''
form.targeting = a.targeting ?? { any_of: [] }
}
function openCreateDialog() {
editingAnnouncement.value = null
resetForm()
showEditDialog.value = true
}
function openEditDialog(row: Announcement) {
editingAnnouncement.value = row
fillFormFromAnnouncement(row)
showEditDialog.value = true
}
function closeEdit() {
showEditDialog.value = false
editingAnnouncement.value = null
}
function buildCreatePayload() {
const startsAt = parseDateTimeLocalInput(form.starts_at_str)
const endsAt = parseDateTimeLocalInput(form.ends_at_str)
return {
title: form.title,
content: form.content,
status: form.status as any,
targeting: form.targeting,
starts_at: startsAt ?? undefined,
ends_at: endsAt ?? undefined
}
}
function buildUpdatePayload(original: Announcement) {
const payload: any = {}
if (form.title !== original.title) payload.title = form.title
if (form.content !== original.content) payload.content = form.content
if (form.status !== original.status) payload.status = form.status
// starts_at / ends_at: distinguish unchanged vs clear(0) vs set
const originalStarts = original.starts_at ? Math.floor(new Date(original.starts_at).getTime() / 1000) : null
const originalEnds = original.ends_at ? Math.floor(new Date(original.ends_at).getTime() / 1000) : null
const newStarts = parseDateTimeLocalInput(form.starts_at_str)
const newEnds = parseDateTimeLocalInput(form.ends_at_str)
if (newStarts !== originalStarts) {
payload.starts_at = newStarts === null ? 0 : newStarts
}
if (newEnds !== originalEnds) {
payload.ends_at = newEnds === null ? 0 : newEnds
}
// targeting: do shallow compare by JSON
if (JSON.stringify(form.targeting ?? {}) !== JSON.stringify(original.targeting ?? {})) {
payload.targeting = form.targeting
}
return payload
}
async function handleSave() {
// Frontend validation for targeting (to avoid ANNOUNCEMENT_INVALID_TARGET)
const anyOf = form.targeting?.any_of ?? []
if (anyOf.length > 50) {
appStore.showError(t('admin.announcements.failedToCreate'))
return
}
for (const g of anyOf) {
const allOf = g?.all_of ?? []
if (allOf.length > 50) {
appStore.showError(t('admin.announcements.failedToCreate'))
return
}
}
saving.value = true
try {
if (!editingAnnouncement.value) {
const payload = buildCreatePayload()
await adminAPI.announcements.create(payload)
appStore.showSuccess(t('common.success'))
showEditDialog.value = false
await loadAnnouncements()
return
}
const original = editingAnnouncement.value
const payload = buildUpdatePayload(original)
await adminAPI.announcements.update(original.id, payload)
appStore.showSuccess(t('common.success'))
showEditDialog.value = false
editingAnnouncement.value = null
await loadAnnouncements()
} catch (error: any) {
console.error('Failed to save announcement:', error)
appStore.showError(error.response?.data?.detail || (editingAnnouncement.value ? t('admin.announcements.failedToUpdate') : t('admin.announcements.failedToCreate')))
} finally {
saving.value = false
}
}
// ===== Delete =====
const showDeleteDialog = ref(false)
const deletingAnnouncement = ref<Announcement | null>(null)
function handleDelete(row: Announcement) {
deletingAnnouncement.value = row
showDeleteDialog.value = true
}
async function confirmDelete() {
if (!deletingAnnouncement.value) return
try {
await adminAPI.announcements.delete(deletingAnnouncement.value.id)
appStore.showSuccess(t('common.success'))
showDeleteDialog.value = false
deletingAnnouncement.value = null
await loadAnnouncements()
} catch (error: any) {
console.error('Failed to delete announcement:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToDelete'))
}
}
// ===== Read status =====
const showReadStatusDialog = ref(false)
const readStatusAnnouncementId = ref<number | null>(null)
function openReadStatus(row: Announcement) {
readStatusAnnouncementId.value = row.id
showReadStatusDialog.value = true
}
onMounted(async () => {
await loadSubscriptionGroups()
await loadAnnouncements()
})
</script>

View File

@@ -0,0 +1,140 @@
<template>
<AppLayout>
<TablePageLayout>
<template #actions>
<div class="flex justify-end gap-3">
<button
@click="loadAnnouncements"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
</div>
</template>
<template #filters>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="unreadOnly" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
<span>{{ t('announcements.unreadOnly') }}</span>
</label>
</div>
</template>
<template #table>
<div v-if="loading" class="flex items-center justify-center py-10">
<Icon name="refresh" size="lg" class="animate-spin text-gray-400" />
</div>
<div v-else-if="announcements.length === 0" class="py-12 text-center text-gray-500 dark:text-gray-400">
{{ unreadOnly ? t('announcements.emptyUnread') : t('announcements.empty') }}
</div>
<div v-else class="space-y-4">
<div
v-for="item in announcements"
:key="item.id"
class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-dark-700 dark:bg-dark-800"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate text-base font-semibold text-gray-900 dark:text-white">
{{ item.title }}
</h3>
<span v-if="!item.read_at" class="badge badge-warning">
{{ t('announcements.unread') }}
</span>
<span v-else class="badge badge-success">
{{ t('announcements.read') }}
</span>
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-dark-400">
<span>{{ formatDateTime(item.created_at) }}</span>
<span v-if="item.starts_at">
{{ t('announcements.startsAt') }}: {{ formatDateTime(item.starts_at) }}
</span>
<span v-if="item.ends_at">
{{ t('announcements.endsAt') }}: {{ formatDateTime(item.ends_at) }}
</span>
</div>
</div>
<div class="flex flex-shrink-0 items-center gap-2">
<button
v-if="!item.read_at"
class="btn btn-secondary"
:disabled="markingReadId === item.id"
@click="markRead(item.id)"
>
{{ markingReadId === item.id ? t('common.processing') : t('announcements.markRead') }}
</button>
<span v-else class="text-xs text-gray-500 dark:text-dark-400">
{{ t('announcements.readAt') }}: {{ formatDateTime(item.read_at) }}
</span>
</div>
</div>
<div class="mt-4 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-200">
{{ item.content }}
</div>
</div>
</div>
</template>
</TablePageLayout>
</AppLayout>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { announcementsAPI } from '@/api'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/format'
import type { UserAnnouncement } from '@/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
const announcements = ref<UserAnnouncement[]>([])
const loading = ref(false)
const unreadOnly = ref(false)
const markingReadId = ref<number | null>(null)
async function loadAnnouncements() {
try {
loading.value = true
announcements.value = await announcementsAPI.list(unreadOnly.value)
} catch (err: any) {
appStore.showError(err?.message || t('common.unknownError'))
} finally {
loading.value = false
}
}
async function markRead(id: number) {
if (markingReadId.value) return
try {
markingReadId.value = id
await announcementsAPI.markRead(id)
await loadAnnouncements()
} catch (err: any) {
appStore.showError(err?.message || t('common.unknownError'))
} finally {
markingReadId.value = null
}
}
watch(unreadOnly, () => {
loadAnnouncements()
})
onMounted(() => {
loadAnnouncements()
})
</script>