feat: add users page

This commit is contained in:
yanghao05
2025-04-10 21:04:35 +08:00
parent ed27cb3534
commit 5a63eee1ce
22 changed files with 797 additions and 60 deletions

View File

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

View File

@@ -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"),
))
}

View 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)
}

View File

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

View File

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

View File

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

View File

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

View 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
}

View File

@@ -0,0 +1,5 @@
package fields
// swagger:enum PostStatus
// ENUM( ok, banned, blocked)
type UserStatus int16

View File

@@ -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"`
}

View File

@@ -17,3 +17,6 @@ types:
assets: Json[[]MediaAsset]
tags: Json[[]string]
meta: PostMeta
users:
status: UserStatus

View File

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

View File

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

View File

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

View File

@@ -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=="],

View File

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

View File

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

View 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}`);
},
}

View 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"
}
]
}

View File

@@ -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) => {

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

View File

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