diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 4063d98..9177ef1 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -54,6 +54,15 @@ type AdminListQuery struct { Status string ReviewStatus string Limit int + Page int + PageSize int +} + +type AdminListResult struct { + Items []ListingDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` } type AdminActionRequest struct { diff --git a/backend/internal/modules/listing/handler.go b/backend/internal/modules/listing/handler.go index 86f97de..06ae9b7 100644 --- a/backend/internal/modules/listing/handler.go +++ b/backend/internal/modules/listing/handler.go @@ -95,12 +95,12 @@ func (h *Handler) ListAdmin(c *gin.Context) { if !ok { return } - items, err := h.service.ListAdmin(query) + result, err := h.service.ListAdmin(query) if err != nil { writeListingError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, result) } func (h *Handler) FindAdmin(c *gin.Context) { @@ -347,6 +347,22 @@ func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) { } query.Limit = value } + if raw := c.Query("page"); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + response.BadRequest(c, "页码不正确") + return query, false + } + query.Page = value + } + if raw := c.Query("page_size"); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + response.BadRequest(c, "每页条数不正确") + return query, false + } + query.PageSize = value + } query.Status = c.Query("status") query.ReviewStatus = c.Query("review_status") return query, true diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 7ab8206..1ce5783 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -170,13 +170,47 @@ func (r *Repository) ListPendingReview() ([]ListingDTO, error) { return rowsToDTO(rows), nil } -func (r *Repository) ListAdmin(query AdminListQuery) ([]ListingDTO, error) { - limit := query.Limit - if limit <= 0 || limit > 500 { - limit = 200 +func (r *Repository) ListAdmin(query AdminListQuery) (*AdminListResult, error) { + page := query.Page + if page <= 0 { + page = 1 + } + pageSize := query.PageSize + if pageSize <= 0 { + pageSize = query.Limit + } + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 } - db := r.baseQuery() + countDB := r.applyAdminListFilters(r.db.Table("rental_listings AS l"), query) + var total int64 + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + + db := r.applyAdminListFilters(r.baseQuery(), query) + offset := (page - 1) * pageSize + if offset < 0 { + offset = 0 + } + var rows []listingRow + err := db.Order("l.id DESC").Limit(pageSize).Offset(offset).Scan(&rows).Error + if err != nil { + return nil, err + } + return &AdminListResult{ + Items: rowsToDTO(rows), + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { if query.OwnerID > 0 { db = db.Where("l.owner_id = ?", query.OwnerID) } @@ -186,13 +220,7 @@ func (r *Repository) ListAdmin(query AdminListQuery) ([]ListingDTO, error) { if query.ReviewStatus != "" { db = db.Where("l.review_status = ?", query.ReviewStatus) } - - var rows []listingRow - err := db.Order("l.id DESC").Limit(limit).Scan(&rows).Error - if err != nil { - return nil, err - } - return rowsToDTO(rows), nil + return db } func (r *Repository) FindAdmin(listingID uint64) (*ListingDTO, error) { diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index 720c736..669f0a7 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -105,7 +105,7 @@ func (s *Service) ListPendingReview() ([]ListingDTO, error) { return s.repo.ListPendingReview() } -func (s *Service) ListAdmin(query AdminListQuery) ([]ListingDTO, error) { +func (s *Service) ListAdmin(query AdminListQuery) (*AdminListResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } diff --git a/frontend/src/api/listings.ts b/frontend/src/api/listings.ts index 62592a8..6144b3f 100644 --- a/frontend/src/api/listings.ts +++ b/frontend/src/api/listings.ts @@ -83,12 +83,31 @@ export interface AdminListingQuery { status?: ListingStatus | '' review_status?: ListingReviewStatus | '' limit?: number + page?: number + page_size?: number +} + +export interface AdminListingPage { + items: Listing[] + total: number + page: number + page_size: number } export async function fetchAdminListings(query: AdminListingQuery = {}) { const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)) - const { data } = await apiClient.get>('/admin/listings', { params }) - return data.data.items + const { data } = await apiClient.get>('/admin/listings', { params }) + return normalizeAdminListingPage(data.data, query) +} + +function normalizeAdminListingPage(data: Partial, query: AdminListingQuery): AdminListingPage { + const items = Array.isArray(data.items) ? data.items : [] + return { + items, + total: Number.isFinite(Number(data.total)) ? Number(data.total) : items.length, + page: Number.isFinite(Number(data.page)) ? Number(data.page) : Number(query.page || 1), + page_size: Number.isFinite(Number(data.page_size)) ? Number(data.page_size) : Number(query.page_size || items.length || 10), + } } export async function fetchAdminListing(id: string | number) { diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 85f9d3d..c21b5d8 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -419,6 +419,75 @@ h1 { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } +.admin-listings-page { + display: flex; + flex-direction: column; + min-height: calc(100vh - 128px); +} + +.listing-control-panel { + display: grid; + grid-template-columns: minmax(340px, 0.65fr) minmax(520px, 1fr) auto; + align-items: end; + gap: 14px; + margin-bottom: 14px; + border-radius: 14px; + background: #ffffff; + padding: 14px 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.listing-metric-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.listing-metric-strip span { + display: inline-flex; + align-items: baseline; + gap: 6px; + min-height: 34px; + border-radius: 8px; + background: #f8f9fe; + padding: 8px 10px; + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.listing-metric-strip strong { + color: #1b2559; + font-size: 16px; + font-weight: 800; +} + +.listing-filter-bar { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(160px, 0.8fr) minmax(170px, 0.8fr); + gap: 12px; +} + +.listing-filter-bar .el-form-item { + margin-bottom: 0; +} + +.listing-filter-bar .el-form-item__label { + color: #8f9bba; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.2px; +} + +.listing-action-strip { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding-bottom: 1px; + white-space: nowrap; +} + /* ========== Dashboard Panels ========== */ .dashboard-panels { display: grid; @@ -559,6 +628,71 @@ h1 { font-size: 12px; } +.table-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: none; + background: #ffffff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.table-pagination { + justify-content: flex-end; + margin-top: 12px; + border-radius: 12px; + padding: 12px 16px; + box-sizing: border-box; + width: 100%; + color: #66728f; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.pagination-controls { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + white-space: nowrap; +} + +.pagination-summary, +.pagination-page, +.pagination-size-label { + flex-shrink: 0; + white-space: nowrap; +} + +.page-size-select { + width: 86px; + flex: 0 0 86px; +} + +.page-size-select .el-select__wrapper { + min-height: 30px; + padding: 4px 8px; +} + +.page-size-select .el-select__selected-item { + min-width: max-content; +} + +.admin-listings-page .table-pagination { + margin-top: auto; +} + +.pagination-controls .el-button--small { + min-width: 44px; + padding: 5px 8px; +} + +.pagination-controls .el-button + .el-button { + margin-left: 0; +} + /* ========== Filter Panel ========== */ .filter-panel { display: grid; diff --git a/frontend/src/views/admin/AdminListingsView.vue b/frontend/src/views/admin/AdminListingsView.vue index 8ed3165..920fb68 100644 --- a/frontend/src/views/admin/AdminListingsView.vue +++ b/frontend/src/views/admin/AdminListingsView.vue @@ -1,8 +1,9 @@