支持在线时间跨天并优化发布与筛选体验

允许在线时段 end 早于 start 表示跨天,统一展示「次日」文案;修复移动端价格区间清空后被 sessionStorage 回写;优化 PC/移动端在线时间控件密度与通栏展示。
This commit is contained in:
yml2213
2026-07-19 17:28:57 +08:00
parent 6450b31129
commit ae79550d78
12 changed files with 410 additions and 136 deletions
@@ -93,7 +93,7 @@ func TestValidateRequiredOnlineTimeRejectsMissing(t *testing.T) {
}
}
func TestValidateRequiredOnlineTimeRejectsInvalidRange(t *testing.T) {
func TestValidateRequiredOnlineTimeAllowsCrossDay(t *testing.T) {
req := CreateRequest{
AssetSummary: map[string]any{
"online_time": map[string]any{
@@ -103,6 +103,21 @@ func TestValidateRequiredOnlineTimeRejectsInvalidRange(t *testing.T) {
},
}
if err := validateRequiredOnlineTime(req); err != nil {
t.Fatalf("expected cross-day range to be valid, got %v", err)
}
}
func TestValidateRequiredOnlineTimeRejectsSameStartEnd(t *testing.T) {
req := CreateRequest{
AssetSummary: map[string]any{
"online_time": map[string]any{
"start": "10:00",
"end": "10:00",
},
},
}
if err := validateRequiredOnlineTime(req); err != ErrInvalidOnlineTime {
t.Fatalf("expected ErrInvalidOnlineTime, got %v", err)
}
@@ -54,7 +54,9 @@ func validateRequiredOnlineTime(req CreateRequest) error {
if !ok {
return ErrInvalidOnlineTime
}
if startMinute >= endMinute {
// 允许跨天:start > end 表示每日 start 至次日 end(如 23:00-05:00
// 仅禁止起止时刻相同(全天请用 00:00-23:59
if startMinute == endMinute {
return ErrInvalidOnlineTime
}
return nil
@@ -21,6 +21,7 @@ import {
getCoinWan,
getListingConsumablePrice,
getListingResources,
getOnlineTimeText,
getResourceQuantity,
getSkinNames,
readAssetNumber,
@@ -424,12 +425,7 @@ function contactPhone(row: Listing) {
function ownerOnlineText(row: Listing) {
const text = row.asset_summary?.online_time_text
if (typeof text === 'string' && text.trim()) return text
const onlineTime = row.asset_summary?.online_time
if (typeof onlineTime !== 'object' || onlineTime === null) return '-'
const start = (onlineTime as Record<string, unknown>).start
const end = (onlineTime as Record<string, unknown>).end
if (typeof start === 'string' && typeof end === 'string' && start && end) return `${start}-${end}`
return '-'
return getOnlineTimeText(row) || '-'
}
function commonRegionText(row: Listing) {
@@ -85,11 +85,19 @@ function toggleChip(sectionKey: string, value: string) {
emit('update:selectedFilters', { ...props.selectedFilters, [sectionKey]: next })
}
/** 仅保留数字与一个小数点,避免 type=number 受控输入删不干净 */
function sanitizeRangeInput(value: string) {
const cleaned = value.replace(/[^\d.]/g, '')
const dot = cleaned.indexOf('.')
if (dot === -1) return cleaned
return cleaned.slice(0, dot + 1) + cleaned.slice(dot + 1).replace(/\./g, '')
}
function updateRange(sectionKey: string, side: 'min' | 'max', value: string) {
const current = props.rangeFilters[sectionKey] || { min: '', max: '' }
emit('update:rangeFilters', {
...props.rangeFilters,
[sectionKey]: { ...current, [side]: value },
[sectionKey]: { ...current, [side]: sanitizeRangeInput(value) },
})
}
@@ -214,17 +222,21 @@ function sectionTopInBody(section: HTMLElement, body: HTMLElement) {
<template v-if="section.type === 'range'">
<div class="range-row">
<input
:value="rangeFilters[section.key]?.min || ''"
type="number"
:value="rangeFilters[section.key]?.min ?? ''"
type="text"
inputmode="decimal"
pattern="[0-9.]*"
autocomplete="off"
:placeholder="section.minPlaceholder || '最低'"
@input="updateRange(section.key, 'min', ($event.target as HTMLInputElement).value)"
/>
<span></span>
<input
:value="rangeFilters[section.key]?.max || ''"
type="number"
:value="rangeFilters[section.key]?.max ?? ''"
type="text"
inputmode="decimal"
pattern="[0-9.]*"
autocomplete="off"
:placeholder="section.maxPlaceholder || '最高'"
@input="updateRange(section.key, 'max', ($event.target as HTMLInputElement).value)"
/>
@@ -382,13 +382,19 @@ onBeforeUnmount(() => {
watch(
() => filterQuerySignature(),
() => {
syncMobileHomeQuery()
// 筛选变化时清空旧列表缓存,避免返回时命中过期数据
homeCache.clearList()
loadListings(true)
// 筛选弹层打开时只改本地 state,避免每个按键写 URL/storage 后被旧缓存回写
if (filterOpen.value) return
applyFilterStateAndReload()
}
)
watch(filterOpen, (open, wasOpen) => {
// 关闭弹层时再统一同步 URL 并刷新列表
if (wasOpen && !open) {
applyFilterStateAndReload()
}
})
watch(
() => route.query,
() => {
@@ -623,10 +629,20 @@ function applyRouteQueryToMobileState() {
rangeFilters.value = nextRanges
}
function applyFilterStateAndReload() {
syncMobileHomeQuery()
// 筛选变化时清空旧列表缓存,避免返回时命中过期数据
homeCache.clearList()
loadListings(true)
}
function syncMobileHomeQuery() {
const query = mergeHomeQuery(route.query, mobileHomeFilterQueryKeys, buildMobileHomeQueryValues())
if (hasHomeQueryValues(query, mobileHomeFilterQueryKeys)) {
storeHomeQuery(mobileHomeFilterStorageKey, query)
} else {
// 筛选被清空时必须清掉 storage,否则 URL 变空会回退读出旧区间(最后一位删不掉)
clearStoredHomeQuery(mobileHomeFilterStorageKey)
}
if (isSameQuery(route.query, query)) return
router.replace({ path: route.path, query })
@@ -10,6 +10,7 @@ import {
readSnapshotResources,
} from '@/features/orders/composables/useOrderSnapshot'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { formatOnlineTimeRange } from '@/shared/utils/listingDisplay'
const props = withDefaults(
defineProps<{
@@ -63,9 +64,8 @@ function formatOnlineTime(value: unknown): string {
const start = String(row.start || '')
const end = String(row.end || '')
if (!start && !end) return '--'
if (start === '全天' || end === '全天' || (start === '00:00' && end === '23:59')) return '全天'
if (start && end) return `${start.replace(':00', '')}-${end.replace(':00', '')}`
return [start, end].filter(Boolean).join(' ~ ')
const text = formatOnlineTimeRange(start, end)
return text || [start, end].filter(Boolean).join(' ~ ') || '--'
}
const primaryRows = computed(() => {
@@ -354,39 +354,24 @@ export function usePublishForm(options: UsePublishFormOptions) {
}
function handleOnlineStartChange(value: string | number | null | undefined) {
const time = normalizeOnlineTimeValue(value)
form.online_start = time
const start = parseOnlineTime(time)
if (start === null) return
const lastTime = parseOnlineTime('23:59')
if (lastTime !== null && start >= lastTime) {
form.online_start = ''
options.notifyWarning('开始时间必须早于结束时间')
return
}
const end = parseOnlineTime(form.online_end)
if (end !== null && start >= end) {
form.online_end = ''
options.notifyWarning('结束时间已清空,请选择晚于开始时间的结束时间')
}
form.online_start = normalizeOnlineTimeValue(value)
}
function handleOnlineEndChange(value: string | number | null | undefined) {
const time = normalizeOnlineTimeValue(value)
form.online_end = time
const end = parseOnlineTime(time)
if (end === null) return
const firstTime = parseOnlineTime('00:00')
if (firstTime !== null && end <= firstTime) {
form.online_end = ''
options.notifyWarning('结束时间必须晚于开始时间')
return
}
form.online_end = normalizeOnlineTimeValue(value)
}
/** 结束时刻早于开始时刻时视为跨天(每日 start 至次日 end) */
function isCrossDayOnline() {
const start = parseOnlineTime(form.online_start)
if (start !== null && end <= start) {
form.online_end = ''
options.notifyWarning('结束时间必须晚于开始时间')
}
const end = parseOnlineTime(form.online_end)
return start !== null && end !== null && end < start
}
function onlineTimeRangeHint() {
if (isAllDayOnline()) return ''
if (!isCrossDayOnline()) return ''
return `每日 ${form.online_start} 至次日 ${form.online_end}`
}
function validateOnlineTime() {
@@ -394,8 +379,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
const end = parseOnlineTime(form.online_end)
if (start === null) return '请选择在线开始时间'
if (end === null) return '请选择在线结束时间'
if (start === end) return '在线开始和结束时间不能相同'
if (end < start) return '在线结束时间必须晚于开始时间'
if (start === end) return '在线开始和结束时间不能相同(全天请点「全天」)'
return ''
}
@@ -920,6 +904,8 @@ export function usePublishForm(options: UsePublishFormOptions) {
resetDraftState,
handleResetDraft,
isAllDayOnline,
isCrossDayOnline,
onlineTimeRangeHint,
selectAllDayOnline,
handleOnlineStartChange,
handleOnlineEndChange,
@@ -207,52 +207,127 @@
gap: 8px;
}
.online-time-field :deep(.van-field__control) {
min-height: 32px;
/* 在线时间:通栏紧凑布局,高度贴近其它 publish-field */
.online-time-block {
margin-bottom: 8px;
padding: 10px 12px;
border-radius: 8px;
background: #f8fafc;
}
.online-time-controls {
.online-time-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.online-time-title {
display: flex;
align-items: center;
gap: 2px;
color: #30343a;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
}
.online-time-title .req {
color: #ee0a24;
font-weight: 800;
}
.online-time-allday {
flex-shrink: 0;
min-height: 28px !important;
padding: 0 12px !important;
font-size: 12px !important;
}
.online-time-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
width: 100%;
}
.online-time-range {
.online-time-pick {
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 100%;
min-width: 0;
}
.online-time-input {
flex: 1;
min-width: 0;
height: 32px;
padding: 0 10px;
min-height: 36px;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #f8fafc;
color: #1e293b;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
background: #fff;
text-align: left;
cursor: pointer;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.online-time-input:focus {
outline: none;
.online-time-pick:active {
border-color: #ffb074;
background: #fff;
box-shadow: 0 0 0 3px rgba(255, 106, 0, 0.1);
}
.online-time-sep {
flex: 0 0 auto;
color: #94a3b8;
font-size: 12px;
.online-time-label {
display: flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
color: #8a94a6;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
}
.online-time-label em {
font-style: normal;
padding: 0 4px;
border-radius: 999px;
background: rgba(255, 106, 0, 0.1);
color: #ff6a00;
font-size: 10px;
font-weight: 800;
}
.online-time-pick strong {
min-width: 0;
color: #20242a;
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
line-height: 1.2;
text-align: right;
}
.online-time-pick strong.placeholder {
color: #94a3b8;
font-size: 13px;
font-weight: 600;
}
.online-time-summary {
margin: 6px 0 0;
color: #64748b;
font-size: 11px;
font-weight: 500;
line-height: 1.35;
}
.online-time-hint {
margin: 6px 0 0;
color: #858c96;
font-size: 11px;
line-height: 1.45;
}
.level-panel {
@@ -3,6 +3,8 @@ import { showDialog, showToast } from 'vant'
import { computed, ref } from 'vue'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
type OnlineTimeSide = 'start' | 'end'
import { usePublishForm } from '@/features/seller/composables/usePublishForm'
import { formatMoney } from '@/shared/utils/money'
import type { AgreementContent } from '@/features/listings/api/listingOptions'
@@ -70,6 +72,7 @@ const {
canAddScreenshot,
getScreenshotLimitHint,
isAllDayOnline,
onlineTimeRangeHint,
selectAllDayOnline,
handleOnlineStartChange,
handleOnlineEndChange,
@@ -117,6 +120,40 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
const unlockSaidOptions = ['是', '否']
// 在线时间:通栏自定义块 + 底部时间选择,避免 van-field 窄列截断原生 time
const onlineTimePickerVisible = ref(false)
const onlineTimePickerSide = ref<OnlineTimeSide>('start')
const onlineTimePickerValue = ref<string[]>(['00', '00'])
const onlineTimePickerTitle = computed(() =>
onlineTimePickerSide.value === 'start' ? '选择开始时间' : '选择结束时间'
)
function parseTimeToColumns(value: string): string[] {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value || '').trim())
if (!match?.[1] || !match[2]) return ['00', '00']
return [match[1].padStart(2, '0'), match[2]]
}
function openOnlineTimePicker(side: OnlineTimeSide) {
onlineTimePickerSide.value = side
const current = side === 'start' ? form.online_start : form.online_end
onlineTimePickerValue.value = parseTimeToColumns(current)
onlineTimePickerVisible.value = true
}
function onOnlineTimePickerConfirm(payload: { selectedValues?: Array<string | number> }) {
const values = payload.selectedValues || onlineTimePickerValue.value
const hour = String(values[0] ?? '00').padStart(2, '0')
const minute = String(values[1] ?? '00').padStart(2, '0')
const time = `${hour}:${minute}`
if (onlineTimePickerSide.value === 'start') {
handleOnlineStartChange(time)
} else {
handleOnlineEndChange(time)
}
onlineTimePickerVisible.value = false
}
// 截图槽位的指导示例图(仅展示、点击放大,不占用上传数量)
const screenshotGuides: Record<string, string> = {
tencentSecurity: tencentSecurityGuide,
@@ -379,41 +416,67 @@ const screenshotGuides: Record<string, string> = {
</template>
</van-field>
<van-field label="在线时间" required class="publish-field online-time-field">
<template #input>
<div class="online-time-controls">
<button
type="button"
class="radio-btn"
:class="{ active: isAllDayOnline() }"
@click="selectAllDayOnline"
>
全天
</button>
<div class="online-time-range">
<input
v-model="form.online_start"
type="time"
class="online-time-input"
aria-label="在线开始时间"
@change="handleOnlineStartChange(form.online_start)"
/>
<span class="online-time-sep"></span>
<input
v-model="form.online_end"
type="time"
class="online-time-input"
aria-label="在线结束时间"
@change="handleOnlineEndChange(form.online_end)"
/>
</div>
<div class="online-time-block">
<div class="online-time-head">
<div class="online-time-title">
<span class="req">*</span>
<span>在线时间</span>
</div>
</template>
</van-field>
<button
type="button"
class="radio-btn online-time-allday"
:class="{ active: isAllDayOnline() }"
@click="selectAllDayOnline"
>
全天
</button>
</div>
<div class="online-time-grid">
<button
type="button"
class="online-time-pick"
@click="openOnlineTimePicker('start')"
>
<span class="online-time-label">开始</span>
<strong :class="{ placeholder: !form.online_start }">
{{ form.online_start || '请选择' }}
</strong>
</button>
<button type="button" class="online-time-pick" @click="openOnlineTimePicker('end')">
<span class="online-time-label">
结束
<em v-if="onlineTimeRangeHint()">次日</em>
</span>
<strong :class="{ placeholder: !form.online_end }">
{{ form.online_end || '请选择' }}
</strong>
</button>
</div>
<p v-if="onlineTimeRangeHint()" class="online-time-summary">
{{ onlineTimeRangeHint() }}
</p>
</div>
<p class="field-hint">
此在线时间指的是百分百能够联系上您的时间若是在此期间联系不上您导致无法上号会扣除您的部分订单金额或上架押金在线时长太短可能无法上架请预留充足时间用于扫码以及冻结人脸请谨慎填写
请填写能百分百联系上您的时间该时段联系不上可能影响上架或扣款请预留扫码与冻结人脸时间
</p>
<van-popup
v-model:show="onlineTimePickerVisible"
position="bottom"
round
teleport="body"
>
<van-time-picker
v-model="onlineTimePickerValue"
:title="onlineTimePickerTitle"
:columns-type="['hour', 'minute']"
@confirm="onOnlineTimePickerConfirm"
@cancel="onlineTimePickerVisible = false"
/>
</van-popup>
<van-field label="封禁记录" required class="publish-field">
<template #input>
<div class="radio-group">
@@ -369,20 +369,20 @@
}
.online-time-row {
align-items: center;
align-items: start;
}
.online-time-body {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.online-time-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
min-width: 0;
}
.online-time-range {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
@@ -390,14 +390,40 @@
.online-time-sep {
flex: 0 0 auto;
color: #94a3b8;
font-size: 13px;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.online-time-next-day {
flex: 0 0 auto;
padding: 2px 6px;
border-radius: 999px;
background: rgba(255, 106, 0, 0.12);
color: #ff6a00;
font-size: 11px;
font-style: normal;
font-weight: 800;
line-height: 1.2;
}
.online-time-summary {
margin: 0;
color: #64748b;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
.online-time-body > .field-hint {
grid-column: auto;
margin: 0;
}
.time-chip {
flex: 0 0 auto;
min-height: 32px;
padding: 0 14px;
padding: 0 12px;
border: 1px solid #d6dce5;
border-radius: 999px;
background: #fff;
@@ -423,11 +449,47 @@
}
.time-input {
width: 128px;
width: 112px;
}
.online-time-range :deep(.el-input__wrapper) {
.online-time-controls :deep(.el-date-editor.el-input),
.online-time-controls :deep(.el-date-editor.el-input__wrapper) {
height: 32px;
width: 112px;
}
.online-time-controls :deep(.el-input__wrapper) {
min-height: 32px;
height: 32px;
padding: 0 10px;
border-radius: 999px;
box-shadow: 0 0 0 1px #d6dce5 inset;
background: #fff;
}
.online-time-controls :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px #c4ccd8 inset;
}
.online-time-controls :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px var(--color-orange-primary) inset;
}
.online-time-controls :deep(.time-input.is-cross .el-input__wrapper) {
box-shadow: 0 0 0 1px rgba(255, 106, 0, 0.4) inset;
background: #fffaf5;
}
.online-time-controls :deep(.el-input__inner) {
height: 30px;
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #30343a;
}
.online-time-controls :deep(.el-input__prefix) {
color: #94a3b8;
}
.login-region-panel {
@@ -938,14 +1000,17 @@
grid-template-columns: 1fr;
}
.online-time-range {
.online-time-controls {
width: 100%;
}
.time-input {
flex: 1;
width: auto;
min-width: 0;
width: 104px;
}
.online-time-controls :deep(.el-date-editor.el-input),
.online-time-controls :deep(.el-date-editor.el-input__wrapper) {
width: 104px;
}
.field-hint {
@@ -81,6 +81,7 @@ const {
canAddScreenshot,
getScreenshotLimitHint,
isAllDayOnline,
onlineTimeRangeHint,
selectAllDayOnline,
handleOnlineStartChange,
handleOnlineEndChange,
@@ -341,36 +342,45 @@ function selectDailyLoss(value: string | number) {
<div class="field-row panel-field-row online-time-row">
<label>在线时间<span>*</span></label>
<div class="online-time-controls">
<button
type="button"
class="time-chip"
:class="{ active: isAllDayOnline() }"
@click="selectAllDayOnline"
>
全天
</button>
<div class="online-time-range">
<div class="online-time-body">
<div class="online-time-controls">
<button
type="button"
class="time-chip"
:class="{ active: isAllDayOnline() }"
@click="selectAllDayOnline"
>
全天
</button>
<el-time-picker
v-model="form.online_start"
value-format="HH:mm"
format="HH:mm"
placeholder="开始"
class="time-input"
:clearable="false"
@change="handleOnlineStartChange"
/>
<span class="online-time-sep"></span>
<em v-if="onlineTimeRangeHint()" class="online-time-next-day">次日</em>
<el-time-picker
v-model="form.online_end"
value-format="HH:mm"
format="HH:mm"
placeholder="结束"
class="time-input"
:class="{ 'is-cross': !!onlineTimeRangeHint() }"
:clearable="false"
@change="handleOnlineEndChange"
/>
</div>
<p v-if="onlineTimeRangeHint()" class="online-time-summary">
{{ onlineTimeRangeHint() }}
</p>
<p class="field-hint">
请填写能稳定联系上您的时间便于扫码冻结人脸和订单交接
</p>
</div>
<p class="field-hint">请填写能稳定联系上您的时间便于扫码冻结人脸和订单交接</p>
</div>
<div v-if="banRecordOptions.length" class="field-row panel-field-row">
+36 -2
View File
@@ -242,14 +242,48 @@ export function assetRegions(item: Listing) {
: []
}
/** 格式化在线时段;end < start 视为跨天,展示「次日」。 */
export function formatOnlineTimeRange(start: string, end: string) {
const startText = String(start || '').trim()
const endText = String(end || '').trim()
if (!startText || !endText) return ''
if (
startText === '全天' ||
endText === '全天' ||
(startText === '00:00' && endText === '23:59')
) {
return '全天'
}
const startMinute = parseClockToMinute(startText)
const endMinute = parseClockToMinute(endText)
const startLabel = formatClockLabel(startText)
const endLabel = formatClockLabel(endText)
if (startMinute !== null && endMinute !== null && endMinute < startMinute) {
return `${startLabel}-次日${endLabel}`
}
return `${startLabel}-${endLabel}`
}
export function getOnlineTimeText(item: Listing) {
const onlineTime = item.asset_summary?.online_time
if (typeof onlineTime !== 'object' || onlineTime === null) return ''
const start = (onlineTime as Record<string, unknown>).start
const end = (onlineTime as Record<string, unknown>).end
if (typeof start !== 'string' || typeof end !== 'string' || !start || !end) return ''
if (start === '全天' || end === '全天' || (start === '00:00' && end === '23:59')) return '全天'
return `${start.replace(':00', '')}-${end.replace(':00', '')}`
return formatOnlineTimeRange(start, end)
}
function parseClockToMinute(value: string) {
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim())
if (!match) return null
const hour = Number(match[1])
const minute = Number(match[2])
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null
return hour * 60 + minute
}
function formatClockLabel(value: string) {
return value.endsWith(':00') ? value.slice(0, -3) : value
}
export function getDailyLoss(item: Listing) {