mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 02:09:43 +00:00
feat: announcement支持强制弹窗通知
This commit is contained in:
@@ -25,6 +25,8 @@ type Announcement struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
// 状态: draft, active, archived
|
||||
Status string `json:"status,omitempty"`
|
||||
// 通知模式: silent(仅铃铛), popup(弹窗提醒)
|
||||
NotifyMode string `json:"notify_mode,omitempty"`
|
||||
// 展示条件(JSON 规则)
|
||||
Targeting domain.AnnouncementTargeting `json:"targeting,omitempty"`
|
||||
// 开始展示时间(为空表示立即生效)
|
||||
@@ -72,7 +74,7 @@ func (*Announcement) scanValues(columns []string) ([]any, error) {
|
||||
values[i] = new([]byte)
|
||||
case announcement.FieldID, announcement.FieldCreatedBy, announcement.FieldUpdatedBy:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case announcement.FieldTitle, announcement.FieldContent, announcement.FieldStatus:
|
||||
case announcement.FieldTitle, announcement.FieldContent, announcement.FieldStatus, announcement.FieldNotifyMode:
|
||||
values[i] = new(sql.NullString)
|
||||
case announcement.FieldStartsAt, announcement.FieldEndsAt, announcement.FieldCreatedAt, announcement.FieldUpdatedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -115,6 +117,12 @@ func (_m *Announcement) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.Status = value.String
|
||||
}
|
||||
case announcement.FieldNotifyMode:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field notify_mode", values[i])
|
||||
} else if value.Valid {
|
||||
_m.NotifyMode = value.String
|
||||
}
|
||||
case announcement.FieldTargeting:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field targeting", values[i])
|
||||
@@ -213,6 +221,9 @@ func (_m *Announcement) String() string {
|
||||
builder.WriteString("status=")
|
||||
builder.WriteString(_m.Status)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("notify_mode=")
|
||||
builder.WriteString(_m.NotifyMode)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("targeting=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.Targeting))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -20,6 +20,8 @@ const (
|
||||
FieldContent = "content"
|
||||
// FieldStatus holds the string denoting the status field in the database.
|
||||
FieldStatus = "status"
|
||||
// FieldNotifyMode holds the string denoting the notify_mode field in the database.
|
||||
FieldNotifyMode = "notify_mode"
|
||||
// FieldTargeting holds the string denoting the targeting field in the database.
|
||||
FieldTargeting = "targeting"
|
||||
// FieldStartsAt holds the string denoting the starts_at field in the database.
|
||||
@@ -53,6 +55,7 @@ var Columns = []string{
|
||||
FieldTitle,
|
||||
FieldContent,
|
||||
FieldStatus,
|
||||
FieldNotifyMode,
|
||||
FieldTargeting,
|
||||
FieldStartsAt,
|
||||
FieldEndsAt,
|
||||
@@ -81,6 +84,10 @@ var (
|
||||
DefaultStatus string
|
||||
// StatusValidator is a validator for the "status" field. It is called by the builders before save.
|
||||
StatusValidator func(string) error
|
||||
// DefaultNotifyMode holds the default value on creation for the "notify_mode" field.
|
||||
DefaultNotifyMode string
|
||||
// NotifyModeValidator is a validator for the "notify_mode" field. It is called by the builders before save.
|
||||
NotifyModeValidator func(string) error
|
||||
// DefaultCreatedAt holds the default value on creation for the "created_at" field.
|
||||
DefaultCreatedAt func() time.Time
|
||||
// DefaultUpdatedAt holds the default value on creation for the "updated_at" field.
|
||||
@@ -112,6 +119,11 @@ func ByStatus(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldStatus, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByNotifyMode orders the results by the notify_mode field.
|
||||
func ByNotifyMode(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldNotifyMode, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByStartsAt orders the results by the starts_at field.
|
||||
func ByStartsAt(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldStartsAt, opts...).ToFunc()
|
||||
|
||||
@@ -70,6 +70,11 @@ func Status(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldEQ(FieldStatus, v))
|
||||
}
|
||||
|
||||
// NotifyMode applies equality check predicate on the "notify_mode" field. It's identical to NotifyModeEQ.
|
||||
func NotifyMode(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldEQ(FieldNotifyMode, v))
|
||||
}
|
||||
|
||||
// StartsAt applies equality check predicate on the "starts_at" field. It's identical to StartsAtEQ.
|
||||
func StartsAt(v time.Time) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldEQ(FieldStartsAt, v))
|
||||
@@ -295,6 +300,71 @@ func StatusContainsFold(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldContainsFold(FieldStatus, v))
|
||||
}
|
||||
|
||||
// NotifyModeEQ applies the EQ predicate on the "notify_mode" field.
|
||||
func NotifyModeEQ(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldEQ(FieldNotifyMode, v))
|
||||
}
|
||||
|
||||
// NotifyModeNEQ applies the NEQ predicate on the "notify_mode" field.
|
||||
func NotifyModeNEQ(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldNEQ(FieldNotifyMode, v))
|
||||
}
|
||||
|
||||
// NotifyModeIn applies the In predicate on the "notify_mode" field.
|
||||
func NotifyModeIn(vs ...string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldIn(FieldNotifyMode, vs...))
|
||||
}
|
||||
|
||||
// NotifyModeNotIn applies the NotIn predicate on the "notify_mode" field.
|
||||
func NotifyModeNotIn(vs ...string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldNotIn(FieldNotifyMode, vs...))
|
||||
}
|
||||
|
||||
// NotifyModeGT applies the GT predicate on the "notify_mode" field.
|
||||
func NotifyModeGT(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldGT(FieldNotifyMode, v))
|
||||
}
|
||||
|
||||
// NotifyModeGTE applies the GTE predicate on the "notify_mode" field.
|
||||
func NotifyModeGTE(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldGTE(FieldNotifyMode, v))
|
||||
}
|
||||
|
||||
// NotifyModeLT applies the LT predicate on the "notify_mode" field.
|
||||
func NotifyModeLT(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldLT(FieldNotifyMode, v))
|
||||
}
|
||||
|
||||
// NotifyModeLTE applies the LTE predicate on the "notify_mode" field.
|
||||
func NotifyModeLTE(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldLTE(FieldNotifyMode, v))
|
||||
}
|
||||
|
||||
// NotifyModeContains applies the Contains predicate on the "notify_mode" field.
|
||||
func NotifyModeContains(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldContains(FieldNotifyMode, v))
|
||||
}
|
||||
|
||||
// NotifyModeHasPrefix applies the HasPrefix predicate on the "notify_mode" field.
|
||||
func NotifyModeHasPrefix(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldHasPrefix(FieldNotifyMode, v))
|
||||
}
|
||||
|
||||
// NotifyModeHasSuffix applies the HasSuffix predicate on the "notify_mode" field.
|
||||
func NotifyModeHasSuffix(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldHasSuffix(FieldNotifyMode, v))
|
||||
}
|
||||
|
||||
// NotifyModeEqualFold applies the EqualFold predicate on the "notify_mode" field.
|
||||
func NotifyModeEqualFold(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldEqualFold(FieldNotifyMode, v))
|
||||
}
|
||||
|
||||
// NotifyModeContainsFold applies the ContainsFold predicate on the "notify_mode" field.
|
||||
func NotifyModeContainsFold(v string) predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldContainsFold(FieldNotifyMode, v))
|
||||
}
|
||||
|
||||
// TargetingIsNil applies the IsNil predicate on the "targeting" field.
|
||||
func TargetingIsNil() predicate.Announcement {
|
||||
return predicate.Announcement(sql.FieldIsNull(FieldTargeting))
|
||||
|
||||
@@ -50,6 +50,20 @@ func (_c *AnnouncementCreate) SetNillableStatus(v *string) *AnnouncementCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNotifyMode sets the "notify_mode" field.
|
||||
func (_c *AnnouncementCreate) SetNotifyMode(v string) *AnnouncementCreate {
|
||||
_c.mutation.SetNotifyMode(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableNotifyMode sets the "notify_mode" field if the given value is not nil.
|
||||
func (_c *AnnouncementCreate) SetNillableNotifyMode(v *string) *AnnouncementCreate {
|
||||
if v != nil {
|
||||
_c.SetNotifyMode(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetTargeting sets the "targeting" field.
|
||||
func (_c *AnnouncementCreate) SetTargeting(v domain.AnnouncementTargeting) *AnnouncementCreate {
|
||||
_c.mutation.SetTargeting(v)
|
||||
@@ -202,6 +216,10 @@ func (_c *AnnouncementCreate) defaults() {
|
||||
v := announcement.DefaultStatus
|
||||
_c.mutation.SetStatus(v)
|
||||
}
|
||||
if _, ok := _c.mutation.NotifyMode(); !ok {
|
||||
v := announcement.DefaultNotifyMode
|
||||
_c.mutation.SetNotifyMode(v)
|
||||
}
|
||||
if _, ok := _c.mutation.CreatedAt(); !ok {
|
||||
v := announcement.DefaultCreatedAt()
|
||||
_c.mutation.SetCreatedAt(v)
|
||||
@@ -238,6 +256,14 @@ func (_c *AnnouncementCreate) check() error {
|
||||
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Announcement.status": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.NotifyMode(); !ok {
|
||||
return &ValidationError{Name: "notify_mode", err: errors.New(`ent: missing required field "Announcement.notify_mode"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.NotifyMode(); ok {
|
||||
if err := announcement.NotifyModeValidator(v); err != nil {
|
||||
return &ValidationError{Name: "notify_mode", err: fmt.Errorf(`ent: validator failed for field "Announcement.notify_mode": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.CreatedAt(); !ok {
|
||||
return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "Announcement.created_at"`)}
|
||||
}
|
||||
@@ -283,6 +309,10 @@ func (_c *AnnouncementCreate) createSpec() (*Announcement, *sqlgraph.CreateSpec)
|
||||
_spec.SetField(announcement.FieldStatus, field.TypeString, value)
|
||||
_node.Status = value
|
||||
}
|
||||
if value, ok := _c.mutation.NotifyMode(); ok {
|
||||
_spec.SetField(announcement.FieldNotifyMode, field.TypeString, value)
|
||||
_node.NotifyMode = value
|
||||
}
|
||||
if value, ok := _c.mutation.Targeting(); ok {
|
||||
_spec.SetField(announcement.FieldTargeting, field.TypeJSON, value)
|
||||
_node.Targeting = value
|
||||
@@ -415,6 +445,18 @@ func (u *AnnouncementUpsert) UpdateStatus() *AnnouncementUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetNotifyMode sets the "notify_mode" field.
|
||||
func (u *AnnouncementUpsert) SetNotifyMode(v string) *AnnouncementUpsert {
|
||||
u.Set(announcement.FieldNotifyMode, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateNotifyMode sets the "notify_mode" field to the value that was provided on create.
|
||||
func (u *AnnouncementUpsert) UpdateNotifyMode() *AnnouncementUpsert {
|
||||
u.SetExcluded(announcement.FieldNotifyMode)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetTargeting sets the "targeting" field.
|
||||
func (u *AnnouncementUpsert) SetTargeting(v domain.AnnouncementTargeting) *AnnouncementUpsert {
|
||||
u.Set(announcement.FieldTargeting, v)
|
||||
@@ -616,6 +658,20 @@ func (u *AnnouncementUpsertOne) UpdateStatus() *AnnouncementUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetNotifyMode sets the "notify_mode" field.
|
||||
func (u *AnnouncementUpsertOne) SetNotifyMode(v string) *AnnouncementUpsertOne {
|
||||
return u.Update(func(s *AnnouncementUpsert) {
|
||||
s.SetNotifyMode(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateNotifyMode sets the "notify_mode" field to the value that was provided on create.
|
||||
func (u *AnnouncementUpsertOne) UpdateNotifyMode() *AnnouncementUpsertOne {
|
||||
return u.Update(func(s *AnnouncementUpsert) {
|
||||
s.UpdateNotifyMode()
|
||||
})
|
||||
}
|
||||
|
||||
// SetTargeting sets the "targeting" field.
|
||||
func (u *AnnouncementUpsertOne) SetTargeting(v domain.AnnouncementTargeting) *AnnouncementUpsertOne {
|
||||
return u.Update(func(s *AnnouncementUpsert) {
|
||||
@@ -1002,6 +1058,20 @@ func (u *AnnouncementUpsertBulk) UpdateStatus() *AnnouncementUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetNotifyMode sets the "notify_mode" field.
|
||||
func (u *AnnouncementUpsertBulk) SetNotifyMode(v string) *AnnouncementUpsertBulk {
|
||||
return u.Update(func(s *AnnouncementUpsert) {
|
||||
s.SetNotifyMode(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateNotifyMode sets the "notify_mode" field to the value that was provided on create.
|
||||
func (u *AnnouncementUpsertBulk) UpdateNotifyMode() *AnnouncementUpsertBulk {
|
||||
return u.Update(func(s *AnnouncementUpsert) {
|
||||
s.UpdateNotifyMode()
|
||||
})
|
||||
}
|
||||
|
||||
// SetTargeting sets the "targeting" field.
|
||||
func (u *AnnouncementUpsertBulk) SetTargeting(v domain.AnnouncementTargeting) *AnnouncementUpsertBulk {
|
||||
return u.Update(func(s *AnnouncementUpsert) {
|
||||
|
||||
@@ -72,6 +72,20 @@ func (_u *AnnouncementUpdate) SetNillableStatus(v *string) *AnnouncementUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNotifyMode sets the "notify_mode" field.
|
||||
func (_u *AnnouncementUpdate) SetNotifyMode(v string) *AnnouncementUpdate {
|
||||
_u.mutation.SetNotifyMode(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableNotifyMode sets the "notify_mode" field if the given value is not nil.
|
||||
func (_u *AnnouncementUpdate) SetNillableNotifyMode(v *string) *AnnouncementUpdate {
|
||||
if v != nil {
|
||||
_u.SetNotifyMode(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetTargeting sets the "targeting" field.
|
||||
func (_u *AnnouncementUpdate) SetTargeting(v domain.AnnouncementTargeting) *AnnouncementUpdate {
|
||||
_u.mutation.SetTargeting(v)
|
||||
@@ -286,6 +300,11 @@ func (_u *AnnouncementUpdate) check() error {
|
||||
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Announcement.status": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.NotifyMode(); ok {
|
||||
if err := announcement.NotifyModeValidator(v); err != nil {
|
||||
return &ValidationError{Name: "notify_mode", err: fmt.Errorf(`ent: validator failed for field "Announcement.notify_mode": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -310,6 +329,9 @@ func (_u *AnnouncementUpdate) sqlSave(ctx context.Context) (_node int, err error
|
||||
if value, ok := _u.mutation.Status(); ok {
|
||||
_spec.SetField(announcement.FieldStatus, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.NotifyMode(); ok {
|
||||
_spec.SetField(announcement.FieldNotifyMode, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.Targeting(); ok {
|
||||
_spec.SetField(announcement.FieldTargeting, field.TypeJSON, value)
|
||||
}
|
||||
@@ -456,6 +478,20 @@ func (_u *AnnouncementUpdateOne) SetNillableStatus(v *string) *AnnouncementUpdat
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNotifyMode sets the "notify_mode" field.
|
||||
func (_u *AnnouncementUpdateOne) SetNotifyMode(v string) *AnnouncementUpdateOne {
|
||||
_u.mutation.SetNotifyMode(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableNotifyMode sets the "notify_mode" field if the given value is not nil.
|
||||
func (_u *AnnouncementUpdateOne) SetNillableNotifyMode(v *string) *AnnouncementUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetNotifyMode(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetTargeting sets the "targeting" field.
|
||||
func (_u *AnnouncementUpdateOne) SetTargeting(v domain.AnnouncementTargeting) *AnnouncementUpdateOne {
|
||||
_u.mutation.SetTargeting(v)
|
||||
@@ -683,6 +719,11 @@ func (_u *AnnouncementUpdateOne) check() error {
|
||||
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Announcement.status": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.NotifyMode(); ok {
|
||||
if err := announcement.NotifyModeValidator(v); err != nil {
|
||||
return &ValidationError{Name: "notify_mode", err: fmt.Errorf(`ent: validator failed for field "Announcement.notify_mode": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -724,6 +765,9 @@ func (_u *AnnouncementUpdateOne) sqlSave(ctx context.Context) (_node *Announceme
|
||||
if value, ok := _u.mutation.Status(); ok {
|
||||
_spec.SetField(announcement.FieldStatus, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.NotifyMode(); ok {
|
||||
_spec.SetField(announcement.FieldNotifyMode, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.Targeting(); ok {
|
||||
_spec.SetField(announcement.FieldTargeting, field.TypeJSON, value)
|
||||
}
|
||||
|
||||
@@ -251,6 +251,7 @@ var (
|
||||
{Name: "title", Type: field.TypeString, Size: 200},
|
||||
{Name: "content", Type: field.TypeString, SchemaType: map[string]string{"postgres": "text"}},
|
||||
{Name: "status", Type: field.TypeString, Size: 20, Default: "draft"},
|
||||
{Name: "notify_mode", Type: field.TypeString, Size: 20, Default: "silent"},
|
||||
{Name: "targeting", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "starts_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "ends_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
@@ -273,17 +274,17 @@ var (
|
||||
{
|
||||
Name: "announcement_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{AnnouncementsColumns[9]},
|
||||
Columns: []*schema.Column{AnnouncementsColumns[10]},
|
||||
},
|
||||
{
|
||||
Name: "announcement_starts_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{AnnouncementsColumns[5]},
|
||||
Columns: []*schema.Column{AnnouncementsColumns[6]},
|
||||
},
|
||||
{
|
||||
Name: "announcement_ends_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{AnnouncementsColumns[6]},
|
||||
Columns: []*schema.Column{AnnouncementsColumns[7]},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5167,6 +5167,7 @@ type AnnouncementMutation struct {
|
||||
title *string
|
||||
content *string
|
||||
status *string
|
||||
notify_mode *string
|
||||
targeting *domain.AnnouncementTargeting
|
||||
starts_at *time.Time
|
||||
ends_at *time.Time
|
||||
@@ -5391,6 +5392,42 @@ func (m *AnnouncementMutation) ResetStatus() {
|
||||
m.status = nil
|
||||
}
|
||||
|
||||
// SetNotifyMode sets the "notify_mode" field.
|
||||
func (m *AnnouncementMutation) SetNotifyMode(s string) {
|
||||
m.notify_mode = &s
|
||||
}
|
||||
|
||||
// NotifyMode returns the value of the "notify_mode" field in the mutation.
|
||||
func (m *AnnouncementMutation) NotifyMode() (r string, exists bool) {
|
||||
v := m.notify_mode
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldNotifyMode returns the old "notify_mode" field's value of the Announcement entity.
|
||||
// If the Announcement object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *AnnouncementMutation) OldNotifyMode(ctx context.Context) (v string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldNotifyMode is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldNotifyMode requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldNotifyMode: %w", err)
|
||||
}
|
||||
return oldValue.NotifyMode, nil
|
||||
}
|
||||
|
||||
// ResetNotifyMode resets all changes to the "notify_mode" field.
|
||||
func (m *AnnouncementMutation) ResetNotifyMode() {
|
||||
m.notify_mode = nil
|
||||
}
|
||||
|
||||
// SetTargeting sets the "targeting" field.
|
||||
func (m *AnnouncementMutation) SetTargeting(dt domain.AnnouncementTargeting) {
|
||||
m.targeting = &dt
|
||||
@@ -5838,7 +5875,7 @@ func (m *AnnouncementMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *AnnouncementMutation) Fields() []string {
|
||||
fields := make([]string, 0, 10)
|
||||
fields := make([]string, 0, 11)
|
||||
if m.title != nil {
|
||||
fields = append(fields, announcement.FieldTitle)
|
||||
}
|
||||
@@ -5848,6 +5885,9 @@ func (m *AnnouncementMutation) Fields() []string {
|
||||
if m.status != nil {
|
||||
fields = append(fields, announcement.FieldStatus)
|
||||
}
|
||||
if m.notify_mode != nil {
|
||||
fields = append(fields, announcement.FieldNotifyMode)
|
||||
}
|
||||
if m.targeting != nil {
|
||||
fields = append(fields, announcement.FieldTargeting)
|
||||
}
|
||||
@@ -5883,6 +5923,8 @@ func (m *AnnouncementMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.Content()
|
||||
case announcement.FieldStatus:
|
||||
return m.Status()
|
||||
case announcement.FieldNotifyMode:
|
||||
return m.NotifyMode()
|
||||
case announcement.FieldTargeting:
|
||||
return m.Targeting()
|
||||
case announcement.FieldStartsAt:
|
||||
@@ -5912,6 +5954,8 @@ func (m *AnnouncementMutation) OldField(ctx context.Context, name string) (ent.V
|
||||
return m.OldContent(ctx)
|
||||
case announcement.FieldStatus:
|
||||
return m.OldStatus(ctx)
|
||||
case announcement.FieldNotifyMode:
|
||||
return m.OldNotifyMode(ctx)
|
||||
case announcement.FieldTargeting:
|
||||
return m.OldTargeting(ctx)
|
||||
case announcement.FieldStartsAt:
|
||||
@@ -5956,6 +6000,13 @@ func (m *AnnouncementMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetStatus(v)
|
||||
return nil
|
||||
case announcement.FieldNotifyMode:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetNotifyMode(v)
|
||||
return nil
|
||||
case announcement.FieldTargeting:
|
||||
v, ok := value.(domain.AnnouncementTargeting)
|
||||
if !ok {
|
||||
@@ -6123,6 +6174,9 @@ func (m *AnnouncementMutation) ResetField(name string) error {
|
||||
case announcement.FieldStatus:
|
||||
m.ResetStatus()
|
||||
return nil
|
||||
case announcement.FieldNotifyMode:
|
||||
m.ResetNotifyMode()
|
||||
return nil
|
||||
case announcement.FieldTargeting:
|
||||
m.ResetTargeting()
|
||||
return nil
|
||||
@@ -10298,7 +10352,7 @@ func (m *GroupMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *GroupMutation) Fields() []string {
|
||||
fields := make([]string, 0, 31)
|
||||
fields := make([]string, 0, 30)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
|
||||
@@ -277,12 +277,18 @@ func init() {
|
||||
announcement.DefaultStatus = announcementDescStatus.Default.(string)
|
||||
// announcement.StatusValidator is a validator for the "status" field. It is called by the builders before save.
|
||||
announcement.StatusValidator = announcementDescStatus.Validators[0].(func(string) error)
|
||||
// announcementDescNotifyMode is the schema descriptor for notify_mode field.
|
||||
announcementDescNotifyMode := announcementFields[3].Descriptor()
|
||||
// announcement.DefaultNotifyMode holds the default value on creation for the notify_mode field.
|
||||
announcement.DefaultNotifyMode = announcementDescNotifyMode.Default.(string)
|
||||
// announcement.NotifyModeValidator is a validator for the "notify_mode" field. It is called by the builders before save.
|
||||
announcement.NotifyModeValidator = announcementDescNotifyMode.Validators[0].(func(string) error)
|
||||
// announcementDescCreatedAt is the schema descriptor for created_at field.
|
||||
announcementDescCreatedAt := announcementFields[8].Descriptor()
|
||||
announcementDescCreatedAt := announcementFields[9].Descriptor()
|
||||
// announcement.DefaultCreatedAt holds the default value on creation for the created_at field.
|
||||
announcement.DefaultCreatedAt = announcementDescCreatedAt.Default.(func() time.Time)
|
||||
// announcementDescUpdatedAt is the schema descriptor for updated_at field.
|
||||
announcementDescUpdatedAt := announcementFields[9].Descriptor()
|
||||
announcementDescUpdatedAt := announcementFields[10].Descriptor()
|
||||
// announcement.DefaultUpdatedAt holds the default value on creation for the updated_at field.
|
||||
announcement.DefaultUpdatedAt = announcementDescUpdatedAt.Default.(func() time.Time)
|
||||
// announcement.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
|
||||
|
||||
@@ -41,6 +41,10 @@ func (Announcement) Fields() []ent.Field {
|
||||
MaxLen(20).
|
||||
Default(domain.AnnouncementStatusDraft).
|
||||
Comment("状态: draft, active, archived"),
|
||||
field.String("notify_mode").
|
||||
MaxLen(20).
|
||||
Default(domain.AnnouncementNotifyModeSilent).
|
||||
Comment("通知模式: silent(仅铃铛), popup(弹窗提醒)"),
|
||||
field.JSON("targeting", domain.AnnouncementTargeting{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
|
||||
@@ -94,6 +94,10 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
@@ -230,6 +234,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
@@ -263,6 +269,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -314,6 +322,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
|
||||
@@ -13,6 +13,11 @@ const (
|
||||
AnnouncementStatusArchived = "archived"
|
||||
)
|
||||
|
||||
const (
|
||||
AnnouncementNotifyModeSilent = "silent"
|
||||
AnnouncementNotifyModePopup = "popup"
|
||||
)
|
||||
|
||||
const (
|
||||
AnnouncementConditionTypeSubscription = "subscription"
|
||||
AnnouncementConditionTypeBalance = "balance"
|
||||
@@ -195,17 +200,18 @@ func (c AnnouncementCondition) validate() error {
|
||||
}
|
||||
|
||||
type Announcement struct {
|
||||
ID int64
|
||||
Title string
|
||||
Content string
|
||||
Status string
|
||||
Targeting AnnouncementTargeting
|
||||
StartsAt *time.Time
|
||||
EndsAt *time.Time
|
||||
CreatedBy *int64
|
||||
UpdatedBy *int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID int64
|
||||
Title string
|
||||
Content string
|
||||
Status string
|
||||
NotifyMode string
|
||||
Targeting AnnouncementTargeting
|
||||
StartsAt *time.Time
|
||||
EndsAt *time.Time
|
||||
CreatedBy *int64
|
||||
UpdatedBy *int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (a *Announcement) IsActiveAt(now time.Time) bool {
|
||||
|
||||
@@ -27,21 +27,23 @@ func NewAnnouncementHandler(announcementService *service.AnnouncementService) *A
|
||||
}
|
||||
|
||||
type CreateAnnouncementRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=draft active archived"`
|
||||
Targeting service.AnnouncementTargeting `json:"targeting"`
|
||||
StartsAt *int64 `json:"starts_at"` // Unix seconds, 0/empty = immediate
|
||||
EndsAt *int64 `json:"ends_at"` // Unix seconds, 0/empty = never
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=draft active archived"`
|
||||
NotifyMode string `json:"notify_mode" binding:"omitempty,oneof=silent popup"`
|
||||
Targeting service.AnnouncementTargeting `json:"targeting"`
|
||||
StartsAt *int64 `json:"starts_at"` // Unix seconds, 0/empty = immediate
|
||||
EndsAt *int64 `json:"ends_at"` // Unix seconds, 0/empty = never
|
||||
}
|
||||
|
||||
type UpdateAnnouncementRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Content *string `json:"content"`
|
||||
Status *string `json:"status" binding:"omitempty,oneof=draft active archived"`
|
||||
Targeting *service.AnnouncementTargeting `json:"targeting"`
|
||||
StartsAt *int64 `json:"starts_at"` // Unix seconds, 0 = clear
|
||||
EndsAt *int64 `json:"ends_at"` // Unix seconds, 0 = clear
|
||||
Title *string `json:"title"`
|
||||
Content *string `json:"content"`
|
||||
Status *string `json:"status" binding:"omitempty,oneof=draft active archived"`
|
||||
NotifyMode *string `json:"notify_mode" binding:"omitempty,oneof=silent popup"`
|
||||
Targeting *service.AnnouncementTargeting `json:"targeting"`
|
||||
StartsAt *int64 `json:"starts_at"` // Unix seconds, 0 = clear
|
||||
EndsAt *int64 `json:"ends_at"` // Unix seconds, 0 = clear
|
||||
}
|
||||
|
||||
// List handles listing announcements with filters
|
||||
@@ -110,11 +112,12 @@ func (h *AnnouncementHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
input := &service.CreateAnnouncementInput{
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Status: req.Status,
|
||||
Targeting: req.Targeting,
|
||||
ActorID: &subject.UserID,
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Status: req.Status,
|
||||
NotifyMode: req.NotifyMode,
|
||||
Targeting: req.Targeting,
|
||||
ActorID: &subject.UserID,
|
||||
}
|
||||
|
||||
if req.StartsAt != nil && *req.StartsAt > 0 {
|
||||
@@ -157,11 +160,12 @@ func (h *AnnouncementHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
input := &service.UpdateAnnouncementInput{
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Status: req.Status,
|
||||
Targeting: req.Targeting,
|
||||
ActorID: &subject.UserID,
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Status: req.Status,
|
||||
NotifyMode: req.NotifyMode,
|
||||
Targeting: req.Targeting,
|
||||
ActorID: &subject.UserID,
|
||||
}
|
||||
|
||||
if req.StartsAt != nil {
|
||||
|
||||
@@ -7,10 +7,11 @@ import (
|
||||
)
|
||||
|
||||
type Announcement struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
NotifyMode string `json:"notify_mode"`
|
||||
|
||||
Targeting service.AnnouncementTargeting `json:"targeting"`
|
||||
|
||||
@@ -25,9 +26,10 @@ type Announcement struct {
|
||||
}
|
||||
|
||||
type UserAnnouncement struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
NotifyMode string `json:"notify_mode"`
|
||||
|
||||
StartsAt *time.Time `json:"starts_at,omitempty"`
|
||||
EndsAt *time.Time `json:"ends_at,omitempty"`
|
||||
@@ -43,17 +45,18 @@ func AnnouncementFromService(a *service.Announcement) *Announcement {
|
||||
return nil
|
||||
}
|
||||
return &Announcement{
|
||||
ID: a.ID,
|
||||
Title: a.Title,
|
||||
Content: a.Content,
|
||||
Status: a.Status,
|
||||
Targeting: a.Targeting,
|
||||
StartsAt: a.StartsAt,
|
||||
EndsAt: a.EndsAt,
|
||||
CreatedBy: a.CreatedBy,
|
||||
UpdatedBy: a.UpdatedBy,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
ID: a.ID,
|
||||
Title: a.Title,
|
||||
Content: a.Content,
|
||||
Status: a.Status,
|
||||
NotifyMode: a.NotifyMode,
|
||||
Targeting: a.Targeting,
|
||||
StartsAt: a.StartsAt,
|
||||
EndsAt: a.EndsAt,
|
||||
CreatedBy: a.CreatedBy,
|
||||
UpdatedBy: a.UpdatedBy,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,13 +65,14 @@ func UserAnnouncementFromService(a *service.UserAnnouncement) *UserAnnouncement
|
||||
return nil
|
||||
}
|
||||
return &UserAnnouncement{
|
||||
ID: a.Announcement.ID,
|
||||
Title: a.Announcement.Title,
|
||||
Content: a.Announcement.Content,
|
||||
StartsAt: a.Announcement.StartsAt,
|
||||
EndsAt: a.Announcement.EndsAt,
|
||||
ReadAt: a.ReadAt,
|
||||
CreatedAt: a.Announcement.CreatedAt,
|
||||
UpdatedAt: a.Announcement.UpdatedAt,
|
||||
ID: a.Announcement.ID,
|
||||
Title: a.Announcement.Title,
|
||||
Content: a.Announcement.Content,
|
||||
NotifyMode: a.Announcement.NotifyMode,
|
||||
StartsAt: a.Announcement.StartsAt,
|
||||
EndsAt: a.Announcement.EndsAt,
|
||||
ReadAt: a.ReadAt,
|
||||
CreatedAt: a.Announcement.CreatedAt,
|
||||
UpdatedAt: a.Announcement.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func (r *announcementRepository) Create(ctx context.Context, a *service.Announce
|
||||
SetTitle(a.Title).
|
||||
SetContent(a.Content).
|
||||
SetStatus(a.Status).
|
||||
SetNotifyMode(a.NotifyMode).
|
||||
SetTargeting(a.Targeting)
|
||||
|
||||
if a.StartsAt != nil {
|
||||
@@ -64,6 +65,7 @@ func (r *announcementRepository) Update(ctx context.Context, a *service.Announce
|
||||
SetTitle(a.Title).
|
||||
SetContent(a.Content).
|
||||
SetStatus(a.Status).
|
||||
SetNotifyMode(a.NotifyMode).
|
||||
SetTargeting(a.Targeting)
|
||||
|
||||
if a.StartsAt != nil {
|
||||
@@ -169,17 +171,18 @@ func announcementEntityToService(m *dbent.Announcement) *service.Announcement {
|
||||
return nil
|
||||
}
|
||||
return &service.Announcement{
|
||||
ID: m.ID,
|
||||
Title: m.Title,
|
||||
Content: m.Content,
|
||||
Status: m.Status,
|
||||
Targeting: m.Targeting,
|
||||
StartsAt: m.StartsAt,
|
||||
EndsAt: m.EndsAt,
|
||||
CreatedBy: m.CreatedBy,
|
||||
UpdatedBy: m.UpdatedBy,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
ID: m.ID,
|
||||
Title: m.Title,
|
||||
Content: m.Content,
|
||||
Status: m.Status,
|
||||
NotifyMode: m.NotifyMode,
|
||||
Targeting: m.Targeting,
|
||||
StartsAt: m.StartsAt,
|
||||
EndsAt: m.EndsAt,
|
||||
CreatedBy: m.CreatedBy,
|
||||
UpdatedBy: m.UpdatedBy,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ const (
|
||||
AnnouncementStatusArchived = domain.AnnouncementStatusArchived
|
||||
)
|
||||
|
||||
const (
|
||||
AnnouncementNotifyModeSilent = domain.AnnouncementNotifyModeSilent
|
||||
AnnouncementNotifyModePopup = domain.AnnouncementNotifyModePopup
|
||||
)
|
||||
|
||||
const (
|
||||
AnnouncementConditionTypeSubscription = domain.AnnouncementConditionTypeSubscription
|
||||
AnnouncementConditionTypeBalance = domain.AnnouncementConditionTypeBalance
|
||||
|
||||
@@ -33,23 +33,25 @@ func NewAnnouncementService(
|
||||
}
|
||||
|
||||
type CreateAnnouncementInput struct {
|
||||
Title string
|
||||
Content string
|
||||
Status string
|
||||
Targeting AnnouncementTargeting
|
||||
StartsAt *time.Time
|
||||
EndsAt *time.Time
|
||||
ActorID *int64 // 管理员用户ID
|
||||
Title string
|
||||
Content string
|
||||
Status string
|
||||
NotifyMode string
|
||||
Targeting AnnouncementTargeting
|
||||
StartsAt *time.Time
|
||||
EndsAt *time.Time
|
||||
ActorID *int64 // 管理员用户ID
|
||||
}
|
||||
|
||||
type UpdateAnnouncementInput struct {
|
||||
Title *string
|
||||
Content *string
|
||||
Status *string
|
||||
Targeting *AnnouncementTargeting
|
||||
StartsAt **time.Time
|
||||
EndsAt **time.Time
|
||||
ActorID *int64 // 管理员用户ID
|
||||
Title *string
|
||||
Content *string
|
||||
Status *string
|
||||
NotifyMode *string
|
||||
Targeting *AnnouncementTargeting
|
||||
StartsAt **time.Time
|
||||
EndsAt **time.Time
|
||||
ActorID *int64 // 管理员用户ID
|
||||
}
|
||||
|
||||
type UserAnnouncement struct {
|
||||
@@ -93,6 +95,14 @@ func (s *AnnouncementService) Create(ctx context.Context, input *CreateAnnouncem
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notifyMode := strings.TrimSpace(input.NotifyMode)
|
||||
if notifyMode == "" {
|
||||
notifyMode = AnnouncementNotifyModeSilent
|
||||
}
|
||||
if !isValidAnnouncementNotifyMode(notifyMode) {
|
||||
return nil, fmt.Errorf("create announcement: invalid notify_mode")
|
||||
}
|
||||
|
||||
if input.StartsAt != nil && input.EndsAt != nil {
|
||||
if !input.StartsAt.Before(*input.EndsAt) {
|
||||
return nil, fmt.Errorf("create announcement: starts_at must be before ends_at")
|
||||
@@ -100,12 +110,13 @@ func (s *AnnouncementService) Create(ctx context.Context, input *CreateAnnouncem
|
||||
}
|
||||
|
||||
a := &Announcement{
|
||||
Title: title,
|
||||
Content: content,
|
||||
Status: status,
|
||||
Targeting: targeting,
|
||||
StartsAt: input.StartsAt,
|
||||
EndsAt: input.EndsAt,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Status: status,
|
||||
NotifyMode: notifyMode,
|
||||
Targeting: targeting,
|
||||
StartsAt: input.StartsAt,
|
||||
EndsAt: input.EndsAt,
|
||||
}
|
||||
if input.ActorID != nil && *input.ActorID > 0 {
|
||||
a.CreatedBy = input.ActorID
|
||||
@@ -150,6 +161,14 @@ func (s *AnnouncementService) Update(ctx context.Context, id int64, input *Updat
|
||||
a.Status = status
|
||||
}
|
||||
|
||||
if input.NotifyMode != nil {
|
||||
notifyMode := strings.TrimSpace(*input.NotifyMode)
|
||||
if !isValidAnnouncementNotifyMode(notifyMode) {
|
||||
return nil, fmt.Errorf("update announcement: invalid notify_mode")
|
||||
}
|
||||
a.NotifyMode = notifyMode
|
||||
}
|
||||
|
||||
if input.Targeting != nil {
|
||||
targeting, err := domain.AnnouncementTargeting(*input.Targeting).NormalizeAndValidate()
|
||||
if err != nil {
|
||||
@@ -376,3 +395,12 @@ func isValidAnnouncementStatus(status string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isValidAnnouncementNotifyMode(mode string) bool {
|
||||
switch mode {
|
||||
case AnnouncementNotifyModeSilent, AnnouncementNotifyModePopup:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import Toast from '@/components/common/Toast.vue'
|
||||
import NavigationProgress from '@/components/common/NavigationProgress.vue'
|
||||
import { useAppStore, useAuthStore, useSubscriptionStore } from '@/stores'
|
||||
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
|
||||
import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore } from '@/stores'
|
||||
import { getSetupStatus } from '@/api/setup'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -11,6 +12,7 @@ const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const subscriptionStore = useSubscriptionStore()
|
||||
const announcementStore = useAnnouncementStore()
|
||||
|
||||
/**
|
||||
* Update favicon dynamically
|
||||
@@ -39,24 +41,55 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch for authentication state and manage subscription data
|
||||
// Watch for authentication state and manage subscription data + announcements
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && authStore.isAuthenticated) {
|
||||
announcementStore.fetchAnnouncements()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
(isAuthenticated) => {
|
||||
(isAuthenticated, oldValue) => {
|
||||
if (isAuthenticated) {
|
||||
// User logged in: preload subscriptions and start polling
|
||||
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
|
||||
console.error('Failed to preload subscriptions:', error)
|
||||
})
|
||||
subscriptionStore.startPolling()
|
||||
|
||||
// Announcements: new login vs page refresh restore
|
||||
if (oldValue === false) {
|
||||
// New login: delay 3s then force fetch
|
||||
setTimeout(() => announcementStore.fetchAnnouncements(true), 3000)
|
||||
} else {
|
||||
// Page refresh restore (oldValue was undefined)
|
||||
announcementStore.fetchAnnouncements()
|
||||
}
|
||||
|
||||
// Register visibility change listener
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
} else {
|
||||
// User logged out: clear data and stop polling
|
||||
subscriptionStore.clear()
|
||||
announcementStore.reset()
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Route change trigger (throttled by store)
|
||||
router.afterEach(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
announcementStore.fetchAnnouncements()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Check if setup is needed
|
||||
try {
|
||||
@@ -78,4 +111,5 @@ onMounted(async () => {
|
||||
<NavigationProgress />
|
||||
<RouterView />
|
||||
<Toast />
|
||||
<AnnouncementPopup />
|
||||
</template>
|
||||
|
||||
@@ -314,16 +314,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { announcementsAPI } from '@/api'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAnnouncementStore } from '@/stores/announcements'
|
||||
import { formatRelativeTime, formatRelativeWithDateTime } from '@/utils/format'
|
||||
import type { UserAnnouncement } from '@/types'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const announcementStore = useAnnouncementStore()
|
||||
|
||||
// Configure marked
|
||||
marked.setOptions({
|
||||
@@ -331,17 +333,14 @@ marked.setOptions({
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
// State
|
||||
const announcements = ref<UserAnnouncement[]>([])
|
||||
// Use store state (storeToRefs for reactivity)
|
||||
const { announcements, loading } = storeToRefs(announcementStore)
|
||||
const unreadCount = computed(() => announcementStore.unreadCount)
|
||||
|
||||
// Local modal state
|
||||
const isModalOpen = ref(false)
|
||||
const detailModalOpen = ref(false)
|
||||
const selectedAnnouncement = ref<UserAnnouncement | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Computed
|
||||
const unreadCount = computed(() =>
|
||||
announcements.value.filter((a) => !a.read_at).length
|
||||
)
|
||||
|
||||
// Methods
|
||||
function renderMarkdown(content: string): string {
|
||||
@@ -350,24 +349,8 @@ function renderMarkdown(content: string): string {
|
||||
return DOMPurify.sanitize(html)
|
||||
}
|
||||
|
||||
async function loadAnnouncements() {
|
||||
try {
|
||||
loading.value = true
|
||||
const allAnnouncements = await announcementsAPI.list(false)
|
||||
announcements.value = allAnnouncements.slice(0, 20)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load announcements:', err)
|
||||
appStore.showError(err?.message || t('common.unknownError'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
isModalOpen.value = true
|
||||
if (announcements.value.length === 0) {
|
||||
loadAnnouncements()
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@@ -389,14 +372,7 @@ function closeDetail() {
|
||||
|
||||
async function markAsRead(id: number) {
|
||||
try {
|
||||
await announcementsAPI.markRead(id)
|
||||
const announcement = announcements.value.find((a) => a.id === id)
|
||||
if (announcement) {
|
||||
announcement.read_at = new Date().toISOString()
|
||||
}
|
||||
if (selectedAnnouncement.value?.id === id) {
|
||||
selectedAnnouncement.value.read_at = new Date().toISOString()
|
||||
}
|
||||
await announcementStore.markAsRead(id)
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('common.unknownError'))
|
||||
}
|
||||
@@ -410,19 +386,10 @@ async function markAsReadAndClose(id: number) {
|
||||
|
||||
async function markAllAsRead() {
|
||||
try {
|
||||
loading.value = true
|
||||
const unreadAnnouncements = announcements.value.filter((a) => !a.read_at)
|
||||
await Promise.all(unreadAnnouncements.map((a) => announcementsAPI.markRead(a.id)))
|
||||
announcements.value.forEach((a) => {
|
||||
if (!a.read_at) {
|
||||
a.read_at = new Date().toISOString()
|
||||
}
|
||||
})
|
||||
await announcementStore.markAllAsRead()
|
||||
appStore.showSuccess(t('announcements.allMarkedAsRead'))
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('common.unknownError'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,22 +405,19 @@ function handleEscape(e: KeyboardEvent) {
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
loadAnnouncements()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
// Restore body overflow in case component is unmounted while modals are open
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
watch([isModalOpen, detailModalOpen], ([modal, detail]) => {
|
||||
if (modal || detail) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
watch(
|
||||
[isModalOpen, detailModalOpen, () => announcementStore.currentPopup],
|
||||
([modal, detail, popup]) => {
|
||||
document.body.style.overflow = (modal || detail || popup) ? 'hidden' : ''
|
||||
}
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
165
frontend/src/components/common/AnnouncementPopup.vue
Normal file
165
frontend/src/components/common/AnnouncementPopup.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="popup-fade">
|
||||
<div
|
||||
v-if="announcementStore.currentPopup"
|
||||
class="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[8vh] backdrop-blur-md"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-[680px] overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header with warm gradient -->
|
||||
<div class="relative overflow-hidden border-b border-amber-100/80 bg-gradient-to-br from-amber-50/80 via-orange-50/50 to-yellow-50/30 px-8 py-6 dark:border-dark-700/50 dark:from-amber-900/20 dark:via-orange-900/10 dark:to-yellow-900/5">
|
||||
<!-- Decorative background -->
|
||||
<div class="absolute right-0 top-0 h-full w-64 bg-gradient-to-l from-orange-100/30 to-transparent dark:from-orange-900/20"></div>
|
||||
<div class="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-gradient-to-br from-amber-400/20 to-orange-500/20 blur-3xl"></div>
|
||||
<div class="absolute -left-4 -bottom-4 h-24 w-24 rounded-full bg-gradient-to-tr from-yellow-400/20 to-amber-500/20 blur-2xl"></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<!-- Icon and badge -->
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-600 text-white shadow-lg shadow-amber-500/30">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-amber-500 to-orange-600 px-2.5 py-1 text-xs font-medium text-white shadow-lg shadow-amber-500/30">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75"></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-white"></span>
|
||||
</span>
|
||||
{{ t('announcements.unread') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="mb-2 text-2xl font-bold leading-tight text-gray-900 dark:text-white">
|
||||
{{ announcementStore.currentPopup.title }}
|
||||
</h2>
|
||||
|
||||
<!-- Time -->
|
||||
<div class="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<time>{{ formatRelativeWithDateTime(announcementStore.currentPopup.created_at) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="max-h-[50vh] overflow-y-auto bg-white px-8 py-8 dark:bg-dark-800">
|
||||
<div class="relative">
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 rounded-full bg-gradient-to-b from-amber-500 via-orange-500 to-yellow-500"></div>
|
||||
<div class="pl-6">
|
||||
<div
|
||||
class="markdown-body prose prose-sm max-w-none dark:prose-invert"
|
||||
v-html="renderedContent"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-gray-100 bg-gray-50/50 px-8 py-5 dark:border-dark-700 dark:bg-dark-900/30">
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
@click="handleDismiss"
|
||||
class="rounded-xl bg-gradient-to-r from-amber-500 to-orange-600 px-6 py-2.5 text-sm font-medium text-white shadow-lg shadow-amber-500/30 transition-all hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ t('announcements.markRead') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useAnnouncementStore } from '@/stores/announcements'
|
||||
import { formatRelativeWithDateTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
const announcementStore = useAnnouncementStore()
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
const content = announcementStore.currentPopup?.content
|
||||
if (!content) return ''
|
||||
const html = marked.parse(content) as string
|
||||
return DOMPurify.sanitize(html)
|
||||
})
|
||||
|
||||
function handleDismiss() {
|
||||
announcementStore.dismissPopup()
|
||||
}
|
||||
|
||||
// Manage body overflow — only set, never unset (bell component handles restore)
|
||||
watch(
|
||||
() => announcementStore.currentPopup,
|
||||
(popup) => {
|
||||
if (popup) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.popup-fade-enter-active {
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.popup-fade-leave-active {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
.popup-fade-enter-from,
|
||||
.popup-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.popup-fade-enter-from > div {
|
||||
transform: scale(0.94) translateY(-12px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.popup-fade-leave-to > div {
|
||||
transform: scale(0.96) translateY(-8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(to bottom, #cbd5e1, #94a3b8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(to bottom, #4b5563, #374151);
|
||||
}
|
||||
</style>
|
||||
@@ -2704,6 +2704,7 @@ export default {
|
||||
columns: {
|
||||
title: 'Title',
|
||||
status: 'Status',
|
||||
notifyMode: 'Notify Mode',
|
||||
targeting: 'Targeting',
|
||||
timeRange: 'Schedule',
|
||||
createdAt: 'Created At',
|
||||
@@ -2714,10 +2715,16 @@ export default {
|
||||
active: 'Active',
|
||||
archived: 'Archived'
|
||||
},
|
||||
notifyModeLabels: {
|
||||
silent: 'Silent',
|
||||
popup: 'Popup'
|
||||
},
|
||||
form: {
|
||||
title: 'Title',
|
||||
content: 'Content (Markdown supported)',
|
||||
status: 'Status',
|
||||
notifyMode: 'Notify Mode',
|
||||
notifyModeHint: 'Popup mode will show a popup notification to users',
|
||||
startsAt: 'Starts At',
|
||||
endsAt: 'Ends At',
|
||||
startsAtHint: 'Leave empty to start immediately',
|
||||
|
||||
@@ -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: '留空表示立即生效',
|
||||
|
||||
143
frontend/src/stores/announcements.ts
Normal file
143
frontend/src/stores/announcements.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user