详情页面kd 展示,发布时间必填

This commit is contained in:
yml2213
2026-06-12 21:24:13 +08:00
parent f6ea3e29a6
commit 7c2f5e34c3
11 changed files with 148 additions and 5 deletions
@@ -28,6 +28,10 @@ func writeListingError(c *gin.Context, err error) {
response.BadRequest(c, "哈夫币数量不正确") response.BadRequest(c, "哈夫币数量不正确")
case errors.Is(err, ErrMissingScreenshot): case errors.Is(err, ErrMissingScreenshot):
response.BadRequest(c, "请至少上传一张账号截图") response.BadRequest(c, "请至少上传一张账号截图")
case errors.Is(err, ErrMissingOnlineTime):
response.BadRequest(c, "请选择在线时间")
case errors.Is(err, ErrInvalidOnlineTime):
response.BadRequest(c, "在线时间不正确")
case errors.Is(err, ErrAgreementRequired): case errors.Is(err, ErrAgreementRequired):
response.BadRequest(c, "请先阅读并同意发布协议") response.BadRequest(c, "请先阅读并同意发布协议")
case errors.Is(err, ErrMissingUploaderName): case errors.Is(err, ErrMissingUploaderName):
@@ -17,6 +17,8 @@ var (
ErrDepositTooLow = errors.New("listing deposit too low") ErrDepositTooLow = errors.New("listing deposit too low")
ErrInvalidHafCoin = errors.New("invalid haf coin amount") ErrInvalidHafCoin = errors.New("invalid haf coin amount")
ErrMissingScreenshot = errors.New("missing screenshot") ErrMissingScreenshot = errors.New("missing screenshot")
ErrMissingOnlineTime = errors.New("missing online time")
ErrInvalidOnlineTime = errors.New("invalid online time")
ErrAgreementRequired = errors.New("listing publish agreement required") ErrAgreementRequired = errors.New("listing publish agreement required")
ErrMissingUploaderName = errors.New("missing uploader name") ErrMissingUploaderName = errors.New("missing uploader name")
ErrMissingUploadData = errors.New("missing upload data") ErrMissingUploadData = errors.New("missing upload data")
@@ -18,6 +18,9 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest)
if err := validateRequest(req, rules); err != nil { if err := validateRequest(req, rules); err != nil {
return nil, err return nil, err
} }
if err := validateRequiredOnlineTime(req); err != nil {
return nil, err
}
reviewRequired, err := s.reviewRequired(ctx) reviewRequired, err := s.reviewRequired(ctx)
if err != nil { if err != nil {
return nil, err 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 { if err := validateRequest(req, rules); err != nil {
return nil, err return nil, err
} }
if err := validateRequiredOnlineTime(req); err != nil {
return nil, err
}
reviewRequired, err := s.reviewRequired(ctx) reviewRequired, err := s.reviewRequired(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -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) { func TestCreateRequiresPublishAgreements(t *testing.T) {
service := NewService(&Repository{}, nil) service := NewService(&Repository{}, nil)
@@ -41,6 +41,63 @@ func validateRequest(req CreateRequest, rules publishRules) error {
return nil 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 { func consumableValue(summary map[string]any) float64 {
if summary == nil { if summary == nil {
return 0 return 0
@@ -15,6 +15,7 @@ import AuthImage from '@/shared/components/business/AuthImage.vue'
import { roundMoney, formatMoney, formatCent } from '@/shared/utils/money' import { roundMoney, formatMoney, formatCent } from '@/shared/utils/money'
import { import {
assetRegions, assetRegions,
formatAssetNumber,
formatEstimatedRentalDuration, formatEstimatedRentalDuration,
formatHafCoinM, formatHafCoinM,
formatListingCode, formatListingCode,
@@ -31,6 +32,7 @@ import {
getOnlineTimeText, getOnlineTimeText,
getLoginMethod, getLoginMethod,
getServerRegion, getServerRegion,
readAssetNumber,
readAssetString, readAssetString,
} from '@/shared/utils/listingDisplay' } from '@/shared/utils/listingDisplay'
@@ -95,8 +97,14 @@ const coverURL = computed(() => {
const detailMetrics = computed(() => { const detailMetrics = computed(() => {
if (!listing.value) return [] if (!listing.value) return []
const dailyLoss = getDailyLoss(listing.value) const dailyLoss = getDailyLoss(listing.value)
const secretKD = readAssetNumber(listing.value, 'secret_kd')
return [ return [
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' }, { label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
{
label: '绝密KD',
value: secretKD > 0 ? formatAssetNumber(secretKD) : '--',
tone: 'coin',
},
{ {
label: '日损耗', label: '日损耗',
value: dailyLoss ? `${dailyLoss}/天` : '--', value: dailyLoss ? `${dailyLoss}/天` : '--',
@@ -164,10 +172,12 @@ const detailSkinGroups = computed(() => {
const accountRows = computed(() => { const accountRows = computed(() => {
if (!listing.value) return [] if (!listing.value) return []
const regions = assetRegions(listing.value) const regions = assetRegions(listing.value)
const secretKD = readAssetNumber(listing.value, 'secret_kd')
return [ return [
{ label: '所属区服', value: getServerRegion(listing.value) || '--' }, { label: '所属区服', value: getServerRegion(listing.value) || '--' },
{ label: '上号方式', value: getLoginMethod(listing.value) || '--' }, { label: '上号方式', value: getLoginMethod(listing.value) || '--' },
{ label: '游戏段位', value: listing.value.rank_level || '--' }, { label: '游戏段位', value: listing.value.rank_level || '--' },
{ label: '绝密KD', value: secretKD > 0 ? formatAssetNumber(secretKD) : '--' },
{ label: 'M单价', value: formatRatio(listing.value) }, { label: 'M单价', value: formatRatio(listing.value) },
{ label: '方便上号', value: getOnlineTimeText(listing.value) || '--' }, { label: '方便上号', value: getOnlineTimeText(listing.value) || '--' },
{ label: '预计可租', value: formatEstimatedRentalDuration(listing.value) }, { label: '预计可租', value: formatEstimatedRentalDuration(listing.value) },
@@ -649,7 +659,7 @@ function listingPrice(item: Listing) {
.detail-summary-row { .detail-summary-row {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px; gap: 14px;
} }
@@ -15,6 +15,7 @@ import AuthImage from '@/shared/components/business/AuthImage.vue'
import { formatCent, formatMoney } from '@/shared/utils/money' import { formatCent, formatMoney } from '@/shared/utils/money'
import { import {
assetRegions, assetRegions,
formatAssetNumber,
formatHafCoinM, formatHafCoinM,
formatListingCode, formatListingCode,
formatRatio, formatRatio,
@@ -29,6 +30,7 @@ import {
getListingTitle, getListingTitle,
getLoginMethod, getLoginMethod,
getServerRegion, getServerRegion,
readAssetNumber,
readAssetString, readAssetString,
} from '@/shared/utils/listingDisplay' } from '@/shared/utils/listingDisplay'
@@ -88,8 +90,14 @@ const orderPriceBreakdown = computed(() => {
const detailMetrics = computed(() => { const detailMetrics = computed(() => {
if (!listing.value) return [] if (!listing.value) return []
const dailyLoss = getDailyLoss(listing.value) const dailyLoss = getDailyLoss(listing.value)
const secretKD = readAssetNumber(listing.value, 'secret_kd')
return [ return [
{ label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' }, { label: '纯币', value: formatHafCoinM(getCoinWan(listing.value)), tone: 'coin' },
{
label: '绝密KD',
value: secretKD > 0 ? formatAssetNumber(secretKD) : '--',
tone: 'coin',
},
{ {
label: '日损耗', label: '日损耗',
value: dailyLoss ? `${dailyLoss}/天` : '--', value: dailyLoss ? `${dailyLoss}/天` : '--',
@@ -345,6 +353,16 @@ async function copyListingCode() {
<span class="info-label">M单价</span> <span class="info-label">M单价</span>
<span class="info-text">{{ formatRatio(listing) }}</span> <span class="info-text">{{ formatRatio(listing) }}</span>
</div> </div>
<div class="info-line">
<span class="info-label">绝密KD</span>
<span class="info-text">
{{
readAssetNumber(listing, 'secret_kd') > 0
? formatAssetNumber(readAssetNumber(listing, 'secret_kd'))
: '--'
}}
</span>
</div>
<div class="info-line"> <div class="info-line">
<span class="info-label">常用登录地</span> <span class="info-label">常用登录地</span>
<span class="info-text">{{ assetRegions(listing).join('、') || '--' }}</span> <span class="info-text">{{ assetRegions(listing).join('、') || '--' }}</span>
@@ -642,7 +660,7 @@ async function copyListingCode() {
/* ========== 资产指标 ========== */ /* ========== 资产指标 ========== */
.metric-row { .metric-row {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px; gap: 8px;
padding: 0 12px; padding: 0 12px;
} }
@@ -650,7 +668,7 @@ async function copyListingCode() {
.metric-item { .metric-item {
background: #fff; background: #fff;
border-radius: 10px; border-radius: 10px;
padding: 10px 6px; padding: 10px 4px;
text-align: center; text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
} }
@@ -357,6 +357,7 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
<van-field <van-field
v-model="form.online_start" v-model="form.online_start"
label="在线开始" label="在线开始"
required
type="time" type="time"
class="publish-field time-field" class="publish-field time-field"
@update:model-value="handleOnlineStartChange" @update:model-value="handleOnlineStartChange"
@@ -390,6 +391,7 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
<van-field <van-field
v-model="form.online_end" v-model="form.online_end"
label="在线结束" label="在线结束"
required
type="time" type="time"
class="publish-field time-field" class="publish-field time-field"
@update:model-value="handleOnlineEndChange" @update:model-value="handleOnlineEndChange"
@@ -98,6 +98,7 @@
.field-row label span, .field-row label span,
.input-block b, .input-block b,
.time-preset-row strong span,
.quantity-meta span, .quantity-meta span,
.upload-copy span { .upload-copy span {
color: var(--color-danger); color: var(--color-danger);
@@ -322,7 +322,7 @@ function selectDailyLoss(value: string | number) {
<div class="time-preset-panel"> <div class="time-preset-panel">
<div class="time-preset-row"> <div class="time-preset-row">
<strong>在线开始</strong> <strong>在线开始<span>*</span></strong>
<div class="time-preset-content"> <div class="time-preset-content">
<el-time-picker <el-time-picker
v-model="form.online_start" v-model="form.online_start"
@@ -352,7 +352,7 @@ function selectDailyLoss(value: string | number) {
</div> </div>
</div> </div>
<div class="time-preset-row"> <div class="time-preset-row">
<strong>在线结束</strong> <strong>在线结束<span>*</span></strong>
<div class="time-preset-content"> <div class="time-preset-content">
<el-time-picker <el-time-picker
v-model="form.online_end" v-model="form.online_end"
@@ -41,6 +41,11 @@ export function formatHafCoinM(amountWan: number) {
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}M` return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}M`
} }
export function formatAssetNumber(value: number) {
const rounded = Math.round(value * 10) / 10
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
}
export function getListingDisplayPrice(item: Listing) { export function getListingDisplayPrice(item: Listing) {
return centToYuan(item.price_cent) return centToYuan(item.price_cent)
} }