Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
7079edc2
Commit
7079edc2
authored
Mar 07, 2026
by
shaw
Browse files
feat: announcement支持强制弹窗通知
parent
a42a1f08
Changes
25
Show whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/zh.ts
View file @
7079edc2
...
...
@@ -2872,6 +2872,7 @@ export default {
columns
:
{
title
:
'
标题
'
,
status
:
'
状态
'
,
notifyMode
:
'
通知方式
'
,
targeting
:
'
展示条件
'
,
timeRange
:
'
有效期
'
,
createdAt
:
'
创建时间
'
,
...
...
@@ -2882,10 +2883,16 @@ export default {
active
:
'
展示中
'
,
archived
:
'
已归档
'
},
notifyModeLabels
:
{
silent
:
'
静默
'
,
popup
:
'
弹窗
'
},
form
:
{
title
:
'
标题
'
,
content
:
'
内容(支持 Markdown)
'
,
status
:
'
状态
'
,
notifyMode
:
'
通知方式
'
,
notifyModeHint
:
'
弹窗模式会自动弹出通知给用户
'
,
startsAt
:
'
开始时间
'
,
endsAt
:
'
结束时间
'
,
startsAtHint
:
'
留空表示立即生效
'
,
...
...
frontend/src/stores/announcements.ts
0 → 100644
View file @
7079edc2
import
{
defineStore
}
from
'
pinia
'
import
{
ref
,
computed
}
from
'
vue
'
import
{
announcementsAPI
}
from
'
@/api
'
import
type
{
UserAnnouncement
}
from
'
@/types
'
const
THROTTLE_MS
=
20
*
60
*
1000
// 20 minutes
export
const
useAnnouncementStore
=
defineStore
(
'
announcements
'
,
()
=>
{
// State
const
announcements
=
ref
<
UserAnnouncement
[]
>
([])
const
loading
=
ref
(
false
)
const
lastFetchTime
=
ref
(
0
)
const
popupQueue
=
ref
<
UserAnnouncement
[]
>
([])
const
currentPopup
=
ref
<
UserAnnouncement
|
null
>
(
null
)
// Session-scoped dedup set — not reactive, used as plain lookup only
let
shownPopupIds
=
new
Set
<
number
>
()
// Getters
const
unreadCount
=
computed
(()
=>
announcements
.
value
.
filter
((
a
)
=>
!
a
.
read_at
).
length
)
// Actions
async
function
fetchAnnouncements
(
force
=
false
)
{
const
now
=
Date
.
now
()
if
(
!
force
&&
lastFetchTime
.
value
>
0
&&
now
-
lastFetchTime
.
value
<
THROTTLE_MS
)
{
return
}
// Set immediately to prevent concurrent duplicate requests
lastFetchTime
.
value
=
now
try
{
loading
.
value
=
true
const
all
=
await
announcementsAPI
.
list
(
false
)
announcements
.
value
=
all
.
slice
(
0
,
20
)
enqueueNewPopups
()
}
catch
(
err
:
any
)
{
// Revert throttle timestamp on failure so retry is allowed
lastFetchTime
.
value
=
0
console
.
error
(
'
Failed to fetch announcements:
'
,
err
)
}
finally
{
loading
.
value
=
false
}
}
function
enqueueNewPopups
()
{
const
newPopups
=
announcements
.
value
.
filter
(
(
a
)
=>
a
.
notify_mode
===
'
popup
'
&&
!
a
.
read_at
&&
!
shownPopupIds
.
has
(
a
.
id
)
)
if
(
newPopups
.
length
===
0
)
return
for
(
const
p
of
newPopups
)
{
if
(
!
popupQueue
.
value
.
some
((
q
)
=>
q
.
id
===
p
.
id
))
{
popupQueue
.
value
.
push
(
p
)
}
}
if
(
!
currentPopup
.
value
)
{
showNextPopup
()
}
}
function
showNextPopup
()
{
if
(
popupQueue
.
value
.
length
===
0
)
{
currentPopup
.
value
=
null
return
}
currentPopup
.
value
=
popupQueue
.
value
.
shift
()
!
shownPopupIds
.
add
(
currentPopup
.
value
.
id
)
}
async
function
dismissPopup
()
{
if
(
!
currentPopup
.
value
)
return
const
id
=
currentPopup
.
value
.
id
currentPopup
.
value
=
null
// Mark as read (fire-and-forget, UI already updated)
markAsRead
(
id
)
// Show next popup after a short delay
if
(
popupQueue
.
value
.
length
>
0
)
{
setTimeout
(()
=>
showNextPopup
(),
300
)
}
}
async
function
markAsRead
(
id
:
number
)
{
try
{
await
announcementsAPI
.
markRead
(
id
)
const
ann
=
announcements
.
value
.
find
((
a
)
=>
a
.
id
===
id
)
if
(
ann
)
{
ann
.
read_at
=
new
Date
().
toISOString
()
}
}
catch
(
err
:
any
)
{
console
.
error
(
'
Failed to mark announcement as read:
'
,
err
)
}
}
async
function
markAllAsRead
()
{
const
unread
=
announcements
.
value
.
filter
((
a
)
=>
!
a
.
read_at
)
if
(
unread
.
length
===
0
)
return
try
{
loading
.
value
=
true
await
Promise
.
all
(
unread
.
map
((
a
)
=>
announcementsAPI
.
markRead
(
a
.
id
)))
announcements
.
value
.
forEach
((
a
)
=>
{
if
(
!
a
.
read_at
)
{
a
.
read_at
=
new
Date
().
toISOString
()
}
})
}
catch
(
err
:
any
)
{
console
.
error
(
'
Failed to mark all as read:
'
,
err
)
throw
err
}
finally
{
loading
.
value
=
false
}
}
function
reset
()
{
announcements
.
value
=
[]
lastFetchTime
.
value
=
0
shownPopupIds
=
new
Set
()
popupQueue
.
value
=
[]
currentPopup
.
value
=
null
loading
.
value
=
false
}
return
{
// State
announcements
,
loading
,
currentPopup
,
// Getters
unreadCount
,
// Actions
fetchAnnouncements
,
dismissPopup
,
markAsRead
,
markAllAsRead
,
reset
,
}
})
frontend/src/stores/index.ts
View file @
7079edc2
...
...
@@ -8,6 +8,7 @@ export { useAppStore } from './app'
export
{
useAdminSettingsStore
}
from
'
./adminSettings
'
export
{
useSubscriptionStore
}
from
'
./subscriptions
'
export
{
useOnboardingStore
}
from
'
./onboarding
'
export
{
useAnnouncementStore
}
from
'
./announcements
'
// Re-export types for convenience
export
type
{
User
,
LoginRequest
,
RegisterRequest
,
AuthResponse
}
from
'
@/types
'
...
...
frontend/src/types/index.ts
View file @
7079edc2
...
...
@@ -155,6 +155,7 @@ export interface UpdateSubscriptionRequest {
// ==================== Announcement Types ====================
export
type
AnnouncementStatus
=
'
draft
'
|
'
active
'
|
'
archived
'
export
type
AnnouncementNotifyMode
=
'
silent
'
|
'
popup
'
export
type
AnnouncementConditionType
=
'
subscription
'
|
'
balance
'
...
...
@@ -180,6 +181,7 @@ export interface Announcement {
title
:
string
content
:
string
status
:
AnnouncementStatus
notify_mode
:
AnnouncementNotifyMode
targeting
:
AnnouncementTargeting
starts_at
?:
string
ends_at
?:
string
...
...
@@ -193,6 +195,7 @@ export interface UserAnnouncement {
id
:
number
title
:
string
content
:
string
notify_mode
:
AnnouncementNotifyMode
starts_at
?:
string
ends_at
?:
string
read_at
?:
string
...
...
@@ -204,6 +207,7 @@ export interface CreateAnnouncementRequest {
title
:
string
content
:
string
status
?:
AnnouncementStatus
notify_mode
?:
AnnouncementNotifyMode
targeting
:
AnnouncementTargeting
starts_at
?:
number
ends_at
?:
number
...
...
@@ -213,6 +217,7 @@ export interface UpdateAnnouncementRequest {
title
?:
string
content
?:
string
status
?:
AnnouncementStatus
notify_mode
?:
AnnouncementNotifyMode
targeting
?:
AnnouncementTargeting
starts_at
?:
number
ends_at
?:
number
...
...
frontend/src/views/admin/AnnouncementsView.vue
View file @
7079edc2
...
...
@@ -68,6 +68,19 @@
</span>
</
template
>
<
template
#cell-notifyMode=
"{ row }"
>
<span
:class=
"[
'badge',
row.notify_mode === 'popup'
? 'badge-warning'
: 'badge-gray'
]"
>
{{
row
.
notify_mode
===
'
popup
'
?
t
(
'
admin.announcements.notifyModeLabels.popup
'
)
:
t
(
'
admin.announcements.notifyModeLabels.silent
'
)
}}
</span>
</
template
>
<
template
#cell-targeting=
"{ row }"
>
<span
class=
"text-sm text-gray-600 dark:text-gray-300"
>
{{
targetingSummary
(
row
.
targeting
)
}}
...
...
@@ -163,7 +176,11 @@
<label
class=
"input-label"
>
{{ t('admin.announcements.form.status') }}
</label>
<Select
v-model=
"form.status"
:options=
"statusOptions"
/>
</div>
<div></div>
<div>
<label
class=
"input-label"
>
{{ t('admin.announcements.form.notifyMode') }}
</label>
<Select
v-model=
"form.notify_mode"
:options=
"notifyModeOptions"
/>
<p
class=
"input-hint"
>
{{ t('admin.announcements.form.notifyModeHint') }}
</p>
</div>
</div>
<div
class=
"grid grid-cols-1 gap-4 md:grid-cols-2"
>
...
...
@@ -271,9 +288,15 @@ const statusOptions = computed(() => [
{
value
:
'
archived
'
,
label
:
t
(
'
admin.announcements.statusLabels.archived
'
)
}
])
const
notifyModeOptions
=
computed
(()
=>
[
{
value
:
'
silent
'
,
label
:
t
(
'
admin.announcements.notifyModeLabels.silent
'
)
},
{
value
:
'
popup
'
,
label
:
t
(
'
admin.announcements.notifyModeLabels.popup
'
)
}
])
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
title
'
,
label
:
t
(
'
admin.announcements.columns.title
'
)
},
{
key
:
'
status
'
,
label
:
t
(
'
admin.announcements.columns.status
'
)
},
{
key
:
'
notifyMode
'
,
label
:
t
(
'
admin.announcements.columns.notifyMode
'
)
},
{
key
:
'
targeting
'
,
label
:
t
(
'
admin.announcements.columns.targeting
'
)
},
{
key
:
'
timeRange
'
,
label
:
t
(
'
admin.announcements.columns.timeRange
'
)
},
{
key
:
'
createdAt
'
,
label
:
t
(
'
admin.announcements.columns.createdAt
'
)
},
...
...
@@ -357,6 +380,7 @@ const form = reactive({
title
:
''
,
content
:
''
,
status
:
'
draft
'
,
notify_mode
:
'
silent
'
,
starts_at_str
:
''
,
ends_at_str
:
''
,
targeting
:
{
any_of
:
[]
}
as
AnnouncementTargeting
...
...
@@ -378,6 +402,7 @@ function resetForm() {
form
.
title
=
''
form
.
content
=
''
form
.
status
=
'
draft
'
form
.
notify_mode
=
'
silent
'
form
.
starts_at_str
=
''
form
.
ends_at_str
=
''
form
.
targeting
=
{
any_of
:
[]
}
...
...
@@ -387,6 +412,7 @@ function fillFormFromAnnouncement(a: Announcement) {
form
.
title
=
a
.
title
form
.
content
=
a
.
content
form
.
status
=
a
.
status
form
.
notify_mode
=
a
.
notify_mode
||
'
silent
'
// Backend returns RFC3339 strings
form
.
starts_at_str
=
a
.
starts_at
?
formatDateTimeLocalInput
(
Math
.
floor
(
new
Date
(
a
.
starts_at
).
getTime
()
/
1000
))
:
''
...
...
@@ -420,6 +446,7 @@ function buildCreatePayload() {
title
:
form
.
title
,
content
:
form
.
content
,
status
:
form
.
status
as
any
,
notify_mode
:
form
.
notify_mode
as
any
,
targeting
:
form
.
targeting
,
starts_at
:
startsAt
??
undefined
,
ends_at
:
endsAt
??
undefined
...
...
@@ -432,6 +459,7 @@ function buildUpdatePayload(original: Announcement) {
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
if
(
form
.
notify_mode
!==
(
original
.
notify_mode
||
'
silent
'
))
payload
.
notify_mode
=
form
.
notify_mode
// 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
...
...
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment