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
d3062b2e
Unverified
Commit
d3062b2e
authored
Feb 02, 2026
by
Wesley Liddick
Committed by
GitHub
Feb 02, 2026
Browse files
Merge pull request #434 from DuckyProject/feat/announcement-system-pr-upstream
feat(announcements): admin/user announcement system
parents
b7777fb4
9bee0a20
Changes
70
Hide whitespace changes
Inline
Side-by-side
frontend/src/api/index.ts
View file @
d3062b2e
...
...
@@ -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
'
...
...
frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue
0 → 100644
View file @
d3062b2e
<
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
>
frontend/src/components/admin/announcements/AnnouncementTargetingEditor.vue
0 → 100644
View file @
d3062b2e
<
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
>
frontend/src/components/layout/AppSidebar.vue
View file @
d3062b2e
...
...
@@ -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
},
...
...
frontend/src/i18n/locales/en.ts
View file @
d3062b2e
...
...
@@ -187,6 +187,7 @@ export default {
// Navigation
nav
:
{
dashboard
:
'
Dashboard
'
,
announcements
:
'
Announcements
'
,
apiKeys
:
'
API Keys
'
,
usage
:
'
Usage
'
,
redeem
:
'
Redeem
'
,
...
...
@@ -1953,6 +1954,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
'
,
...
...
@@ -3065,6 +3133,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
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
d3062b2e
...
...
@@ -184,6 +184,7 @@ export default {
// Navigation
nav
:
{
dashboard
:
'
仪表盘
'
,
announcements
:
'
公告
'
,
apiKeys
:
'
API 密钥
'
,
usage
:
'
使用记录
'
,
redeem
:
'
兑换
'
,
...
...
@@ -2100,6 +2101,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
:
'
优惠码管理
'
,
...
...
@@ -3214,6 +3282,21 @@ export default {
notConfiguredDesc
:
'
管理员已开启入口,但尚未配置购买订阅链接,请联系管理员。
'
},
// Announcements Page
announcements
:
{
title
:
'
公告
'
,
description
:
'
查看系统公告
'
,
unreadOnly
:
'
仅显示未读
'
,
markRead
:
'
标记已读
'
,
readAt
:
'
已读时间
'
,
read
:
'
已读
'
,
unread
:
'
未读
'
,
startsAt
:
'
开始时间
'
,
endsAt
:
'
结束时间
'
,
empty
:
'
暂无公告
'
,
emptyUnread
:
'
暂无未读公告
'
},
// User Subscriptions Page
userSubscriptions
:
{
title
:
'
我的订阅
'
,
...
...
frontend/src/router/index.ts
View file @
d3062b2e
...
...
@@ -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
'
,
...
...
frontend/src/types/index.ts
View file @
d3062b2e
...
...
@@ -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
{
...
...
frontend/src/views/admin/AnnouncementsView.vue
0 → 100644
View file @
d3062b2e
<
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
>
frontend/src/views/user/AnnouncementsView.vue
0 → 100644
View file @
d3062b2e
<
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
>
Prev
1
2
3
4
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