From 404bf0f8d2a345775a2043d14143f0a53fba7c7d Mon Sep 17 00:00:00 2001 From: Edric Li Date: Thu, 1 Jan 2026 18:59:38 +0800 Subject: [PATCH] refactor: migrate wechat to user attributes and enhance users list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the hardcoded wechat field to the new extensible user attributes system and improve the users management UI. Migration: - Add migration 019 to move wechat data to user_attribute_values - Remove wechat field from User entity, DTOs, and API contracts - Clean up wechat-related code from backend and frontend UsersView enhancements: - Add text labels to action buttons (Filter Settings, Column Settings, Attributes Config) for better UX - Change status column to show colored dot + Chinese text instead of English text - Add dynamic attribute columns support with batch loading - Add column visibility settings with localStorage persistence - Add filter settings modal for search and filter preferences - Update i18n translations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/ent/schema/user.go | 5 +- backend/ent/user.go | 33 +- backend/ent/user/user.go | 42 +- backend/ent/user/where.go | 93 +-- backend/ent/user_create.go | 102 +-- backend/ent/user_query.go | 76 +- backend/ent/user_update.go | 207 ++++- .../internal/handler/admin/usage_handler.go | 2 +- .../internal/handler/admin/user_handler.go | 44 +- backend/internal/handler/dto/mappers.go | 1 - backend/internal/handler/dto/types.go | 1 - backend/internal/handler/user_handler.go | 2 - backend/internal/repository/api_key_repo.go | 1 - .../repository/fixtures_integration_test.go | 1 - .../migrations_schema_integration_test.go | 1 - backend/internal/repository/user_repo.go | 86 +- .../repository/user_repo_integration_test.go | 14 - backend/internal/server/api_contract_test.go | 1 - backend/internal/service/admin_service.go | 12 +- .../service/admin_service_create_user_test.go | 2 - backend/internal/service/user.go | 1 - backend/internal/service/user_service.go | 15 +- .../019_migrate_wechat_to_attributes.sql | 83 ++ frontend/src/api/admin/users.ts | 27 +- frontend/src/api/user.ts | 1 - frontend/src/i18n/locales/en.ts | 67 +- frontend/src/i18n/locales/zh.ts | 69 +- frontend/src/types/index.ts | 75 +- frontend/src/views/admin/UsersView.vue | 749 +++++++++++++++--- frontend/src/views/user/ProfileView.vue | 39 +- 30 files changed, 1390 insertions(+), 462 deletions(-) create mode 100644 backend/migrations/019_migrate_wechat_to_attributes.sql diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index c1f742d1..f29b6123 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -57,9 +57,7 @@ func (User) Fields() []ent.Field { field.String("username"). MaxLen(100). Default(""), - field.String("wechat"). - MaxLen(100). - Default(""), + // wechat field migrated to user_attribute_values (see migration 019) field.String("notes"). SchemaType(map[string]string{dialect.Postgres: "text"}). Default(""), @@ -75,6 +73,7 @@ func (User) Edges() []ent.Edge { edge.To("allowed_groups", Group.Type). Through("user_allowed_groups", UserAllowedGroup.Type), edge.To("usage_logs", UsageLog.Type), + edge.To("attribute_values", UserAttributeValue.Type), } } diff --git a/backend/ent/user.go b/backend/ent/user.go index eda67c84..d7e1668d 100644 --- a/backend/ent/user.go +++ b/backend/ent/user.go @@ -37,8 +37,6 @@ type User struct { Status string `json:"status,omitempty"` // Username holds the value of the "username" field. Username string `json:"username,omitempty"` - // Wechat holds the value of the "wechat" field. - Wechat string `json:"wechat,omitempty"` // Notes holds the value of the "notes" field. Notes string `json:"notes,omitempty"` // Edges holds the relations/edges for other nodes in the graph. @@ -61,11 +59,13 @@ type UserEdges struct { AllowedGroups []*Group `json:"allowed_groups,omitempty"` // UsageLogs holds the value of the usage_logs edge. UsageLogs []*UsageLog `json:"usage_logs,omitempty"` + // AttributeValues holds the value of the attribute_values edge. + AttributeValues []*UserAttributeValue `json:"attribute_values,omitempty"` // UserAllowedGroups holds the value of the user_allowed_groups edge. UserAllowedGroups []*UserAllowedGroup `json:"user_allowed_groups,omitempty"` // loadedTypes holds the information for reporting if a // type was loaded (or requested) in eager-loading or not. - loadedTypes [7]bool + loadedTypes [8]bool } // APIKeysOrErr returns the APIKeys value or an error if the edge @@ -122,10 +122,19 @@ func (e UserEdges) UsageLogsOrErr() ([]*UsageLog, error) { return nil, &NotLoadedError{edge: "usage_logs"} } +// AttributeValuesOrErr returns the AttributeValues value or an error if the edge +// was not loaded in eager-loading. +func (e UserEdges) AttributeValuesOrErr() ([]*UserAttributeValue, error) { + if e.loadedTypes[6] { + return e.AttributeValues, nil + } + return nil, &NotLoadedError{edge: "attribute_values"} +} + // UserAllowedGroupsOrErr returns the UserAllowedGroups value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) UserAllowedGroupsOrErr() ([]*UserAllowedGroup, error) { - if e.loadedTypes[6] { + if e.loadedTypes[7] { return e.UserAllowedGroups, nil } return nil, &NotLoadedError{edge: "user_allowed_groups"} @@ -140,7 +149,7 @@ func (*User) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullFloat64) case user.FieldID, user.FieldConcurrency: values[i] = new(sql.NullInt64) - case user.FieldEmail, user.FieldPasswordHash, user.FieldRole, user.FieldStatus, user.FieldUsername, user.FieldWechat, user.FieldNotes: + case user.FieldEmail, user.FieldPasswordHash, user.FieldRole, user.FieldStatus, user.FieldUsername, user.FieldNotes: values[i] = new(sql.NullString) case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt: values[i] = new(sql.NullTime) @@ -226,12 +235,6 @@ func (_m *User) assignValues(columns []string, values []any) error { } else if value.Valid { _m.Username = value.String } - case user.FieldWechat: - if value, ok := values[i].(*sql.NullString); !ok { - return fmt.Errorf("unexpected type %T for field wechat", values[i]) - } else if value.Valid { - _m.Wechat = value.String - } case user.FieldNotes: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field notes", values[i]) @@ -281,6 +284,11 @@ func (_m *User) QueryUsageLogs() *UsageLogQuery { return NewUserClient(_m.config).QueryUsageLogs(_m) } +// QueryAttributeValues queries the "attribute_values" edge of the User entity. +func (_m *User) QueryAttributeValues() *UserAttributeValueQuery { + return NewUserClient(_m.config).QueryAttributeValues(_m) +} + // QueryUserAllowedGroups queries the "user_allowed_groups" edge of the User entity. func (_m *User) QueryUserAllowedGroups() *UserAllowedGroupQuery { return NewUserClient(_m.config).QueryUserAllowedGroups(_m) @@ -341,9 +349,6 @@ func (_m *User) String() string { builder.WriteString("username=") builder.WriteString(_m.Username) builder.WriteString(", ") - builder.WriteString("wechat=") - builder.WriteString(_m.Wechat) - builder.WriteString(", ") builder.WriteString("notes=") builder.WriteString(_m.Notes) builder.WriteByte(')') diff --git a/backend/ent/user/user.go b/backend/ent/user/user.go index 9ad87890..9c40ab09 100644 --- a/backend/ent/user/user.go +++ b/backend/ent/user/user.go @@ -35,8 +35,6 @@ const ( FieldStatus = "status" // FieldUsername holds the string denoting the username field in the database. FieldUsername = "username" - // FieldWechat holds the string denoting the wechat field in the database. - FieldWechat = "wechat" // FieldNotes holds the string denoting the notes field in the database. FieldNotes = "notes" // EdgeAPIKeys holds the string denoting the api_keys edge name in mutations. @@ -51,6 +49,8 @@ const ( EdgeAllowedGroups = "allowed_groups" // EdgeUsageLogs holds the string denoting the usage_logs edge name in mutations. EdgeUsageLogs = "usage_logs" + // EdgeAttributeValues holds the string denoting the attribute_values edge name in mutations. + EdgeAttributeValues = "attribute_values" // EdgeUserAllowedGroups holds the string denoting the user_allowed_groups edge name in mutations. EdgeUserAllowedGroups = "user_allowed_groups" // Table holds the table name of the user in the database. @@ -95,6 +95,13 @@ const ( UsageLogsInverseTable = "usage_logs" // UsageLogsColumn is the table column denoting the usage_logs relation/edge. UsageLogsColumn = "user_id" + // AttributeValuesTable is the table that holds the attribute_values relation/edge. + AttributeValuesTable = "user_attribute_values" + // AttributeValuesInverseTable is the table name for the UserAttributeValue entity. + // It exists in this package in order to avoid circular dependency with the "userattributevalue" package. + AttributeValuesInverseTable = "user_attribute_values" + // AttributeValuesColumn is the table column denoting the attribute_values relation/edge. + AttributeValuesColumn = "user_id" // UserAllowedGroupsTable is the table that holds the user_allowed_groups relation/edge. UserAllowedGroupsTable = "user_allowed_groups" // UserAllowedGroupsInverseTable is the table name for the UserAllowedGroup entity. @@ -117,7 +124,6 @@ var Columns = []string{ FieldConcurrency, FieldStatus, FieldUsername, - FieldWechat, FieldNotes, } @@ -171,10 +177,6 @@ var ( DefaultUsername string // UsernameValidator is a validator for the "username" field. It is called by the builders before save. UsernameValidator func(string) error - // DefaultWechat holds the default value on creation for the "wechat" field. - DefaultWechat string - // WechatValidator is a validator for the "wechat" field. It is called by the builders before save. - WechatValidator func(string) error // DefaultNotes holds the default value on creation for the "notes" field. DefaultNotes string ) @@ -237,11 +239,6 @@ func ByUsername(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldUsername, opts...).ToFunc() } -// ByWechat orders the results by the wechat field. -func ByWechat(opts ...sql.OrderTermOption) OrderOption { - return sql.OrderByField(FieldWechat, opts...).ToFunc() -} - // ByNotes orders the results by the notes field. func ByNotes(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldNotes, opts...).ToFunc() @@ -331,6 +328,20 @@ func ByUsageLogs(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { } } +// ByAttributeValuesCount orders the results by attribute_values count. +func ByAttributeValuesCount(opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborsCount(s, newAttributeValuesStep(), opts...) + } +} + +// ByAttributeValues orders the results by attribute_values terms. +func ByAttributeValues(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newAttributeValuesStep(), append([]sql.OrderTerm{term}, terms...)...) + } +} + // ByUserAllowedGroupsCount orders the results by user_allowed_groups count. func ByUserAllowedGroupsCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { @@ -386,6 +397,13 @@ func newUsageLogsStep() *sqlgraph.Step { sqlgraph.Edge(sqlgraph.O2M, false, UsageLogsTable, UsageLogsColumn), ) } +func newAttributeValuesStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(AttributeValuesInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, AttributeValuesTable, AttributeValuesColumn), + ) +} func newUserAllowedGroupsStep() *sqlgraph.Step { return sqlgraph.NewStep( sqlgraph.From(Table, FieldID), diff --git a/backend/ent/user/where.go b/backend/ent/user/where.go index 81959cf4..c3db075e 100644 --- a/backend/ent/user/where.go +++ b/backend/ent/user/where.go @@ -105,11 +105,6 @@ func Username(v string) predicate.User { return predicate.User(sql.FieldEQ(FieldUsername, v)) } -// Wechat applies equality check predicate on the "wechat" field. It's identical to WechatEQ. -func Wechat(v string) predicate.User { - return predicate.User(sql.FieldEQ(FieldWechat, v)) -} - // Notes applies equality check predicate on the "notes" field. It's identical to NotesEQ. func Notes(v string) predicate.User { return predicate.User(sql.FieldEQ(FieldNotes, v)) @@ -650,71 +645,6 @@ func UsernameContainsFold(v string) predicate.User { return predicate.User(sql.FieldContainsFold(FieldUsername, v)) } -// WechatEQ applies the EQ predicate on the "wechat" field. -func WechatEQ(v string) predicate.User { - return predicate.User(sql.FieldEQ(FieldWechat, v)) -} - -// WechatNEQ applies the NEQ predicate on the "wechat" field. -func WechatNEQ(v string) predicate.User { - return predicate.User(sql.FieldNEQ(FieldWechat, v)) -} - -// WechatIn applies the In predicate on the "wechat" field. -func WechatIn(vs ...string) predicate.User { - return predicate.User(sql.FieldIn(FieldWechat, vs...)) -} - -// WechatNotIn applies the NotIn predicate on the "wechat" field. -func WechatNotIn(vs ...string) predicate.User { - return predicate.User(sql.FieldNotIn(FieldWechat, vs...)) -} - -// WechatGT applies the GT predicate on the "wechat" field. -func WechatGT(v string) predicate.User { - return predicate.User(sql.FieldGT(FieldWechat, v)) -} - -// WechatGTE applies the GTE predicate on the "wechat" field. -func WechatGTE(v string) predicate.User { - return predicate.User(sql.FieldGTE(FieldWechat, v)) -} - -// WechatLT applies the LT predicate on the "wechat" field. -func WechatLT(v string) predicate.User { - return predicate.User(sql.FieldLT(FieldWechat, v)) -} - -// WechatLTE applies the LTE predicate on the "wechat" field. -func WechatLTE(v string) predicate.User { - return predicate.User(sql.FieldLTE(FieldWechat, v)) -} - -// WechatContains applies the Contains predicate on the "wechat" field. -func WechatContains(v string) predicate.User { - return predicate.User(sql.FieldContains(FieldWechat, v)) -} - -// WechatHasPrefix applies the HasPrefix predicate on the "wechat" field. -func WechatHasPrefix(v string) predicate.User { - return predicate.User(sql.FieldHasPrefix(FieldWechat, v)) -} - -// WechatHasSuffix applies the HasSuffix predicate on the "wechat" field. -func WechatHasSuffix(v string) predicate.User { - return predicate.User(sql.FieldHasSuffix(FieldWechat, v)) -} - -// WechatEqualFold applies the EqualFold predicate on the "wechat" field. -func WechatEqualFold(v string) predicate.User { - return predicate.User(sql.FieldEqualFold(FieldWechat, v)) -} - -// WechatContainsFold applies the ContainsFold predicate on the "wechat" field. -func WechatContainsFold(v string) predicate.User { - return predicate.User(sql.FieldContainsFold(FieldWechat, v)) -} - // NotesEQ applies the EQ predicate on the "notes" field. func NotesEQ(v string) predicate.User { return predicate.User(sql.FieldEQ(FieldNotes, v)) @@ -918,6 +848,29 @@ func HasUsageLogsWith(preds ...predicate.UsageLog) predicate.User { }) } +// HasAttributeValues applies the HasEdge predicate on the "attribute_values" edge. +func HasAttributeValues() predicate.User { + return predicate.User(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, AttributeValuesTable, AttributeValuesColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasAttributeValuesWith applies the HasEdge predicate on the "attribute_values" edge with a given conditions (other predicates). +func HasAttributeValuesWith(preds ...predicate.UserAttributeValue) predicate.User { + return predicate.User(func(s *sql.Selector) { + step := newAttributeValuesStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + // HasUserAllowedGroups applies the HasEdge predicate on the "user_allowed_groups" edge. func HasUserAllowedGroups() predicate.User { return predicate.User(func(s *sql.Selector) { diff --git a/backend/ent/user_create.go b/backend/ent/user_create.go index 51bdc493..6313db5f 100644 --- a/backend/ent/user_create.go +++ b/backend/ent/user_create.go @@ -16,6 +16,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/redeemcode" "github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/user" + "github.com/Wei-Shaw/sub2api/ent/userattributevalue" "github.com/Wei-Shaw/sub2api/ent/usersubscription" ) @@ -151,20 +152,6 @@ func (_c *UserCreate) SetNillableUsername(v *string) *UserCreate { return _c } -// SetWechat sets the "wechat" field. -func (_c *UserCreate) SetWechat(v string) *UserCreate { - _c.mutation.SetWechat(v) - return _c -} - -// SetNillableWechat sets the "wechat" field if the given value is not nil. -func (_c *UserCreate) SetNillableWechat(v *string) *UserCreate { - if v != nil { - _c.SetWechat(*v) - } - return _c -} - // SetNotes sets the "notes" field. func (_c *UserCreate) SetNotes(v string) *UserCreate { _c.mutation.SetNotes(v) @@ -269,6 +256,21 @@ func (_c *UserCreate) AddUsageLogs(v ...*UsageLog) *UserCreate { return _c.AddUsageLogIDs(ids...) } +// AddAttributeValueIDs adds the "attribute_values" edge to the UserAttributeValue entity by IDs. +func (_c *UserCreate) AddAttributeValueIDs(ids ...int64) *UserCreate { + _c.mutation.AddAttributeValueIDs(ids...) + return _c +} + +// AddAttributeValues adds the "attribute_values" edges to the UserAttributeValue entity. +func (_c *UserCreate) AddAttributeValues(v ...*UserAttributeValue) *UserCreate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _c.AddAttributeValueIDs(ids...) +} + // Mutation returns the UserMutation object of the builder. func (_c *UserCreate) Mutation() *UserMutation { return _c.mutation @@ -340,10 +342,6 @@ func (_c *UserCreate) defaults() error { v := user.DefaultUsername _c.mutation.SetUsername(v) } - if _, ok := _c.mutation.Wechat(); !ok { - v := user.DefaultWechat - _c.mutation.SetWechat(v) - } if _, ok := _c.mutation.Notes(); !ok { v := user.DefaultNotes _c.mutation.SetNotes(v) @@ -405,14 +403,6 @@ func (_c *UserCreate) check() error { return &ValidationError{Name: "username", err: fmt.Errorf(`ent: validator failed for field "User.username": %w`, err)} } } - if _, ok := _c.mutation.Wechat(); !ok { - return &ValidationError{Name: "wechat", err: errors.New(`ent: missing required field "User.wechat"`)} - } - if v, ok := _c.mutation.Wechat(); ok { - if err := user.WechatValidator(v); err != nil { - return &ValidationError{Name: "wechat", err: fmt.Errorf(`ent: validator failed for field "User.wechat": %w`, err)} - } - } if _, ok := _c.mutation.Notes(); !ok { return &ValidationError{Name: "notes", err: errors.New(`ent: missing required field "User.notes"`)} } @@ -483,10 +473,6 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { _spec.SetField(user.FieldUsername, field.TypeString, value) _node.Username = value } - if value, ok := _c.mutation.Wechat(); ok { - _spec.SetField(user.FieldWechat, field.TypeString, value) - _node.Wechat = value - } if value, ok := _c.mutation.Notes(); ok { _spec.SetField(user.FieldNotes, field.TypeString, value) _node.Notes = value @@ -591,6 +577,22 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { } _spec.Edges = append(_spec.Edges, edge) } + if nodes := _c.mutation.AttributeValuesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AttributeValuesTable, + Columns: []string{user.AttributeValuesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(userattributevalue.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges = append(_spec.Edges, edge) + } return _node, _spec } @@ -769,18 +771,6 @@ func (u *UserUpsert) UpdateUsername() *UserUpsert { return u } -// SetWechat sets the "wechat" field. -func (u *UserUpsert) SetWechat(v string) *UserUpsert { - u.Set(user.FieldWechat, v) - return u -} - -// UpdateWechat sets the "wechat" field to the value that was provided on create. -func (u *UserUpsert) UpdateWechat() *UserUpsert { - u.SetExcluded(user.FieldWechat) - return u -} - // SetNotes sets the "notes" field. func (u *UserUpsert) SetNotes(v string) *UserUpsert { u.Set(user.FieldNotes, v) @@ -985,20 +975,6 @@ func (u *UserUpsertOne) UpdateUsername() *UserUpsertOne { }) } -// SetWechat sets the "wechat" field. -func (u *UserUpsertOne) SetWechat(v string) *UserUpsertOne { - return u.Update(func(s *UserUpsert) { - s.SetWechat(v) - }) -} - -// UpdateWechat sets the "wechat" field to the value that was provided on create. -func (u *UserUpsertOne) UpdateWechat() *UserUpsertOne { - return u.Update(func(s *UserUpsert) { - s.UpdateWechat() - }) -} - // SetNotes sets the "notes" field. func (u *UserUpsertOne) SetNotes(v string) *UserUpsertOne { return u.Update(func(s *UserUpsert) { @@ -1371,20 +1347,6 @@ func (u *UserUpsertBulk) UpdateUsername() *UserUpsertBulk { }) } -// SetWechat sets the "wechat" field. -func (u *UserUpsertBulk) SetWechat(v string) *UserUpsertBulk { - return u.Update(func(s *UserUpsert) { - s.SetWechat(v) - }) -} - -// UpdateWechat sets the "wechat" field to the value that was provided on create. -func (u *UserUpsertBulk) UpdateWechat() *UserUpsertBulk { - return u.Update(func(s *UserUpsert) { - s.UpdateWechat() - }) -} - // SetNotes sets the "notes" field. func (u *UserUpsertBulk) SetNotes(v string) *UserUpsertBulk { return u.Update(func(s *UserUpsert) { diff --git a/backend/ent/user_query.go b/backend/ent/user_query.go index c172dda3..80b182c1 100644 --- a/backend/ent/user_query.go +++ b/backend/ent/user_query.go @@ -19,6 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/ent/userallowedgroup" + "github.com/Wei-Shaw/sub2api/ent/userattributevalue" "github.com/Wei-Shaw/sub2api/ent/usersubscription" ) @@ -35,6 +36,7 @@ type UserQuery struct { withAssignedSubscriptions *UserSubscriptionQuery withAllowedGroups *GroupQuery withUsageLogs *UsageLogQuery + withAttributeValues *UserAttributeValueQuery withUserAllowedGroups *UserAllowedGroupQuery // intermediate query (i.e. traversal path). sql *sql.Selector @@ -204,6 +206,28 @@ func (_q *UserQuery) QueryUsageLogs() *UsageLogQuery { return query } +// QueryAttributeValues chains the current query on the "attribute_values" edge. +func (_q *UserQuery) QueryAttributeValues() *UserAttributeValueQuery { + query := (&UserAttributeValueClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(user.Table, user.FieldID, selector), + sqlgraph.To(userattributevalue.Table, userattributevalue.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, user.AttributeValuesTable, user.AttributeValuesColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + // QueryUserAllowedGroups chains the current query on the "user_allowed_groups" edge. func (_q *UserQuery) QueryUserAllowedGroups() *UserAllowedGroupQuery { query := (&UserAllowedGroupClient{config: _q.config}).Query() @@ -424,6 +448,7 @@ func (_q *UserQuery) Clone() *UserQuery { withAssignedSubscriptions: _q.withAssignedSubscriptions.Clone(), withAllowedGroups: _q.withAllowedGroups.Clone(), withUsageLogs: _q.withUsageLogs.Clone(), + withAttributeValues: _q.withAttributeValues.Clone(), withUserAllowedGroups: _q.withUserAllowedGroups.Clone(), // clone intermediate query. sql: _q.sql.Clone(), @@ -497,6 +522,17 @@ func (_q *UserQuery) WithUsageLogs(opts ...func(*UsageLogQuery)) *UserQuery { return _q } +// WithAttributeValues tells the query-builder to eager-load the nodes that are connected to +// the "attribute_values" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *UserQuery) WithAttributeValues(opts ...func(*UserAttributeValueQuery)) *UserQuery { + query := (&UserAttributeValueClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withAttributeValues = query + return _q +} + // WithUserAllowedGroups tells the query-builder to eager-load the nodes that are connected to // the "user_allowed_groups" edge. The optional arguments are used to configure the query builder of the edge. func (_q *UserQuery) WithUserAllowedGroups(opts ...func(*UserAllowedGroupQuery)) *UserQuery { @@ -586,13 +622,14 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e var ( nodes = []*User{} _spec = _q.querySpec() - loadedTypes = [7]bool{ + loadedTypes = [8]bool{ _q.withAPIKeys != nil, _q.withRedeemCodes != nil, _q.withSubscriptions != nil, _q.withAssignedSubscriptions != nil, _q.withAllowedGroups != nil, _q.withUsageLogs != nil, + _q.withAttributeValues != nil, _q.withUserAllowedGroups != nil, } ) @@ -658,6 +695,13 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e return nil, err } } + if query := _q.withAttributeValues; query != nil { + if err := _q.loadAttributeValues(ctx, query, nodes, + func(n *User) { n.Edges.AttributeValues = []*UserAttributeValue{} }, + func(n *User, e *UserAttributeValue) { n.Edges.AttributeValues = append(n.Edges.AttributeValues, e) }); err != nil { + return nil, err + } + } if query := _q.withUserAllowedGroups; query != nil { if err := _q.loadUserAllowedGroups(ctx, query, nodes, func(n *User) { n.Edges.UserAllowedGroups = []*UserAllowedGroup{} }, @@ -885,6 +929,36 @@ func (_q *UserQuery) loadUsageLogs(ctx context.Context, query *UsageLogQuery, no } return nil } +func (_q *UserQuery) loadAttributeValues(ctx context.Context, query *UserAttributeValueQuery, nodes []*User, init func(*User), assign func(*User, *UserAttributeValue)) error { + fks := make([]driver.Value, 0, len(nodes)) + nodeids := make(map[int64]*User) + for i := range nodes { + fks = append(fks, nodes[i].ID) + nodeids[nodes[i].ID] = nodes[i] + if init != nil { + init(nodes[i]) + } + } + if len(query.ctx.Fields) > 0 { + query.ctx.AppendFieldOnce(userattributevalue.FieldUserID) + } + query.Where(predicate.UserAttributeValue(func(s *sql.Selector) { + s.Where(sql.InValues(s.C(user.AttributeValuesColumn), fks...)) + })) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + fk := n.UserID + node, ok := nodeids[fk] + if !ok { + return fmt.Errorf(`unexpected referenced foreign-key "user_id" returned %v for node %v`, fk, n.ID) + } + assign(node, n) + } + return nil +} func (_q *UserQuery) loadUserAllowedGroups(ctx context.Context, query *UserAllowedGroupQuery, nodes []*User, init func(*User), assign func(*User, *UserAllowedGroup)) error { fks := make([]driver.Value, 0, len(nodes)) nodeids := make(map[int64]*User) diff --git a/backend/ent/user_update.go b/backend/ent/user_update.go index 31e57a43..ed5d3a76 100644 --- a/backend/ent/user_update.go +++ b/backend/ent/user_update.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/redeemcode" "github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/user" + "github.com/Wei-Shaw/sub2api/ent/userattributevalue" "github.com/Wei-Shaw/sub2api/ent/usersubscription" ) @@ -171,20 +172,6 @@ func (_u *UserUpdate) SetNillableUsername(v *string) *UserUpdate { return _u } -// SetWechat sets the "wechat" field. -func (_u *UserUpdate) SetWechat(v string) *UserUpdate { - _u.mutation.SetWechat(v) - return _u -} - -// SetNillableWechat sets the "wechat" field if the given value is not nil. -func (_u *UserUpdate) SetNillableWechat(v *string) *UserUpdate { - if v != nil { - _u.SetWechat(*v) - } - return _u -} - // SetNotes sets the "notes" field. func (_u *UserUpdate) SetNotes(v string) *UserUpdate { _u.mutation.SetNotes(v) @@ -289,6 +276,21 @@ func (_u *UserUpdate) AddUsageLogs(v ...*UsageLog) *UserUpdate { return _u.AddUsageLogIDs(ids...) } +// AddAttributeValueIDs adds the "attribute_values" edge to the UserAttributeValue entity by IDs. +func (_u *UserUpdate) AddAttributeValueIDs(ids ...int64) *UserUpdate { + _u.mutation.AddAttributeValueIDs(ids...) + return _u +} + +// AddAttributeValues adds the "attribute_values" edges to the UserAttributeValue entity. +func (_u *UserUpdate) AddAttributeValues(v ...*UserAttributeValue) *UserUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddAttributeValueIDs(ids...) +} + // Mutation returns the UserMutation object of the builder. func (_u *UserUpdate) Mutation() *UserMutation { return _u.mutation @@ -420,6 +422,27 @@ func (_u *UserUpdate) RemoveUsageLogs(v ...*UsageLog) *UserUpdate { return _u.RemoveUsageLogIDs(ids...) } +// ClearAttributeValues clears all "attribute_values" edges to the UserAttributeValue entity. +func (_u *UserUpdate) ClearAttributeValues() *UserUpdate { + _u.mutation.ClearAttributeValues() + return _u +} + +// RemoveAttributeValueIDs removes the "attribute_values" edge to UserAttributeValue entities by IDs. +func (_u *UserUpdate) RemoveAttributeValueIDs(ids ...int64) *UserUpdate { + _u.mutation.RemoveAttributeValueIDs(ids...) + return _u +} + +// RemoveAttributeValues removes "attribute_values" edges to UserAttributeValue entities. +func (_u *UserUpdate) RemoveAttributeValues(v ...*UserAttributeValue) *UserUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveAttributeValueIDs(ids...) +} + // Save executes the query and returns the number of nodes affected by the update operation. func (_u *UserUpdate) Save(ctx context.Context) (int, error) { if err := _u.defaults(); err != nil { @@ -489,11 +512,6 @@ func (_u *UserUpdate) check() error { return &ValidationError{Name: "username", err: fmt.Errorf(`ent: validator failed for field "User.username": %w`, err)} } } - if v, ok := _u.mutation.Wechat(); ok { - if err := user.WechatValidator(v); err != nil { - return &ValidationError{Name: "wechat", err: fmt.Errorf(`ent: validator failed for field "User.wechat": %w`, err)} - } - } return nil } @@ -545,9 +563,6 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.Username(); ok { _spec.SetField(user.FieldUsername, field.TypeString, value) } - if value, ok := _u.mutation.Wechat(); ok { - _spec.SetField(user.FieldWechat, field.TypeString, value) - } if value, ok := _u.mutation.Notes(); ok { _spec.SetField(user.FieldNotes, field.TypeString, value) } @@ -833,6 +848,51 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.AttributeValuesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AttributeValuesTable, + Columns: []string{user.AttributeValuesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(userattributevalue.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedAttributeValuesIDs(); len(nodes) > 0 && !_u.mutation.AttributeValuesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AttributeValuesTable, + Columns: []string{user.AttributeValuesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(userattributevalue.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.AttributeValuesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AttributeValuesTable, + Columns: []string{user.AttributeValuesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(userattributevalue.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{user.Label} @@ -991,20 +1051,6 @@ func (_u *UserUpdateOne) SetNillableUsername(v *string) *UserUpdateOne { return _u } -// SetWechat sets the "wechat" field. -func (_u *UserUpdateOne) SetWechat(v string) *UserUpdateOne { - _u.mutation.SetWechat(v) - return _u -} - -// SetNillableWechat sets the "wechat" field if the given value is not nil. -func (_u *UserUpdateOne) SetNillableWechat(v *string) *UserUpdateOne { - if v != nil { - _u.SetWechat(*v) - } - return _u -} - // SetNotes sets the "notes" field. func (_u *UserUpdateOne) SetNotes(v string) *UserUpdateOne { _u.mutation.SetNotes(v) @@ -1109,6 +1155,21 @@ func (_u *UserUpdateOne) AddUsageLogs(v ...*UsageLog) *UserUpdateOne { return _u.AddUsageLogIDs(ids...) } +// AddAttributeValueIDs adds the "attribute_values" edge to the UserAttributeValue entity by IDs. +func (_u *UserUpdateOne) AddAttributeValueIDs(ids ...int64) *UserUpdateOne { + _u.mutation.AddAttributeValueIDs(ids...) + return _u +} + +// AddAttributeValues adds the "attribute_values" edges to the UserAttributeValue entity. +func (_u *UserUpdateOne) AddAttributeValues(v ...*UserAttributeValue) *UserUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddAttributeValueIDs(ids...) +} + // Mutation returns the UserMutation object of the builder. func (_u *UserUpdateOne) Mutation() *UserMutation { return _u.mutation @@ -1240,6 +1301,27 @@ func (_u *UserUpdateOne) RemoveUsageLogs(v ...*UsageLog) *UserUpdateOne { return _u.RemoveUsageLogIDs(ids...) } +// ClearAttributeValues clears all "attribute_values" edges to the UserAttributeValue entity. +func (_u *UserUpdateOne) ClearAttributeValues() *UserUpdateOne { + _u.mutation.ClearAttributeValues() + return _u +} + +// RemoveAttributeValueIDs removes the "attribute_values" edge to UserAttributeValue entities by IDs. +func (_u *UserUpdateOne) RemoveAttributeValueIDs(ids ...int64) *UserUpdateOne { + _u.mutation.RemoveAttributeValueIDs(ids...) + return _u +} + +// RemoveAttributeValues removes "attribute_values" edges to UserAttributeValue entities. +func (_u *UserUpdateOne) RemoveAttributeValues(v ...*UserAttributeValue) *UserUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveAttributeValueIDs(ids...) +} + // Where appends a list predicates to the UserUpdate builder. func (_u *UserUpdateOne) Where(ps ...predicate.User) *UserUpdateOne { _u.mutation.Where(ps...) @@ -1322,11 +1404,6 @@ func (_u *UserUpdateOne) check() error { return &ValidationError{Name: "username", err: fmt.Errorf(`ent: validator failed for field "User.username": %w`, err)} } } - if v, ok := _u.mutation.Wechat(); ok { - if err := user.WechatValidator(v); err != nil { - return &ValidationError{Name: "wechat", err: fmt.Errorf(`ent: validator failed for field "User.wechat": %w`, err)} - } - } return nil } @@ -1395,9 +1472,6 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) { if value, ok := _u.mutation.Username(); ok { _spec.SetField(user.FieldUsername, field.TypeString, value) } - if value, ok := _u.mutation.Wechat(); ok { - _spec.SetField(user.FieldWechat, field.TypeString, value) - } if value, ok := _u.mutation.Notes(); ok { _spec.SetField(user.FieldNotes, field.TypeString, value) } @@ -1683,6 +1757,51 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.AttributeValuesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AttributeValuesTable, + Columns: []string{user.AttributeValuesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(userattributevalue.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedAttributeValuesIDs(); len(nodes) > 0 && !_u.mutation.AttributeValuesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AttributeValuesTable, + Columns: []string{user.AttributeValuesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(userattributevalue.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.AttributeValuesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AttributeValuesTable, + Columns: []string{user.AttributeValuesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(userattributevalue.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } _node = &User{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index 9a8e3244..a75948f7 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -246,7 +246,7 @@ func (h *UsageHandler) SearchUsers(c *gin.Context) { } // Limit to 30 results - users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, "", "", keyword) + users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword}) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 681b59ea..11bdebd2 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -27,7 +27,6 @@ type CreateUserRequest struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` Username string `json:"username"` - Wechat string `json:"wechat"` Notes string `json:"notes"` Balance float64 `json:"balance"` Concurrency int `json:"concurrency"` @@ -40,7 +39,6 @@ type UpdateUserRequest struct { Email string `json:"email" binding:"omitempty,email"` Password string `json:"password" binding:"omitempty,min=6"` Username *string `json:"username"` - Wechat *string `json:"wechat"` Notes *string `json:"notes"` Balance *float64 `json:"balance"` Concurrency *int `json:"concurrency"` @@ -57,13 +55,22 @@ type UpdateBalanceRequest struct { // List handles listing all users with pagination // GET /api/v1/admin/users +// Query params: +// - status: filter by user status +// - role: filter by user role +// - search: search in email, username +// - attr[{id}]: filter by custom attribute value, e.g. attr[1]=company func (h *UserHandler) List(c *gin.Context) { page, pageSize := response.ParsePagination(c) - status := c.Query("status") - role := c.Query("role") - search := c.Query("search") - users, total, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, status, role, search) + filters := service.UserListFilters{ + Status: c.Query("status"), + Role: c.Query("role"), + Search: c.Query("search"), + Attributes: parseAttributeFilters(c), + } + + users, total, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, filters) if err != nil { response.ErrorFrom(c, err) return @@ -76,6 +83,29 @@ func (h *UserHandler) List(c *gin.Context) { response.Paginated(c, out, total, page, pageSize) } +// parseAttributeFilters extracts attribute filters from query params +// Format: attr[{attributeID}]=value, e.g. attr[1]=company&attr[2]=developer +func parseAttributeFilters(c *gin.Context) map[int64]string { + result := make(map[int64]string) + + // Get all query params and look for attr[*] pattern + for key, values := range c.Request.URL.Query() { + if len(values) == 0 || values[0] == "" { + continue + } + // Check if key matches pattern attr[{id}] + if len(key) > 5 && key[:5] == "attr[" && key[len(key)-1] == ']' { + idStr := key[5 : len(key)-1] + id, err := strconv.ParseInt(idStr, 10, 64) + if err == nil && id > 0 { + result[id] = values[0] + } + } + } + + return result +} + // GetByID handles getting a user by ID // GET /api/v1/admin/users/:id func (h *UserHandler) GetByID(c *gin.Context) { @@ -107,7 +137,6 @@ func (h *UserHandler) Create(c *gin.Context) { Email: req.Email, Password: req.Password, Username: req.Username, - Wechat: req.Wechat, Notes: req.Notes, Balance: req.Balance, Concurrency: req.Concurrency, @@ -141,7 +170,6 @@ func (h *UserHandler) Update(c *gin.Context) { Email: req.Email, Password: req.Password, Username: req.Username, - Wechat: req.Wechat, Notes: req.Notes, Balance: req.Balance, Concurrency: req.Concurrency, diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index a3d71c6b..f94bb7c2 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -10,7 +10,6 @@ func UserFromServiceShallow(u *service.User) *User { ID: u.ID, Email: u.Email, Username: u.Username, - Wechat: u.Wechat, Notes: u.Notes, Role: u.Role, Balance: u.Balance, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 24d78e98..75021875 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -6,7 +6,6 @@ type User struct { ID int64 `json:"id"` Email string `json:"email"` Username string `json:"username"` - Wechat string `json:"wechat"` Notes string `json:"notes"` Role string `json:"role"` Balance float64 `json:"balance"` diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index f4639b1f..d968951c 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -30,7 +30,6 @@ type ChangePasswordRequest struct { // UpdateProfileRequest represents the update profile request payload type UpdateProfileRequest struct { Username *string `json:"username"` - Wechat *string `json:"wechat"` } // GetProfile handles getting user profile @@ -99,7 +98,6 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { svcReq := service.UpdateProfileRequest{ Username: req.Username, - Wechat: req.Wechat, } updatedUser, err := h.userService.UpdateProfile(c.Request.Context(), subject.UserID, svcReq) if err != nil { diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 3ba2fd85..9fcee1ca 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -294,7 +294,6 @@ func userEntityToService(u *dbent.User) *service.User { ID: u.ID, Email: u.Email, Username: u.Username, - Wechat: u.Wechat, Notes: u.Notes, PasswordHash: u.PasswordHash, Role: u.Role, diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index 8f13c532..ab8e8a4f 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -40,7 +40,6 @@ func mustCreateUser(t *testing.T, client *dbent.Client, u *service.User) *servic SetBalance(u.Balance). SetConcurrency(u.Concurrency). SetUsername(u.Username). - SetWechat(u.Wechat). SetNotes(u.Notes) if !u.CreatedAt.IsZero() { create.SetCreatedAt(u.CreatedAt) diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index 4c7848b2..e8f652c4 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -23,7 +23,6 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { // users: columns required by repository queries requireColumn(t, tx, "users", "username", "character varying", 100, false) - requireColumn(t, tx, "users", "wechat", "character varying", 100, false) requireColumn(t, tx, "users", "notes", "text", 0, false) // accounts: schedulable and rate-limit fields diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 8393ae7c..57c2ef83 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -9,6 +9,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" dbuser "github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/ent/userallowedgroup" + "github.com/Wei-Shaw/sub2api/ent/userattributevalue" "github.com/Wei-Shaw/sub2api/ent/usersubscription" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/service" @@ -50,7 +51,6 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error created, err := txClient.User.Create(). SetEmail(userIn.Email). SetUsername(userIn.Username). - SetWechat(userIn.Wechat). SetNotes(userIn.Notes). SetPasswordHash(userIn.PasswordHash). SetRole(userIn.Role). @@ -133,7 +133,6 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error updated, err := txClient.User.UpdateOneID(userIn.ID). SetEmail(userIn.Email). SetUsername(userIn.Username). - SetWechat(userIn.Wechat). SetNotes(userIn.Notes). SetPasswordHash(userIn.PasswordHash). SetRole(userIn.Role). @@ -171,28 +170,38 @@ func (r *userRepository) Delete(ctx context.Context, id int64) error { } func (r *userRepository) List(ctx context.Context, params pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) { - return r.ListWithFilters(ctx, params, "", "", "") + return r.ListWithFilters(ctx, params, service.UserListFilters{}) } -func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, status, role, search string) ([]service.User, *pagination.PaginationResult, error) { +func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) { q := r.client.User.Query() - if status != "" { - q = q.Where(dbuser.StatusEQ(status)) + if filters.Status != "" { + q = q.Where(dbuser.StatusEQ(filters.Status)) } - if role != "" { - q = q.Where(dbuser.RoleEQ(role)) + if filters.Role != "" { + q = q.Where(dbuser.RoleEQ(filters.Role)) } - if search != "" { + if filters.Search != "" { q = q.Where( dbuser.Or( - dbuser.EmailContainsFold(search), - dbuser.UsernameContainsFold(search), - dbuser.WechatContainsFold(search), + dbuser.EmailContainsFold(filters.Search), + dbuser.UsernameContainsFold(filters.Search), ), ) } + // If attribute filters are specified, we need to filter by user IDs first + var allowedUserIDs []int64 + if len(filters.Attributes) > 0 { + allowedUserIDs = r.filterUsersByAttributes(ctx, filters.Attributes) + if len(allowedUserIDs) == 0 { + // No users match the attribute filters + return []service.User{}, paginationResultFromTotal(0, params), nil + } + q = q.Where(dbuser.IDIn(allowedUserIDs...)) + } + total, err := q.Clone().Count(ctx) if err != nil { return nil, nil, err @@ -252,6 +261,59 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination. return outUsers, paginationResultFromTotal(int64(total), params), nil } +// filterUsersByAttributes returns user IDs that match ALL the given attribute filters +func (r *userRepository) filterUsersByAttributes(ctx context.Context, attrs map[int64]string) []int64 { + if len(attrs) == 0 { + return nil + } + + // For each attribute filter, get the set of matching user IDs + // Then intersect all sets to get users matching ALL filters + var resultSet map[int64]struct{} + first := true + + for attrID, value := range attrs { + // Query user_attribute_values for this attribute + values, err := r.client.UserAttributeValue.Query(). + Where( + userattributevalue.AttributeIDEQ(attrID), + userattributevalue.ValueContainsFold(value), + ). + All(ctx) + if err != nil { + continue + } + + currentSet := make(map[int64]struct{}, len(values)) + for _, v := range values { + currentSet[v.UserID] = struct{}{} + } + + if first { + resultSet = currentSet + first = false + } else { + // Intersect with previous results + for userID := range resultSet { + if _, ok := currentSet[userID]; !ok { + delete(resultSet, userID) + } + } + } + + // Early exit if no users match + if len(resultSet) == 0 { + return nil + } + } + + result := make([]int64, 0, len(resultSet)) + for userID := range resultSet { + result = append(result, userID) + } + return result +} + func (r *userRepository) UpdateBalance(ctx context.Context, id int64, amount float64) error { client := clientFromContext(ctx, r.client) n, err := client.User.Update().Where(dbuser.IDEQ(id)).AddBalance(amount).Save(ctx) diff --git a/backend/internal/repository/user_repo_integration_test.go b/backend/internal/repository/user_repo_integration_test.go index 55db00c3..c492fda6 100644 --- a/backend/internal/repository/user_repo_integration_test.go +++ b/backend/internal/repository/user_repo_integration_test.go @@ -202,16 +202,6 @@ func (s *UserRepoSuite) TestListWithFilters_SearchByUsername() { s.Require().Equal("JohnDoe", users[0].Username) } -func (s *UserRepoSuite) TestListWithFilters_SearchByWechat() { - s.mustCreateUser(&service.User{Email: "w1@test.com", Wechat: "wx_hello"}) - s.mustCreateUser(&service.User{Email: "w2@test.com", Wechat: "wx_world"}) - - users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", "wx_hello") - s.Require().NoError(err) - s.Require().Len(users, 1) - s.Require().Equal("wx_hello", users[0].Wechat) -} - func (s *UserRepoSuite) TestListWithFilters_LoadsActiveSubscriptions() { user := s.mustCreateUser(&service.User{Email: "sub@test.com", Status: service.StatusActive}) groupActive := s.mustCreateGroup("g-sub-active") @@ -238,7 +228,6 @@ func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() { s.mustCreateUser(&service.User{ Email: "a@example.com", Username: "Alice", - Wechat: "wx_a", Role: service.RoleUser, Status: service.StatusActive, Balance: 10, @@ -246,7 +235,6 @@ func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() { target := s.mustCreateUser(&service.User{ Email: "b@example.com", Username: "Bob", - Wechat: "wx_b", Role: service.RoleAdmin, Status: service.StatusActive, Balance: 1, @@ -448,7 +436,6 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() { user1 := s.mustCreateUser(&service.User{ Email: "a@example.com", Username: "Alice", - Wechat: "wx_a", Role: service.RoleUser, Status: service.StatusActive, Balance: 10, @@ -456,7 +443,6 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() { user2 := s.mustCreateUser(&service.User{ Email: "b@example.com", Username: "Bob", - Wechat: "wx_b", Role: service.RoleAdmin, Status: service.StatusActive, Balance: 1, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 3912c8fb..51f5e43d 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -51,7 +51,6 @@ func TestAPIContracts(t *testing.T) { "id": 1, "email": "alice@example.com", "username": "alice", - "wechat": "wx_alice", "notes": "hello", "role": "user", "balance": 12.5, diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index feeb19a0..e3ff0fbd 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -13,7 +13,7 @@ import ( // AdminService interface defines admin management operations type AdminService interface { // User management - ListUsers(ctx context.Context, page, pageSize int, status, role, search string) ([]User, int64, error) + ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters) ([]User, int64, error) GetUser(ctx context.Context, id int64) (*User, error) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) @@ -69,7 +69,6 @@ type CreateUserInput struct { Email string Password string Username string - Wechat string Notes string Balance float64 Concurrency int @@ -80,7 +79,6 @@ type UpdateUserInput struct { Email string Password string Username *string - Wechat *string Notes *string Balance *float64 // 使用指针区分"未提供"和"设置为0" Concurrency *int // 使用指针区分"未提供"和"设置为0" @@ -251,9 +249,9 @@ func NewAdminService( } // User management implementations -func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, status, role, search string) ([]User, int64, error) { +func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters) ([]User, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} - users, result, err := s.userRepo.ListWithFilters(ctx, params, status, role, search) + users, result, err := s.userRepo.ListWithFilters(ctx, params, filters) if err != nil { return nil, 0, err } @@ -268,7 +266,6 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu user := &User{ Email: input.Email, Username: input.Username, - Wechat: input.Wechat, Notes: input.Notes, Role: RoleUser, // Always create as regular user, never admin Balance: input.Balance, @@ -310,9 +307,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda if input.Username != nil { user.Username = *input.Username } - if input.Wechat != nil { - user.Wechat = *input.Wechat - } if input.Notes != nil { user.Notes = *input.Notes } diff --git a/backend/internal/service/admin_service_create_user_test.go b/backend/internal/service/admin_service_create_user_test.go index cfa52de8..a0fe4d87 100644 --- a/backend/internal/service/admin_service_create_user_test.go +++ b/backend/internal/service/admin_service_create_user_test.go @@ -18,7 +18,6 @@ func TestAdminService_CreateUser_Success(t *testing.T) { Email: "user@test.com", Password: "strong-pass", Username: "tester", - Wechat: "wx", Notes: "note", Balance: 12.5, Concurrency: 7, @@ -31,7 +30,6 @@ func TestAdminService_CreateUser_Success(t *testing.T) { require.Equal(t, int64(10), user.ID) require.Equal(t, input.Email, user.Email) require.Equal(t, input.Username, user.Username) - require.Equal(t, input.Wechat, user.Wechat) require.Equal(t, input.Notes, user.Notes) require.Equal(t, input.Balance, user.Balance) require.Equal(t, input.Concurrency, user.Concurrency) diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index fe670202..894243df 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -10,7 +10,6 @@ type User struct { ID int64 Email string Username string - Wechat string Notes string PasswordHash string Role string diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 44a94d32..d5e1f869 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -14,6 +14,14 @@ var ( ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions") ) +// UserListFilters contains all filter options for listing users +type UserListFilters struct { + Status string // User status filter + Role string // User role filter + Search string // Search in email, username + Attributes map[int64]string // Custom attribute filters: attributeID -> value +} + type UserRepository interface { Create(ctx context.Context, user *User) error GetByID(ctx context.Context, id int64) (*User, error) @@ -23,7 +31,7 @@ type UserRepository interface { Delete(ctx context.Context, id int64) error List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) - ListWithFilters(ctx context.Context, params pagination.PaginationParams, status, role, search string) ([]User, *pagination.PaginationResult, error) + ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UserListFilters) ([]User, *pagination.PaginationResult, error) UpdateBalance(ctx context.Context, id int64, amount float64) error DeductBalance(ctx context.Context, id int64, amount float64) error @@ -36,7 +44,6 @@ type UserRepository interface { type UpdateProfileRequest struct { Email *string `json:"email"` Username *string `json:"username"` - Wechat *string `json:"wechat"` Concurrency *int `json:"concurrency"` } @@ -100,10 +107,6 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat user.Username = *req.Username } - if req.Wechat != nil { - user.Wechat = *req.Wechat - } - if req.Concurrency != nil { user.Concurrency = *req.Concurrency } diff --git a/backend/migrations/019_migrate_wechat_to_attributes.sql b/backend/migrations/019_migrate_wechat_to_attributes.sql new file mode 100644 index 00000000..765ca498 --- /dev/null +++ b/backend/migrations/019_migrate_wechat_to_attributes.sql @@ -0,0 +1,83 @@ +-- Migration: Move wechat field from users table to user_attribute_values +-- This migration: +-- 1. Creates a "wechat" attribute definition +-- 2. Migrates existing wechat data to user_attribute_values +-- 3. Does NOT drop the wechat column (for rollback safety, can be done in a later migration) + +-- +goose Up +-- +goose StatementBegin + +-- Step 1: Insert wechat attribute definition if not exists +INSERT INTO user_attribute_definitions (key, name, description, type, options, required, validation, placeholder, display_order, enabled, created_at, updated_at) +SELECT 'wechat', '微信', '用户微信号', 'text', '[]'::jsonb, false, '{}'::jsonb, '请输入微信号', 0, true, NOW(), NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL +); + +-- Step 2: Migrate existing wechat values to user_attribute_values +-- Only migrate non-empty values +INSERT INTO user_attribute_values (user_id, attribute_id, value, created_at, updated_at) +SELECT + u.id, + (SELECT id FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL LIMIT 1), + u.wechat, + NOW(), + NOW() +FROM users u +WHERE u.wechat IS NOT NULL + AND u.wechat != '' + AND u.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM user_attribute_values uav + WHERE uav.user_id = u.id + AND uav.attribute_id = (SELECT id FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL LIMIT 1) + ); + +-- Step 3: Update display_order to ensure wechat appears first +UPDATE user_attribute_definitions +SET display_order = -1 +WHERE key = 'wechat' AND deleted_at IS NULL; + +-- Reorder all attributes starting from 0 +WITH ordered AS ( + SELECT id, ROW_NUMBER() OVER (ORDER BY display_order, id) - 1 as new_order + FROM user_attribute_definitions + WHERE deleted_at IS NULL +) +UPDATE user_attribute_definitions +SET display_order = ordered.new_order +FROM ordered +WHERE user_attribute_definitions.id = ordered.id; + +-- Step 4: Drop the redundant wechat column from users table +ALTER TABLE users DROP COLUMN IF EXISTS wechat; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +-- Restore wechat column +ALTER TABLE users ADD COLUMN IF NOT EXISTS wechat VARCHAR(100) DEFAULT ''; + +-- Copy attribute values back to users.wechat column +UPDATE users u +SET wechat = uav.value +FROM user_attribute_values uav +JOIN user_attribute_definitions uad ON uav.attribute_id = uad.id +WHERE uav.user_id = u.id + AND uad.key = 'wechat' + AND uad.deleted_at IS NULL; + +-- Delete migrated attribute values +DELETE FROM user_attribute_values +WHERE attribute_id IN ( + SELECT id FROM user_attribute_definitions WHERE key = 'wechat' AND deleted_at IS NULL +); + +-- Soft-delete the wechat attribute definition +UPDATE user_attribute_definitions +SET deleted_at = NOW() +WHERE key = 'wechat' AND deleted_at IS NULL; + +-- +goose StatementEnd diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 2901f4ce..44963cf9 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -10,7 +10,7 @@ import type { User, UpdateUserRequest, PaginatedResponse } from '@/types' * List all users with pagination * @param page - Page number (default: 1) * @param pageSize - Items per page (default: 20) - * @param filters - Optional filters (status, role, search) + * @param filters - Optional filters (status, role, search, attributes) * @param options - Optional request options (signal) * @returns Paginated list of users */ @@ -21,17 +21,32 @@ export async function list( status?: 'active' | 'disabled' role?: 'admin' | 'user' search?: string + attributes?: Record // attributeId -> value }, options?: { signal?: AbortSignal } ): Promise> { + // Build params with attribute filters in attr[id]=value format + const params: Record = { + page, + page_size: pageSize, + status: filters?.status, + role: filters?.role, + search: filters?.search + } + + // Add attribute filters as attr[id]=value + if (filters?.attributes) { + for (const [attrId, value] of Object.entries(filters.attributes)) { + if (value) { + params[`attr[${attrId}]`] = value + } + } + } + const { data } = await apiClient.get>('/admin/users', { - params: { - page, - page_size: pageSize, - ...filters - }, + params, signal: options?.signal }) return data diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index d34ce20e..bfc0e30b 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -22,7 +22,6 @@ export async function getProfile(): Promise { */ export async function updateProfile(profile: { username?: string - wechat?: string }): Promise { const { data } = await apiClient.put('/user', profile) return data diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index fabf8775..f11afef6 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -434,9 +434,7 @@ export default { administrator: 'Administrator', user: 'User', username: 'Username', - wechat: 'WeChat ID', enterUsername: 'Enter username', - enterWechat: 'Enter WeChat ID', editProfile: 'Edit Profile', updateProfile: 'Update Profile', updating: 'Updating...', @@ -565,12 +563,10 @@ export default { email: 'Email', password: 'Password', username: 'Username', - wechat: 'WeChat ID', notes: 'Notes', enterEmail: 'Enter email', enterPassword: 'Enter password', enterUsername: 'Enter username (optional)', - enterWechat: 'Enter WeChat ID (optional)', enterNotes: 'Enter notes (admin only)', notesHint: 'This note is only visible to administrators', enterNewPassword: 'Enter new password (optional)', @@ -582,7 +578,6 @@ export default { columns: { user: 'User', username: 'Username', - wechat: 'WeChat ID', notes: 'Notes', role: 'Role', subscriptions: 'Subscriptions', @@ -653,7 +648,67 @@ export default { failedToDeposit: 'Failed to deposit', failedToWithdraw: 'Failed to withdraw', useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance', - insufficientBalance: 'Insufficient balance, balance cannot be negative after withdrawal' + insufficientBalance: 'Insufficient balance, balance cannot be negative after withdrawal', + // Settings Dropdowns + filterSettings: 'Filter Settings', + columnSettings: 'Column Settings', + filterValue: 'Enter value', + // User Attributes + attributes: { + title: 'User Attributes', + description: 'Configure custom user attribute fields', + configButton: 'Attributes', + addAttribute: 'Add Attribute', + editAttribute: 'Edit Attribute', + deleteAttribute: 'Delete Attribute', + deleteConfirm: "Are you sure you want to delete attribute '{name}'? All user values for this attribute will be deleted.", + noAttributes: 'No custom attributes', + noAttributesHint: 'Click the button above to add custom attributes', + key: 'Attribute Key', + keyHint: 'For programmatic reference, only letters, numbers and underscores', + name: 'Display Name', + nameHint: 'Name shown in forms', + type: 'Attribute Type', + fieldDescription: 'Description', + fieldDescriptionHint: 'Description text for the attribute', + placeholder: 'Placeholder', + placeholderHint: 'Placeholder text for input field', + required: 'Required', + enabled: 'Enabled', + options: 'Options', + optionsHint: 'For select/multi-select types', + addOption: 'Add Option', + optionValue: 'Option Value', + optionLabel: 'Display Text', + validation: 'Validation Rules', + minLength: 'Min Length', + maxLength: 'Max Length', + min: 'Min Value', + max: 'Max Value', + pattern: 'Regex Pattern', + patternMessage: 'Validation Error Message', + types: { + text: 'Text', + textarea: 'Textarea', + number: 'Number', + email: 'Email', + url: 'URL', + date: 'Date', + select: 'Select', + multi_select: 'Multi-Select' + }, + created: 'Attribute created successfully', + updated: 'Attribute updated successfully', + deleted: 'Attribute deleted successfully', + reordered: 'Attribute order updated successfully', + failedToLoad: 'Failed to load attributes', + failedToCreate: 'Failed to create attribute', + failedToUpdate: 'Failed to update attribute', + failedToDelete: 'Failed to delete attribute', + failedToReorder: 'Failed to update order', + keyExists: 'Attribute key already exists', + dragToReorder: 'Drag to reorder' + } }, // Groups diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index dc9a19e5..6805a3c7 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -430,9 +430,7 @@ export default { administrator: '管理员', user: '用户', username: '用户名', - wechat: '微信号', enterUsername: '输入用户名', - enterWechat: '输入微信号', editProfile: '编辑个人资料', updateProfile: '更新资料', updating: '更新中...', @@ -583,12 +581,10 @@ export default { email: '邮箱', password: '密码', username: '用户名', - wechat: '微信号', notes: '备注', enterEmail: '请输入邮箱', enterPassword: '请输入密码', enterUsername: '请输入用户名(选填)', - enterWechat: '请输入微信号(选填)', enterNotes: '请输入备注(仅管理员可见)', notesHint: '此备注仅对管理员可见', enterNewPassword: '请输入新密码(选填)', @@ -601,7 +597,6 @@ export default { user: '用户', email: '邮箱', username: '用户名', - wechat: '微信号', notes: '备注', role: '角色', subscriptions: '订阅分组', @@ -655,8 +650,6 @@ export default { emailPlaceholder: '请输入邮箱', usernameLabel: '用户名', usernamePlaceholder: '请输入用户名(选填)', - wechatLabel: '微信号', - wechatPlaceholder: '请输入微信号(选填)', notesLabel: '备注', notesPlaceholder: '请输入备注(仅管理员可见)', notesHint: '此备注仅对管理员可见', @@ -711,7 +704,67 @@ export default { failedToDeposit: '充值失败', failedToWithdraw: '退款失败', useDepositWithdrawButtons: '请使用充值/退款按钮调整余额', - insufficientBalance: '余额不足,退款后余额不能为负数' + insufficientBalance: '余额不足,退款后余额不能为负数', + // Settings Dropdowns + filterSettings: '筛选设置', + columnSettings: '列设置', + filterValue: '输入值', + // User Attributes + attributes: { + title: '用户属性配置', + description: '配置用户的自定义属性字段', + configButton: '属性配置', + addAttribute: '添加属性', + editAttribute: '编辑属性', + deleteAttribute: '删除属性', + deleteConfirm: "确定要删除属性 '{name}' 吗?所有用户的该属性值将被删除。", + noAttributes: '暂无自定义属性', + noAttributesHint: '点击上方按钮添加自定义属性', + key: '属性键', + keyHint: '用于程序引用,只能包含字母、数字和下划线', + name: '显示名称', + nameHint: '在表单中显示的名称', + type: '属性类型', + fieldDescription: '描述', + fieldDescriptionHint: '属性的说明文字', + placeholder: '占位符', + placeholderHint: '输入框的提示文字', + required: '必填', + enabled: '启用', + options: '选项配置', + optionsHint: '用于单选/多选类型', + addOption: '添加选项', + optionValue: '选项值', + optionLabel: '显示文本', + validation: '验证规则', + minLength: '最小长度', + maxLength: '最大长度', + min: '最小值', + max: '最大值', + pattern: '正则表达式', + patternMessage: '验证失败提示', + types: { + text: '单行文本', + textarea: '多行文本', + number: '数字', + email: '邮箱', + url: '链接', + date: '日期', + select: '单选', + multi_select: '多选' + }, + created: '属性创建成功', + updated: '属性更新成功', + deleted: '属性删除成功', + reordered: '属性排序更新成功', + failedToLoad: '加载属性列表失败', + failedToCreate: '创建属性失败', + failedToUpdate: '更新属性失败', + failedToDelete: '删除属性失败', + failedToReorder: '更新排序失败', + keyExists: '属性键已存在', + dragToReorder: '拖拽排序' + } }, // Groups Management diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 80488602..47155a5d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -7,7 +7,6 @@ export interface User { id: number username: string - wechat: string notes: string email: string role: 'admin' | 'user' // User role for authorization @@ -634,7 +633,6 @@ export interface UpdateUserRequest { email?: string password?: string username?: string - wechat?: string notes?: string role?: 'admin' | 'user' balance?: number @@ -771,3 +769,76 @@ export interface AccountUsageStatsResponse { summary: AccountUsageSummary models: ModelStat[] } + +// ==================== User Attribute Types ==================== + +export type UserAttributeType = 'text' | 'textarea' | 'number' | 'email' | 'url' | 'date' | 'select' | 'multi_select' + +export interface UserAttributeOption { + value: string + label: string +} + +export interface UserAttributeValidation { + min_length?: number + max_length?: number + min?: number + max?: number + pattern?: string + message?: string +} + +export interface UserAttributeDefinition { + id: number + key: string + name: string + description: string + type: UserAttributeType + options: UserAttributeOption[] + required: boolean + validation: UserAttributeValidation + placeholder: string + display_order: number + enabled: boolean + created_at: string + updated_at: string +} + +export interface UserAttributeValue { + id: number + user_id: number + attribute_id: number + value: string + created_at: string + updated_at: string +} + +export interface CreateUserAttributeRequest { + key: string + name: string + description?: string + type: UserAttributeType + options?: UserAttributeOption[] + required?: boolean + validation?: UserAttributeValidation + placeholder?: string + display_order?: number + enabled?: boolean +} + +export interface UpdateUserAttributeRequest { + key?: string + name?: string + description?: string + type?: UserAttributeType + options?: UserAttributeOption[] + required?: boolean + validation?: UserAttributeValidation + placeholder?: string + display_order?: number + enabled?: boolean +} + +export interface UserAttributeValuesMap { + [attributeId: number]: string +} diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index 03bc2060..e070d08a 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -1,86 +1,289 @@ - - + + +