diff --git a/.gitignore b/.gitignore index 7f5020f..3f3e24c 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ frontend/components.d.ts # Docker / local data .docker-data/ backups/ +deploy/backup/ +distribution-admin/ .DS_Store backend/api diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 6358e57..3c3024f 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -93,6 +93,11 @@ type TransferOwnerRequest struct { Reason string `json:"reason"` } +type AdminUpdateScreenshotsRequest struct { + ScreenshotURLs []string `json:"screenshot_urls" binding:"required,min=1"` + ScreenshotGroups map[string]any `json:"screenshot_groups"` +} + type AdminListQuery struct { OwnerID uint64 Keyword string diff --git a/backend/internal/modules/listing/handler_admin.go b/backend/internal/modules/listing/handler_admin.go index b66f750..8c7234c 100644 --- a/backend/internal/modules/listing/handler_admin.go +++ b/backend/internal/modules/listing/handler_admin.go @@ -182,3 +182,27 @@ func (h *Handler) Reject(c *gin.Context) { } response.OK(c, item) } + +func (h *Handler) UpdateScreenshots(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req AdminUpdateScreenshotsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "截图参数不正确") + return + } + item, err := h.service.UpdateScreenshots(c.Request.Context(), adminID, id, req, auditMeta(c)) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + diff --git a/backend/internal/modules/listing/review.go b/backend/internal/modules/listing/review.go index 80121f2..2ea4e74 100644 --- a/backend/internal/modules/listing/review.go +++ b/backend/internal/modules/listing/review.go @@ -386,3 +386,52 @@ func (r *Repository) Reject(ctx context.Context, listingID uint64, req ReviewReq }) return dto, err } + +func (r *Repository) UpdateScreenshots(ctx context.Context, adminID uint64, listingID uint64, req AdminUpdateScreenshotsRequest, meta AuditMeta) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findForReviewUpdate(tx, listingID) + if err != nil { + return err + } + if listing.Status == "rented" || listing.InTransaction { + return ErrListingLocked + } + + screenshots, err := marshalScreenshots(req.ScreenshotURLs) + if err != nil { + return err + } + account.ScreenshotURLS = screenshots + + if req.ScreenshotGroups != nil { + summary := decodeAssetSummary(account.AssetSummary) + if summary == nil { + summary = map[string]any{} + } + summary["screenshot_groups"] = req.ScreenshotGroups + assetSummary, err := marshalAssetSummary(summary) + if err != nil { + return err + } + account.AssetSummary = assetSummary + } + + if err := tx.Save(account).Error; err != nil { + return err + } + + if err := appendAuditLog(tx, adminID, "listing.update_screenshots", "listing", listing.ID, meta, map[string]any{ + "listing_id": listing.ID, + "account_id": account.ID, + "screenshot_urls": req.ScreenshotURLs, + }); err != nil { + return err + } + + dto = toDTO(*account, *listing) + return nil + }) + return dto, err +} + diff --git a/backend/internal/modules/listing/service_review.go b/backend/internal/modules/listing/service_review.go index fe5bb81..fd0ea95 100644 --- a/backend/internal/modules/listing/service_review.go +++ b/backend/internal/modules/listing/service_review.go @@ -81,3 +81,14 @@ func (s *Service) Reject(ctx context.Context, id uint64, req ReviewRequest) (*Li } return s.repo.Reject(ctx, id, req) } + +func (s *Service) UpdateScreenshots(ctx context.Context, adminID uint64, id uint64, req AdminUpdateScreenshotsRequest, meta AuditMeta) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if len(req.ScreenshotURLs) == 0 { + return nil, ErrInvalidInput + } + return s.repo.UpdateScreenshots(ctx, adminID, id, req, meta) +} + diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 2ccb380..08a6ae3 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -699,3 +699,73 @@ func TestApplyPublicScreenshotGroups(t *testing.T) { t.Fatalf("expected /api/listings/88/screenshots/1, got %#v", skinList) } } + +func TestUpdateScreenshots(t *testing.T) { + db := database.NewTestDB() + if err := database.MigrateListingLifecycleTestSchema(db); err != nil { + t.Fatalf("failed to migrate test db: %v", err) + } + if err := db.AutoMigrate(&model.AuditLog{}); err != nil { + t.Fatalf("failed to migrate audit log: %v", err) + } + repo := NewRepository(db, nil) + account := model.GameAccount{ + OwnerID: 1, + GameName: "delta_force", + ServerRegion: "QQ", + LoginPlatform: "QQ账号密码", + Title: "待审核商品", + Status: "draft", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("failed to create account: %v", err) + } + listing := model.RentalListing{ + ListingNo: "202609070001", + AccountID: account.ID, + OwnerID: 1, + Status: "draft", + ReviewStatus: "pending", + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("failed to create listing: %v", err) + } + + meta := AuditMeta{IP: "127.0.0.1", UserAgent: "TestAgent", RequestID: "req-1"} + req := AdminUpdateScreenshotsRequest{ + ScreenshotURLs: []string{ + "/api/files/object?key=edited1.png", + "/api/files/object?key=edited2.png", + }, + ScreenshotGroups: map[string]any{ + "coin": []string{"/api/files/object?key=edited1.png"}, + "gameId": []string{"/api/files/object?key=edited2.png"}, + }, + } + + updated, err := repo.UpdateScreenshots(t.Context(), 999, listing.ID, req, meta) + if err != nil { + t.Fatalf("UpdateScreenshots failed: %v", err) + } + + if len(updated.ScreenshotURLS) != 2 || updated.ScreenshotURLS[0] != "/api/files/object?key=edited1.png" { + t.Fatalf("unexpected screenshot_urls: %#v", updated.ScreenshotURLS) + } + + groups, ok := updated.AssetSummary["screenshot_groups"].(map[string]any) + if !ok { + t.Fatalf("screenshot_groups not found in AssetSummary: %#v", updated.AssetSummary) + } + coinList, ok := groups["coin"].([]string) + if !ok { + if anyList, isAny := groups["coin"].([]any); isAny { + for _, v := range anyList { + coinList = append(coinList, v.(string)) + } + } + } + if len(coinList) != 1 || coinList[0] != "/api/files/object?key=edited1.png" { + t.Fatalf("unexpected coin screenshots: %#v", coinList) + } +} + diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 21d8804..9597ac6 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -649,6 +649,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.PUT("/listings/:id/external-adjustment", requirePerm("listing:edit_external"), listingHandler.AdjustExternalListing) adminRoutes.POST("/listings/:id/transfer-owner", requirePerm("listing:approve"), listingHandler.TransferOwner) adminRoutes.POST("/listings/:id/reject", requirePerm("listing:reject"), listingHandler.Reject) + adminRoutes.PUT("/listings/:id/screenshots", requirePerm("listing:approve"), listingHandler.UpdateScreenshots) adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline) adminRoutes.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal) diff --git a/frontend/src/features/admin/components/AdminListingReviewEvidenceDialog.vue b/frontend/src/features/admin/components/AdminListingReviewEvidenceDialog.vue new file mode 100644 index 0000000..ca5f9ed --- /dev/null +++ b/frontend/src/features/admin/components/AdminListingReviewEvidenceDialog.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/frontend/src/features/admin/components/AdminListingReviewPreviewDialog.vue b/frontend/src/features/admin/components/AdminListingReviewPreviewDialog.vue new file mode 100644 index 0000000..f661eb2 --- /dev/null +++ b/frontend/src/features/admin/components/AdminListingReviewPreviewDialog.vue @@ -0,0 +1,655 @@ + + + + + diff --git a/frontend/src/features/admin/components/AdminListingReviewPriceDialog.vue b/frontend/src/features/admin/components/AdminListingReviewPriceDialog.vue new file mode 100644 index 0000000..e44597e --- /dev/null +++ b/frontend/src/features/admin/components/AdminListingReviewPriceDialog.vue @@ -0,0 +1,347 @@ + + + + + diff --git a/frontend/src/features/admin/components/AdminListingReviewRejectDialog.vue b/frontend/src/features/admin/components/AdminListingReviewRejectDialog.vue new file mode 100644 index 0000000..108a95c --- /dev/null +++ b/frontend/src/features/admin/components/AdminListingReviewRejectDialog.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/frontend/src/features/admin/components/AdminListingReviewShortcutsDialog.vue b/frontend/src/features/admin/components/AdminListingReviewShortcutsDialog.vue new file mode 100644 index 0000000..7c67648 --- /dev/null +++ b/frontend/src/features/admin/components/AdminListingReviewShortcutsDialog.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/frontend/src/features/admin/components/ImageEditorDialog.vue b/frontend/src/features/admin/components/ImageEditorDialog.vue new file mode 100644 index 0000000..5cfdde2 --- /dev/null +++ b/frontend/src/features/admin/components/ImageEditorDialog.vue @@ -0,0 +1,888 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminListingReviewView.css b/frontend/src/features/admin/views/AdminListingReviewView.css index 20599a7..dbb1b08 100644 --- a/frontend/src/features/admin/views/AdminListingReviewView.css +++ b/frontend/src/features/admin/views/AdminListingReviewView.css @@ -1,75 +1,160 @@ +/* ========== Admin Listing Review Page Styling ========== */ .review-page { - min-height: calc(100vh - 128px); + min-height: calc(100vh - 120px); + padding-bottom: 80px; } .review-header { - margin-bottom: 18px; + margin-bottom: 20px; + align-items: center; } +.refresh-stamp { + display: inline-flex; + align-items: center; + gap: 6px; + color: #64748b; + font-size: 13px; + font-weight: 500; + margin-right: 8px; +} + +/* ========== Top Summary Metric Cards ========== */ .review-summary { display: grid; - grid-template-columns: repeat(4, minmax(120px, 1fr)); - gap: 12px; - margin-bottom: 14px; + grid-template-columns: repeat(4, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 20px; } .summary-card { + position: relative; min-width: 0; - border: 1px solid #e8edf4; - border-radius: 8px; + border: 1px solid #e2e8f0; + border-radius: 12px; background: #ffffff; - padding: 14px 16px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + padding: 16px 20px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05); text-align: left; cursor: pointer; - transition: all 0.2s ease; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; +} + +.summary-card-inner { + display: flex; + align-items: center; + gap: 16px; +} + +.summary-card-icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 12px; + font-size: 22px; + flex-shrink: 0; + transition: transform 0.25s ease; +} + +.summary-card-text { + min-width: 0; + display: flex; + flex-direction: column; +} + +.summary-card-text span { + color: #64748b; + font-size: 13px; + font-weight: 600; + line-height: 1.2; +} + +.summary-card-text strong { + margin: 4px 0 2px; + color: #0f172a; + font-size: 26px; + font-weight: 800; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.summary-card-text small { + color: #94a3b8; + font-size: 12px; + font-weight: 500; +} + +/* Thematic colors for cards */ +.summary-card--all .summary-card-icon { + background: #eff6ff; + color: #2563eb; +} + +.summary-card--external .summary-card-icon { + background: #f5f3ff; + color: #7c3aed; +} + +.summary-card--warning .summary-card-icon { + background: #fffbeb; + color: #d97706; +} + +.summary-card--danger .summary-card-icon { + background: #fef2f2; + color: #dc2626; } .summary-card:hover { - border-color: #1477ff; - box-shadow: 0 2px 8px rgba(20, 119, 255, 0.15); - transform: translateY(-1px); + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); } -.summary-card.active { - border-color: #1477ff; - background: #1477ff; - box-shadow: 0 2px 8px rgba(20, 119, 255, 0.25); +.summary-card:hover .summary-card-icon { + transform: scale(1.08); } -.summary-card.active span { - color: #ffffff; +/* Active Filter States */ +.summary-card--all.active { + border-color: #2563eb; + background: #f0f7ff; + box-shadow: + 0 0 0 1px #2563eb, + 0 6px 20px rgba(37, 99, 235, 0.15); } -.summary-card.active strong { - color: #ffffff; +.summary-card--external.active { + border-color: #7c3aed; + background: #faf5ff; + box-shadow: + 0 0 0 1px #7c3aed, + 0 6px 20px rgba(124, 58, 237, 0.15); } -.review-summary span, -.queue-footer span, -.panel-title span, -.inventory-grid span, -.resource-table span, -.price-decision-card span, -.adjust-current span, -.adjust-field span { - color: #8f9bba; - font-size: 12px; - font-weight: 600; +.summary-card--warning.active { + border-color: #d97706; + background: #fffdf5; + box-shadow: + 0 0 0 1px #d97706, + 0 6px 20px rgba(217, 119, 6, 0.15); } -.review-summary strong { - display: block; - margin-top: 5px; - color: #1b2559; - font-size: 24px; - font-weight: 800; +.summary-card--danger.active { + border-color: #dc2626; + background: #fff5f5; + box-shadow: + 0 0 0 1px #dc2626, + 0 6px 20px rgba(220, 38, 38, 0.15); } +/* ========== Review Workbench Split Layout ========== */ .review-workbench { display: grid; - grid-template-columns: minmax(360px, 420px) minmax(0, 1fr); - gap: 16px; + grid-template-columns: 380px minmax(0, 1fr); + gap: 20px; align-items: start; } @@ -80,432 +165,1108 @@ min-width: 0; } +/* ========== Left: Queue Sidebar ========== */ .review-queue { position: sticky; top: 16px; - border-radius: 8px; + height: calc(100vh - 120px); + display: flex; + flex-direction: column; + border: 1px solid #e2e8f0; + border-radius: 12px; background: #ffffff; - padding: 14px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + padding: 16px; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05); } .queue-toolbar { - display: grid; - grid-template-columns: minmax(0, 1fr) 120px; + display: flex; + flex-direction: column; gap: 10px; margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid #f1f5f9; +} + +.queue-filter-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.queue-select { + width: 100%; +} + +.queue-count-strip { + display: flex; + align-items: center; + justify-content: space-between; + color: #64748b; + font-size: 12px; + font-weight: 600; +} + +.shortcut-tip { + color: #94a3b8; + font-size: 11px; } .queue-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; + padding-right: 4px; +} + +/* Queue Card Item */ +.queue-item { + position: relative; + width: 100%; + display: flex; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #ffffff; + padding: 0; + text-align: left; + cursor: pointer; + overflow: hidden; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.item-accent-bar { + width: 4px; + background: #94a3b8; + flex-shrink: 0; + transition: + width 0.2s ease, + background 0.2s ease; +} + +.queue-item.has-default-shot .item-accent-bar { + background: #f59e0b; +} + +.queue-item.has-deposit-risk .item-accent-bar { + background: #f97316; +} + +.queue-item.has-ban .item-accent-bar { + background: #ef4444; +} + +.queue-card-content { + flex: 1; + min-width: 0; + padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; - max-height: calc(100vh - 300px); - overflow: auto; - padding-right: 2px; } -.queue-item { - width: 100%; - min-height: 112px; - border: 1px solid #e8ecf1; - border-radius: 8px; - background: #ffffff; - padding: 12px; - text-align: left; - cursor: pointer; - transition: - border-color 0.2s, - box-shadow 0.2s, - background 0.2s; -} - -.queue-item:hover, -.queue-item.active { - border-color: #4f7cff; - background: #f8faff; - box-shadow: 0 6px 18px rgba(79, 124, 255, 0.1); -} - -.queue-title-row, -.queue-footer, -.panel-title, -.hero-title { +.queue-top-line { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 12px; } -.queue-title-row strong { - display: block; - min-width: 0; - color: #1b2559; - font-size: 14px; - font-weight: 800; - line-height: 1.4; -} - -.queue-title-row span { - flex-shrink: 0; - color: #4f7cff; - font-weight: 800; -} - -.queue-title-row .queue-code { - display: inline-flex; - margin-bottom: 4px; - padding: 2px 7px; - border-radius: 999px; - background: #eff6ff; - font-size: 12px; -} - -.queue-meta, -.risk-strip, -.skin-list, -.reject-reasons { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.queue-meta { - margin: 10px 0; -} - -.queue-meta span { - border-radius: 6px; - background: #f0f2f5; - padding: 4px 7px; - color: #4b5563; +.queue-code { + color: #64748b; font-size: 12px; font-weight: 700; + font-family: monospace; } -.queue-footer { +.queue-coin { + display: inline-flex; align-items: center; + gap: 4px; + color: #b45309; + background: #fef3c7; + padding: 2px 7px; + border-radius: 6px; + font-size: 12px; + font-weight: 800; } -.queue-tags, -.panel-tags { +.queue-title-row { + display: flex; + align-items: center; + gap: 10px; +} + +.queue-thumb { + position: relative; + width: 44px; + height: 44px; + border-radius: 8px; + overflow: hidden; + background: #f1f5f9; + flex-shrink: 0; + border: 1px solid #e2e8f0; +} + +.queue-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.thumb-warning-badge { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(217, 119, 6, 0.9); + color: #ffffff; + font-size: 9px; + font-weight: 700; + text-align: center; + line-height: 13px; +} + +.queue-title { + flex: 1; + min-width: 0; + color: #0f172a; + font-size: 13px; + font-weight: 700; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.queue-specs { display: flex; flex-wrap: wrap; - justify-content: flex-end; gap: 6px; } +.spec-pill { + padding: 2px 6px; + border-radius: 4px; + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 600; +} + +.queue-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-top: 6px; + border-top: 1px solid #f8fafc; +} + +.queue-price-block { + display: flex; + align-items: baseline; + gap: 6px; +} + +.queue-price { + color: #2563eb; + font-size: 14px; + font-weight: 800; +} + +.queue-deposit { + color: #64748b; + font-size: 11px; +} + +.queue-tags { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; +} + +/* Hover & Active Queue Item */ +.queue-item:hover { + border-color: #93c5fd; + background: #f8fafc; + transform: translateX(2px); +} + +.queue-item.active { + border-color: #2563eb; + background: #f0f7ff; + box-shadow: + 0 0 0 1px #2563eb, + 0 4px 16px rgba(37, 99, 235, 0.12); +} + +.queue-item.active .item-accent-bar { + background: #2563eb; + width: 6px; +} + +.queue-item.active .queue-title { + color: #1d4ed8; +} + +/* ========== Right: Audit Workbench Main Area ========== */ .review-detail { display: flex; flex-direction: column; - gap: 14px; + gap: 18px; } .review-hero-panel, .review-panel { - border-radius: 8px; + border: 1px solid #e2e8f0; + border-radius: 12px; background: #ffffff; - padding: 18px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + padding: 20px 24px; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04); } -.hero-title h2, -.panel-title h3 { - margin: 0; - color: #1b2559; - font-size: 18px; - font-weight: 800; - line-height: 1.35; +/* Hero Panel */ +.hero-top-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; } -.hero-title p { - margin: 0 0 6px; - color: #8f9bba; - font-size: 12px; +.hero-meta-strip { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.hero-code-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 6px; + background: #f1f5f9; + color: #334155; + font-size: 13px; font-weight: 700; + font-family: monospace; + cursor: pointer; + transition: all 0.2s; +} + +.hero-code-badge:hover { + background: #e2e8f0; + color: #0f172a; +} + +.copy-icon { + font-size: 13px; +} + +.hero-submit-time { + display: inline-flex; + align-items: center; + gap: 5px; + color: #64748b; + font-size: 12px; } .hero-actions { display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 8px; -} - -.risk-strip { - margin-top: 14px; -} - -.review-metrics { - grid-template-columns: repeat(5, minmax(132px, 1fr)); - gap: 12px; - margin-bottom: 0; -} - -.review-metrics .metric-card { - border-radius: 8px; - padding: 16px; -} - -.review-metrics .metric-card strong { - font-size: 20px; -} - -.review-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; -} - -.panel-title { align-items: center; - margin-bottom: 14px; + gap: 8px; + flex-wrap: wrap; } -.price-panel-title p { - margin: 5px 0 0; - color: #8f9bba; +.action-link { + text-decoration: none; +} + +.btn-approve { + font-weight: 700; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25); +} + +.hero-title { + margin: 0 0 14px; + color: #0f172a; + font-size: 22px; + font-weight: 800; + line-height: 1.35; +} + +.risk-tag-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 4px; +} + +/* Panel Heading Component */ +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid #f1f5f9; +} + +.heading-left { + display: flex; + align-items: center; + gap: 10px; +} + +.heading-icon { + font-size: 20px; + color: #2563eb; +} + +.heading-left h3 { + margin: 0; + color: #0f172a; + font-size: 16px; + font-weight: 800; + line-height: 1.2; +} + +.heading-left p { + margin: 3px 0 0; + color: #64748b; + font-size: 12px; +} + +/* ========== Interactive Pricing Pipeline ========== */ +.pricing-pipeline { + display: grid; + grid-template-columns: 1fr auto 1fr auto 1.15fr 1fr; + align-items: center; + gap: 12px; +} + +.pipeline-card { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 130px; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #f8fafc; + padding: 14px 16px; +} + +.pipeline-card .card-badge { + display: inline-block; + align-self: flex-start; + padding: 2px 8px; + border-radius: 4px; + background: #e2e8f0; + color: #475569; + font-size: 11px; + font-weight: 700; +} + +.pipeline-card .card-value { + color: #0f172a; + font-size: 24px; + font-weight: 800; + line-height: 1.15; +} + +.pipeline-card .card-meta { + color: #475569; font-size: 12px; font-weight: 600; } -.price-decision-grid { - display: grid; - grid-template-columns: repeat(4, minmax(150px, 1fr)); - gap: 12px; +.pipeline-card .card-breakdown { + margin-top: auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + color: #64748b; + font-size: 11px; + font-weight: 500; + padding-top: 6px; + border-top: 1px solid rgba(0, 0, 0, 0.05); } -.price-decision-card { - display: grid; - gap: 7px; - min-width: 0; - min-height: 128px; - border: 1px solid #e8ecf1; - border-radius: 8px; - background: #f8f9fe; - padding: 14px; -} - -.price-decision-card strong { - color: #1b2559; - font-size: 24px; - font-weight: 900; - line-height: 1.1; -} - -.price-decision-card p, -.price-decision-card small { - margin: 0; - color: #4b5563; - font-size: 12px; - font-weight: 700; - line-height: 1.45; -} - -.price-decision-card small { - color: #8f9bba; -} - -.price-decision-card.buyer { - border-color: #bfdbfe; - background: #eff6ff; -} - -.price-decision-card.buyer strong { - color: #2563eb; -} - -.price-decision-card.rule { +.pipeline-card--rule { border-color: #fed7aa; background: #fff7ed; } -.price-decision-card.rule strong { +.pipeline-card--rule .card-badge { + background: #ffedd5; + color: #c2410c; +} + +.pipeline-card--rule .markup-value { color: #ea580c; } -.price-decision-card.deposit { +.admin-reason { + color: #ea580c; + font-weight: 700; +} + +.pipeline-card--buyer { + border: 2px solid #2563eb; + background: linear-gradient(135deg, #eff6ff 0%, #e0f2fe 100%); + box-shadow: 0 4px 16px rgba(37, 99, 235, 0.12); +} + +.pipeline-card--buyer .card-badge--highlight { + background: #2563eb; + color: #ffffff; +} + +.pipeline-card--buyer .card-value--highlight { + color: #1d4ed8; + font-size: 26px; +} + +.pipeline-card--deposit { border-color: #e0e7ff; background: #f5f7ff; } -.detail-list { - display: grid; - gap: 10px; - margin: 0; +.pipeline-card--deposit .card-badge { + background: #ede9fe; + color: #6d28d9; } -.detail-list div { - display: grid; - grid-template-columns: 86px minmax(0, 1fr); - gap: 10px; - align-items: start; +.pipeline-arrow { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + color: #94a3b8; + font-size: 18px; } -.detail-list dt { - color: #8f9bba; - font-size: 12px; +.arrow-label { + font-size: 10px; + font-weight: 700; + color: #94a3b8; +} + +.warning-text { + color: #dc2626 !important; font-weight: 700; } -.detail-list dd { - min-width: 0; - margin: 0; - color: #1f2937; - font-size: 13px; - font-weight: 700; - line-height: 1.45; - overflow-wrap: anywhere; -} - -.inventory-grid { +/* ========== Dual Columns: Core Specs Grid ========== */ +.review-grid { display: grid; - grid-template-columns: repeat(3, minmax(110px, 1fr)); - gap: 10px; - margin-bottom: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; } -.inventory-grid div { - border-radius: 8px; - background: #f8f9fe; - padding: 12px; -} - -.inventory-grid strong { - display: block; - margin-top: 4px; - color: #1b2559; - font-size: 22px; - font-weight: 900; -} - -.resource-table { +.spec-grid { display: grid; - gap: 8px; - margin-bottom: 12px; -} - -.resource-table div { - display: grid; - grid-template-columns: minmax(120px, 1fr) 72px minmax(120px, 1fr); - gap: 10px; - align-items: center; - border-bottom: 1px solid #f0f2f5; - padding-bottom: 8px; -} - -.resource-table strong { - color: #1f2937; - font-size: 13px; - font-weight: 800; -} - -.resource-table em { - color: #8f9bba; - font-size: 12px; - font-style: normal; - font-weight: 600; -} - -.skin-list { - align-items: center; -} - -.skin-list > span { - color: #8f9bba; - font-size: 13px; -} - -.screenshot-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } -.screenshot-tile { +.spec-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border-radius: 8px; + background: #f8fafc; + border: 1px solid #f1f5f9; +} + +.spec-item--full { + grid-column: 1 / -1; +} + +.spec-label { + color: #64748b; + font-size: 11px; + font-weight: 700; +} + +.spec-val { + color: #0f172a; + font-size: 13px; + font-weight: 700; +} + +.rank-badge { + display: inline-block; + align-self: flex-start; + padding: 2px 8px; + border-radius: 4px; + background: #eff6ff; + color: #2563eb; + font-size: 12px; + font-weight: 800; +} + +.ban-val { + display: flex; + align-items: center; + gap: 6px; + font-weight: 700; +} + +.safe-icon { + color: #16a34a; + font-size: 16px; +} + +.ban-icon { + color: #dc2626; + font-size: 16px; +} + +.spec-item.ban-warning { + border-color: #fca5a5; + background: #fef2f2; +} + +.spec-item.ban-warning .spec-val { + color: #b91c1c; +} + +.copyable-val { + display: flex; + align-items: center; + justify-content: space-between; +} + +/* ========== Inventory & Consumables ========== */ +.consumable-total-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + border-radius: 6px; + background: #f1f5f9; + color: #475569; + font-size: 12px; +} + +.consumable-total-badge strong { + color: #2563eb; + font-size: 14px; + font-weight: 800; +} + +.heavy-gear-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.gear-card { + display: flex; + align-items: center; + gap: 14px; + padding: 14px; + border-radius: 10px; + border: 1px solid #e2e8f0; + background: #f8fafc; +} + +.gear-icon { + font-size: 28px; +} + +.gear-info { + display: flex; + flex-direction: column; +} + +.gear-info span { + color: #64748b; + font-size: 12px; + font-weight: 600; +} + +.gear-info strong { + color: #0f172a; + font-size: 20px; + font-weight: 900; +} + +.gear-info em { + color: #64748b; + font-size: 12px; + font-style: normal; + font-weight: normal; +} + +.resource-pill-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.resource-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 6px; + background: #f1f5f9; + font-size: 12px; +} + +.res-name { + color: #334155; + font-weight: 600; +} + +.res-qty { + color: #2563eb; + font-weight: 800; +} + +.res-mode { + color: #64748b; +} + +.skins-section { + padding-top: 12px; + border-top: 1px solid #f1f5f9; +} + +.skins-header { + margin-bottom: 8px; + color: #475569; + font-size: 12px; + font-weight: 700; +} + +.skin-chips-wrapper { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.skin-chip { + display: inline-flex; + padding: 4px 10px; + border-radius: 6px; + background: #ecfdf5; + border: 1px solid #a7f3d0; + color: #047857; + font-size: 12px; + font-weight: 600; +} + +.empty-skins-tip { + margin: 0; + color: #94a3b8; + font-size: 12px; +} + +/* ========== Verification Checklist & Screenshot Gallery ========== */ +.verification-checklist { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.check-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid #e2e8f0; + background: #f8fafc; + font-size: 12px; +} + +.check-icon { + font-size: 16px; + flex-shrink: 0; +} + +.check-label { + flex: 1; + min-width: 0; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.check-status-tag { + font-size: 11px; + font-weight: 600; +} + +.check-item--provided { + border-color: #bbf7d0; + background: #f0fdf4; + color: #15803d; +} + +.check-item--provided .check-icon { + color: #16a34a; +} + +.check-item--default { + border-color: #fde68a; + background: #fffbeb; + color: #b45309; +} + +.check-item--default .check-icon { + color: #d97706; +} + +.check-item--missing { + border-color: #e2e8f0; + background: #f8fafc; + color: #94a3b8; +} + +.check-item--missing .check-icon { + color: #cbd5e1; +} + +.screenshot-gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 14px; +} + +.screenshot-card { position: relative; display: block; width: 100%; aspect-ratio: 16 / 9; + border-radius: 10px; overflow: hidden; - border: 1px solid #e8ecf1; - border-radius: 8px; - background: #f8f9fe; - padding: 0; + border: 1px solid #e2e8f0; + background: #f8fafc; cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); } -.screenshot-tile img { +.screenshot-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); + border-color: #93c5fd; +} + +.image-wrapper { + position: relative; + width: 100%; + height: 100%; +} + +.image-wrapper img { display: block; width: 100%; height: 100%; object-fit: cover; } -.screenshot-tile span { +.default-badge { position: absolute; top: 8px; left: 8px; - border-radius: 6px; - background: rgba(245, 158, 11, 0.95); - padding: 3px 7px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + border-radius: 4px; + background: rgba(217, 119, 6, 0.95); + backdrop-filter: blur(4px); + color: #ffffff; + font-size: 11px; + font-weight: 700; + z-index: 2; +} + +.category-tag { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient( + to top, + rgba(15, 23, 42, 0.9) 0%, + rgba(15, 23, 42, 0.4) 70%, + transparent 100% + ); color: #ffffff; font-size: 12px; - font-weight: 800; + font-weight: 700; + text-align: center; + padding: 14px 6px 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + z-index: 2; +} + +.image-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px; + background: rgba(15, 23, 42, 0.65); + backdrop-filter: blur(3px); + opacity: 0; + transition: opacity 0.2s ease; + z-index: 3; +} + +.screenshot-card:hover .image-overlay { + opacity: 1; +} + +.image-overlay .el-button { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: transform 0.15s ease; +} + +.image-overlay .el-button:hover { + transform: scale(1.1); } .empty-warning { display: flex; align-items: center; - gap: 8px; - min-height: 84px; + gap: 10px; border-radius: 8px; background: #fff7ed; + border: 1px solid #fed7aa; padding: 16px; color: #c2410c; font-weight: 700; + font-size: 13px; } -.dialog-screenshots { - max-height: 540px; - overflow: auto; +/* ========== Floating Sticky Action Dock ========== */ +.floating-dock { + position: fixed; + bottom: 24px; + right: 32px; + z-index: 900; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 10px 18px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(16px); + border: 1px solid rgba(226, 232, 240, 0.9); + box-shadow: + 0 10px 30px rgba(15, 23, 42, 0.15), + 0 2px 8px rgba(15, 23, 42, 0.08); + animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); } -.reject-dialog p { - margin-top: 0; +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } } -.reject-reasons { - margin: 12px 0; -} - -.price-adjust-dialog { - display: grid; - gap: 14px; -} - -.adjust-current { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); +.dock-info { + display: flex; + align-items: center; gap: 10px; } -.adjust-current div { - display: grid; - gap: 5px; - border-radius: 8px; - background: #f8f9fe; - padding: 12px; +.dock-code { + padding: 2px 6px; + border-radius: 4px; + background: #f1f5f9; + color: #475569; + font-size: 12px; + font-weight: 700; + font-family: monospace; } -.adjust-current strong { - color: #1b2559; - font-size: 18px; - font-weight: 900; +.dock-title { + max-width: 220px; + color: #0f172a; + font-size: 13px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.adjust-current .preview-value { +.dock-price { color: #2563eb; + font-size: 14px; + font-weight: 800; +} + +.dock-risk-tag { + color: #dc2626; + background: #fee2e2; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 700; +} + +.dock-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.btn-approve-dock { + font-weight: 700; + box-shadow: 0 2px 10px rgba(37, 99, 235, 0.3); +} + +/* ========== Dialogs Styling ========== */ +.dialog-item-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px solid #f1f5f9; +} + +.dialog-code { + padding: 2px 8px; + border-radius: 4px; + background: #f1f5f9; + color: #475569; + font-size: 12px; + font-weight: 700; + font-family: monospace; +} + +.reject-quick-tags { + margin-bottom: 14px; +} + +.quick-tags-title { + margin: 0 0 8px; + color: #64748b; + font-size: 12px; + font-weight: 600; +} + +.quick-tags-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.quick-reason-pill { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #ffffff; + padding: 5px 10px; + color: #334155; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.quick-reason-pill:hover { + border-color: #2563eb; + color: #2563eb; +} + +.quick-reason-pill.selected { + border-color: #2563eb; + background: #eff6ff; + color: #2563eb; +} + +.reject-tip { + display: flex; + align-items: center; + gap: 6px; + margin-top: 10px; + color: #64748b; + font-size: 12px; +} + +/* Price Adjust Modal */ +.adjust-preview-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.preview-box { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + border-radius: 8px; + background: #f8fafc; + border: 1px solid #e2e8f0; +} + +.preview-box--highlight { + border-color: #93c5fd; + background: #eff6ff; +} + +.box-label { + color: #64748b; + font-size: 11px; + font-weight: 700; +} + +.box-val { + color: #0f172a; + font-size: 18px; + font-weight: 800; +} + +.highlight-val { + color: #2563eb; +} + +.preview-box small { + color: #64748b; + font-size: 11px; +} + +.adjust-mode-section { + margin-bottom: 14px; } .adjust-mode { @@ -518,73 +1279,182 @@ .adjust-mode :deep(.el-radio-button__inner) { width: 100%; - border-color: #d7deea; - font-weight: 800; + font-weight: 700; } -.adjust-mode :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { - border-color: #2563eb; - background: #2563eb; - box-shadow: -1px 0 0 0 #2563eb; +.adjust-fields-box { + display: flex; + flex-direction: column; + gap: 14px; } .adjust-field { - display: grid; - gap: 8px; + display: flex; + flex-direction: column; + gap: 6px; } -.adjust-field > span { - color: #52607a; +.field-title { + color: #475569; font-size: 12px; - font-weight: 800; + font-weight: 700; } .adjust-field :deep(.el-input-number) { width: 100%; } +/* Evidence Dialog */ +.evidence-header-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid #f1f5f9; +} + +.evidence-item-info { + display: flex; + align-items: center; + gap: 8px; +} + +.evidence-code { + padding: 2px 8px; + border-radius: 4px; + background: #f1f5f9; + color: #475569; + font-size: 12px; + font-weight: 700; + font-family: monospace; +} + +.dialog-gallery { + max-height: 560px; + overflow-y: auto; + padding-right: 4px; +} + +/* Shortcuts Cheatsheet */ +.shortcuts-guide-table { + display: flex; + flex-direction: column; + gap: 10px; +} + +.shortcut-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + background: #f8fafc; + font-size: 13px; +} + +.key-badge { + display: inline-block; + padding: 2px 8px; + border: 1px solid #cbd5e1; + border-bottom-width: 2px; + border-radius: 4px; + background: #ffffff; + color: #0f172a; + font-family: monospace; + font-size: 12px; + font-weight: 700; +} + +.key-desc { + margin-left: auto; + color: #475569; + font-weight: 600; +} + +/* Empty Workbench */ .empty-detail { min-height: 420px; display: grid; place-items: center; - border-radius: 8px; + border-radius: 12px; background: #ffffff; } -@media (max-width: 1280px) { +/* ========== Responsive Breakpoints ========== */ +@media (max-width: 1360px) { + .review-workbench { + grid-template-columns: 340px minmax(0, 1fr); + } + + .pricing-pipeline { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + } + + .pipeline-arrow { + display: none; + } +} + +@media (max-width: 1100px) { + .review-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .review-workbench { grid-template-columns: 1fr; } .review-queue { position: static; + height: auto; + max-height: 480px; } - .queue-list { - max-height: none; + .floating-dock { + right: 16px; + left: 16px; + justify-content: space-between; + } + + .dock-title { + max-width: 140px; } } -@media (max-width: 900px) { - .review-summary, - .price-decision-grid, +@media (max-width: 768px) { + .review-summary { + grid-template-columns: 1fr; + } + + .pricing-pipeline { + grid-template-columns: 1fr; + } + .review-grid { grid-template-columns: 1fr; } - .adjust-current { + .heavy-gear-grid { grid-template-columns: 1fr; } - .hero-title { + .verification-checklist { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .hero-top-row { flex-direction: column; } .hero-actions { + width: 100%; justify-content: flex-start; } - .queue-toolbar { - grid-template-columns: 1fr; + .floating-dock { + display: none; } } diff --git a/frontend/src/features/admin/views/AdminListingReviewView.vue b/frontend/src/features/admin/views/AdminListingReviewView.vue index 0559b70..d7edc31 100644 --- a/frontend/src/features/admin/views/AdminListingReviewView.vue +++ b/frontend/src/features/admin/views/AdminListingReviewView.vue @@ -1,12 +1,36 @@ diff --git a/frontend/src/features/listings/api/listings.ts b/frontend/src/features/listings/api/listings.ts index 19ed28a..9e97b3a 100644 --- a/frontend/src/features/listings/api/listings.ts +++ b/frontend/src/features/listings/api/listings.ts @@ -300,3 +300,19 @@ export async function rejectListing(id: number, reason: string) { }) return data.data } + +export interface AdminListingScreenshotsPayload { + screenshot_urls: string[] + screenshot_groups?: Record +} + +export async function adminUpdateListingScreenshots( + id: number, + payload: AdminListingScreenshotsPayload +) { + const { data } = await apiClient.put>( + `/admin/listings/${id}/screenshots`, + payload + ) + return data.data +}