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:
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(')')
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
83
backend/migrations/019_migrate_wechat_to_attributes.sql
Normal file
83
backend/migrations/019_migrate_wechat_to_attributes.sql
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user