diff --git a/backend/internal/modules/listing/handler_error.go b/backend/internal/modules/listing/handler_error.go index 094b1ce..0a21338 100644 --- a/backend/internal/modules/listing/handler_error.go +++ b/backend/internal/modules/listing/handler_error.go @@ -28,6 +28,10 @@ func writeListingError(c *gin.Context, err error) { response.BadRequest(c, "哈夫币数量不正确") case errors.Is(err, ErrMissingScreenshot): response.BadRequest(c, "请至少上传一张账号截图") + case errors.Is(err, ErrMissingOnlineTime): + response.BadRequest(c, "请选择在线时间") + case errors.Is(err, ErrInvalidOnlineTime): + response.BadRequest(c, "在线时间不正确") case errors.Is(err, ErrAgreementRequired): response.BadRequest(c, "请先阅读并同意发布协议") case errors.Is(err, ErrMissingUploaderName): diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index 3c466b3..a8a7614 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -17,6 +17,8 @@ var ( ErrDepositTooLow = errors.New("listing deposit too low") ErrInvalidHafCoin = errors.New("invalid haf coin amount") ErrMissingScreenshot = errors.New("missing screenshot") + ErrMissingOnlineTime = errors.New("missing online time") + ErrInvalidOnlineTime = errors.New("invalid online time") ErrAgreementRequired = errors.New("listing publish agreement required") ErrMissingUploaderName = errors.New("missing uploader name") ErrMissingUploadData = errors.New("missing upload data") diff --git a/backend/internal/modules/listing/service_mutation.go b/backend/internal/modules/listing/service_mutation.go index 0cc2c20..fd71d84 100644 --- a/backend/internal/modules/listing/service_mutation.go +++ b/backend/internal/modules/listing/service_mutation.go @@ -18,6 +18,9 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest) if err := validateRequest(req, rules); err != nil { return nil, err } + if err := validateRequiredOnlineTime(req); err != nil { + return nil, err + } reviewRequired, err := s.reviewRequired(ctx) if err != nil { return nil, err @@ -36,6 +39,9 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd if err := validateRequest(req, rules); err != nil { return nil, err } + if err := validateRequiredOnlineTime(req); err != nil { + return nil, err + } reviewRequired, err := s.reviewRequired(ctx) if err != nil { return nil, err diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 4d2bb24..9414b5c 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -58,6 +58,44 @@ func TestValidateRequestRequiresDepositAboveConsumables(t *testing.T) { } } +func TestValidateRequiredOnlineTime(t *testing.T) { + req := CreateRequest{ + AssetSummary: map[string]any{ + "online_time": map[string]any{ + "start": "09:00", + "end": "23:00", + }, + }, + } + + if err := validateRequiredOnlineTime(req); err != nil { + t.Fatalf("expected valid online time, got %v", err) + } +} + +func TestValidateRequiredOnlineTimeRejectsMissing(t *testing.T) { + req := CreateRequest{AssetSummary: map[string]any{}} + + if err := validateRequiredOnlineTime(req); err != ErrMissingOnlineTime { + t.Fatalf("expected ErrMissingOnlineTime, got %v", err) + } +} + +func TestValidateRequiredOnlineTimeRejectsInvalidRange(t *testing.T) { + req := CreateRequest{ + AssetSummary: map[string]any{ + "online_time": map[string]any{ + "start": "23:00", + "end": "09:00", + }, + }, + } + + if err := validateRequiredOnlineTime(req); err != ErrInvalidOnlineTime { + t.Fatalf("expected ErrInvalidOnlineTime, got %v", err) + } +} + func TestCreateRequiresPublishAgreements(t *testing.T) { service := NewService(&Repository{}, nil) diff --git a/backend/internal/modules/listing/service_validation.go b/backend/internal/modules/listing/service_validation.go index c678c13..0f70307 100644 --- a/backend/internal/modules/listing/service_validation.go +++ b/backend/internal/modules/listing/service_validation.go @@ -41,6 +41,63 @@ func validateRequest(req CreateRequest, rules publishRules) error { return nil } +func validateRequiredOnlineTime(req CreateRequest) error { + start, end, ok := readOnlineTime(req.AssetSummary) + if !ok { + return ErrMissingOnlineTime + } + startMinute, ok := parseOnlineMinute(start) + if !ok { + return ErrInvalidOnlineTime + } + endMinute, ok := parseOnlineMinute(end) + if !ok { + return ErrInvalidOnlineTime + } + if startMinute >= endMinute { + return ErrInvalidOnlineTime + } + return nil +} + +func readOnlineTime(summary map[string]any) (string, string, bool) { + if summary == nil { + return "", "", false + } + raw, ok := summary["online_time"] + if !ok { + return "", "", false + } + onlineTime, ok := raw.(map[string]any) + if !ok { + return "", "", false + } + start, okStart := onlineTime["start"].(string) + end, okEnd := onlineTime["end"].(string) + start = strings.TrimSpace(start) + end = strings.TrimSpace(end) + return start, end, okStart && okEnd && start != "" && end != "" +} + +func parseOnlineMinute(value string) (int, bool) { + parts := strings.Split(strings.TrimSpace(value), ":") + if len(parts) != 2 { + return 0, false + } + hour, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, false + } + minute, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, false + } + if hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return 0, false + } + return hour*60 + minute, true +} + func consumableValue(summary map[string]any) float64 { if summary == nil { return 0 diff --git a/frontend/src/features/listings/views/ListingDetailView.vue b/frontend/src/features/listings/views/ListingDetailView.vue index 13f97b9..3f81cd2 100644 --- a/frontend/src/features/listings/views/ListingDetailView.vue +++ b/frontend/src/features/listings/views/ListingDetailView.vue @@ -15,6 +15,7 @@ import AuthImage from '@/shared/components/business/AuthImage.vue' import { roundMoney, formatMoney, formatCent } from '@/shared/utils/money' import { assetRegions, + formatAssetNumber, formatEstimatedRentalDuration, formatHafCoinM, formatListingCode, @@ -31,6 +32,7 @@ import { getOnlineTimeText, getLoginMethod, getServerRegion, + readAssetNumber, readAssetString, } from '@/shared/utils/listingDisplay' @@ -95,8 +97,14 @@ const coverURL = computed(() => { const detailMetrics = computed(() => { if (!listing.value) return [] const dailyLoss = getDailyLoss(listing.value) + const secretKD = readAssetNumber(listing.value, 'secret_kd') return [ { label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' }, + { + label: '绝密KD', + value: secretKD > 0 ? formatAssetNumber(secretKD) : '--', + tone: 'coin', + }, { label: '日损耗', value: dailyLoss ? `${dailyLoss}/天` : '--', @@ -164,10 +172,12 @@ const detailSkinGroups = computed(() => { const accountRows = computed(() => { if (!listing.value) return [] const regions = assetRegions(listing.value) + const secretKD = readAssetNumber(listing.value, 'secret_kd') return [ { label: '所属区服', value: getServerRegion(listing.value) || '--' }, { label: '上号方式', value: getLoginMethod(listing.value) || '--' }, { label: '游戏段位', value: listing.value.rank_level || '--' }, + { label: '绝密KD', value: secretKD > 0 ? formatAssetNumber(secretKD) : '--' }, { label: 'M单价', value: formatRatio(listing.value) }, { label: '方便上号', value: getOnlineTimeText(listing.value) || '--' }, { label: '预计可租', value: formatEstimatedRentalDuration(listing.value) }, @@ -649,7 +659,7 @@ function listingPrice(item: Listing) { .detail-summary-row { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; } diff --git a/frontend/src/features/listings/views/MobileListingDetailView.vue b/frontend/src/features/listings/views/MobileListingDetailView.vue index 639ed3c..da56fe6 100644 --- a/frontend/src/features/listings/views/MobileListingDetailView.vue +++ b/frontend/src/features/listings/views/MobileListingDetailView.vue @@ -15,6 +15,7 @@ import AuthImage from '@/shared/components/business/AuthImage.vue' import { formatCent, formatMoney } from '@/shared/utils/money' import { assetRegions, + formatAssetNumber, formatHafCoinM, formatListingCode, formatRatio, @@ -29,6 +30,7 @@ import { getListingTitle, getLoginMethod, getServerRegion, + readAssetNumber, readAssetString, } from '@/shared/utils/listingDisplay' @@ -88,8 +90,14 @@ const orderPriceBreakdown = computed(() => { const detailMetrics = computed(() => { if (!listing.value) return [] const dailyLoss = getDailyLoss(listing.value) + const secretKD = readAssetNumber(listing.value, 'secret_kd') return [ { label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' }, + { + label: '绝密KD', + value: secretKD > 0 ? formatAssetNumber(secretKD) : '--', + tone: 'coin', + }, { label: '日损耗', value: dailyLoss ? `${dailyLoss}/天` : '--', @@ -345,6 +353,16 @@ async function copyListingCode() { M单价 {{ formatRatio(listing) }} +
+ 绝密KD + + {{ + readAssetNumber(listing, 'secret_kd') > 0 + ? formatAssetNumber(readAssetNumber(listing, 'secret_kd')) + : '--' + }} + +
常用登录地 {{ assetRegions(listing).join('、') || '--' }} @@ -642,7 +660,7 @@ async function copyListingCode() { /* ========== 资产指标 ========== */ .metric-row { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; padding: 0 12px; } @@ -650,7 +668,7 @@ async function copyListingCode() { .metric-item { background: #fff; border-radius: 10px; - padding: 10px 6px; + padding: 10px 4px; text-align: center; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue index 5ba33ed..3f3da8b 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue @@ -357,6 +357,7 @@ function selectRadio(value: T, setter: (value: T) => void) { (value: T, setter: (value: T) => void) {
- 在线开始 + 在线开始*
- 在线结束 + 在线结束*