增加渠道上传预统计
This commit is contained in:
@@ -51,6 +51,7 @@ func (RentalListing) TableName() string {
|
||||
type ListingUpload struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
UploaderName string `gorm:"size:64;not null;index" json:"uploader_name"`
|
||||
SourceChannel string `gorm:"size:32;not null;default:'';index" json:"source_channel"`
|
||||
MatchedAdminID *uint64 `gorm:"index" json:"matched_admin_id"`
|
||||
OwnerID *uint64 `gorm:"index" json:"owner_id"`
|
||||
ClientUploadTime *time.Time `json:"client_upload_time"`
|
||||
|
||||
@@ -24,17 +24,27 @@ type MetricsDTO struct {
|
||||
|
||||
// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。
|
||||
type ListingDailyOverviewDTO struct {
|
||||
Today ListingDayStatsDTO `json:"today"`
|
||||
Trend []ListingDayStatsDTO `json:"trend"`
|
||||
Days int `json:"days"`
|
||||
Timezone string `json:"timezone"`
|
||||
Today ListingDayStatsDTO `json:"today"`
|
||||
Trend []ListingDayStatsDTO `json:"trend"`
|
||||
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
|
||||
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
|
||||
Days int `json:"days"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
type ListingDayStatsDTO struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD(上海时区)
|
||||
PublishedCount int64 `json:"published_count"`
|
||||
ActiveOfflineCount int64 `json:"active_offline_count"` // 号主 + 后台主动下架
|
||||
TradeLeaveCount int64 `json:"trade_leave_count"` // rented / completed / sealed / 交易侧归档下架
|
||||
Date string `json:"date"` // YYYY-MM-DD(上海时区)
|
||||
PublishedCount int64 `json:"published_count"`
|
||||
ActiveOfflineCount int64 `json:"active_offline_count"` // 号主 + 后台主动下架
|
||||
TradeLeaveCount int64 `json:"trade_leave_count"` // rented / completed / sealed / 交易侧归档下架
|
||||
}
|
||||
|
||||
type ListingChannelDayStatsDTO struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD(上海时区)
|
||||
SourceChannel string `json:"source_channel"`
|
||||
PublishedCount int64 `json:"published_count"`
|
||||
ActiveOfflineCount int64 `json:"active_offline_count"`
|
||||
TradeLeaveCount int64 `json:"trade_leave_count"`
|
||||
}
|
||||
|
||||
type PendingDTO struct {
|
||||
|
||||
@@ -2,6 +2,7 @@ package admindashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/listingstatus"
|
||||
@@ -11,7 +12,11 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const listingDailyTrendDays = 7
|
||||
const (
|
||||
listingDailyTrendDays = 7
|
||||
listingChannelWebsite = "站内发布"
|
||||
listingChannelExternalUnknown = "未填写"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
@@ -102,39 +107,50 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
|
||||
start := end.AddDate(0, 0, -days)
|
||||
|
||||
type eventRow struct {
|
||||
EventType string
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
EventType string
|
||||
Source string
|
||||
SourceChannel string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
events := make([]eventRow, 0)
|
||||
if err := r.db.WithContext(ctx).Model(&model.ListingStatusEvent{}).
|
||||
Select("event_type, source, created_at").
|
||||
Where("created_at >= ? AND created_at < ?", start, end).
|
||||
db := r.db.WithContext(ctx)
|
||||
uploadSource := db.Table("listing_uploads").
|
||||
Select("listing_id, MAX(id) AS upload_id, MAX(NULLIF(source_channel, '')) AS source_channel").
|
||||
Where("listing_id IS NOT NULL").
|
||||
Group("listing_id")
|
||||
if err := db.Table("listing_status_events AS e").
|
||||
Select(`e.event_type, e.source, e.created_at,
|
||||
CASE
|
||||
WHEN lu.upload_id IS NULL THEN ?
|
||||
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
|
||||
ELSE lu.source_channel
|
||||
END AS source_channel`, listingChannelWebsite, listingChannelExternalUnknown).
|
||||
Joins("LEFT JOIN (?) AS lu ON lu.listing_id = e.listing_id", uploadSource).
|
||||
Where("e.created_at >= ? AND e.created_at < ?", start, end).
|
||||
Scan(&events).Error; err != nil {
|
||||
return ListingDailyOverviewDTO{}, err
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
published int64
|
||||
activeOffline int64
|
||||
tradeLeave int64
|
||||
}
|
||||
byDay := make(map[string]*bucket, days)
|
||||
byDay := make(map[string]*listingDailyBucket, days)
|
||||
byChannel := make(map[listingChannelKey]*listingDailyBucket)
|
||||
for _, ev := range events {
|
||||
key := ev.CreatedAt.In(loc).Format("2006-01-02")
|
||||
b := byDay[key]
|
||||
if b == nil {
|
||||
b = &bucket{}
|
||||
b = &listingDailyBucket{}
|
||||
byDay[key] = b
|
||||
}
|
||||
switch {
|
||||
case ev.EventType == listingstatus.EventPublished:
|
||||
b.published++
|
||||
case isActiveOfflineEvent(ev.EventType, ev.Source):
|
||||
b.activeOffline++
|
||||
case isTradeLeaveEvent(ev.EventType, ev.Source):
|
||||
b.tradeLeave++
|
||||
if !addListingDailyEvent(b, ev.EventType, ev.Source) {
|
||||
continue
|
||||
}
|
||||
channel := ev.SourceChannel
|
||||
channelBucketKey := listingChannelKey{date: key, sourceChannel: channel}
|
||||
cb := byChannel[channelBucketKey]
|
||||
if cb == nil {
|
||||
cb = &listingDailyBucket{}
|
||||
byChannel[channelBucketKey] = cb
|
||||
}
|
||||
addListingDailyEvent(cb, ev.EventType, ev.Source)
|
||||
}
|
||||
|
||||
trend := make([]ListingDayStatsDTO, 0, days)
|
||||
@@ -154,14 +170,101 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
|
||||
if len(trend) > 0 {
|
||||
todayStats = trend[len(trend)-1]
|
||||
}
|
||||
channelTrend := listingChannelTrend(byChannel)
|
||||
todayChannels := make([]ListingChannelDayStatsDTO, 0)
|
||||
for _, item := range channelTrend {
|
||||
if item.Date == todayStats.Date {
|
||||
todayChannels = append(todayChannels, item)
|
||||
}
|
||||
}
|
||||
return ListingDailyOverviewDTO{
|
||||
Today: todayStats,
|
||||
Trend: trend,
|
||||
Days: days,
|
||||
Timezone: "Asia/Shanghai",
|
||||
Today: todayStats,
|
||||
Trend: trend,
|
||||
TodayChannels: todayChannels,
|
||||
ChannelTrend: channelTrend,
|
||||
Days: days,
|
||||
Timezone: "Asia/Shanghai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type listingChannelKey struct {
|
||||
date string
|
||||
sourceChannel string
|
||||
}
|
||||
|
||||
type listingDailyBucket struct {
|
||||
published int64
|
||||
activeOffline int64
|
||||
tradeLeave int64
|
||||
}
|
||||
|
||||
func addListingDailyEvent(b *listingDailyBucket, eventType, source string) bool {
|
||||
switch {
|
||||
case eventType == listingstatus.EventPublished:
|
||||
b.published++
|
||||
return true
|
||||
case isActiveOfflineEvent(eventType, source):
|
||||
b.activeOffline++
|
||||
return true
|
||||
case isTradeLeaveEvent(eventType, source):
|
||||
b.tradeLeave++
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func listingChannelTrend(rows map[listingChannelKey]*listingDailyBucket) []ListingChannelDayStatsDTO {
|
||||
keys := make([]listingChannelKey, 0, len(rows))
|
||||
for key := range rows {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].date != keys[j].date {
|
||||
return keys[i].date < keys[j].date
|
||||
}
|
||||
leftRank := listingChannelRank(keys[i].sourceChannel)
|
||||
rightRank := listingChannelRank(keys[j].sourceChannel)
|
||||
if leftRank != rightRank {
|
||||
return leftRank < rightRank
|
||||
}
|
||||
return keys[i].sourceChannel < keys[j].sourceChannel
|
||||
})
|
||||
items := make([]ListingChannelDayStatsDTO, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
b := rows[key]
|
||||
items = append(items, ListingChannelDayStatsDTO{
|
||||
Date: key.date,
|
||||
SourceChannel: key.sourceChannel,
|
||||
PublishedCount: b.published,
|
||||
ActiveOfflineCount: b.activeOffline,
|
||||
TradeLeaveCount: b.tradeLeave,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func listingChannelRank(channel string) int {
|
||||
switch channel {
|
||||
case "咸鱼":
|
||||
return 10
|
||||
case "淘宝":
|
||||
return 20
|
||||
case "京东":
|
||||
return 30
|
||||
case "QQ":
|
||||
return 40
|
||||
case "微信":
|
||||
return 50
|
||||
case listingChannelExternalUnknown:
|
||||
return 90
|
||||
case listingChannelWebsite:
|
||||
return 100
|
||||
default:
|
||||
return 80
|
||||
}
|
||||
}
|
||||
|
||||
func isActiveOfflineEvent(eventType, source string) bool {
|
||||
return eventType == listingstatus.EventOffline &&
|
||||
(source == listingstatus.SourceSeller || source == listingstatus.SourceAdmin)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package admindashboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/listingstatus"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestListingDailyOverviewGroupsBySourceChannel(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := db.AutoMigrate(&model.ListingStatusEvent{}, &model.ListingUpload{}); err != nil {
|
||||
t.Fatalf("AutoMigrate() error = %v", err)
|
||||
}
|
||||
loc := timeutil.ShanghaiLocation()
|
||||
now := time.Date(2026, 7, 25, 12, 0, 0, 0, loc)
|
||||
listingID1 := uint64(1)
|
||||
listingID2 := uint64(2)
|
||||
listingID4 := uint64(4)
|
||||
|
||||
uploads := []model.ListingUpload{
|
||||
{ListingID: &listingID1, UploaderName: "客服1", SourceChannel: "淘宝"},
|
||||
{ListingID: &listingID2, UploaderName: "客服2", SourceChannel: "微信"},
|
||||
{ListingID: &listingID4, UploaderName: "客服3"},
|
||||
}
|
||||
if err := db.Create(&uploads).Error; err != nil {
|
||||
t.Fatalf("create uploads error = %v", err)
|
||||
}
|
||||
|
||||
events := []model.ListingStatusEvent{
|
||||
{ListingID: listingID1, EventType: listingstatus.EventPublished, Source: listingstatus.SourceReview, CreatedAt: now},
|
||||
{ListingID: listingID1, EventType: listingstatus.EventOffline, Source: listingstatus.SourceAdmin, CreatedAt: now.Add(time.Minute)},
|
||||
{ListingID: listingID2, EventType: listingstatus.EventRented, Source: listingstatus.SourceOrder, CreatedAt: now.Add(2 * time.Minute)},
|
||||
{ListingID: 3, EventType: listingstatus.EventPublished, Source: listingstatus.SourceSeller, CreatedAt: now.Add(3 * time.Minute)},
|
||||
{ListingID: listingID4, EventType: listingstatus.EventPublished, Source: listingstatus.SourceReview, CreatedAt: now.Add(4 * time.Minute)},
|
||||
}
|
||||
if err := db.Create(&events).Error; err != nil {
|
||||
t.Fatalf("create events error = %v", err)
|
||||
}
|
||||
|
||||
overview, err := NewRepository(db).listingDailyOverview(t.Context(), now, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("listingDailyOverview() error = %v", err)
|
||||
}
|
||||
if overview.Today.PublishedCount != 3 || overview.Today.ActiveOfflineCount != 1 || overview.Today.TradeLeaveCount != 1 {
|
||||
t.Fatalf("today stats = %#v", overview.Today)
|
||||
}
|
||||
|
||||
statsByChannel := make(map[string]ListingChannelDayStatsDTO)
|
||||
for _, item := range overview.TodayChannels {
|
||||
statsByChannel[item.SourceChannel] = item
|
||||
}
|
||||
assertChannelStats(t, statsByChannel["淘宝"], 1, 1, 0)
|
||||
assertChannelStats(t, statsByChannel["微信"], 0, 0, 1)
|
||||
assertChannelStats(t, statsByChannel[listingChannelWebsite], 1, 0, 0)
|
||||
assertChannelStats(t, statsByChannel[listingChannelExternalUnknown], 1, 0, 0)
|
||||
}
|
||||
|
||||
func assertChannelStats(t *testing.T, item ListingChannelDayStatsDTO, published, offline, tradeLeave int64) {
|
||||
t.Helper()
|
||||
if item.PublishedCount != published || item.ActiveOfflineCount != offline || item.TradeLeaveCount != tradeLeave {
|
||||
t.Fatalf("channel %q stats = %#v, want published=%d offline=%d tradeLeave=%d", item.SourceChannel, item, published, offline, tradeLeave)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type ListingDTO struct {
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||
OwnerNickname string `json:"owner_nickname,omitempty"`
|
||||
SourceChannel string `json:"source_channel,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
GameName string `json:"game_name"`
|
||||
@@ -149,10 +150,12 @@ type AdminActionRequest struct {
|
||||
type AuditMeta = auditlog.Meta
|
||||
|
||||
type ExternalUploadRequest struct {
|
||||
UploadTime int64 `json:"uploadTime"`
|
||||
UploaderName string `json:"uploaderName"`
|
||||
Uploaderame string `json:"uploaderame"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
UploadTime int64 `json:"uploadTime"`
|
||||
UploaderName string `json:"uploaderName"`
|
||||
Uploaderame string `json:"uploaderame"`
|
||||
SourceChannel string `json:"sourceChannel"`
|
||||
SourceChannelSnake string `json:"source_channel"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func (r ExternalUploadRequest) normalizedUploaderName() string {
|
||||
@@ -162,6 +165,13 @@ func (r ExternalUploadRequest) normalizedUploaderName() string {
|
||||
return strings.TrimSpace(r.Uploaderame)
|
||||
}
|
||||
|
||||
func (r ExternalUploadRequest) normalizedSourceChannel() string {
|
||||
if channel := normalizeExternalSourceChannel(r.SourceChannel); channel != "" {
|
||||
return channel
|
||||
}
|
||||
return normalizeExternalSourceChannel(r.SourceChannelSnake)
|
||||
}
|
||||
|
||||
type ExternalAccountData struct {
|
||||
LoginMethod string `json:"loginMethod"`
|
||||
Rank string `json:"rank"`
|
||||
|
||||
@@ -5,11 +5,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var externalOnlineTimePattern = regexp.MustCompile(`^\D*(\d{1,2})(?:\s*[::点.]\s*(\d{1,2}))?\D+(\d{1,2})(?:\s*[::点.]\s*(\d{1,2}))?\D*$`)
|
||||
|
||||
func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRequest, meta ExternalUploadMeta) (*ExternalUploadResponse, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
@@ -18,6 +21,7 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe
|
||||
if uploaderName == "" {
|
||||
return nil, ErrMissingUploaderName
|
||||
}
|
||||
sourceChannel := req.normalizedSourceChannel()
|
||||
items, err := parseExternalUploadItems(req.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -45,6 +49,7 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe
|
||||
parsedPayload, _ := json.Marshal(item)
|
||||
dto, err := s.repo.CreateFromExternalUpload(ctx, externalUploadCreate{
|
||||
UploaderName: uploaderName,
|
||||
SourceChannel: sourceChannel,
|
||||
ClientUploadTime: clientUploadTime,
|
||||
ClientIP: meta.IP,
|
||||
RawPayload: meta.RawPayload,
|
||||
@@ -85,6 +90,17 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func normalizeExternalSourceChannel(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if len([]rune(value)) > 32 {
|
||||
return string([]rune(value)[:32])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseExternalUploadItems(raw json.RawMessage) ([]ExternalAccountData, error) {
|
||||
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || string(raw) == "null" {
|
||||
return nil, ErrMissingUploadData
|
||||
@@ -228,6 +244,7 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item
|
||||
loadLevel := levelText(item.DailyConsumption.Weight)
|
||||
skins := cleanStrings(item.Inventory.Skins)
|
||||
remark := strings.TrimSpace(item.Remark)
|
||||
onlineTimeText := normalizeExternalOnlineTimeText(item.OwnerOnlineTime)
|
||||
assetSummary := map[string]any{
|
||||
"face_owner": "",
|
||||
"secret_kd": item.SecretKD,
|
||||
@@ -239,7 +256,7 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item
|
||||
"load_level": loadLevel,
|
||||
"resources": externalResources(item.Inventory),
|
||||
"skin_groups": externalSkinGroups(skins),
|
||||
"online_time_text": strings.TrimSpace(item.OwnerOnlineTime),
|
||||
"online_time_text": onlineTimeText,
|
||||
"ban_record": normalizeBanRecord(item.BanRecord),
|
||||
"common_regions": commonRegions(item.CommonRegion),
|
||||
"remark": remark,
|
||||
@@ -261,6 +278,12 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item
|
||||
"platform_rule_type": "external_upload",
|
||||
},
|
||||
}
|
||||
if start, end, ok := parseExternalOnlineTimeRange(onlineTimeText); ok {
|
||||
assetSummary["online_time"] = map[string]any{
|
||||
"start": start,
|
||||
"end": end,
|
||||
}
|
||||
}
|
||||
return CreateRequest{
|
||||
Title: externalUploadTitle(item, insurance, hafCoinM),
|
||||
Description: remark,
|
||||
@@ -275,6 +298,49 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeExternalOnlineTimeText(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
start, end, ok := parseExternalOnlineTimeRange(value)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
return start + " 至 " + end
|
||||
}
|
||||
|
||||
func parseExternalOnlineTimeRange(value string) (string, string, bool) {
|
||||
matches := externalOnlineTimePattern.FindStringSubmatch(strings.TrimSpace(value))
|
||||
if len(matches) != 5 {
|
||||
return "", "", false
|
||||
}
|
||||
start, ok := normalizeExternalTimePart(matches[1], matches[2])
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
end, ok := normalizeExternalTimePart(matches[3], matches[4])
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
func normalizeExternalTimePart(hourText, minuteText string) (string, bool) {
|
||||
hour, err := strconv.Atoi(strings.TrimSpace(hourText))
|
||||
if err != nil || hour < 0 || hour > 23 {
|
||||
return "", false
|
||||
}
|
||||
minute := 0
|
||||
if strings.TrimSpace(minuteText) != "" {
|
||||
minute, err = strconv.Atoi(strings.TrimSpace(minuteText))
|
||||
if err != nil || minute < 0 || minute > 59 {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%02d:%02d", hour, minute), true
|
||||
}
|
||||
|
||||
func externalUploadTitle(item ExternalAccountData, insurance string, hafCoinM float64) string {
|
||||
parts := []string{
|
||||
strings.TrimSpace(item.Rank),
|
||||
@@ -402,7 +468,6 @@ func normalizeBanRecord(value string) string {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func commonRegions(value string) []string {
|
||||
return cleanStrings(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ func (r *Repository) ensureCreateCooldown(tx *gorm.DB, ownerID uint64, cooldown
|
||||
|
||||
type externalUploadCreate struct {
|
||||
UploaderName string
|
||||
SourceChannel string
|
||||
ClientUploadTime *time.Time
|
||||
ClientIP string
|
||||
RawPayload []byte
|
||||
@@ -186,6 +187,7 @@ func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload extern
|
||||
listingID := listing.ID
|
||||
uploadRow := model.ListingUpload{
|
||||
UploaderName: upload.UploaderName,
|
||||
SourceChannel: upload.SourceChannel,
|
||||
MatchedAdminID: &matchedAdminID,
|
||||
OwnerID: &ownerID,
|
||||
ClientUploadTime: upload.ClientUploadTime,
|
||||
|
||||
@@ -18,6 +18,7 @@ type listingRow struct {
|
||||
Title string
|
||||
OwnerPhone string
|
||||
OwnerNickname string
|
||||
SourceChannel string `gorm:"column:source_channel"`
|
||||
Description string
|
||||
GameName string
|
||||
ServerRegion string
|
||||
@@ -63,14 +64,20 @@ func sellerListings(items []ListingDTO) []ListingDTO {
|
||||
}
|
||||
|
||||
func applyPublicListingURLs(item *ListingDTO) {
|
||||
item.SourceChannel = ""
|
||||
item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus)
|
||||
if item.AssetSummary != nil {
|
||||
delete(item.AssetSummary, "import_meta")
|
||||
delete(item.AssetSummary, "price_breakdown")
|
||||
}
|
||||
}
|
||||
|
||||
func applySellerListingPrice(item *ListingDTO) {
|
||||
if item == nil || item.AssetSummary == nil {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
item.SourceChannel = ""
|
||||
if item.AssetSummary == nil {
|
||||
return
|
||||
}
|
||||
breakdown, ok := item.AssetSummary["price_breakdown"].(map[string]any)
|
||||
@@ -103,6 +110,7 @@ func (row listingRow) toDTO() ListingDTO {
|
||||
OwnerID: row.OwnerID,
|
||||
OwnerPhone: row.OwnerPhone,
|
||||
OwnerNickname: row.OwnerNickname,
|
||||
SourceChannel: row.SourceChannel,
|
||||
Title: row.Title,
|
||||
Description: row.Description,
|
||||
GameName: row.GameName,
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
sourceChannelWebsite = "站内发布"
|
||||
sourceChannelExternalUnknown = "未填写"
|
||||
)
|
||||
|
||||
func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*AdminListResult, error) {
|
||||
page := query.Page
|
||||
if page <= 0 {
|
||||
@@ -199,11 +204,21 @@ func (r *Repository) findDTO(ctx context.Context, where string, args ...any) (*L
|
||||
}
|
||||
|
||||
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||
uploadSource := r.db.WithContext(ctx).Table("listing_uploads").
|
||||
Select("listing_id, MAX(id) AS upload_id, MAX(NULLIF(source_channel, '')) AS source_channel").
|
||||
Where("listing_id IS NOT NULL").
|
||||
Group("listing_id")
|
||||
return r.db.WithContext(ctx).Table("rental_listings AS l").
|
||||
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
|
||||
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname`).
|
||||
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname,
|
||||
CASE
|
||||
WHEN lu.upload_id IS NULL THEN ?
|
||||
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
|
||||
ELSE lu.source_channel
|
||||
END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown).
|
||||
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
||||
Joins("LEFT JOIN users AS u ON u.id = l.owner_id")
|
||||
Joins("LEFT JOIN users AS u ON u.id = l.owner_id").
|
||||
Joins("LEFT JOIN (?) AS lu ON lu.listing_id = l.id", uploadSource)
|
||||
}
|
||||
|
||||
func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
|
||||
|
||||
@@ -319,6 +319,34 @@ func TestApplySellerListingPriceKeepsFallbackPrice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
|
||||
item := &ListingDTO{
|
||||
ID: 1,
|
||||
Status: "published",
|
||||
ReviewStatus: "approved",
|
||||
SourceChannel: "淘宝",
|
||||
ScreenshotURLS: []string{
|
||||
"https://example.com/account.png",
|
||||
},
|
||||
AssetSummary: map[string]any{
|
||||
"import_meta": map[string]any{"uploader_name": "客服1"},
|
||||
"price_breakdown": map[string]any{"buyer_total_price": 100},
|
||||
},
|
||||
}
|
||||
|
||||
applyPublicListingURLs(item)
|
||||
|
||||
if item.SourceChannel != "" {
|
||||
t.Fatalf("expected public listing source channel hidden, got %q", item.SourceChannel)
|
||||
}
|
||||
if _, ok := item.AssetSummary["import_meta"]; ok {
|
||||
t.Fatal("expected import_meta hidden from public listing")
|
||||
}
|
||||
if _, ok := item.AssetSummary["price_breakdown"]; ok {
|
||||
t.Fatal("expected price_breakdown hidden from public listing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
|
||||
items, err := parseExternalUploadItems(json.RawMessage(validExternalUploadDataJSON()))
|
||||
if err != nil {
|
||||
@@ -346,18 +374,118 @@ func TestParseExternalUploadItemsReportsMissingFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportExternalUploadStoresSourceChannel(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.AdminUser{}, &model.ListingUpload{}); err != nil {
|
||||
t.Fatalf("failed to migrate upload tables: %v", err)
|
||||
}
|
||||
admin := model.AdminUser{
|
||||
Username: "客服1",
|
||||
PasswordHash: "hash",
|
||||
Nickname: "客服1",
|
||||
Status: "active",
|
||||
}
|
||||
if err := db.Create(&admin).Error; err != nil {
|
||||
t.Fatalf("failed to create admin: %v", err)
|
||||
}
|
||||
|
||||
service := NewService(NewRepository(db, nil), nil)
|
||||
resp, err := service.ImportExternalUpload(t.Context(), ExternalUploadRequest{
|
||||
UploaderName: "客服1",
|
||||
SourceChannel: "淘宝",
|
||||
Data: json.RawMessage(validExternalUploadDataJSON()),
|
||||
}, ExternalUploadMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportExternalUpload() error = %v", err)
|
||||
}
|
||||
if resp.Success != 1 || resp.ListingID == 0 {
|
||||
t.Fatalf("unexpected response: %#v", resp)
|
||||
}
|
||||
|
||||
var upload model.ListingUpload
|
||||
if err := db.Where("listing_id = ?", resp.ListingID).First(&upload).Error; err != nil {
|
||||
t.Fatalf("failed to find listing upload: %v", err)
|
||||
}
|
||||
if upload.SourceChannel != "淘宝" {
|
||||
t.Fatalf("source channel = %q, want 淘宝", upload.SourceChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPendingReviewIncludesSourceChannel(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.ListingUpload{}); err != nil {
|
||||
t.Fatalf("failed to migrate listing uploads: %v", err)
|
||||
}
|
||||
user := model.User{Phone: "18800000001", RealnameStatus: "verified", Status: "active"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
account := model.GameAccount{
|
||||
OwnerID: user.ID,
|
||||
GameName: "delta_force",
|
||||
ServerRegion: "QQ",
|
||||
LoginPlatform: "QQ账号密码",
|
||||
Title: "待审核账号",
|
||||
RankLevel: "黑鹰",
|
||||
Status: "draft",
|
||||
}
|
||||
if err := db.Create(&account).Error; err != nil {
|
||||
t.Fatalf("failed to create account: %v", err)
|
||||
}
|
||||
listing := model.RentalListing{
|
||||
ListingNo: "202607250001",
|
||||
AccountID: account.ID,
|
||||
OwnerID: user.ID,
|
||||
PriceCent: 10000,
|
||||
DepositAmountCent: 50000,
|
||||
Status: "draft",
|
||||
ReviewStatus: "pending",
|
||||
}
|
||||
if err := db.Create(&listing).Error; err != nil {
|
||||
t.Fatalf("failed to create listing: %v", err)
|
||||
}
|
||||
listingID := listing.ID
|
||||
if err := db.Create(&model.ListingUpload{
|
||||
UploaderName: "客服1",
|
||||
SourceChannel: "淘宝",
|
||||
OwnerID: &user.ID,
|
||||
ListingID: &listingID,
|
||||
Status: "draft_created",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to create listing upload: %v", err)
|
||||
}
|
||||
|
||||
items, err := NewRepository(db, nil).ListPendingReview(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("ListPendingReview() error = %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 pending listing, got %d", len(items))
|
||||
}
|
||||
if items[0].SourceChannel != "淘宝" {
|
||||
t.Fatalf("source channel = %q, want 淘宝", items[0].SourceChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
|
||||
req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{
|
||||
LoginMethod: "QQ账号密码",
|
||||
Rank: "黑鹰",
|
||||
Level: 60,
|
||||
SafeSlots: 9,
|
||||
SecretKD: 1.6,
|
||||
DailyLossM: 7,
|
||||
Deposit: 400,
|
||||
BanRecord: "有",
|
||||
CommonRegion: "郑州",
|
||||
Remark: "有语音封禁\n封禁历史:2026/01/23封禁3天",
|
||||
LoginMethod: "QQ账号密码",
|
||||
Rank: "黑鹰",
|
||||
Level: 60,
|
||||
SafeSlots: 9,
|
||||
SecretKD: 1.6,
|
||||
DailyLossM: 7,
|
||||
Deposit: 400,
|
||||
BanRecord: "有",
|
||||
CommonRegion: "郑州",
|
||||
OwnerOnlineTime: "8:00-1:00",
|
||||
Remark: "有语音封禁\n封禁历史:2026/01/23封禁3天",
|
||||
Currency: ExternalUploadCurrency{
|
||||
HafuCoin: 197.1,
|
||||
RecycleRatio: 43,
|
||||
@@ -399,6 +527,16 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
|
||||
if req.AssetSummary["stamina_level"] != "7级" || req.AssetSummary["load_level"] != "7级" {
|
||||
t.Fatalf("unexpected stamina/load: %#v", req.AssetSummary)
|
||||
}
|
||||
if req.AssetSummary["online_time_text"] != "08:00 至 01:00" {
|
||||
t.Fatalf("online_time_text = %#v", req.AssetSummary["online_time_text"])
|
||||
}
|
||||
onlineTime, ok := req.AssetSummary["online_time"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("online_time missing: %#v", req.AssetSummary["online_time"])
|
||||
}
|
||||
if onlineTime["start"] != "08:00" || onlineTime["end"] != "01:00" {
|
||||
t.Fatalf("online_time = %#v", onlineTime)
|
||||
}
|
||||
groups := req.AssetSummary["skin_groups"].(map[string][]string)
|
||||
if len(groups["melee"]) != 2 {
|
||||
t.Fatalf("expected melee skins mapped, got %#v", groups)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE listing_uploads
|
||||
ADD COLUMN source_channel VARCHAR(32) NOT NULL DEFAULT '' COMMENT '订单来源渠道:咸鱼/淘宝/京东/QQ/微信/自定义' AFTER uploader_name,
|
||||
ADD KEY idx_listing_uploads_source_channel (source_channel, created_at);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE listing_uploads
|
||||
DROP KEY idx_listing_uploads_source_channel,
|
||||
DROP COLUMN source_channel;
|
||||
@@ -27,9 +27,15 @@ export interface ListingDayStats {
|
||||
trade_leave_count: number
|
||||
}
|
||||
|
||||
export interface ListingChannelDayStats extends ListingDayStats {
|
||||
source_channel: string
|
||||
}
|
||||
|
||||
export interface ListingDailyOverview {
|
||||
today: ListingDayStats
|
||||
trend: ListingDayStats[]
|
||||
today_channels: ListingChannelDayStats[]
|
||||
channel_trend: ListingChannelDayStats[]
|
||||
days: number
|
||||
timezone: string
|
||||
}
|
||||
@@ -82,6 +88,8 @@ export async function fetchAdminDashboard() {
|
||||
listing_daily: {
|
||||
today: listingDaily?.today ?? emptyDayStats(),
|
||||
trend: listingDaily?.trend ?? [],
|
||||
today_channels: listingDaily?.today_channels ?? [],
|
||||
channel_trend: listingDaily?.channel_trend ?? [],
|
||||
days: listingDaily?.days ?? 7,
|
||||
timezone: listingDaily?.timezone ?? 'Asia/Shanghai',
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import {
|
||||
fetchAdminDashboard,
|
||||
type AdminDashboard,
|
||||
type ListingChannelDayStats,
|
||||
type ListingDayStats,
|
||||
} from '@/features/admin/api/adminDashboard'
|
||||
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
|
||||
@@ -40,6 +41,7 @@ const {
|
||||
})
|
||||
|
||||
const listingTrend = computed(() => dashboard.value?.listing_daily?.trend ?? [])
|
||||
const listingChannelTrend = computed(() => dashboard.value?.listing_daily?.channel_trend ?? [])
|
||||
const listingToday = computed(
|
||||
() =>
|
||||
dashboard.value?.listing_daily?.today ?? {
|
||||
@@ -49,6 +51,25 @@ const listingToday = computed(
|
||||
trade_leave_count: 0,
|
||||
}
|
||||
)
|
||||
const channelDisplayOrder = ['咸鱼', '淘宝', '京东', 'QQ', '微信', '自定义', '站内发布', '未填写']
|
||||
const listingTodayChannels = computed<ListingChannelDayStats[]>(() => {
|
||||
const date = listingToday.value.date
|
||||
const rows = dashboard.value?.listing_daily?.today_channels ?? []
|
||||
const byChannel = new Map(rows.map(row => [row.source_channel || '未填写', row]))
|
||||
const ordered = channelDisplayOrder.map(channel => ({
|
||||
date,
|
||||
source_channel: channel,
|
||||
published_count: byChannel.get(channel)?.published_count ?? 0,
|
||||
active_offline_count: byChannel.get(channel)?.active_offline_count ?? 0,
|
||||
trade_leave_count: byChannel.get(channel)?.trade_leave_count ?? 0,
|
||||
}))
|
||||
const extras = rows.filter(row => !channelDisplayOrder.includes(row.source_channel || '未填写'))
|
||||
return [...ordered, ...extras]
|
||||
})
|
||||
|
||||
function leaveCount(row: ListingChannelDayStats | ListingDayStats) {
|
||||
return Number(row.active_offline_count || 0) + Number(row.trade_leave_count || 0)
|
||||
}
|
||||
|
||||
function trendMax(getter: (row: ListingDayStats) => number) {
|
||||
const values = listingTrend.value.map(getter)
|
||||
@@ -248,6 +269,24 @@ function shortDate(date: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="listing-channel-summary">
|
||||
<div
|
||||
v-for="row in listingTodayChannels"
|
||||
:key="`today-channel-${row.source_channel}`"
|
||||
class="listing-channel-item"
|
||||
>
|
||||
<div class="listing-channel-item-head">
|
||||
<span>{{ row.source_channel || '未填写' }}</span>
|
||||
<strong>{{ row.published_count + leaveCount(row) }}</strong>
|
||||
</div>
|
||||
<div class="listing-channel-counts">
|
||||
<span>上架 {{ row.published_count }}</span>
|
||||
<span>主动下架 {{ row.active_offline_count }}</span>
|
||||
<span>交易离架 {{ row.trade_leave_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table class="table-panel listing-trend-table" :data="listingTrend" size="small">
|
||||
<el-table-column prop="date" label="日期" min-width="120" />
|
||||
<el-table-column prop="published_count" label="上架" width="100" align="center" />
|
||||
@@ -259,6 +298,35 @@ function shortDate(date: string) {
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="listingChannelTrend.length" class="listing-channel-block">
|
||||
<div class="listing-channel-title">近 7 日渠道明细</div>
|
||||
<el-table
|
||||
class="table-panel listing-channel-table"
|
||||
:data="listingChannelTrend"
|
||||
size="small"
|
||||
>
|
||||
<el-table-column prop="date" label="日期" min-width="120" />
|
||||
<el-table-column prop="source_channel" label="渠道" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="listing-channel-name">{{ row.source_channel || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="published_count" label="上架" width="100" align="center" />
|
||||
<el-table-column
|
||||
prop="active_offline_count"
|
||||
label="主动下架"
|
||||
width="110"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="trade_leave_count" label="交易离架" width="110" align="center" />
|
||||
<el-table-column label="合计离架" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ leaveCount(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 待处理事项 & 快捷入口 -->
|
||||
@@ -789,10 +857,80 @@ function shortDate(date: string) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.listing-channel-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.listing-channel-item {
|
||||
min-width: 0;
|
||||
border: 1px solid #e8edf4;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.listing-channel-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.listing-channel-item-head span {
|
||||
min-width: 0;
|
||||
color: #1b2559;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.listing-channel-item-head strong {
|
||||
flex-shrink: 0;
|
||||
color: #4f7cff;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.listing-channel-counts {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.listing-channel-counts span {
|
||||
color: #8f9bba;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.listing-channel-block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.listing-channel-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1b2559;
|
||||
}
|
||||
|
||||
.listing-channel-name {
|
||||
font-weight: 700;
|
||||
color: #1b2559;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.listing-daily-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.listing-channel-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -802,6 +940,10 @@ function shortDate(date: string) {
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.listing-channel-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -137,7 +137,12 @@ function selectListing(row: Listing) {
|
||||
function replaceListing(next: Listing) {
|
||||
const index = listings.value.findIndex(item => item.id === next.id)
|
||||
if (index >= 0) {
|
||||
listings.value.splice(index, 1, next)
|
||||
const previous = listings.value[index]!
|
||||
listings.value.splice(index, 1, {
|
||||
...previous,
|
||||
...next,
|
||||
source_channel: next.source_channel || previous.source_channel,
|
||||
})
|
||||
} else {
|
||||
listings.value.unshift(next)
|
||||
}
|
||||
@@ -422,6 +427,13 @@ function contactPhone(row: Listing) {
|
||||
return typeof value === 'string' && value.trim() ? value : '-'
|
||||
}
|
||||
|
||||
function sourceChannel(row: Listing) {
|
||||
if (typeof row.source_channel === 'string' && row.source_channel.trim()) {
|
||||
return row.source_channel.trim()
|
||||
}
|
||||
return isExternalUpload(row) ? '未填写' : '站内发布'
|
||||
}
|
||||
|
||||
function ownerOnlineText(row: Listing) {
|
||||
const text = row.asset_summary?.online_time_text
|
||||
if (typeof text === 'string' && text.trim()) return text
|
||||
@@ -456,7 +468,15 @@ function isDefaultScreenshot(url: string) {
|
||||
|
||||
function riskItems(row: Listing): RiskItem[] {
|
||||
const items: RiskItem[] = []
|
||||
if (isExternalUpload(row)) items.push({ label: `外部上传:${uploaderName(row)}`, level: 'info' })
|
||||
if (isExternalUpload(row)) {
|
||||
items.push({
|
||||
label: `外部上传:${uploaderName(row)} / 渠道:${sourceChannel(row)}`,
|
||||
level: 'info',
|
||||
})
|
||||
} else {
|
||||
items.push({ label: `来源渠道:${sourceChannel(row)}`, level: 'info' })
|
||||
}
|
||||
const infoCount = items.length
|
||||
if (!row.screenshot_urls?.length) items.push({ label: '没有账号截图', level: 'danger' })
|
||||
if (hasDefaultScreenshot(row)) items.push({ label: '使用默认截图', level: 'warning' })
|
||||
if (hasBanRecord(row)) items.push({ label: `封禁记录:${banRecordText(row)}`, level: 'danger' })
|
||||
@@ -472,7 +492,7 @@ function riskItems(row: Listing): RiskItem[] {
|
||||
items.push({ label: '烽火等级接近下限', level: 'warning' })
|
||||
if (!readAssetString(row, 'season_insurance'))
|
||||
items.push({ label: '缺少保险格数', level: 'warning' })
|
||||
if (!items.length) items.push({ label: '未发现明显风险', level: 'info' })
|
||||
if (items.length === infoCount) items.push({ label: '未发现明显风险', level: 'info' })
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -495,6 +515,7 @@ function reviewSearchText(row: Listing) {
|
||||
row.login_platform,
|
||||
row.rank_level,
|
||||
uploaderName(row),
|
||||
sourceChannel(row),
|
||||
getSkinNames(row).join(' '),
|
||||
]
|
||||
.join(' ')
|
||||
@@ -602,7 +623,7 @@ async function openScreenshot(url: string) {
|
||||
v-model="filters.keyword"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
placeholder="搜索编号、标题、客服、段位、皮肤"
|
||||
placeholder="搜索编号、标题、客服、渠道、段位、皮肤"
|
||||
/>
|
||||
<el-select v-model="filters.risk" placeholder="风险筛选">
|
||||
<el-option label="全部待审" value="all" />
|
||||
@@ -642,10 +663,17 @@ async function openScreenshot(url: string) {
|
||||
formatCent(item.deposit_amount_cent)
|
||||
}}</span
|
||||
>
|
||||
<el-tag v-if="isExternalUpload(item)" size="small" type="info">{{
|
||||
uploaderName(item)
|
||||
}}</el-tag>
|
||||
<el-tag v-if="hasDefaultScreenshot(item)" size="small" type="warning">默认图</el-tag>
|
||||
<div class="queue-tags">
|
||||
<el-tag size="small" type="primary" effect="plain">{{
|
||||
sourceChannel(item)
|
||||
}}</el-tag>
|
||||
<el-tag v-if="isExternalUpload(item)" size="small" type="info">{{
|
||||
uploaderName(item)
|
||||
}}</el-tag>
|
||||
<el-tag v-if="hasDefaultScreenshot(item)" size="small" type="warning"
|
||||
>默认图</el-tag
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<el-empty
|
||||
@@ -782,11 +810,20 @@ async function openScreenshot(url: string) {
|
||||
<div class="review-panel">
|
||||
<div class="panel-title">
|
||||
<h3>上传信息</h3>
|
||||
<el-tag v-if="isExternalUpload(selectedListing)" size="small" type="info"
|
||||
>外部上传</el-tag
|
||||
>
|
||||
<div class="panel-tags">
|
||||
<el-tag size="small" type="primary" effect="plain">{{
|
||||
sourceChannel(selectedListing)
|
||||
}}</el-tag>
|
||||
<el-tag v-if="isExternalUpload(selectedListing)" size="small" type="info"
|
||||
>外部上传</el-tag
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="detail-list">
|
||||
<div>
|
||||
<dt>来源渠道</dt>
|
||||
<dd>{{ sourceChannel(selectedListing) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>上传人</dt>
|
||||
<dd>{{ uploaderName(selectedListing) }}</dd>
|
||||
@@ -1206,6 +1243,14 @@ async function openScreenshot(url: string) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.queue-tags,
|
||||
.panel-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.review-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface Listing {
|
||||
owner_id: number
|
||||
owner_phone?: string
|
||||
owner_nickname?: string
|
||||
source_channel?: string
|
||||
title: string
|
||||
description: string
|
||||
game_name: string
|
||||
|
||||
Reference in New Issue
Block a user