diff --git a/backend/ent/announcement.go b/backend/ent/announcement.go index 93d7a375..6c5b21da 100644 --- a/backend/ent/announcement.go +++ b/backend/ent/announcement.go @@ -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(", ") diff --git a/backend/ent/announcement/announcement.go b/backend/ent/announcement/announcement.go index 4f34ee05..71ba25ff 100644 --- a/backend/ent/announcement/announcement.go +++ b/backend/ent/announcement/announcement.go @@ -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() diff --git a/backend/ent/announcement/where.go b/backend/ent/announcement/where.go index d3cad2a5..2eea5f0b 100644 --- a/backend/ent/announcement/where.go +++ b/backend/ent/announcement/where.go @@ -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)) diff --git a/backend/ent/announcement_create.go b/backend/ent/announcement_create.go index 151d4c11..d9029792 100644 --- a/backend/ent/announcement_create.go +++ b/backend/ent/announcement_create.go @@ -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) { diff --git a/backend/ent/announcement_update.go b/backend/ent/announcement_update.go index 702d0817..f93f4f0e 100644 --- a/backend/ent/announcement_update.go +++ b/backend/ent/announcement_update.go @@ -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) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 8e54f31c..65d44d5a 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -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]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 6c6194a6..425c0199 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -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) } diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 7ae4d253..8d231e7c 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -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. diff --git a/backend/ent/schema/announcement.go b/backend/ent/schema/announcement.go index 1568778f..14159fc3 100644 --- a/backend/ent/schema/announcement.go +++ b/backend/ent/schema/announcement.go @@ -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"}). diff --git a/backend/go.sum b/backend/go.sum index 66169717..993a1d54 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/domain/announcement.go b/backend/internal/domain/announcement.go index 7dc9a9cc..0e68fb0f 100644 --- a/backend/internal/domain/announcement.go +++ b/backend/internal/domain/announcement.go @@ -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 { diff --git a/backend/internal/handler/admin/announcement_handler.go b/backend/internal/handler/admin/announcement_handler.go index 0b5d0fbc..d1312bc0 100644 --- a/backend/internal/handler/admin/announcement_handler.go +++ b/backend/internal/handler/admin/announcement_handler.go @@ -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 { diff --git a/backend/internal/handler/dto/announcement.go b/backend/internal/handler/dto/announcement.go index bc0db1b2..16650b8e 100644 --- a/backend/internal/handler/dto/announcement.go +++ b/backend/internal/handler/dto/announcement.go @@ -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, } } diff --git a/backend/internal/repository/announcement_repo.go b/backend/internal/repository/announcement_repo.go index 52029e4e..53dc335f 100644 --- a/backend/internal/repository/announcement_repo.go +++ b/backend/internal/repository/announcement_repo.go @@ -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, } } diff --git a/backend/internal/service/announcement.go b/backend/internal/service/announcement.go index 2ba5af5d..25c66eb4 100644 --- a/backend/internal/service/announcement.go +++ b/backend/internal/service/announcement.go @@ -14,6 +14,11 @@ const ( AnnouncementStatusArchived = domain.AnnouncementStatusArchived ) +const ( + AnnouncementNotifyModeSilent = domain.AnnouncementNotifyModeSilent + AnnouncementNotifyModePopup = domain.AnnouncementNotifyModePopup +) + const ( AnnouncementConditionTypeSubscription = domain.AnnouncementConditionTypeSubscription AnnouncementConditionTypeBalance = domain.AnnouncementConditionTypeBalance diff --git a/backend/internal/service/announcement_service.go b/backend/internal/service/announcement_service.go index c2588e6c..c0a0681a 100644 --- a/backend/internal/service/announcement_service.go +++ b/backend/internal/service/announcement_service.go @@ -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 + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b831c9ff..4fc6a7c8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,9 +1,10 @@ diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 87d8d816..71b2be82 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index e21819f6..331a43e8 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -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: '留空表示立即生效', diff --git a/frontend/src/stores/announcements.ts b/frontend/src/stores/announcements.ts new file mode 100644 index 00000000..6f636d93 --- /dev/null +++ b/frontend/src/stores/announcements.ts @@ -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([]) + const loading = ref(false) + const lastFetchTime = ref(0) + const popupQueue = ref([]) + const currentPopup = ref(null) + + // Session-scoped dedup set — not reactive, used as plain lookup only + let shownPopupIds = new Set() + + // 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, + } +}) diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index 05c18e7e..5f51807c 100644 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -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' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d6b91463..18075643 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 diff --git a/frontend/src/views/admin/AnnouncementsView.vue b/frontend/src/views/admin/AnnouncementsView.vue index 08d7b871..1c716807 100644 --- a/frontend/src/views/admin/AnnouncementsView.vue +++ b/frontend/src/views/admin/AnnouncementsView.vue @@ -68,6 +68,19 @@ + + + {{ row.notify_mode === 'popup' ? t('admin.announcements.notifyModeLabels.popup') : t('admin.announcements.notifyModeLabels.silent') }} + + + {{ targetingSummary(row.targeting) }} @@ -163,7 +176,11 @@ {{ t('admin.announcements.form.status') }} - + + {{ t('admin.announcements.form.notifyMode') }} + + {{ t('admin.announcements.form.notifyModeHint') }} + @@ -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(() => [ { 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
{{ t('admin.announcements.form.notifyModeHint') }}