feat: add users page
This commit is contained in:
@@ -29,11 +29,13 @@ func Provide(opts ...opt.Option) error {
|
||||
medias *medias,
|
||||
posts *posts,
|
||||
uploads *uploads,
|
||||
users *users,
|
||||
) (contracts.HttpRoute, error) {
|
||||
obj := &Routes{
|
||||
medias: medias,
|
||||
posts: posts,
|
||||
uploads: uploads,
|
||||
users: users,
|
||||
}
|
||||
if err := obj.Prepare(); err != nil {
|
||||
return nil, err
|
||||
@@ -56,5 +58,12 @@ func Provide(opts ...opt.Option) error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := container.Container.Provide(func() (*users, error) {
|
||||
obj := &users{}
|
||||
|
||||
return obj, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type Routes struct {
|
||||
medias *medias
|
||||
posts *posts
|
||||
uploads *uploads
|
||||
users *users
|
||||
}
|
||||
|
||||
func (r *Routes) Prepare() error {
|
||||
@@ -83,4 +84,11 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
r.uploads.Token,
|
||||
))
|
||||
|
||||
// 注册路由组: users
|
||||
router.Get("/v1/admin/users", DataFunc2(
|
||||
r.users.List,
|
||||
Query[requests.Pagination]("pagination"),
|
||||
Query[UserListQuery]("query"),
|
||||
))
|
||||
|
||||
}
|
||||
|
||||
24
backend/app/http/admin/users.go
Normal file
24
backend/app/http/admin/users.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"quyun/app/models"
|
||||
"quyun/app/requests"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type UserListQuery struct {
|
||||
Keyword *string `query:"keyword"`
|
||||
}
|
||||
|
||||
// @provider
|
||||
type users struct{}
|
||||
|
||||
// List users
|
||||
// @Router /v1/admin/users [get]
|
||||
// @Bind pagination query
|
||||
// @Bind query query
|
||||
func (ctl *users) List(ctx fiber.Ctx, pagination *requests.Pagination, query *UserListQuery) (*requests.Pager, error) {
|
||||
cond := models.Users.BuildConditionWithKey(query.Keyword)
|
||||
return models.Users.List(ctx.Context(), pagination, cond)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"quyun/app/requests"
|
||||
"quyun/app/service/testx"
|
||||
"quyun/database"
|
||||
"quyun/database/fields"
|
||||
"quyun/database/schemas/public/model"
|
||||
"quyun/database/schemas/public/table"
|
||||
|
||||
@@ -195,19 +196,19 @@ func (s *MediasTestSuite) Test_ConvertFileTypeByMimeType() {
|
||||
Convey("image", func() {
|
||||
mimeType := "image/jpeg"
|
||||
fileType := Medias.ConvertFileTypeByMimeType(mimeType)
|
||||
So(fileType, ShouldEqual, MediaTypeImage)
|
||||
So(fileType, ShouldEqual, fields.MediaAssetTypeImage)
|
||||
})
|
||||
|
||||
Convey("video", func() {
|
||||
mimeType := "video/mp4"
|
||||
fileType := Medias.ConvertFileTypeByMimeType(mimeType)
|
||||
So(fileType, ShouldEqual, MediaTypeVideo)
|
||||
So(fileType, ShouldEqual, fields.MediaAssetTypeVideo)
|
||||
})
|
||||
|
||||
Convey("invalid mime type", func() {
|
||||
mimeType := "invalid/type"
|
||||
fileType := Medias.ConvertFileTypeByMimeType(mimeType)
|
||||
So(fileType, ShouldEqual, MediaTypeUnknown)
|
||||
So(fileType, ShouldEqual, fields.MediaAssetTypeUnknown)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func (m *postsModel) Update(ctx context.Context, id int64, model *model.Posts) e
|
||||
model.UpdatedAt = time.Now()
|
||||
|
||||
tbl := table.Posts
|
||||
stmt := tbl.UPDATE(tbl.MutableColumns).MODEL(model).WHERE(tbl.ID.EQ(Int64(id)))
|
||||
stmt := tbl.UPDATE(tbl.MutableColumns.Except(tbl.CreatedAt, tbl.DeletedAt)).MODEL(model).WHERE(tbl.ID.EQ(Int64(id)))
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
|
||||
_, err := stmt.ExecContext(ctx, db)
|
||||
|
||||
@@ -2,7 +2,9 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"quyun/app/requests"
|
||||
"quyun/database/schemas/public/model"
|
||||
"quyun/database/schemas/public/table"
|
||||
|
||||
@@ -75,3 +77,123 @@ func (m *usersModel) Posts(ctx context.Context, userID int64) ([]*model.Posts, e
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
// BuildConditionWithKey builds the WHERE clause for user queries
|
||||
func (m *usersModel) BuildConditionWithKey(key *string) BoolExpression {
|
||||
tbl := table.Users
|
||||
|
||||
cond := tbl.DeletedAt.IS_NULL()
|
||||
|
||||
if key == nil || *key == "" {
|
||||
return cond
|
||||
}
|
||||
|
||||
cond = cond.AND(
|
||||
tbl.Username.LIKE(String("%" + *key + "%")),
|
||||
)
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
// countByCondition counts users matching the given condition
|
||||
func (m *usersModel) countByCondition(ctx context.Context, expr BoolExpression) (int64, error) {
|
||||
var cnt struct {
|
||||
Cnt int64
|
||||
}
|
||||
|
||||
tbl := table.Users
|
||||
stmt := SELECT(COUNT(tbl.ID).AS("cnt")).FROM(tbl).WHERE(expr)
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
|
||||
err := stmt.QueryContext(ctx, db, &cnt)
|
||||
if err != nil {
|
||||
m.log.Errorf("error counting users: %v", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return cnt.Cnt, nil
|
||||
}
|
||||
|
||||
// List returns a paginated list of users
|
||||
func (m *usersModel) List(ctx context.Context, pagination *requests.Pagination, cond BoolExpression) (*requests.Pager, error) {
|
||||
pagination.Format()
|
||||
|
||||
tbl := table.Users
|
||||
stmt := tbl.
|
||||
SELECT(tbl.AllColumns).
|
||||
WHERE(cond).
|
||||
ORDER_BY(tbl.ID.DESC()).
|
||||
LIMIT(pagination.Limit).
|
||||
OFFSET(pagination.Offset)
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
|
||||
var users []model.Users = make([]model.Users, 0)
|
||||
err := stmt.QueryContext(ctx, db, &users)
|
||||
if err != nil {
|
||||
m.log.Errorf("error querying users: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
count, err := m.countByCondition(ctx, cond)
|
||||
if err != nil {
|
||||
m.log.Errorf("error getting user count: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &requests.Pager{
|
||||
Items: users,
|
||||
Total: count,
|
||||
Pagination: *pagination,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create creates a new user
|
||||
func (m *usersModel) Create(ctx context.Context, model *model.Users) error {
|
||||
model.CreatedAt = time.Now()
|
||||
model.UpdatedAt = time.Now()
|
||||
|
||||
tbl := table.Users
|
||||
stmt := tbl.INSERT(tbl.MutableColumns).MODEL(model)
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
|
||||
_, err := stmt.ExecContext(ctx, db)
|
||||
if err != nil {
|
||||
m.log.Errorf("error creating user: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates an existing user
|
||||
func (m *usersModel) Update(ctx context.Context, id int64, model *model.Users) error {
|
||||
model.UpdatedAt = time.Now()
|
||||
|
||||
tbl := table.Users
|
||||
stmt := tbl.UPDATE(tbl.MutableColumns.Except(tbl.CreatedAt, tbl.DeletedAt)).MODEL(model).WHERE(tbl.ID.EQ(Int64(id)))
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
|
||||
_, err := stmt.ExecContext(ctx, db)
|
||||
if err != nil {
|
||||
m.log.Errorf("error updating user: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByID soft deletes a user by ID
|
||||
func (m *usersModel) DeleteByID(ctx context.Context, id int64) error {
|
||||
tbl := table.Users
|
||||
stmt := tbl.
|
||||
UPDATE(tbl.DeletedAt).
|
||||
SET(TimestampT(time.Now())).
|
||||
WHERE(
|
||||
tbl.ID.EQ(Int64(id)),
|
||||
)
|
||||
m.log.Infof("sql: %s", stmt.DebugSql())
|
||||
|
||||
if _, err := stmt.ExecContext(ctx, db); err != nil {
|
||||
m.log.Errorf("error deleting user: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@ import (
|
||||
|
||||
"quyun/app/service/testx"
|
||||
"quyun/database"
|
||||
"quyun/database/fields"
|
||||
"quyun/database/schemas/public/model"
|
||||
"quyun/database/schemas/public/table"
|
||||
|
||||
"github.com/samber/lo"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"go.ipao.vip/atom/contracts"
|
||||
|
||||
// . "github.com/go-jet/jet/v2/postgres"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
@@ -36,8 +40,25 @@ func Test_Users(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *UsersTestSuite) Test_Demo() {
|
||||
Convey("Test_Demo", s.T(), func() {
|
||||
func (s *UsersTestSuite) Test_BatchInsert() {
|
||||
Convey("Test_BatchInsert", s.T(), func() {
|
||||
database.Truncate(context.Background(), db, table.Users.TableName())
|
||||
|
||||
count := 100
|
||||
for i := 0; i < count; i++ {
|
||||
// generate openid use nanoid
|
||||
openID, err := gonanoid.New(10)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
user := &model.Users{
|
||||
Status: fields.UserStatusOk,
|
||||
OpenID: openID,
|
||||
Username: "Username_" + openID,
|
||||
Avatar: lo.ToPtr("Avatar_" + openID),
|
||||
}
|
||||
err = Users.Create(context.Background(), user)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
241
backend/database/fields/users.gen.go
Normal file
241
backend/database/fields/users.gen.go
Normal file
@@ -0,0 +1,241 @@
|
||||
// Code generated by go-enum DO NOT EDIT.
|
||||
// Version: -
|
||||
// Revision: -
|
||||
// Build Date: -
|
||||
// Built By: -
|
||||
|
||||
package fields
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// UserStatusOk is a UserStatus of type Ok.
|
||||
UserStatusOk UserStatus = iota
|
||||
// UserStatusBanned is a UserStatus of type Banned.
|
||||
UserStatusBanned
|
||||
// UserStatusBlocked is a UserStatus of type Blocked.
|
||||
UserStatusBlocked
|
||||
)
|
||||
|
||||
var ErrInvalidUserStatus = fmt.Errorf("not a valid UserStatus, try [%s]", strings.Join(_UserStatusNames, ", "))
|
||||
|
||||
const _UserStatusName = "okbannedblocked"
|
||||
|
||||
var _UserStatusNames = []string{
|
||||
_UserStatusName[0:2],
|
||||
_UserStatusName[2:8],
|
||||
_UserStatusName[8:15],
|
||||
}
|
||||
|
||||
// UserStatusNames returns a list of possible string values of UserStatus.
|
||||
func UserStatusNames() []string {
|
||||
tmp := make([]string, len(_UserStatusNames))
|
||||
copy(tmp, _UserStatusNames)
|
||||
return tmp
|
||||
}
|
||||
|
||||
// UserStatusValues returns a list of the values for UserStatus
|
||||
func UserStatusValues() []UserStatus {
|
||||
return []UserStatus{
|
||||
UserStatusOk,
|
||||
UserStatusBanned,
|
||||
UserStatusBlocked,
|
||||
}
|
||||
}
|
||||
|
||||
var _UserStatusMap = map[UserStatus]string{
|
||||
UserStatusOk: _UserStatusName[0:2],
|
||||
UserStatusBanned: _UserStatusName[2:8],
|
||||
UserStatusBlocked: _UserStatusName[8:15],
|
||||
}
|
||||
|
||||
// String implements the Stringer interface.
|
||||
func (x UserStatus) String() string {
|
||||
if str, ok := _UserStatusMap[x]; ok {
|
||||
return str
|
||||
}
|
||||
return fmt.Sprintf("UserStatus(%d)", x)
|
||||
}
|
||||
|
||||
// IsValid provides a quick way to determine if the typed value is
|
||||
// part of the allowed enumerated values
|
||||
func (x UserStatus) IsValid() bool {
|
||||
_, ok := _UserStatusMap[x]
|
||||
return ok
|
||||
}
|
||||
|
||||
var _UserStatusValue = map[string]UserStatus{
|
||||
_UserStatusName[0:2]: UserStatusOk,
|
||||
_UserStatusName[2:8]: UserStatusBanned,
|
||||
_UserStatusName[8:15]: UserStatusBlocked,
|
||||
}
|
||||
|
||||
// ParseUserStatus attempts to convert a string to a UserStatus.
|
||||
func ParseUserStatus(name string) (UserStatus, error) {
|
||||
if x, ok := _UserStatusValue[name]; ok {
|
||||
return x, nil
|
||||
}
|
||||
return UserStatus(0), fmt.Errorf("%s is %w", name, ErrInvalidUserStatus)
|
||||
}
|
||||
|
||||
var errUserStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (x *UserStatus) Scan(value interface{}) (err error) {
|
||||
if value == nil {
|
||||
*x = UserStatus(0)
|
||||
return
|
||||
}
|
||||
|
||||
// A wider range of scannable types.
|
||||
// driver.Value values at the top of the list for expediency
|
||||
switch v := value.(type) {
|
||||
case int64:
|
||||
*x = UserStatus(v)
|
||||
case string:
|
||||
*x, err = ParseUserStatus(v)
|
||||
if err != nil {
|
||||
// try parsing the integer value as a string
|
||||
if val, verr := strconv.Atoi(v); verr == nil {
|
||||
*x, err = UserStatus(val), nil
|
||||
}
|
||||
}
|
||||
case []byte:
|
||||
*x, err = ParseUserStatus(string(v))
|
||||
if err != nil {
|
||||
// try parsing the integer value as a string
|
||||
if val, verr := strconv.Atoi(string(v)); verr == nil {
|
||||
*x, err = UserStatus(val), nil
|
||||
}
|
||||
}
|
||||
case UserStatus:
|
||||
*x = v
|
||||
case int:
|
||||
*x = UserStatus(v)
|
||||
case *UserStatus:
|
||||
if v == nil {
|
||||
return errUserStatusNilPtr
|
||||
}
|
||||
*x = *v
|
||||
case uint:
|
||||
*x = UserStatus(v)
|
||||
case uint64:
|
||||
*x = UserStatus(v)
|
||||
case *int:
|
||||
if v == nil {
|
||||
return errUserStatusNilPtr
|
||||
}
|
||||
*x = UserStatus(*v)
|
||||
case *int64:
|
||||
if v == nil {
|
||||
return errUserStatusNilPtr
|
||||
}
|
||||
*x = UserStatus(*v)
|
||||
case float64: // json marshals everything as a float64 if it's a number
|
||||
*x = UserStatus(v)
|
||||
case *float64: // json marshals everything as a float64 if it's a number
|
||||
if v == nil {
|
||||
return errUserStatusNilPtr
|
||||
}
|
||||
*x = UserStatus(*v)
|
||||
case *uint:
|
||||
if v == nil {
|
||||
return errUserStatusNilPtr
|
||||
}
|
||||
*x = UserStatus(*v)
|
||||
case *uint64:
|
||||
if v == nil {
|
||||
return errUserStatusNilPtr
|
||||
}
|
||||
*x = UserStatus(*v)
|
||||
case *string:
|
||||
if v == nil {
|
||||
return errUserStatusNilPtr
|
||||
}
|
||||
*x, err = ParseUserStatus(*v)
|
||||
if err != nil {
|
||||
// try parsing the integer value as a string
|
||||
if val, verr := strconv.Atoi(*v); verr == nil {
|
||||
*x, err = UserStatus(val), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (x UserStatus) Value() (driver.Value, error) {
|
||||
return int64(x), nil
|
||||
}
|
||||
|
||||
// Set implements the Golang flag.Value interface func.
|
||||
func (x *UserStatus) Set(val string) error {
|
||||
v, err := ParseUserStatus(val)
|
||||
*x = v
|
||||
return err
|
||||
}
|
||||
|
||||
// Get implements the Golang flag.Getter interface func.
|
||||
func (x *UserStatus) Get() interface{} {
|
||||
return *x
|
||||
}
|
||||
|
||||
// Type implements the github.com/spf13/pFlag Value interface.
|
||||
func (x *UserStatus) Type() string {
|
||||
return "UserStatus"
|
||||
}
|
||||
|
||||
type NullUserStatus struct {
|
||||
UserStatus UserStatus
|
||||
Valid bool
|
||||
}
|
||||
|
||||
func NewNullUserStatus(val interface{}) (x NullUserStatus) {
|
||||
x.Scan(val) // yes, we ignore this error, it will just be an invalid value.
|
||||
return
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (x *NullUserStatus) Scan(value interface{}) (err error) {
|
||||
if value == nil {
|
||||
x.UserStatus, x.Valid = UserStatus(0), false
|
||||
return
|
||||
}
|
||||
|
||||
err = x.UserStatus.Scan(value)
|
||||
x.Valid = (err == nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (x NullUserStatus) Value() (driver.Value, error) {
|
||||
if !x.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
// driver.Value accepts int64 for int values.
|
||||
return int64(x.UserStatus), nil
|
||||
}
|
||||
|
||||
type NullUserStatusStr struct {
|
||||
NullUserStatus
|
||||
}
|
||||
|
||||
func NewNullUserStatusStr(val interface{}) (x NullUserStatusStr) {
|
||||
x.Scan(val) // yes, we ignore this error, it will just be an invalid value.
|
||||
return
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (x NullUserStatusStr) Value() (driver.Value, error) {
|
||||
if !x.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return x.UserStatus.String(), nil
|
||||
}
|
||||
5
backend/database/fields/users.go
Normal file
5
backend/database/fields/users.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package fields
|
||||
|
||||
// swagger:enum PostStatus
|
||||
// ENUM( ok, banned, blocked)
|
||||
type UserStatus int16
|
||||
@@ -8,16 +8,17 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"quyun/database/fields"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Users struct {
|
||||
ID int64 `sql:"primary_key" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at"`
|
||||
Status int16 `json:"status"`
|
||||
OpenID string `json:"open_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar *string `json:"avatar"`
|
||||
ID int64 `sql:"primary_key" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at"`
|
||||
Status fields.UserStatus `json:"status"`
|
||||
OpenID string `json:"open_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar *string `json:"avatar"`
|
||||
}
|
||||
|
||||
@@ -17,3 +17,6 @@ types:
|
||||
assets: Json[[]MediaAsset]
|
||||
tags: Json[[]string]
|
||||
meta: PostMeta
|
||||
|
||||
users:
|
||||
status: UserStatus
|
||||
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/opentracing/opentracing-go v1.2.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/pressly/goose/v3 v3.24.1
|
||||
@@ -102,7 +103,6 @@ require (
|
||||
github.com/jcmturner/gofork v1.7.6 // indirect
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
github.com/jinzhu/copier v0.4.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
|
||||
@@ -168,8 +168,6 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
|
||||
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
@@ -194,6 +192,8 @@ github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJV
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
||||
@@ -46,4 +46,8 @@ GET {{host}}/v1/admin/posts?page=1&limit=10&keyword=99123 HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
### delete posts
|
||||
DELETE {{host}}/v1/admin/posts/103 HTTP/1.1
|
||||
DELETE {{host}}/v1/admin/posts/103 HTTP/1.1
|
||||
|
||||
### get users
|
||||
GET {{host}}/v1/admin/users HTTP/1.1
|
||||
Content-Type: application/json
|
||||
@@ -8,6 +8,9 @@
|
||||
"@tailwindcss/vite": "^4.0.17",
|
||||
"axios": "^1.8.4",
|
||||
"daisyui": "^5.0.9",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"install": "^0.13.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.3",
|
||||
"tailwindcss": "^4.0.17",
|
||||
@@ -198,6 +201,10 @@
|
||||
|
||||
"daisyui": ["daisyui@5.0.9", "https://registry.npmmirror.com/daisyui/-/daisyui-5.0.9.tgz", {}, "sha512-RsaehHh45f+0shWgZZaOY09/8eOae2voRsqJCD71j9yrnYgcke0Nj5ys0ZxrW4SPcc4+q96kWyJu0Z8P1zZdoA=="],
|
||||
|
||||
"date-fns": ["date-fns@4.1.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.13", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.0.3", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.0.3.tgz", {}, "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw=="],
|
||||
@@ -242,6 +249,8 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"install": ["install@0.13.0", "https://registry.npmmirror.com/install/-/install-0.13.0.tgz", {}, "sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA=="],
|
||||
|
||||
"jiti": ["jiti@2.4.2", "https://registry.npmmirror.com/jiti/-/jiti-2.4.2.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.29.2", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.29.2.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.29.2", "lightningcss-darwin-x64": "1.29.2", "lightningcss-freebsd-x64": "1.29.2", "lightningcss-linux-arm-gnueabihf": "1.29.2", "lightningcss-linux-arm64-gnu": "1.29.2", "lightningcss-linux-arm64-musl": "1.29.2", "lightningcss-linux-x64-gnu": "1.29.2", "lightningcss-linux-x64-musl": "1.29.2", "lightningcss-win32-arm64-msvc": "1.29.2", "lightningcss-win32-x64-msvc": "1.29.2" } }, "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA=="],
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"@tailwindcss/vite": "^4.0.17",
|
||||
"axios": "^1.8.4",
|
||||
"daisyui": "^5.0.9",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"install": "^0.13.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.3",
|
||||
"tailwindcss": "^4.0.17",
|
||||
|
||||
@@ -22,6 +22,11 @@ const navItems = ref([
|
||||
icon: 'pi pi-file',
|
||||
command: () => router.push('/posts')
|
||||
},
|
||||
{
|
||||
label: 'Usesrs',
|
||||
icon: 'pi pi-users',
|
||||
command: () => router.push('/users')
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: 'pi pi-cog',
|
||||
|
||||
19
frontend/admin/src/api/userService.js
Normal file
19
frontend/admin/src/api/userService.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import httpClient from './httpClient';
|
||||
|
||||
export const userService = {
|
||||
getUsers({ page = 1, limit = 10, keyword = '' } = {}) {
|
||||
return httpClient.get('/admin/users', {
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
keyword: keyword.trim()
|
||||
}
|
||||
});
|
||||
},
|
||||
getUser(id) {
|
||||
return httpClient.get(`/admin/users/${id}`);
|
||||
},
|
||||
deleteUser(id) {
|
||||
return httpClient.delete(`/admin/users/${id}`);
|
||||
},
|
||||
}
|
||||
107
frontend/admin/src/api/users_list.json
Normal file
107
frontend/admin/src/api/users_list.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"total": 100,
|
||||
"items": [
|
||||
{
|
||||
"id": 100,
|
||||
"created_at": "2025-04-10T20:38:25.224535Z",
|
||||
"updated_at": "2025-04-10T20:38:25.224535Z",
|
||||
"deleted_at": null,
|
||||
"status": 0,
|
||||
"open_id": "ovxaRhVa-t",
|
||||
"username": "Username_ovxaRhVa-t",
|
||||
"avatar": "Avatar_ovxaRhVa-t"
|
||||
},
|
||||
{
|
||||
"id": 99,
|
||||
"created_at": "2025-04-10T20:38:25.220304Z",
|
||||
"updated_at": "2025-04-10T20:38:25.220304Z",
|
||||
"deleted_at": null,
|
||||
"status": 0,
|
||||
"open_id": "pnQlEwOawi",
|
||||
"username": "Username_pnQlEwOawi",
|
||||
"avatar": "Avatar_pnQlEwOawi"
|
||||
},
|
||||
{
|
||||
"id": 98,
|
||||
"created_at": "2025-04-10T20:38:25.215726Z",
|
||||
"updated_at": "2025-04-10T20:38:25.215726Z",
|
||||
"deleted_at": null,
|
||||
"status": 0,
|
||||
"open_id": "NnjpNALmUE",
|
||||
"username": "Username_NnjpNALmUE",
|
||||
"avatar": "Avatar_NnjpNALmUE"
|
||||
},
|
||||
{
|
||||
"id": 97,
|
||||
"created_at": "2025-04-10T20:38:25.211126Z",
|
||||
"updated_at": "2025-04-10T20:38:25.211126Z",
|
||||
"deleted_at": null,
|
||||
"status": 0,
|
||||
"open_id": "m3xb_ShqB8",
|
||||
"username": "Username_m3xb_ShqB8",
|
||||
"avatar": "Avatar_m3xb_ShqB8"
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"created_at": "2025-04-10T20:38:25.204553Z",
|
||||
"updated_at": "2025-04-10T20:38:25.204553Z",
|
||||
"deleted_at": null,
|
||||
"status": 0,
|
||||
"open_id": "TDdXtAZQsQ",
|
||||
"username": "Username_TDdXtAZQsQ",
|
||||
"avatar": "Avatar_TDdXtAZQsQ"
|
||||
},
|
||||
{
|
||||
"id": 95,
|
||||
"created_at": "2025-04-10T20:38:25.199782Z",
|
||||
"updated_at": "2025-04-10T20:38:25.199783Z",
|
||||
"deleted_at": null,
|
||||
"status": 0,
|
||||
"open_id": "BW9oaHl7at",
|
||||
"username": "Username_BW9oaHl7at",
|
||||
"avatar": "Avatar_BW9oaHl7at"
|
||||
},
|
||||
{
|
||||
"id": 94,
|
||||
"created_at": "2025-04-10T20:38:25.194655Z",
|
||||
"updated_at": "2025-04-10T20:38:25.194655Z",
|
||||
"deleted_at": null,
|
||||
"status": 0,
|
||||
"open_id": "qL9iCPjSVh",
|
||||
"username": "Username_qL9iCPjSVh",
|
||||
"avatar": "Avatar_qL9iCPjSVh"
|
||||
},
|
||||
{
|
||||
"id": 93,
|
||||
"created_at": "2025-04-10T20:38:25.190243Z",
|
||||
"updated_at": "2025-04-10T20:38:25.190243Z",
|
||||
"deleted_at": null,
|
||||
"status": 0,
|
||||
"open_id": "ctQtyed6Gt",
|
||||
"username": "Username_ctQtyed6Gt",
|
||||
"avatar": "Avatar_ctQtyed6Gt"
|
||||
},
|
||||
{
|
||||
"id": 92,
|
||||
"created_at": "2025-04-10T20:38:25.186196Z",
|
||||
"updated_at": "2025-04-10T20:38:25.186196Z",
|
||||
"deleted_at": null,
|
||||
"status": 0,
|
||||
"open_id": "3Z4V0x6l_B",
|
||||
"username": "Username_3Z4V0x6l_B",
|
||||
"avatar": "Avatar_3Z4V0x6l_B"
|
||||
},
|
||||
{
|
||||
"id": 91,
|
||||
"created_at": "2025-04-10T20:38:25.181205Z",
|
||||
"updated_at": "2025-04-10T20:38:25.181205Z",
|
||||
"deleted_at": null,
|
||||
"status": 0,
|
||||
"open_id": "kQwboErzWc",
|
||||
"username": "Username_kQwboErzWc",
|
||||
"avatar": "Avatar_kQwboErzWc"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -66,48 +66,7 @@ const mediaTotalPages = computed(() => {
|
||||
});
|
||||
|
||||
// Sample media data - in a real app, this would come from an API
|
||||
const mediaItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
fileName: 'sunset-beach.jpg',
|
||||
fileType: 'Image',
|
||||
thumbnailUrl: 'https://via.placeholder.com/300x225',
|
||||
fileSize: '2.4 MB',
|
||||
uploadTime: 'Today, 10:30 AM'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
fileName: 'presentation.pdf',
|
||||
fileType: 'PDF',
|
||||
thumbnailUrl: null,
|
||||
fileSize: '4.8 MB',
|
||||
uploadTime: 'Yesterday, 3:45 PM'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
fileName: 'promo_video.mp4',
|
||||
fileType: 'Video',
|
||||
thumbnailUrl: null,
|
||||
fileSize: '24.8 MB',
|
||||
uploadTime: 'Aug 28, 2023'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
fileName: 'report_q3.docx',
|
||||
fileType: 'Document',
|
||||
thumbnailUrl: null,
|
||||
fileSize: '1.2 MB',
|
||||
uploadTime: 'Aug 25, 2023'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
fileName: 'podcast_interview.mp3',
|
||||
fileType: 'Audio',
|
||||
thumbnailUrl: null,
|
||||
fileSize: '18.5 MB',
|
||||
uploadTime: 'Aug 20, 2023'
|
||||
}
|
||||
]);
|
||||
const mediaItems = ref([]);
|
||||
|
||||
// Fetch post data by ID
|
||||
const fetchPost = async (id) => {
|
||||
|
||||
191
frontend/admin/src/pages/UserPage.vue
Normal file
191
frontend/admin/src/pages/UserPage.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup>
|
||||
import { userService } from '@/api/userService';
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import Badge from 'primevue/badge';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
import Toast from 'primevue/toast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const globalFilterValue = ref('');
|
||||
const loading = ref(false);
|
||||
const searchTimeout = ref(null);
|
||||
const filters = ref({
|
||||
global: { value: null, matchMode: 'contains' },
|
||||
status: { value: null, matchMode: 'equals' }
|
||||
});
|
||||
|
||||
// Table state
|
||||
const users = ref({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
// DataTable pagination state
|
||||
const first = ref(0);
|
||||
const rows = ref(10);
|
||||
|
||||
// 配置 dayjs
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Calculate current page from first and rows
|
||||
const currentPage = (first.value / rows.value) + 1;
|
||||
const response = await userService.getUsers({
|
||||
page: currentPage,
|
||||
limit: rows.value,
|
||||
keyword: globalFilterValue.value
|
||||
});
|
||||
users.value = response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '加载用户数据失败', life: 3000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onPage = (event) => {
|
||||
first.value = event.first;
|
||||
rows.value = event.rows;
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
const onSearch = (event) => {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
searchTimeout.value = setTimeout(() => {
|
||||
first.value = 0; // Reset to first page when searching
|
||||
fetchUsers();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
return dayjs.tz(date, 'Asia/Shanghai').format('YYYY-MM-DD HH:mm:ss');
|
||||
};
|
||||
|
||||
const handleDelete = (user) => {
|
||||
confirm.require({
|
||||
message: `确定要删除用户 "${user.username}" 吗?`,
|
||||
header: '确认删除',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
await userService.deleteUser(user.id);
|
||||
toast.add({ severity: 'success', summary: '成功', detail: '用户已删除', life: 3000 });
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '删除用户失败', life: 3000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-semibold text-gray-800">用户列表</h1>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="pb-10 flex">
|
||||
<InputText v-model="globalFilterValue" placeholder="搜索用户..." class="flex-1" @input="onSearch" />
|
||||
</div>
|
||||
|
||||
<DataTable v-model:filters="filters" :value="users.items" :paginator="true" :rows="rows"
|
||||
:totalRecords="users.total" :loading="loading" :lazy="true" :first="first" @page="onPage"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
:rowsPerPageOptions="[10, 25, 50]"
|
||||
currentPageReportTemplate="显示第 {first} 到 {last} 条,共 {totalRecords} 条结果" dataKey="id"
|
||||
:globalFilterFields="['username', 'open_id']" stripedRows removableSort class="p-datatable-sm"
|
||||
responsiveLayout="scroll">
|
||||
|
||||
<template #empty>
|
||||
<div class="text-center p-4">未找到用户。</div>
|
||||
</template>
|
||||
|
||||
<template #loading>
|
||||
<div class="flex flex-col items-center justify-center p-4">
|
||||
<ProgressSpinner style="width:50px;height:50px" />
|
||||
<span class="mt-2">加载用户数据...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column field="id" header="ID" sortable></Column>
|
||||
|
||||
<Column field="username" header="用户名" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="avatar">
|
||||
<div class="mask mask-squircle w-12 h-12">
|
||||
<img :src="data.avatar" :alt="data.username" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold">{{ data.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="open_id" header="OpenID" sortable></Column>
|
||||
|
||||
<Column field="status" header="状态" sortable>
|
||||
<template #body="{ data }">
|
||||
<Badge :value="data.status === 0 ? '活跃' : '禁用'"
|
||||
:severity="data.status === 0 ? 'success' : 'danger'" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="created_at" header="创建时间" sortable>
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="updated_at" header="更新时间" sortable>
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.updated_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="操作" :exportable="false" style="min-width:8rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex justify-center space-x-2">
|
||||
<Button icon="pi pi-trash" rounded text severity="danger" @click="handleDelete(data)"
|
||||
aria-label="删除" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -35,6 +35,11 @@ const routes = [
|
||||
component: () => import('./pages/PostEditPage.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'Users',
|
||||
component: () => import('./pages/UserPage.vue'),
|
||||
}
|
||||
];
|
||||
|
||||
// Create the router instance
|
||||
|
||||
Reference in New Issue
Block a user