refactor: migrate wechat to user attributes and enhance users list

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 <noreply@anthropic.com>
This commit is contained in:
Edric Li
2026-01-01 18:59:38 +08:00
parent f44cf642bc
commit 404bf0f8d2
30 changed files with 1390 additions and 462 deletions

View File

@@ -57,9 +57,7 @@ func (User) Fields() []ent.Field {
field.String("username"). field.String("username").
MaxLen(100). MaxLen(100).
Default(""), Default(""),
field.String("wechat"). // wechat field migrated to user_attribute_values (see migration 019)
MaxLen(100).
Default(""),
field.String("notes"). field.String("notes").
SchemaType(map[string]string{dialect.Postgres: "text"}). SchemaType(map[string]string{dialect.Postgres: "text"}).
Default(""), Default(""),
@@ -75,6 +73,7 @@ func (User) Edges() []ent.Edge {
edge.To("allowed_groups", Group.Type). edge.To("allowed_groups", Group.Type).
Through("user_allowed_groups", UserAllowedGroup.Type), Through("user_allowed_groups", UserAllowedGroup.Type),
edge.To("usage_logs", UsageLog.Type), edge.To("usage_logs", UsageLog.Type),
edge.To("attribute_values", UserAttributeValue.Type),
} }
} }

View File

@@ -37,8 +37,6 @@ type User struct {
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
// Username holds the value of the "username" field. // Username holds the value of the "username" field.
Username string `json:"username,omitempty"` 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 holds the value of the "notes" field.
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
// Edges holds the relations/edges for other nodes in the graph. // Edges holds the relations/edges for other nodes in the graph.
@@ -61,11 +59,13 @@ type UserEdges struct {
AllowedGroups []*Group `json:"allowed_groups,omitempty"` AllowedGroups []*Group `json:"allowed_groups,omitempty"`
// UsageLogs holds the value of the usage_logs edge. // UsageLogs holds the value of the usage_logs edge.
UsageLogs []*UsageLog `json:"usage_logs,omitempty"` 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 holds the value of the user_allowed_groups edge.
UserAllowedGroups []*UserAllowedGroup `json:"user_allowed_groups,omitempty"` UserAllowedGroups []*UserAllowedGroup `json:"user_allowed_groups,omitempty"`
// loadedTypes holds the information for reporting if a // loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not. // 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 // 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"} 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 // UserAllowedGroupsOrErr returns the UserAllowedGroups value or an error if the edge
// was not loaded in eager-loading. // was not loaded in eager-loading.
func (e UserEdges) UserAllowedGroupsOrErr() ([]*UserAllowedGroup, error) { func (e UserEdges) UserAllowedGroupsOrErr() ([]*UserAllowedGroup, error) {
if e.loadedTypes[6] { if e.loadedTypes[7] {
return e.UserAllowedGroups, nil return e.UserAllowedGroups, nil
} }
return nil, &NotLoadedError{edge: "user_allowed_groups"} return nil, &NotLoadedError{edge: "user_allowed_groups"}
@@ -140,7 +149,7 @@ func (*User) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullFloat64) values[i] = new(sql.NullFloat64)
case user.FieldID, user.FieldConcurrency: case user.FieldID, user.FieldConcurrency:
values[i] = new(sql.NullInt64) 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) values[i] = new(sql.NullString)
case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt: case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt:
values[i] = new(sql.NullTime) values[i] = new(sql.NullTime)
@@ -226,12 +235,6 @@ func (_m *User) assignValues(columns []string, values []any) error {
} else if value.Valid { } else if value.Valid {
_m.Username = value.String _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: case user.FieldNotes:
if value, ok := values[i].(*sql.NullString); !ok { if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field notes", values[i]) 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) 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. // QueryUserAllowedGroups queries the "user_allowed_groups" edge of the User entity.
func (_m *User) QueryUserAllowedGroups() *UserAllowedGroupQuery { func (_m *User) QueryUserAllowedGroups() *UserAllowedGroupQuery {
return NewUserClient(_m.config).QueryUserAllowedGroups(_m) return NewUserClient(_m.config).QueryUserAllowedGroups(_m)
@@ -341,9 +349,6 @@ func (_m *User) String() string {
builder.WriteString("username=") builder.WriteString("username=")
builder.WriteString(_m.Username) builder.WriteString(_m.Username)
builder.WriteString(", ") builder.WriteString(", ")
builder.WriteString("wechat=")
builder.WriteString(_m.Wechat)
builder.WriteString(", ")
builder.WriteString("notes=") builder.WriteString("notes=")
builder.WriteString(_m.Notes) builder.WriteString(_m.Notes)
builder.WriteByte(')') builder.WriteByte(')')

View File

@@ -35,8 +35,6 @@ const (
FieldStatus = "status" FieldStatus = "status"
// FieldUsername holds the string denoting the username field in the database. // FieldUsername holds the string denoting the username field in the database.
FieldUsername = "username" 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 holds the string denoting the notes field in the database.
FieldNotes = "notes" FieldNotes = "notes"
// EdgeAPIKeys holds the string denoting the api_keys edge name in mutations. // EdgeAPIKeys holds the string denoting the api_keys edge name in mutations.
@@ -51,6 +49,8 @@ const (
EdgeAllowedGroups = "allowed_groups" EdgeAllowedGroups = "allowed_groups"
// EdgeUsageLogs holds the string denoting the usage_logs edge name in mutations. // EdgeUsageLogs holds the string denoting the usage_logs edge name in mutations.
EdgeUsageLogs = "usage_logs" 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 holds the string denoting the user_allowed_groups edge name in mutations.
EdgeUserAllowedGroups = "user_allowed_groups" EdgeUserAllowedGroups = "user_allowed_groups"
// Table holds the table name of the user in the database. // Table holds the table name of the user in the database.
@@ -95,6 +95,13 @@ const (
UsageLogsInverseTable = "usage_logs" UsageLogsInverseTable = "usage_logs"
// UsageLogsColumn is the table column denoting the usage_logs relation/edge. // UsageLogsColumn is the table column denoting the usage_logs relation/edge.
UsageLogsColumn = "user_id" 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 is the table that holds the user_allowed_groups relation/edge.
UserAllowedGroupsTable = "user_allowed_groups" UserAllowedGroupsTable = "user_allowed_groups"
// UserAllowedGroupsInverseTable is the table name for the UserAllowedGroup entity. // UserAllowedGroupsInverseTable is the table name for the UserAllowedGroup entity.
@@ -117,7 +124,6 @@ var Columns = []string{
FieldConcurrency, FieldConcurrency,
FieldStatus, FieldStatus,
FieldUsername, FieldUsername,
FieldWechat,
FieldNotes, FieldNotes,
} }
@@ -171,10 +177,6 @@ var (
DefaultUsername string DefaultUsername string
// UsernameValidator is a validator for the "username" field. It is called by the builders before save. // UsernameValidator is a validator for the "username" field. It is called by the builders before save.
UsernameValidator func(string) error 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 holds the default value on creation for the "notes" field.
DefaultNotes string DefaultNotes string
) )
@@ -237,11 +239,6 @@ func ByUsername(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUsername, opts...).ToFunc() 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. // ByNotes orders the results by the notes field.
func ByNotes(opts ...sql.OrderTermOption) OrderOption { func ByNotes(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldNotes, opts...).ToFunc() 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. // ByUserAllowedGroupsCount orders the results by user_allowed_groups count.
func ByUserAllowedGroupsCount(opts ...sql.OrderTermOption) OrderOption { func ByUserAllowedGroupsCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) { return func(s *sql.Selector) {
@@ -386,6 +397,13 @@ func newUsageLogsStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.O2M, false, UsageLogsTable, UsageLogsColumn), 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 { func newUserAllowedGroupsStep() *sqlgraph.Step {
return sqlgraph.NewStep( return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID), sqlgraph.From(Table, FieldID),

View File

@@ -105,11 +105,6 @@ func Username(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldUsername, v)) 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. // Notes applies equality check predicate on the "notes" field. It's identical to NotesEQ.
func Notes(v string) predicate.User { func Notes(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldNotes, v)) return predicate.User(sql.FieldEQ(FieldNotes, v))
@@ -650,71 +645,6 @@ func UsernameContainsFold(v string) predicate.User {
return predicate.User(sql.FieldContainsFold(FieldUsername, v)) 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. // NotesEQ applies the EQ predicate on the "notes" field.
func NotesEQ(v string) predicate.User { func NotesEQ(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldNotes, v)) 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. // HasUserAllowedGroups applies the HasEdge predicate on the "user_allowed_groups" edge.
func HasUserAllowedGroups() predicate.User { func HasUserAllowedGroups() predicate.User {
return predicate.User(func(s *sql.Selector) { return predicate.User(func(s *sql.Selector) {

View File

@@ -16,6 +16,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/redeemcode" "github.com/Wei-Shaw/sub2api/ent/redeemcode"
"github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userattributevalue"
"github.com/Wei-Shaw/sub2api/ent/usersubscription" "github.com/Wei-Shaw/sub2api/ent/usersubscription"
) )
@@ -151,20 +152,6 @@ func (_c *UserCreate) SetNillableUsername(v *string) *UserCreate {
return _c 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. // SetNotes sets the "notes" field.
func (_c *UserCreate) SetNotes(v string) *UserCreate { func (_c *UserCreate) SetNotes(v string) *UserCreate {
_c.mutation.SetNotes(v) _c.mutation.SetNotes(v)
@@ -269,6 +256,21 @@ func (_c *UserCreate) AddUsageLogs(v ...*UsageLog) *UserCreate {
return _c.AddUsageLogIDs(ids...) 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. // Mutation returns the UserMutation object of the builder.
func (_c *UserCreate) Mutation() *UserMutation { func (_c *UserCreate) Mutation() *UserMutation {
return _c.mutation return _c.mutation
@@ -340,10 +342,6 @@ func (_c *UserCreate) defaults() error {
v := user.DefaultUsername v := user.DefaultUsername
_c.mutation.SetUsername(v) _c.mutation.SetUsername(v)
} }
if _, ok := _c.mutation.Wechat(); !ok {
v := user.DefaultWechat
_c.mutation.SetWechat(v)
}
if _, ok := _c.mutation.Notes(); !ok { if _, ok := _c.mutation.Notes(); !ok {
v := user.DefaultNotes v := user.DefaultNotes
_c.mutation.SetNotes(v) _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)} 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 { if _, ok := _c.mutation.Notes(); !ok {
return &ValidationError{Name: "notes", err: errors.New(`ent: missing required field "User.notes"`)} 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) _spec.SetField(user.FieldUsername, field.TypeString, value)
_node.Username = 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 { if value, ok := _c.mutation.Notes(); ok {
_spec.SetField(user.FieldNotes, field.TypeString, value) _spec.SetField(user.FieldNotes, field.TypeString, value)
_node.Notes = value _node.Notes = value
@@ -591,6 +577,22 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
} }
_spec.Edges = append(_spec.Edges, edge) _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 return _node, _spec
} }
@@ -769,18 +771,6 @@ func (u *UserUpsert) UpdateUsername() *UserUpsert {
return u 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. // SetNotes sets the "notes" field.
func (u *UserUpsert) SetNotes(v string) *UserUpsert { func (u *UserUpsert) SetNotes(v string) *UserUpsert {
u.Set(user.FieldNotes, v) 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. // SetNotes sets the "notes" field.
func (u *UserUpsertOne) SetNotes(v string) *UserUpsertOne { func (u *UserUpsertOne) SetNotes(v string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) { 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. // SetNotes sets the "notes" field.
func (u *UserUpsertBulk) SetNotes(v string) *UserUpsertBulk { func (u *UserUpsertBulk) SetNotes(v string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) { return u.Update(func(s *UserUpsert) {

View File

@@ -19,6 +19,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup" "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/ent/usersubscription"
) )
@@ -35,6 +36,7 @@ type UserQuery struct {
withAssignedSubscriptions *UserSubscriptionQuery withAssignedSubscriptions *UserSubscriptionQuery
withAllowedGroups *GroupQuery withAllowedGroups *GroupQuery
withUsageLogs *UsageLogQuery withUsageLogs *UsageLogQuery
withAttributeValues *UserAttributeValueQuery
withUserAllowedGroups *UserAllowedGroupQuery withUserAllowedGroups *UserAllowedGroupQuery
// intermediate query (i.e. traversal path). // intermediate query (i.e. traversal path).
sql *sql.Selector sql *sql.Selector
@@ -204,6 +206,28 @@ func (_q *UserQuery) QueryUsageLogs() *UsageLogQuery {
return query 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. // QueryUserAllowedGroups chains the current query on the "user_allowed_groups" edge.
func (_q *UserQuery) QueryUserAllowedGroups() *UserAllowedGroupQuery { func (_q *UserQuery) QueryUserAllowedGroups() *UserAllowedGroupQuery {
query := (&UserAllowedGroupClient{config: _q.config}).Query() query := (&UserAllowedGroupClient{config: _q.config}).Query()
@@ -424,6 +448,7 @@ func (_q *UserQuery) Clone() *UserQuery {
withAssignedSubscriptions: _q.withAssignedSubscriptions.Clone(), withAssignedSubscriptions: _q.withAssignedSubscriptions.Clone(),
withAllowedGroups: _q.withAllowedGroups.Clone(), withAllowedGroups: _q.withAllowedGroups.Clone(),
withUsageLogs: _q.withUsageLogs.Clone(), withUsageLogs: _q.withUsageLogs.Clone(),
withAttributeValues: _q.withAttributeValues.Clone(),
withUserAllowedGroups: _q.withUserAllowedGroups.Clone(), withUserAllowedGroups: _q.withUserAllowedGroups.Clone(),
// clone intermediate query. // clone intermediate query.
sql: _q.sql.Clone(), sql: _q.sql.Clone(),
@@ -497,6 +522,17 @@ func (_q *UserQuery) WithUsageLogs(opts ...func(*UsageLogQuery)) *UserQuery {
return _q 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 // 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. // 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 { func (_q *UserQuery) WithUserAllowedGroups(opts ...func(*UserAllowedGroupQuery)) *UserQuery {
@@ -586,13 +622,14 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e
var ( var (
nodes = []*User{} nodes = []*User{}
_spec = _q.querySpec() _spec = _q.querySpec()
loadedTypes = [7]bool{ loadedTypes = [8]bool{
_q.withAPIKeys != nil, _q.withAPIKeys != nil,
_q.withRedeemCodes != nil, _q.withRedeemCodes != nil,
_q.withSubscriptions != nil, _q.withSubscriptions != nil,
_q.withAssignedSubscriptions != nil, _q.withAssignedSubscriptions != nil,
_q.withAllowedGroups != nil, _q.withAllowedGroups != nil,
_q.withUsageLogs != nil, _q.withUsageLogs != nil,
_q.withAttributeValues != nil,
_q.withUserAllowedGroups != nil, _q.withUserAllowedGroups != nil,
} }
) )
@@ -658,6 +695,13 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e
return nil, err 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 query := _q.withUserAllowedGroups; query != nil {
if err := _q.loadUserAllowedGroups(ctx, query, nodes, if err := _q.loadUserAllowedGroups(ctx, query, nodes,
func(n *User) { n.Edges.UserAllowedGroups = []*UserAllowedGroup{} }, func(n *User) { n.Edges.UserAllowedGroups = []*UserAllowedGroup{} },
@@ -885,6 +929,36 @@ func (_q *UserQuery) loadUsageLogs(ctx context.Context, query *UsageLogQuery, no
} }
return nil 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 { 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)) fks := make([]driver.Value, 0, len(nodes))
nodeids := make(map[int64]*User) nodeids := make(map[int64]*User)

View File

@@ -17,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/redeemcode" "github.com/Wei-Shaw/sub2api/ent/redeemcode"
"github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userattributevalue"
"github.com/Wei-Shaw/sub2api/ent/usersubscription" "github.com/Wei-Shaw/sub2api/ent/usersubscription"
) )
@@ -171,20 +172,6 @@ func (_u *UserUpdate) SetNillableUsername(v *string) *UserUpdate {
return _u 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. // SetNotes sets the "notes" field.
func (_u *UserUpdate) SetNotes(v string) *UserUpdate { func (_u *UserUpdate) SetNotes(v string) *UserUpdate {
_u.mutation.SetNotes(v) _u.mutation.SetNotes(v)
@@ -289,6 +276,21 @@ func (_u *UserUpdate) AddUsageLogs(v ...*UsageLog) *UserUpdate {
return _u.AddUsageLogIDs(ids...) 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. // Mutation returns the UserMutation object of the builder.
func (_u *UserUpdate) Mutation() *UserMutation { func (_u *UserUpdate) Mutation() *UserMutation {
return _u.mutation return _u.mutation
@@ -420,6 +422,27 @@ func (_u *UserUpdate) RemoveUsageLogs(v ...*UsageLog) *UserUpdate {
return _u.RemoveUsageLogIDs(ids...) 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. // Save executes the query and returns the number of nodes affected by the update operation.
func (_u *UserUpdate) Save(ctx context.Context) (int, error) { func (_u *UserUpdate) Save(ctx context.Context) (int, error) {
if err := _u.defaults(); err != nil { 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)} 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 return nil
} }
@@ -545,9 +563,6 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.Username(); ok { if value, ok := _u.mutation.Username(); ok {
_spec.SetField(user.FieldUsername, field.TypeString, value) _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 { if value, ok := _u.mutation.Notes(); ok {
_spec.SetField(user.FieldNotes, field.TypeString, value) _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) _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 _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil {
if _, ok := err.(*sqlgraph.NotFoundError); ok { if _, ok := err.(*sqlgraph.NotFoundError); ok {
err = &NotFoundError{user.Label} err = &NotFoundError{user.Label}
@@ -991,20 +1051,6 @@ func (_u *UserUpdateOne) SetNillableUsername(v *string) *UserUpdateOne {
return _u 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. // SetNotes sets the "notes" field.
func (_u *UserUpdateOne) SetNotes(v string) *UserUpdateOne { func (_u *UserUpdateOne) SetNotes(v string) *UserUpdateOne {
_u.mutation.SetNotes(v) _u.mutation.SetNotes(v)
@@ -1109,6 +1155,21 @@ func (_u *UserUpdateOne) AddUsageLogs(v ...*UsageLog) *UserUpdateOne {
return _u.AddUsageLogIDs(ids...) 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. // Mutation returns the UserMutation object of the builder.
func (_u *UserUpdateOne) Mutation() *UserMutation { func (_u *UserUpdateOne) Mutation() *UserMutation {
return _u.mutation return _u.mutation
@@ -1240,6 +1301,27 @@ func (_u *UserUpdateOne) RemoveUsageLogs(v ...*UsageLog) *UserUpdateOne {
return _u.RemoveUsageLogIDs(ids...) 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. // Where appends a list predicates to the UserUpdate builder.
func (_u *UserUpdateOne) Where(ps ...predicate.User) *UserUpdateOne { func (_u *UserUpdateOne) Where(ps ...predicate.User) *UserUpdateOne {
_u.mutation.Where(ps...) _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)} 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 return nil
} }
@@ -1395,9 +1472,6 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) {
if value, ok := _u.mutation.Username(); ok { if value, ok := _u.mutation.Username(); ok {
_spec.SetField(user.FieldUsername, field.TypeString, value) _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 { if value, ok := _u.mutation.Notes(); ok {
_spec.SetField(user.FieldNotes, field.TypeString, value) _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) _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} _node = &User{config: _u.config}
_spec.Assign = _node.assignValues _spec.Assign = _node.assignValues
_spec.ScanValues = _node.scanValues _spec.ScanValues = _node.scanValues

View File

@@ -246,7 +246,7 @@ func (h *UsageHandler) SearchUsers(c *gin.Context) {
} }
// Limit to 30 results // 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 { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return

View File

@@ -27,7 +27,6 @@ type CreateUserRequest struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"` Password string `json:"password" binding:"required,min=6"`
Username string `json:"username"` Username string `json:"username"`
Wechat string `json:"wechat"`
Notes string `json:"notes"` Notes string `json:"notes"`
Balance float64 `json:"balance"` Balance float64 `json:"balance"`
Concurrency int `json:"concurrency"` Concurrency int `json:"concurrency"`
@@ -40,7 +39,6 @@ type UpdateUserRequest struct {
Email string `json:"email" binding:"omitempty,email"` Email string `json:"email" binding:"omitempty,email"`
Password string `json:"password" binding:"omitempty,min=6"` Password string `json:"password" binding:"omitempty,min=6"`
Username *string `json:"username"` Username *string `json:"username"`
Wechat *string `json:"wechat"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Balance *float64 `json:"balance"` Balance *float64 `json:"balance"`
Concurrency *int `json:"concurrency"` Concurrency *int `json:"concurrency"`
@@ -57,13 +55,22 @@ type UpdateBalanceRequest struct {
// List handles listing all users with pagination // List handles listing all users with pagination
// GET /api/v1/admin/users // 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) { func (h *UserHandler) List(c *gin.Context) {
page, pageSize := response.ParsePagination(c) 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 { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
@@ -76,6 +83,29 @@ func (h *UserHandler) List(c *gin.Context) {
response.Paginated(c, out, total, page, pageSize) 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 // GetByID handles getting a user by ID
// GET /api/v1/admin/users/:id // GET /api/v1/admin/users/:id
func (h *UserHandler) GetByID(c *gin.Context) { func (h *UserHandler) GetByID(c *gin.Context) {
@@ -107,7 +137,6 @@ func (h *UserHandler) Create(c *gin.Context) {
Email: req.Email, Email: req.Email,
Password: req.Password, Password: req.Password,
Username: req.Username, Username: req.Username,
Wechat: req.Wechat,
Notes: req.Notes, Notes: req.Notes,
Balance: req.Balance, Balance: req.Balance,
Concurrency: req.Concurrency, Concurrency: req.Concurrency,
@@ -141,7 +170,6 @@ func (h *UserHandler) Update(c *gin.Context) {
Email: req.Email, Email: req.Email,
Password: req.Password, Password: req.Password,
Username: req.Username, Username: req.Username,
Wechat: req.Wechat,
Notes: req.Notes, Notes: req.Notes,
Balance: req.Balance, Balance: req.Balance,
Concurrency: req.Concurrency, Concurrency: req.Concurrency,

View File

@@ -10,7 +10,6 @@ func UserFromServiceShallow(u *service.User) *User {
ID: u.ID, ID: u.ID,
Email: u.Email, Email: u.Email,
Username: u.Username, Username: u.Username,
Wechat: u.Wechat,
Notes: u.Notes, Notes: u.Notes,
Role: u.Role, Role: u.Role,
Balance: u.Balance, Balance: u.Balance,

View File

@@ -6,7 +6,6 @@ type User struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Username string `json:"username"` Username string `json:"username"`
Wechat string `json:"wechat"`
Notes string `json:"notes"` Notes string `json:"notes"`
Role string `json:"role"` Role string `json:"role"`
Balance float64 `json:"balance"` Balance float64 `json:"balance"`

View File

@@ -30,7 +30,6 @@ type ChangePasswordRequest struct {
// UpdateProfileRequest represents the update profile request payload // UpdateProfileRequest represents the update profile request payload
type UpdateProfileRequest struct { type UpdateProfileRequest struct {
Username *string `json:"username"` Username *string `json:"username"`
Wechat *string `json:"wechat"`
} }
// GetProfile handles getting user profile // GetProfile handles getting user profile
@@ -99,7 +98,6 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
svcReq := service.UpdateProfileRequest{ svcReq := service.UpdateProfileRequest{
Username: req.Username, Username: req.Username,
Wechat: req.Wechat,
} }
updatedUser, err := h.userService.UpdateProfile(c.Request.Context(), subject.UserID, svcReq) updatedUser, err := h.userService.UpdateProfile(c.Request.Context(), subject.UserID, svcReq)
if err != nil { if err != nil {

View File

@@ -294,7 +294,6 @@ func userEntityToService(u *dbent.User) *service.User {
ID: u.ID, ID: u.ID,
Email: u.Email, Email: u.Email,
Username: u.Username, Username: u.Username,
Wechat: u.Wechat,
Notes: u.Notes, Notes: u.Notes,
PasswordHash: u.PasswordHash, PasswordHash: u.PasswordHash,
Role: u.Role, Role: u.Role,

View File

@@ -40,7 +40,6 @@ func mustCreateUser(t *testing.T, client *dbent.Client, u *service.User) *servic
SetBalance(u.Balance). SetBalance(u.Balance).
SetConcurrency(u.Concurrency). SetConcurrency(u.Concurrency).
SetUsername(u.Username). SetUsername(u.Username).
SetWechat(u.Wechat).
SetNotes(u.Notes) SetNotes(u.Notes)
if !u.CreatedAt.IsZero() { if !u.CreatedAt.IsZero() {
create.SetCreatedAt(u.CreatedAt) create.SetCreatedAt(u.CreatedAt)

View File

@@ -23,7 +23,6 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
// users: columns required by repository queries // users: columns required by repository queries
requireColumn(t, tx, "users", "username", "character varying", 100, false) 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) requireColumn(t, tx, "users", "notes", "text", 0, false)
// accounts: schedulable and rate-limit fields // accounts: schedulable and rate-limit fields

View File

@@ -9,6 +9,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
dbuser "github.com/Wei-Shaw/sub2api/ent/user" dbuser "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup" "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/ent/usersubscription"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service" "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(). created, err := txClient.User.Create().
SetEmail(userIn.Email). SetEmail(userIn.Email).
SetUsername(userIn.Username). SetUsername(userIn.Username).
SetWechat(userIn.Wechat).
SetNotes(userIn.Notes). SetNotes(userIn.Notes).
SetPasswordHash(userIn.PasswordHash). SetPasswordHash(userIn.PasswordHash).
SetRole(userIn.Role). 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). updated, err := txClient.User.UpdateOneID(userIn.ID).
SetEmail(userIn.Email). SetEmail(userIn.Email).
SetUsername(userIn.Username). SetUsername(userIn.Username).
SetWechat(userIn.Wechat).
SetNotes(userIn.Notes). SetNotes(userIn.Notes).
SetPasswordHash(userIn.PasswordHash). SetPasswordHash(userIn.PasswordHash).
SetRole(userIn.Role). 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) { 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() q := r.client.User.Query()
if status != "" { if filters.Status != "" {
q = q.Where(dbuser.StatusEQ(status)) q = q.Where(dbuser.StatusEQ(filters.Status))
} }
if role != "" { if filters.Role != "" {
q = q.Where(dbuser.RoleEQ(role)) q = q.Where(dbuser.RoleEQ(filters.Role))
} }
if search != "" { if filters.Search != "" {
q = q.Where( q = q.Where(
dbuser.Or( dbuser.Or(
dbuser.EmailContainsFold(search), dbuser.EmailContainsFold(filters.Search),
dbuser.UsernameContainsFold(search), dbuser.UsernameContainsFold(filters.Search),
dbuser.WechatContainsFold(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) total, err := q.Clone().Count(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -252,6 +261,59 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
return outUsers, paginationResultFromTotal(int64(total), params), nil 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 { func (r *userRepository) UpdateBalance(ctx context.Context, id int64, amount float64) error {
client := clientFromContext(ctx, r.client) client := clientFromContext(ctx, r.client)
n, err := client.User.Update().Where(dbuser.IDEQ(id)).AddBalance(amount).Save(ctx) n, err := client.User.Update().Where(dbuser.IDEQ(id)).AddBalance(amount).Save(ctx)

View File

@@ -202,16 +202,6 @@ func (s *UserRepoSuite) TestListWithFilters_SearchByUsername() {
s.Require().Equal("JohnDoe", users[0].Username) 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() { func (s *UserRepoSuite) TestListWithFilters_LoadsActiveSubscriptions() {
user := s.mustCreateUser(&service.User{Email: "sub@test.com", Status: service.StatusActive}) user := s.mustCreateUser(&service.User{Email: "sub@test.com", Status: service.StatusActive})
groupActive := s.mustCreateGroup("g-sub-active") groupActive := s.mustCreateGroup("g-sub-active")
@@ -238,7 +228,6 @@ func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() {
s.mustCreateUser(&service.User{ s.mustCreateUser(&service.User{
Email: "a@example.com", Email: "a@example.com",
Username: "Alice", Username: "Alice",
Wechat: "wx_a",
Role: service.RoleUser, Role: service.RoleUser,
Status: service.StatusActive, Status: service.StatusActive,
Balance: 10, Balance: 10,
@@ -246,7 +235,6 @@ func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() {
target := s.mustCreateUser(&service.User{ target := s.mustCreateUser(&service.User{
Email: "b@example.com", Email: "b@example.com",
Username: "Bob", Username: "Bob",
Wechat: "wx_b",
Role: service.RoleAdmin, Role: service.RoleAdmin,
Status: service.StatusActive, Status: service.StatusActive,
Balance: 1, Balance: 1,
@@ -448,7 +436,6 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
user1 := s.mustCreateUser(&service.User{ user1 := s.mustCreateUser(&service.User{
Email: "a@example.com", Email: "a@example.com",
Username: "Alice", Username: "Alice",
Wechat: "wx_a",
Role: service.RoleUser, Role: service.RoleUser,
Status: service.StatusActive, Status: service.StatusActive,
Balance: 10, Balance: 10,
@@ -456,7 +443,6 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
user2 := s.mustCreateUser(&service.User{ user2 := s.mustCreateUser(&service.User{
Email: "b@example.com", Email: "b@example.com",
Username: "Bob", Username: "Bob",
Wechat: "wx_b",
Role: service.RoleAdmin, Role: service.RoleAdmin,
Status: service.StatusActive, Status: service.StatusActive,
Balance: 1, Balance: 1,

View File

@@ -51,7 +51,6 @@ func TestAPIContracts(t *testing.T) {
"id": 1, "id": 1,
"email": "alice@example.com", "email": "alice@example.com",
"username": "alice", "username": "alice",
"wechat": "wx_alice",
"notes": "hello", "notes": "hello",
"role": "user", "role": "user",
"balance": 12.5, "balance": 12.5,

View File

@@ -13,7 +13,7 @@ import (
// AdminService interface defines admin management operations // AdminService interface defines admin management operations
type AdminService interface { type AdminService interface {
// User management // 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) GetUser(ctx context.Context, id int64) (*User, error)
CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error)
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error)
@@ -69,7 +69,6 @@ type CreateUserInput struct {
Email string Email string
Password string Password string
Username string Username string
Wechat string
Notes string Notes string
Balance float64 Balance float64
Concurrency int Concurrency int
@@ -80,7 +79,6 @@ type UpdateUserInput struct {
Email string Email string
Password string Password string
Username *string Username *string
Wechat *string
Notes *string Notes *string
Balance *float64 // 使用指针区分"未提供"和"设置为0" Balance *float64 // 使用指针区分"未提供"和"设置为0"
Concurrency *int // 使用指针区分"未提供"和"设置为0" Concurrency *int // 使用指针区分"未提供"和"设置为0"
@@ -251,9 +249,9 @@ func NewAdminService(
} }
// User management implementations // 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} 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 { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -268,7 +266,6 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu
user := &User{ user := &User{
Email: input.Email, Email: input.Email,
Username: input.Username, Username: input.Username,
Wechat: input.Wechat,
Notes: input.Notes, Notes: input.Notes,
Role: RoleUser, // Always create as regular user, never admin Role: RoleUser, // Always create as regular user, never admin
Balance: input.Balance, Balance: input.Balance,
@@ -310,9 +307,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
if input.Username != nil { if input.Username != nil {
user.Username = *input.Username user.Username = *input.Username
} }
if input.Wechat != nil {
user.Wechat = *input.Wechat
}
if input.Notes != nil { if input.Notes != nil {
user.Notes = *input.Notes user.Notes = *input.Notes
} }

View File

@@ -18,7 +18,6 @@ func TestAdminService_CreateUser_Success(t *testing.T) {
Email: "user@test.com", Email: "user@test.com",
Password: "strong-pass", Password: "strong-pass",
Username: "tester", Username: "tester",
Wechat: "wx",
Notes: "note", Notes: "note",
Balance: 12.5, Balance: 12.5,
Concurrency: 7, Concurrency: 7,
@@ -31,7 +30,6 @@ func TestAdminService_CreateUser_Success(t *testing.T) {
require.Equal(t, int64(10), user.ID) require.Equal(t, int64(10), user.ID)
require.Equal(t, input.Email, user.Email) require.Equal(t, input.Email, user.Email)
require.Equal(t, input.Username, user.Username) 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.Notes, user.Notes)
require.Equal(t, input.Balance, user.Balance) require.Equal(t, input.Balance, user.Balance)
require.Equal(t, input.Concurrency, user.Concurrency) require.Equal(t, input.Concurrency, user.Concurrency)

View File

@@ -10,7 +10,6 @@ type User struct {
ID int64 ID int64
Email string Email string
Username string Username string
Wechat string
Notes string Notes string
PasswordHash string PasswordHash string
Role string Role string

View File

@@ -14,6 +14,14 @@ var (
ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions") 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 { type UserRepository interface {
Create(ctx context.Context, user *User) error Create(ctx context.Context, user *User) error
GetByID(ctx context.Context, id int64) (*User, error) GetByID(ctx context.Context, id int64) (*User, error)
@@ -23,7 +31,7 @@ type UserRepository interface {
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error
List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, 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 UpdateBalance(ctx context.Context, id int64, amount float64) error
DeductBalance(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 { type UpdateProfileRequest struct {
Email *string `json:"email"` Email *string `json:"email"`
Username *string `json:"username"` Username *string `json:"username"`
Wechat *string `json:"wechat"`
Concurrency *int `json:"concurrency"` Concurrency *int `json:"concurrency"`
} }
@@ -100,10 +107,6 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
user.Username = *req.Username user.Username = *req.Username
} }
if req.Wechat != nil {
user.Wechat = *req.Wechat
}
if req.Concurrency != nil { if req.Concurrency != nil {
user.Concurrency = *req.Concurrency user.Concurrency = *req.Concurrency
} }

View File

@@ -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

View File

@@ -10,7 +10,7 @@ import type { User, UpdateUserRequest, PaginatedResponse } from '@/types'
* List all users with pagination * List all users with pagination
* @param page - Page number (default: 1) * @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20) * @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) * @param options - Optional request options (signal)
* @returns Paginated list of users * @returns Paginated list of users
*/ */
@@ -21,17 +21,32 @@ export async function list(
status?: 'active' | 'disabled' status?: 'active' | 'disabled'
role?: 'admin' | 'user' role?: 'admin' | 'user'
search?: string search?: string
attributes?: Record<number, string> // attributeId -> value
}, },
options?: { options?: {
signal?: AbortSignal signal?: AbortSignal
} }
): Promise<PaginatedResponse<User>> { ): Promise<PaginatedResponse<User>> {
// Build params with attribute filters in attr[id]=value format
const params: Record<string, any> = {
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<PaginatedResponse<User>>('/admin/users', { const { data } = await apiClient.get<PaginatedResponse<User>>('/admin/users', {
params: { params,
page,
page_size: pageSize,
...filters
},
signal: options?.signal signal: options?.signal
}) })
return data return data

View File

@@ -22,7 +22,6 @@ export async function getProfile(): Promise<User> {
*/ */
export async function updateProfile(profile: { export async function updateProfile(profile: {
username?: string username?: string
wechat?: string
}): Promise<User> { }): Promise<User> {
const { data } = await apiClient.put<User>('/user', profile) const { data } = await apiClient.put<User>('/user', profile)
return data return data

View File

@@ -434,9 +434,7 @@ export default {
administrator: 'Administrator', administrator: 'Administrator',
user: 'User', user: 'User',
username: 'Username', username: 'Username',
wechat: 'WeChat ID',
enterUsername: 'Enter username', enterUsername: 'Enter username',
enterWechat: 'Enter WeChat ID',
editProfile: 'Edit Profile', editProfile: 'Edit Profile',
updateProfile: 'Update Profile', updateProfile: 'Update Profile',
updating: 'Updating...', updating: 'Updating...',
@@ -565,12 +563,10 @@ export default {
email: 'Email', email: 'Email',
password: 'Password', password: 'Password',
username: 'Username', username: 'Username',
wechat: 'WeChat ID',
notes: 'Notes', notes: 'Notes',
enterEmail: 'Enter email', enterEmail: 'Enter email',
enterPassword: 'Enter password', enterPassword: 'Enter password',
enterUsername: 'Enter username (optional)', enterUsername: 'Enter username (optional)',
enterWechat: 'Enter WeChat ID (optional)',
enterNotes: 'Enter notes (admin only)', enterNotes: 'Enter notes (admin only)',
notesHint: 'This note is only visible to administrators', notesHint: 'This note is only visible to administrators',
enterNewPassword: 'Enter new password (optional)', enterNewPassword: 'Enter new password (optional)',
@@ -582,7 +578,6 @@ export default {
columns: { columns: {
user: 'User', user: 'User',
username: 'Username', username: 'Username',
wechat: 'WeChat ID',
notes: 'Notes', notes: 'Notes',
role: 'Role', role: 'Role',
subscriptions: 'Subscriptions', subscriptions: 'Subscriptions',
@@ -653,7 +648,67 @@ export default {
failedToDeposit: 'Failed to deposit', failedToDeposit: 'Failed to deposit',
failedToWithdraw: 'Failed to withdraw', failedToWithdraw: 'Failed to withdraw',
useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance', 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 // Groups

View File

@@ -430,9 +430,7 @@ export default {
administrator: '管理员', administrator: '管理员',
user: '用户', user: '用户',
username: '用户名', username: '用户名',
wechat: '微信号',
enterUsername: '输入用户名', enterUsername: '输入用户名',
enterWechat: '输入微信号',
editProfile: '编辑个人资料', editProfile: '编辑个人资料',
updateProfile: '更新资料', updateProfile: '更新资料',
updating: '更新中...', updating: '更新中...',
@@ -583,12 +581,10 @@ export default {
email: '邮箱', email: '邮箱',
password: '密码', password: '密码',
username: '用户名', username: '用户名',
wechat: '微信号',
notes: '备注', notes: '备注',
enterEmail: '请输入邮箱', enterEmail: '请输入邮箱',
enterPassword: '请输入密码', enterPassword: '请输入密码',
enterUsername: '请输入用户名(选填)', enterUsername: '请输入用户名(选填)',
enterWechat: '请输入微信号(选填)',
enterNotes: '请输入备注(仅管理员可见)', enterNotes: '请输入备注(仅管理员可见)',
notesHint: '此备注仅对管理员可见', notesHint: '此备注仅对管理员可见',
enterNewPassword: '请输入新密码(选填)', enterNewPassword: '请输入新密码(选填)',
@@ -601,7 +597,6 @@ export default {
user: '用户', user: '用户',
email: '邮箱', email: '邮箱',
username: '用户名', username: '用户名',
wechat: '微信号',
notes: '备注', notes: '备注',
role: '角色', role: '角色',
subscriptions: '订阅分组', subscriptions: '订阅分组',
@@ -655,8 +650,6 @@ export default {
emailPlaceholder: '请输入邮箱', emailPlaceholder: '请输入邮箱',
usernameLabel: '用户名', usernameLabel: '用户名',
usernamePlaceholder: '请输入用户名(选填)', usernamePlaceholder: '请输入用户名(选填)',
wechatLabel: '微信号',
wechatPlaceholder: '请输入微信号(选填)',
notesLabel: '备注', notesLabel: '备注',
notesPlaceholder: '请输入备注(仅管理员可见)', notesPlaceholder: '请输入备注(仅管理员可见)',
notesHint: '此备注仅对管理员可见', notesHint: '此备注仅对管理员可见',
@@ -711,7 +704,67 @@ export default {
failedToDeposit: '充值失败', failedToDeposit: '充值失败',
failedToWithdraw: '退款失败', failedToWithdraw: '退款失败',
useDepositWithdrawButtons: '请使用充值/退款按钮调整余额', 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 // Groups Management

View File

@@ -7,7 +7,6 @@
export interface User { export interface User {
id: number id: number
username: string username: string
wechat: string
notes: string notes: string
email: string email: string
role: 'admin' | 'user' // User role for authorization role: 'admin' | 'user' // User role for authorization
@@ -634,7 +633,6 @@ export interface UpdateUserRequest {
email?: string email?: string
password?: string password?: string
username?: string username?: string
wechat?: string
notes?: string notes?: string
role?: 'admin' | 'user' role?: 'admin' | 'user'
balance?: number balance?: number
@@ -771,3 +769,76 @@ export interface AccountUsageStatsResponse {
summary: AccountUsageSummary summary: AccountUsageSummary
models: ModelStat[] 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
}

View File

@@ -1,86 +1,289 @@
<template> <template>
<AppLayout> <AppLayout>
<TablePageLayout> <TablePageLayout>
<!-- Page Header Actions --> <!-- Single Row: Search, Filters, and Actions -->
<template #actions>
<div class="flex justify-end gap-3">
<button
@click="loadUsers"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<svg
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button @click="showCreateModal = true" class="btn btn-primary">
<svg
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ t('admin.users.createUser') }}
</button>
</div>
</template>
<!-- Search and Filters -->
<template #filters> <template #filters>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="relative max-w-md flex-1"> <!-- Left: Search + Active Filters -->
<svg <div class="flex flex-1 flex-wrap items-center gap-3">
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" <!-- Search Box -->
fill="none" <div class="relative w-64">
stroke="currentColor" <svg
viewBox="0 0 24 24" class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
stroke-width="1.5" fill="none"
> stroke="currentColor"
<path viewBox="0 0 24 24"
stroke-linecap="round" stroke-width="1.5"
stroke-linejoin="round" >
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" <path
/> stroke-linecap="round"
</svg> stroke-linejoin="round"
<input d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
v-model="searchQuery" />
type="text" </svg>
:placeholder="t('admin.users.searchUsers')" <input
class="input pl-10" v-model="searchQuery"
@input="handleSearch" type="text"
/> :placeholder="t('admin.users.searchUsers')"
class="input pl-10"
@input="handleSearch"
/>
</div>
<!-- Role Filter (visible when enabled) -->
<div v-if="visibleFilters.has('role')" class="relative">
<select
v-model="filters.role"
@change="applyFilter"
class="input w-32 cursor-pointer appearance-none pr-8"
>
<option value="">{{ t('admin.users.allRoles') }}</option>
<option value="admin">{{ t('admin.users.admin') }}</option>
<option value="user">{{ t('admin.users.user') }}</option>
</select>
<svg
class="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</div>
<!-- Status Filter (visible when enabled) -->
<div v-if="visibleFilters.has('status')" class="relative">
<select
v-model="filters.status"
@change="applyFilter"
class="input w-32 cursor-pointer appearance-none pr-8"
>
<option value="">{{ t('admin.users.allStatus') }}</option>
<option value="active">{{ t('common.active') }}</option>
<option value="disabled">{{ t('admin.users.disabled') }}</option>
</select>
<svg
class="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</div>
<!-- Dynamic Attribute Filters -->
<template v-for="(value, attrId) in activeAttributeFilters" :key="attrId">
<div v-if="visibleFilters.has(`attr_${attrId}`)" class="relative">
<!-- Text/Email/URL/Textarea/Date type: styled input -->
<input
v-if="['text', 'textarea', 'email', 'url', 'date'].includes(getAttributeDefinition(Number(attrId))?.type || 'text')"
:value="value"
@input="(e) => updateAttributeFilter(Number(attrId), (e.target as HTMLInputElement).value)"
@keyup.enter="applyFilter"
:placeholder="getAttributeDefinitionName(Number(attrId))"
class="input w-36"
/>
<!-- Number type: number input -->
<input
v-else-if="getAttributeDefinition(Number(attrId))?.type === 'number'"
:value="value"
type="number"
@input="(e) => updateAttributeFilter(Number(attrId), (e.target as HTMLInputElement).value)"
@keyup.enter="applyFilter"
:placeholder="getAttributeDefinitionName(Number(attrId))"
class="input w-32"
/>
<!-- Select/Multi-select type -->
<template v-else-if="['select', 'multi_select'].includes(getAttributeDefinition(Number(attrId))?.type || '')">
<select
:value="value"
@change="(e) => { updateAttributeFilter(Number(attrId), (e.target as HTMLSelectElement).value); applyFilter() }"
class="input w-36 cursor-pointer appearance-none pr-8"
>
<option value="">{{ getAttributeDefinitionName(Number(attrId)) }}</option>
<option
v-for="opt in getAttributeDefinition(Number(attrId))?.options || []"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
<svg
class="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</template>
<!-- Fallback -->
<input
v-else
:value="value"
@input="(e) => updateAttributeFilter(Number(attrId), (e.target as HTMLInputElement).value)"
@keyup.enter="applyFilter"
:placeholder="getAttributeDefinitionName(Number(attrId))"
class="input w-36"
/>
</div>
</template>
</div>
<!-- Right: Actions and Settings -->
<div class="flex items-center gap-3">
<!-- Refresh Button -->
<button
@click="loadUsers"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<svg
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<!-- Filter Settings Dropdown -->
<div class="relative" ref="filterDropdownRef">
<button
@click="showFilterDropdown = !showFilterDropdown"
class="btn btn-secondary"
>
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</svg>
{{ t('admin.users.filterSettings') }}
</button>
<!-- Dropdown menu -->
<div
v-if="showFilterDropdown"
class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<!-- Built-in filters -->
<button
v-for="filter in builtInFilters"
:key="filter.key"
@click="toggleBuiltInFilter(filter.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ filter.name }}</span>
<svg
v-if="visibleFilters.has(filter.key)"
class="h-4 w-4 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Divider if custom attributes exist -->
<div
v-if="filterableAttributes.length > 0"
class="my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Custom attribute filters -->
<button
v-for="attr in filterableAttributes"
:key="attr.id"
@click="toggleAttributeFilter(attr)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ attr.name }}</span>
<svg
v-if="visibleFilters.has(`attr_${attr.id}`)"
class="h-4 w-4 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</div>
<!-- Column Settings Dropdown -->
<div class="relative" ref="columnDropdownRef">
<button
@click="showColumnDropdown = !showColumnDropdown"
class="btn btn-secondary"
>
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
</svg>
{{ t('admin.users.columnSettings') }}
</button>
<!-- Dropdown menu -->
<div
v-if="showColumnDropdown"
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="col in toggleableColumns"
:key="col.key"
@click="toggleColumn(col.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<span>{{ col.label }}</span>
<svg
v-if="isColumnVisible(col.key)"
class="h-4 w-4 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</div>
<!-- Attributes Config Button -->
<button @click="showAttributesModal = true" class="btn btn-secondary">
<svg
class="mr-1.5 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ t('admin.users.attributes.configButton') }}
</button>
<!-- Create User Button -->
<button @click="showCreateModal = true" class="btn btn-primary">
<svg
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ t('admin.users.createUser') }}
</button>
</div>
</div> </div>
<div class="flex flex-wrap gap-3">
<Select
v-model="filters.role"
:options="roleOptions"
:placeholder="t('admin.users.allRoles')"
class="w-36"
@change="loadUsers"
/>
<Select
v-model="filters.status"
:options="statusOptions"
:placeholder="t('admin.users.allStatus')"
class="w-36"
@change="loadUsers"
/>
</div>
</div>
</template> </template>
<!-- Users Table --> <!-- Users Table -->
@@ -103,10 +306,6 @@
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value || '-' }}</span> <span class="text-sm text-gray-700 dark:text-gray-300">{{ value || '-' }}</span>
</template> </template>
<template #cell-wechat="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value || '-' }}</span>
</template>
<template #cell-notes="{ value }"> <template #cell-notes="{ value }">
<div class="max-w-xs"> <div class="max-w-xs">
<span <span
@@ -120,6 +319,22 @@
</div> </div>
</template> </template>
<!-- Dynamic attribute columns -->
<template
v-for="def in attributeDefinitions.filter(d => d.enabled)"
:key="def.id"
#[`cell-attr_${def.id}`]="{ row }"
>
<div class="max-w-xs">
<span
class="block truncate text-sm text-gray-700 dark:text-gray-300"
:title="getAttributeValue(row.id, def.id)"
>
{{ getAttributeValue(row.id, def.id) }}
</span>
</div>
</template>
<template #cell-role="{ value }"> <template #cell-role="{ value }">
<span :class="['badge', value === 'admin' ? 'badge-purple' : 'badge-gray']"> <span :class="['badge', value === 'admin' ? 'badge-purple' : 'badge-gray']">
{{ value }} {{ value }}
@@ -189,9 +404,17 @@
</template> </template>
<template #cell-status="{ value }"> <template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']"> <div class="flex items-center gap-1.5">
{{ value }} <span
</span> :class="[
'inline-block h-2 w-2 rounded-full',
value === 'active' ? 'bg-green-500' : 'bg-red-500'
]"
></span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ value === 'active' ? t('common.active') : t('admin.users.disabled') }}
</span>
</div>
</template> </template>
<template #cell-created_at="{ value }"> <template #cell-created_at="{ value }">
@@ -471,15 +694,6 @@
:placeholder="t('admin.users.enterUsername')" :placeholder="t('admin.users.enterUsername')"
/> />
</div> </div>
<div>
<label class="input-label">{{ t('admin.users.wechat') }}</label>
<input
v-model="createForm.wechat"
type="text"
class="input"
:placeholder="t('admin.users.enterWechat')"
/>
</div>
<div> <div>
<label class="input-label">{{ t('admin.users.notes') }}</label> <label class="input-label">{{ t('admin.users.notes') }}</label>
<textarea <textarea
@@ -640,15 +854,6 @@
:placeholder="t('admin.users.enterUsername')" :placeholder="t('admin.users.enterUsername')"
/> />
</div> </div>
<div>
<label class="input-label">{{ t('admin.users.wechat') }}</label>
<input
v-model="editForm.wechat"
type="text"
class="input"
:placeholder="t('admin.users.enterWechat')"
/>
</div>
<div> <div>
<label class="input-label">{{ t('admin.users.notes') }}</label> <label class="input-label">{{ t('admin.users.notes') }}</label>
<textarea <textarea
@@ -664,6 +869,12 @@
<input v-model.number="editForm.concurrency" type="number" class="input" /> <input v-model.number="editForm.concurrency" type="number" class="input" />
</div> </div>
<!-- Custom Attributes -->
<UserAttributeForm
v-model="editForm.customAttributes"
:user-id="editingUser?.id"
/>
</form> </form>
<template #footer> <template #footer>
@@ -1179,6 +1390,12 @@
@confirm="confirmDelete" @confirm="confirmDelete"
@cancel="showDeleteDialog = false" @cancel="showDeleteDialog = false"
/> />
<!-- User Attributes Config Modal -->
<UserAttributesConfigModal
:show="showAttributesModal"
@close="handleAttributesModalClose"
/>
</AppLayout> </AppLayout>
</template> </template>
@@ -1191,7 +1408,7 @@ import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { User, ApiKey, Group } from '@/types' import type { User, ApiKey, Group, UserAttributeValuesMap, UserAttributeDefinition } from '@/types'
import type { BatchUserUsageStats } from '@/api/admin/dashboard' import type { BatchUserUsageStats } from '@/api/admin/dashboard'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
@@ -1201,17 +1418,66 @@ import Pagination from '@/components/common/Pagination.vue'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import GroupBadge from '@/components/common/GroupBadge.vue' import GroupBadge from '@/components/common/GroupBadge.vue'
import UserAttributesConfigModal from '@/components/user/UserAttributesConfigModal.vue'
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
const appStore = useAppStore() const appStore = useAppStore()
const { copyToClipboard: clipboardCopy } = useClipboard() const { copyToClipboard: clipboardCopy } = useClipboard()
const columns = computed<Column[]>(() => [ // Generate dynamic attribute columns from enabled definitions
const attributeColumns = computed<Column[]>(() =>
attributeDefinitions.value
.filter(def => def.enabled)
.map(def => ({
key: `attr_${def.id}`,
label: def.name,
sortable: false
}))
)
// Get formatted attribute value for display in table
const getAttributeValue = (userId: number, attrId: number): string => {
const userAttrs = userAttributeValues.value[userId]
if (!userAttrs) return '-'
const value = userAttrs[attrId]
if (!value) return '-'
// Find definition for this attribute
const def = attributeDefinitions.value.find(d => d.id === attrId)
if (!def) return value
// Format based on type
if (def.type === 'multi_select' && value) {
try {
const arr = JSON.parse(value)
if (Array.isArray(arr)) {
// Map values to labels
return arr.map(v => {
const opt = def.options?.find(o => o.value === v)
return opt?.label || v
}).join(', ')
}
} catch {
return value
}
}
if (def.type === 'select' && value && def.options) {
const opt = def.options.find(o => o.value === value)
return opt?.label || value
}
return value
}
// All possible columns (for column settings)
const allColumns = computed<Column[]>(() => [
{ key: 'email', label: t('admin.users.columns.user'), sortable: true }, { key: 'email', label: t('admin.users.columns.user'), sortable: true },
{ key: 'username', label: t('admin.users.columns.username'), sortable: true }, { key: 'username', label: t('admin.users.columns.username'), sortable: true },
{ key: 'wechat', label: t('admin.users.columns.wechat'), sortable: false },
{ key: 'notes', label: t('admin.users.columns.notes'), sortable: false }, { key: 'notes', label: t('admin.users.columns.notes'), sortable: false },
// Dynamic attribute columns
...attributeColumns.value,
{ key: 'role', label: t('admin.users.columns.role'), sortable: true }, { key: 'role', label: t('admin.users.columns.role'), sortable: true },
{ key: 'subscriptions', label: t('admin.users.columns.subscriptions'), sortable: false }, { key: 'subscriptions', label: t('admin.users.columns.subscriptions'), sortable: false },
{ key: 'balance', label: t('admin.users.columns.balance'), sortable: true }, { key: 'balance', label: t('admin.users.columns.balance'), sortable: true },
@@ -1222,27 +1488,154 @@ const columns = computed<Column[]>(() => [
{ key: 'actions', label: t('admin.users.columns.actions'), sortable: false } { key: 'actions', label: t('admin.users.columns.actions'), sortable: false }
]) ])
// Filter options // Columns that can be toggled (exclude email and actions which are always visible)
const roleOptions = computed(() => [ const toggleableColumns = computed(() =>
{ value: '', label: t('admin.users.allRoles') }, allColumns.value.filter(col => col.key !== 'email' && col.key !== 'actions')
{ value: 'admin', label: t('admin.users.admin') }, )
{ value: 'user', label: t('admin.users.user') }
])
const statusOptions = computed(() => [ // Hidden columns (stored in Set - columns NOT in this set are visible)
{ value: '', label: t('admin.users.allStatus') }, // This way, new columns are visible by default
{ value: 'active', label: t('common.active') }, const hiddenColumns = reactive<Set<string>>(new Set())
{ value: 'disabled', label: t('admin.users.disabled') }
]) // Default hidden columns (columns hidden by default on first load)
const DEFAULT_HIDDEN_COLUMNS = ['notes', 'subscriptions', 'usage', 'concurrency']
// localStorage key for column settings
const HIDDEN_COLUMNS_KEY = 'user-hidden-columns'
// Load saved column settings
const loadSavedColumns = () => {
try {
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
if (saved) {
const parsed = JSON.parse(saved) as string[]
parsed.forEach(key => hiddenColumns.add(key))
} else {
// Use default hidden columns on first load
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
}
} catch (e) {
console.error('Failed to load saved columns:', e)
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
}
}
// Save column settings to localStorage
const saveColumnsToStorage = () => {
try {
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
} catch (e) {
console.error('Failed to save columns:', e)
}
}
// Toggle column visibility
const toggleColumn = (key: string) => {
if (hiddenColumns.has(key)) {
hiddenColumns.delete(key)
} else {
hiddenColumns.add(key)
}
saveColumnsToStorage()
}
// Check if column is visible (not in hidden set)
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
// Filtered columns based on visibility
const columns = computed<Column[]>(() =>
allColumns.value.filter(col =>
col.key === 'email' || col.key === 'actions' || !hiddenColumns.has(col.key)
)
)
const users = ref<User[]>([]) const users = ref<User[]>([])
const loading = ref(false) const loading = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
// Filter values (role, status, and custom attributes)
const filters = reactive({ const filters = reactive({
role: '', role: '',
status: '' status: ''
}) })
const activeAttributeFilters = reactive<Record<number, string>>({})
// Visible filters tracking (which filters are shown in the UI)
// Keys: 'role', 'status', 'attr_${id}'
const visibleFilters = reactive<Set<string>>(new Set())
// Dropdown states
const showFilterDropdown = ref(false)
const showColumnDropdown = ref(false)
// Dropdown refs for click outside detection
const filterDropdownRef = ref<HTMLElement | null>(null)
const columnDropdownRef = ref<HTMLElement | null>(null)
// localStorage keys
const FILTER_VALUES_KEY = 'user-filter-values'
const VISIBLE_FILTERS_KEY = 'user-visible-filters'
// All filterable attribute definitions (enabled attributes)
const filterableAttributes = computed(() =>
attributeDefinitions.value.filter(def => def.enabled)
)
// Built-in filter definitions
const builtInFilters = computed(() => [
{ key: 'role', name: t('admin.users.columns.role'), type: 'select' as const },
{ key: 'status', name: t('admin.users.columns.status'), type: 'select' as const }
])
// Load saved filters from localStorage
const loadSavedFilters = () => {
try {
// Load visible filters
const savedVisible = localStorage.getItem(VISIBLE_FILTERS_KEY)
if (savedVisible) {
const parsed = JSON.parse(savedVisible) as string[]
parsed.forEach(key => visibleFilters.add(key))
}
// Load filter values
const savedValues = localStorage.getItem(FILTER_VALUES_KEY)
if (savedValues) {
const parsed = JSON.parse(savedValues)
if (parsed.role) filters.role = parsed.role
if (parsed.status) filters.status = parsed.status
if (parsed.attributes) {
Object.assign(activeAttributeFilters, parsed.attributes)
}
}
} catch (e) {
console.error('Failed to load saved filters:', e)
}
}
// Save filters to localStorage
const saveFiltersToStorage = () => {
try {
// Save visible filters
localStorage.setItem(VISIBLE_FILTERS_KEY, JSON.stringify([...visibleFilters]))
// Save filter values
const values = {
role: filters.role,
status: filters.status,
attributes: activeAttributeFilters
}
localStorage.setItem(FILTER_VALUES_KEY, JSON.stringify(values))
} catch (e) {
console.error('Failed to save filters:', e)
}
}
// Get attribute definition by ID
const getAttributeDefinition = (attrId: number): UserAttributeDefinition | undefined => {
return attributeDefinitions.value.find(d => d.id === attrId)
}
const usageStats = ref<Record<string, BatchUserUsageStats>>({}) const usageStats = ref<Record<string, BatchUserUsageStats>>({})
// User attribute definitions and values
const attributeDefinitions = ref<UserAttributeDefinition[]>([])
const userAttributeValues = ref<Record<number, Record<number, string>>>({})
const pagination = reactive({ const pagination = reactive({
page: 1, page: 1,
page_size: 20, page_size: 20,
@@ -1254,6 +1647,7 @@ const showCreateModal = ref(false)
const showEditModal = ref(false) const showEditModal = ref(false)
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
const showApiKeysModal = ref(false) const showApiKeysModal = ref(false)
const showAttributesModal = ref(false)
const submitting = ref(false) const submitting = ref(false)
const editingUser = ref<User | null>(null) const editingUser = ref<User | null>(null)
const deletingUser = ref<User | null>(null) const deletingUser = ref<User | null>(null)
@@ -1317,6 +1711,14 @@ const handleClickOutside = (event: MouseEvent) => {
if (!target.closest('.action-menu-trigger') && !target.closest('.action-menu-content')) { if (!target.closest('.action-menu-trigger') && !target.closest('.action-menu-content')) {
closeActionMenu() closeActionMenu()
} }
// Close filter dropdown when clicking outside
if (filterDropdownRef.value && !filterDropdownRef.value.contains(target)) {
showFilterDropdown.value = false
}
// Close column dropdown when clicking outside
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
showColumnDropdown.value = false
}
} }
// Allowed groups modal state // Allowed groups modal state
@@ -1341,7 +1743,6 @@ const createForm = reactive({
email: '', email: '',
password: '', password: '',
username: '', username: '',
wechat: '',
notes: '', notes: '',
balance: 0, balance: 0,
concurrency: 1 concurrency: 1
@@ -1351,9 +1752,9 @@ const editForm = reactive({
email: '', email: '',
password: '', password: '',
username: '', username: '',
wechat: '',
notes: '', notes: '',
concurrency: 1 concurrency: 1,
customAttributes: {} as UserAttributeValuesMap
}) })
const editPasswordCopied = ref(false) const editPasswordCopied = ref(false)
@@ -1404,6 +1805,21 @@ const copyEditPassword = async () => {
} }
} }
const loadAttributeDefinitions = async () => {
try {
attributeDefinitions.value = await adminAPI.userAttributes.listEnabledDefinitions()
} catch (e) {
console.error('Failed to load attribute definitions:', e)
}
}
// Handle attributes modal close - reload definitions and users
const handleAttributesModalClose = async () => {
showAttributesModal.value = false
await loadAttributeDefinitions()
loadUsers()
}
const loadUsers = async () => { const loadUsers = async () => {
abortController?.abort() abortController?.abort()
const currentAbortController = new AbortController() const currentAbortController = new AbortController()
@@ -1411,13 +1827,22 @@ const loadUsers = async () => {
const { signal } = currentAbortController const { signal } = currentAbortController
loading.value = true loading.value = true
try { try {
// Build attribute filters from active filters
const attrFilters: Record<number, string> = {}
for (const [attrId, value] of Object.entries(activeAttributeFilters)) {
if (value) {
attrFilters[Number(attrId)] = value
}
}
const response = await adminAPI.users.list( const response = await adminAPI.users.list(
pagination.page, pagination.page,
pagination.page_size, pagination.page_size,
{ {
role: filters.role as any, role: filters.role as any,
status: filters.status as any, status: filters.status as any,
search: searchQuery.value || undefined search: searchQuery.value || undefined,
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined
}, },
{ signal } { signal }
) )
@@ -1428,9 +1853,10 @@ const loadUsers = async () => {
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
// Load usage stats for all users in the list // Load usage stats and attribute values for all users in the list
if (response.items.length > 0) { if (response.items.length > 0) {
const userIds = response.items.map((u) => u.id) const userIds = response.items.map((u) => u.id)
// Load usage stats
try { try {
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds) const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds)
if (signal.aborted) { if (signal.aborted) {
@@ -1443,6 +1869,21 @@ const loadUsers = async () => {
} }
console.error('Failed to load usage stats:', e) console.error('Failed to load usage stats:', e)
} }
// Load attribute values
if (attributeDefinitions.value.length > 0) {
try {
const attrResponse = await adminAPI.userAttributes.getBatchUserAttributes(userIds)
if (signal.aborted) {
return
}
userAttributeValues.value = attrResponse.attributes
} catch (e) {
if (signal.aborted) {
return
}
console.error('Failed to load user attribute values:', e)
}
}
} }
} catch (error) { } catch (error) {
const errorInfo = error as { name?: string; code?: string } const errorInfo = error as { name?: string; code?: string }
@@ -1478,12 +1919,54 @@ const handlePageSizeChange = (pageSize: number) => {
loadUsers() loadUsers()
} }
// Filter helpers
const getAttributeDefinitionName = (attrId: number): string => {
const def = attributeDefinitions.value.find(d => d.id === attrId)
return def?.name || String(attrId)
}
// Toggle a built-in filter (role/status)
const toggleBuiltInFilter = (key: string) => {
if (visibleFilters.has(key)) {
visibleFilters.delete(key)
if (key === 'role') filters.role = ''
if (key === 'status') filters.status = ''
} else {
visibleFilters.add(key)
}
saveFiltersToStorage()
loadUsers()
}
// Toggle a custom attribute filter
const toggleAttributeFilter = (attr: UserAttributeDefinition) => {
const key = `attr_${attr.id}`
if (visibleFilters.has(key)) {
visibleFilters.delete(key)
delete activeAttributeFilters[attr.id]
} else {
visibleFilters.add(key)
activeAttributeFilters[attr.id] = ''
}
saveFiltersToStorage()
loadUsers()
}
const updateAttributeFilter = (attrId: number, value: string) => {
activeAttributeFilters[attrId] = value
}
// Apply filter and save to localStorage
const applyFilter = () => {
saveFiltersToStorage()
loadUsers()
}
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false
createForm.email = '' createForm.email = ''
createForm.password = '' createForm.password = ''
createForm.username = '' createForm.username = ''
createForm.wechat = ''
createForm.notes = '' createForm.notes = ''
createForm.balance = 0 createForm.balance = 0
createForm.concurrency = 1 createForm.concurrency = 1
@@ -1514,9 +1997,9 @@ const handleEdit = (user: User) => {
editForm.email = user.email editForm.email = user.email
editForm.password = '' editForm.password = ''
editForm.username = user.username || '' editForm.username = user.username || ''
editForm.wechat = user.wechat || ''
editForm.notes = user.notes || '' editForm.notes = user.notes || ''
editForm.concurrency = user.concurrency editForm.concurrency = user.concurrency
editForm.customAttributes = {}
editPasswordCopied.value = false editPasswordCopied.value = false
showEditModal.value = true showEditModal.value = true
} }
@@ -1525,6 +2008,7 @@ const closeEditModal = () => {
showEditModal.value = false showEditModal.value = false
editingUser.value = null editingUser.value = null
editForm.password = '' editForm.password = ''
editForm.customAttributes = {}
editPasswordCopied.value = false editPasswordCopied.value = false
} }
@@ -1536,7 +2020,6 @@ const handleUpdateUser = async () => {
const updateData: Record<string, any> = { const updateData: Record<string, any> = {
email: editForm.email, email: editForm.email,
username: editForm.username, username: editForm.username,
wechat: editForm.wechat,
notes: editForm.notes, notes: editForm.notes,
concurrency: editForm.concurrency concurrency: editForm.concurrency
} }
@@ -1545,6 +2028,15 @@ const handleUpdateUser = async () => {
} }
await adminAPI.users.update(editingUser.value.id, updateData) await adminAPI.users.update(editingUser.value.id, updateData)
// Save custom attributes if any
if (Object.keys(editForm.customAttributes).length > 0) {
await adminAPI.userAttributes.updateUserAttributeValues(
editingUser.value.id,
editForm.customAttributes
)
}
appStore.showSuccess(t('admin.users.userUpdated')) appStore.showSuccess(t('admin.users.userUpdated'))
closeEditModal() closeEditModal()
loadUsers() loadUsers()
@@ -1730,7 +2222,10 @@ const handleBalanceSubmit = async () => {
} }
} }
onMounted(() => { onMounted(async () => {
await loadAttributeDefinitions()
loadSavedFilters()
loadSavedColumns()
loadUsers() loadUsers()
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
}) })

View File

@@ -89,25 +89,6 @@
</svg> </svg>
<span class="truncate">{{ user.username }}</span> <span class="truncate">{{ user.username }}</span>
</div> </div>
<div
v-if="user?.wechat"
class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<svg
class="h-4 w-4 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
</svg>
<span class="truncate">{{ user.wechat }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -170,19 +151,6 @@
/> />
</div> </div>
<div>
<label for="wechat" class="input-label">
{{ t('profile.wechat') }}
</label>
<input
id="wechat"
v-model="profileForm.wechat"
type="text"
class="input"
:placeholder="t('profile.enterWechat')"
/>
</div>
<div class="flex justify-end pt-4"> <div class="flex justify-end pt-4">
<button type="submit" :disabled="updatingProfile" class="btn btn-primary"> <button type="submit" :disabled="updatingProfile" class="btn btn-primary">
{{ updatingProfile ? t('profile.updating') : t('profile.updateProfile') }} {{ updatingProfile ? t('profile.updating') : t('profile.updateProfile') }}
@@ -338,8 +306,7 @@ const passwordForm = ref({
}) })
const profileForm = ref({ const profileForm = ref({
username: '', username: ''
wechat: ''
}) })
const changingPassword = ref(false) const changingPassword = ref(false)
@@ -354,7 +321,6 @@ onMounted(async () => {
// Initialize profile form with current user data // Initialize profile form with current user data
if (user.value) { if (user.value) {
profileForm.value.username = user.value.username || '' profileForm.value.username = user.value.username || ''
profileForm.value.wechat = user.value.wechat || ''
} }
} catch (error) { } catch (error) {
console.error('Failed to load contact info:', error) console.error('Failed to load contact info:', error)
@@ -407,8 +373,7 @@ const handleUpdateProfile = async () => {
updatingProfile.value = true updatingProfile.value = true
try { try {
const updatedUser = await userAPI.updateProfile({ const updatedUser = await userAPI.updateProfile({
username: profileForm.value.username, username: profileForm.value.username
wechat: profileForm.value.wechat
}) })
// Update auth store with new user data // Update auth store with new user data