新增:钱包 History 默认展示并显示问题到期,支持筛选与日期范围。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
马丁 2026-07-26 20:32:16 +08:00
parent d3f237168c
commit c8cb4500a3
8 changed files with 1001 additions and 16 deletions

View File

@ -52,6 +52,8 @@ export interface HistoryRecordItem {
eventId?: number eventId?: number
marketID?: string | number marketID?: string | number
marketId?: string | number marketId?: string | number
/** 问题/事件到期时间(列表接口关联填充) */
endDate?: string
} }
/** GET /hr/getHistoryRecordPublic 请求参数 */ /** GET /hr/getHistoryRecordPublic 请求参数 */
@ -63,6 +65,8 @@ export interface GetHistoryRecordPublicParams {
title?: string title?: string
name?: string name?: string
bio?: string bio?: string
/** 记录类型TRADE / SPLIT / MERGE 等 */
type?: string
createdAtRange?: string[] createdAtRange?: string[]
} }
@ -76,6 +80,8 @@ export interface GetHistoryRecordListClientParams {
title?: string title?: string
name?: string name?: string
bio?: string bio?: string
/** 记录类型TRADE / SPLIT / MERGE 等 */
type?: string
createdAtRange?: string[] createdAtRange?: string[]
isFunding?: boolean isFunding?: boolean
} }
@ -111,7 +117,8 @@ export async function getHistoryRecordListClient(
title: params.title, title: params.title,
name: params.name, name: params.name,
bio: params.bio, bio: params.bio,
createdAtRange: params.createdAtRange, type: params.type,
'createdAtRange[]': params.createdAtRange,
isFunding: params.isFunding === undefined ? undefined : String(params.isFunding), isFunding: params.isFunding === undefined ? undefined : String(params.isFunding),
}) })
return get<HistoryRecordListClientResponse>('/hr/getHistoryRecordListClient', query, config) return get<HistoryRecordListClientResponse>('/hr/getHistoryRecordListClient', query, config)
@ -132,7 +139,8 @@ export async function getHistoryRecordPublic(
title: params.title, title: params.title,
name: params.name, name: params.name,
bio: params.bio, bio: params.bio,
createdAtRange: params.createdAtRange, type: params.type,
'createdAtRange[]': params.createdAtRange,
}) })
return get<HistoryRecordPublicResponse>('/hr/getHistoryRecordPublic', query) return get<HistoryRecordPublicResponse>('/hr/getHistoryRecordPublic', query)
} }
@ -164,6 +172,8 @@ export interface HistoryDisplayItem {
tradeEventSlug?: string tradeEventSlug?: string
/** 跳转详情 query.marketId */ /** 跳转详情 query.marketId */
detailMarketId?: string detailMarketId?: string
/** 问题到期时间展示文案,如 "Jul 26, 2026" */
expiresAt?: string
} }
function formatTimeAgo(dateStr: string | undefined, timestamp?: number): string { function formatTimeAgo(dateStr: string | undefined, timestamp?: number): string {
@ -179,6 +189,18 @@ function formatTimeAgo(dateStr: string | undefined, timestamp?: number): string
return new Date(ms).toLocaleDateString() return new Date(ms).toLocaleDateString()
} }
function formatExpiresAt(endDate: string | undefined): string {
if (!endDate) return ''
try {
const d = new Date(endDate)
return !Number.isNaN(d.getTime())
? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: endDate
} catch {
return endDate
}
}
/** 将相对路径转为完整 URL */ /** 将相对路径转为完整 URL */
function toFullIconUrl(icon: string | undefined): string | undefined { function toFullIconUrl(icon: string | undefined): string | undefined {
if (!icon?.trim()) return undefined if (!icon?.trim()) return undefined
@ -226,6 +248,7 @@ export function mapHistoryRecordToDisplayItem(record: HistoryRecordItem): Histor
const rawMk = record.marketID ?? record.marketId const rawMk = record.marketID ?? record.marketId
const detailMarketId = const detailMarketId =
rawMk != null && String(rawMk).trim() !== '' ? String(rawMk).trim() : undefined rawMk != null && String(rawMk).trim() !== '' ? String(rawMk).trim() : undefined
const expiresAt = formatExpiresAt(record.endDate)
return { return {
id, id,
market, market,
@ -244,6 +267,7 @@ export function mapHistoryRecordToDisplayItem(record: HistoryRecordItem): Histor
tradeEventId: tradeEventId || undefined, tradeEventId: tradeEventId || undefined,
tradeEventSlug: tradeEventSlug || undefined, tradeEventSlug: tradeEventSlug || undefined,
detailMarketId, detailMarketId,
expiresAt: expiresAt || undefined,
} }
} }

View File

@ -0,0 +1,610 @@
<template>
<div class="wallet-date-range">
<v-btn
class="wallet-filter-chip"
:class="{ 'wallet-filter-chip--active': hasRange }"
variant="outlined"
size="small"
@click="openDialog"
>
<v-icon start size="16">mdi-calendar-month-outline</v-icon>
<span class="wallet-filter-chip-label">{{ triggerLabel }}</span>
<v-icon end size="16">mdi-chevron-down</v-icon>
</v-btn>
<v-dialog v-model="dialogOpen" :max-width="mobile ? 360 : 560" scrim opacity="0.35">
<v-card class="date-range-popover" elevation="8" rounded="lg">
<div class="date-range-body" :class="{ 'date-range-body--mobile': mobile }">
<div class="date-range-presets">
<button
v-for="preset in presets"
:key="preset.key"
type="button"
class="date-range-preset"
:class="{ 'date-range-preset--active': activePreset === preset.key }"
@click="onPreset(preset.key)"
>
{{ preset.label }}
</button>
</div>
<div class="date-range-calendar">
<div class="cal-header">
<button type="button" class="cal-nav" aria-label="prev month" @click="shiftMonth(-1)">
<v-icon size="18">mdi-chevron-left</v-icon>
</button>
<div class="cal-title">{{ monthTitle }}</div>
<button type="button" class="cal-nav" aria-label="next month" @click="shiftMonth(1)">
<v-icon size="18">mdi-chevron-right</v-icon>
</button>
</div>
<div class="cal-weekdays">
<span v-for="w in weekdayLabels" :key="w" class="cal-weekday">{{ w }}</span>
</div>
<div class="cal-grid">
<button
v-for="(cell, idx) in monthCells"
:key="idx"
type="button"
class="cal-day"
:class="dayClass(cell)"
:disabled="!cell"
@click="cell && onSelectDay(cell)"
>
<span v-if="cell" class="cal-day-num">{{ cell.day }}</span>
</button>
</div>
<div class="cal-hint">{{ rangeHint }}</div>
</div>
</div>
<div class="date-range-footer">
<v-btn class="date-range-clear-btn" variant="outlined" size="small" @click="clearDraft">
{{ t('wallet.clearDateRange') }}
</v-btn>
<div class="date-range-footer-actions">
<v-btn variant="outlined" size="small" @click="onCancel">{{ t('common.cancel') }}</v-btn>
<v-btn color="primary" variant="flat" size="small" @click="onConfirm">
{{ t('wallet.setDate') }}
</v-btn>
</div>
</div>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
const props = defineProps({
dateFrom: { type: String, default: '' },
dateTo: { type: String, default: '' },
})
const emit = defineEmits<{
'update:dateFrom': [value: string]
'update:dateTo': [value: string]
}>()
const { t, locale } = useI18n()
const { mobile } = useDisplay()
const dialogOpen = ref(false)
const draftFrom = ref('')
const draftTo = ref('')
const activePreset = ref('')
/** 当前展示月份的 1 号 */
const viewMonth = ref<Date>(startOfMonth(new Date()))
type PresetKey =
| 'today'
| 'yesterday'
| 'lastWeek'
| 'lastMonth'
| 'last3Months'
| 'ytd'
| 'lastYear'
| 'all'
interface DayCell {
ymd: string
day: number
inCurrentMonth: boolean
}
const presets = computed(() => [
{ key: 'today' as const, label: t('wallet.datePreset.today') },
{ key: 'yesterday' as const, label: t('wallet.datePreset.yesterday') },
{ key: 'lastWeek' as const, label: t('wallet.datePreset.lastWeek') },
{ key: 'lastMonth' as const, label: t('wallet.datePreset.lastMonth') },
{ key: 'last3Months' as const, label: t('wallet.datePreset.last3Months') },
{ key: 'ytd' as const, label: t('wallet.datePreset.ytd') },
{ key: 'lastYear' as const, label: t('wallet.datePreset.lastYear') },
{ key: 'all' as const, label: t('wallet.datePreset.all') },
])
const hasRange = computed(() => !!props.dateFrom || !!props.dateTo)
const triggerLabel = computed(() => {
if (props.dateFrom && props.dateTo) {
if (props.dateFrom === props.dateTo) return props.dateFrom
return `${props.dateFrom} ~ ${props.dateTo}`
}
if (props.dateFrom) return `${props.dateFrom} ~`
if (props.dateTo) return `~ ${props.dateTo}`
return t('wallet.date')
})
const rangeHint = computed(() => {
if (draftFrom.value && draftTo.value) {
return draftFrom.value === draftTo.value
? draftFrom.value
: `${draftFrom.value} ~ ${draftTo.value}`
}
if (draftFrom.value) return `${draftFrom.value} → …`
return t('wallet.dateRangeHint')
})
const weekdayLabels = computed(() => {
const base = new Date(2024, 0, 7) // Sunday
const fmt = new Intl.DateTimeFormat(locale.value || undefined, { weekday: 'narrow' })
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(base)
d.setDate(base.getDate() + i)
return fmt.format(d)
})
})
const monthTitle = computed(() => {
return new Intl.DateTimeFormat(locale.value || undefined, {
year: 'numeric',
month: 'long',
}).format(viewMonth.value)
})
const monthCells = computed((): Array<DayCell | null> => {
const first = viewMonth.value
const year = first.getFullYear()
const month = first.getMonth()
const firstWeekday = first.getDay() // 0=Sun
const daysInMonth = new Date(year, month + 1, 0).getDate()
const cells: Array<DayCell | null> = []
for (let i = 0; i < firstWeekday; i++) cells.push(null)
for (let day = 1; day <= daysInMonth; day++) {
const ymd = formatLocalYmd(new Date(year, month, day))
cells.push({ ymd, day, inCurrentMonth: true })
}
while (cells.length % 7 !== 0) cells.push(null)
return cells
})
function startOfMonth(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1)
}
function formatLocalYmd(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function parseYmd(ymd: string): Date | null {
if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return null
const parts = ymd.split('-').map(Number)
const y = parts[0]
const m = parts[1]
const d = parts[2]
if (y == null || m == null || d == null) return null
const date = new Date(y, m - 1, d)
if (Number.isNaN(date.getTime())) return null
return date
}
function syncDraftFromProps() {
draftFrom.value = props.dateFrom || ''
draftTo.value = props.dateTo || ''
activePreset.value = ''
const anchor = parseYmd(props.dateFrom || props.dateTo || '') ?? new Date()
viewMonth.value = startOfMonth(anchor)
}
function openDialog() {
syncDraftFromProps()
dialogOpen.value = true
}
function shiftMonth(delta: number) {
const cur = viewMonth.value
viewMonth.value = new Date(cur.getFullYear(), cur.getMonth() + delta, 1)
}
/**
* 范围选择
* - 无选中点击设为起点
* - 已有起点无终点点击设为终点自动校正先后
* - 已有完整范围重新开始点击作为新起点
*/
function onSelectDay(cell: DayCell) {
activePreset.value = ''
const ymd = cell.ymd
if (!draftFrom.value || (draftFrom.value && draftTo.value)) {
draftFrom.value = ymd
draftTo.value = ''
return
}
//
if (ymd < draftFrom.value) {
draftTo.value = draftFrom.value
draftFrom.value = ymd
} else {
draftTo.value = ymd
}
}
function dayClass(cell: DayCell | null): Record<string, boolean> {
if (!cell) return { 'cal-day--empty': true }
const ymd = cell.ymd
const from = draftFrom.value
const to = draftTo.value
const isStart = !!from && ymd === from
const isEnd = !!to && ymd === to
const inRange = !!from && !!to && ymd > from && ymd < to
const isToday = ymd === formatLocalYmd(new Date())
const isSingle = isStart && isEnd
return {
'cal-day--today': isToday,
'cal-day--start': isStart,
'cal-day--end': isEnd,
'cal-day--in-range': inRange,
'cal-day--single': isSingle,
}
}
function rangeFromPreset(key: PresetKey): { from: string; to: string } | null {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
if (key === 'all') return null
if (key === 'today') {
const s = formatLocalYmd(today)
return { from: s, to: s }
}
if (key === 'yesterday') {
const y = new Date(today)
y.setDate(y.getDate() - 1)
const s = formatLocalYmd(y)
return { from: s, to: s }
}
if (key === 'lastWeek') {
const end = new Date(today)
end.setDate(end.getDate() - 1)
const start = new Date(end)
start.setDate(start.getDate() - 6)
return { from: formatLocalYmd(start), to: formatLocalYmd(end) }
}
if (key === 'lastMonth') {
const start = new Date(today.getFullYear(), today.getMonth() - 1, 1)
const end = new Date(today.getFullYear(), today.getMonth(), 0)
return { from: formatLocalYmd(start), to: formatLocalYmd(end) }
}
if (key === 'last3Months') {
const end = today
const start = new Date(today)
start.setMonth(start.getMonth() - 3)
return { from: formatLocalYmd(start), to: formatLocalYmd(end) }
}
if (key === 'ytd') {
const start = new Date(today.getFullYear(), 0, 1)
return { from: formatLocalYmd(start), to: formatLocalYmd(today) }
}
const start = new Date(today.getFullYear() - 1, 0, 1)
const end = new Date(today.getFullYear() - 1, 11, 31)
return { from: formatLocalYmd(start), to: formatLocalYmd(end) }
}
function onPreset(key: PresetKey) {
activePreset.value = key
const range = rangeFromPreset(key)
if (!range) {
draftFrom.value = ''
draftTo.value = ''
return
}
draftFrom.value = range.from
draftTo.value = range.to
viewMonth.value = startOfMonth(parseYmd(range.from) ?? new Date())
}
function clearDraft() {
draftFrom.value = ''
draftTo.value = ''
activePreset.value = 'all'
}
function onCancel() {
dialogOpen.value = false
}
function onConfirm() {
//
const from = draftFrom.value
const to = draftTo.value || draftFrom.value
if (!from) {
emit('update:dateFrom', '')
emit('update:dateTo', '')
} else {
emit('update:dateFrom', from)
emit('update:dateTo', to)
}
dialogOpen.value = false
}
</script>
<style scoped lang="scss">
.wallet-date-range {
display: inline-flex;
}
.wallet-filter-chip {
text-transform: none;
font-weight: 600;
font-size: 12px;
letter-spacing: 0;
border-radius: 999px !important;
border-color: #e5e7eb !important;
color: #374151;
background: #fff;
min-height: 34px;
padding-inline: 12px;
}
.wallet-filter-chip--active {
border-color: #2d5bff !important;
color: #2d5bff;
background: #eef2ff;
}
.wallet-filter-chip-label {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-range-popover {
border: 1px solid #e5e7eb;
overflow: hidden;
}
.date-range-body {
display: flex;
min-height: 320px;
}
.date-range-body--mobile {
flex-direction: column;
min-height: 0;
}
.date-range-presets {
width: 132px;
flex-shrink: 0;
padding: 12px 8px;
border-right: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
gap: 2px;
background: #fafafa;
}
.date-range-body--mobile .date-range-presets {
width: 100%;
flex-direction: row;
flex-wrap: wrap;
border-right: 0;
border-bottom: 1px solid #e5e7eb;
gap: 4px;
}
.date-range-preset {
appearance: none;
border: 0;
background: transparent;
text-align: left;
padding: 8px 10px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: #374151;
cursor: pointer;
line-height: 1.3;
}
.date-range-body--mobile .date-range-preset {
padding: 6px 10px;
font-size: 12px;
}
.date-range-preset:hover {
background: #f3f4f6;
}
.date-range-preset--active {
background: #eef2ff;
color: #2d5bff;
font-weight: 600;
}
.date-range-calendar {
flex: 1;
min-width: 0;
padding: 14px 16px 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.cal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.cal-title {
font-size: 14px;
font-weight: 700;
color: #111827;
}
.cal-nav {
appearance: none;
border: 1px solid #e5e7eb;
background: #fff;
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #374151;
}
.cal-nav:hover {
background: #f3f4f6;
}
.cal-weekdays,
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.cal-weekday {
text-align: center;
font-size: 11px;
font-weight: 600;
color: #9ca3af;
padding: 4px 0;
}
.cal-day {
appearance: none;
border: 0;
background: transparent;
height: 36px;
border-radius: 0;
position: relative;
cursor: pointer;
padding: 0;
color: #111827;
}
.cal-day--empty {
cursor: default;
pointer-events: none;
}
.cal-day-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
position: relative;
z-index: 1;
}
.cal-day:hover:not(.cal-day--empty) .cal-day-num {
background: #f3f4f6;
}
.cal-day--today .cal-day-num {
box-shadow: inset 0 0 0 1px #d1d5db;
}
.cal-day--in-range {
background: #eef2ff;
}
.cal-day--start,
.cal-day--end,
.cal-day--single {
background: #eef2ff;
}
.cal-day--start {
border-radius: 999px 0 0 999px;
}
.cal-day--end {
border-radius: 0 999px 999px 0;
}
.cal-day--single {
border-radius: 999px;
background: transparent;
}
.cal-day--start .cal-day-num,
.cal-day--end .cal-day-num,
.cal-day--single .cal-day-num {
background: #2d5bff;
color: #fff;
}
.cal-day--start:hover .cal-day-num,
.cal-day--end:hover .cal-day-num,
.cal-day--single:hover .cal-day-num {
background: #2d5bff;
}
.cal-hint {
min-height: 18px;
font-size: 12px;
color: #6b7280;
text-align: center;
padding-top: 4px;
}
.date-range-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid #e5e7eb;
background: #fff;
flex-wrap: wrap;
}
.date-range-footer-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.date-range-clear-btn,
.date-range-footer-actions :deep(.v-btn) {
text-transform: none;
font-weight: 600;
letter-spacing: 0;
border-radius: 10px !important;
}
</style>

View File

@ -272,6 +272,25 @@
"openOrders": "Open orders", "openOrders": "Open orders",
"history": "History", "history": "History",
"searchPlaceholder": "Search", "searchPlaceholder": "Search",
"searchEventPlaceholder": "Search",
"date": "Date",
"dateFrom": "Start date",
"dateTo": "End date",
"clearFilters": "Clear",
"clearDateRange": "Clear date range",
"setDate": "Set date",
"dateRangeHint": "Select start date, then end date",
"typeFilter": "Type",
"datePreset": {
"today": "Today",
"yesterday": "Yesterday",
"lastWeek": "Last week",
"lastMonth": "Last month",
"last3Months": "Last 3 months",
"ytd": "Year to date",
"lastYear": "Last year",
"all": "All"
},
"currentValue": "Current value", "currentValue": "Current value",
"closeLosses": "Close Losses", "closeLosses": "Close Losses",
"all": "All", "all": "All",
@ -283,6 +302,7 @@
"noHistoryFound": "You haven't traded any polymarkets yet", "noHistoryFound": "You haven't traded any polymarkets yet",
"historySideBuy": "BUY", "historySideBuy": "BUY",
"historySideSell": "SELL", "historySideSell": "SELL",
"historyTypeTrade": "Trade",
"historyTypeSplit": "SPLIT", "historyTypeSplit": "SPLIT",
"historyTypeMerge": "MERGE", "historyTypeMerge": "MERGE",
"historyTypeRedeem": "REDEEM", "historyTypeRedeem": "REDEEM",

View File

@ -272,6 +272,25 @@
"openOrders": "未約定", "openOrders": "未約定",
"history": "履歴", "history": "履歴",
"searchPlaceholder": "検索", "searchPlaceholder": "検索",
"searchEventPlaceholder": "検索",
"date": "日付",
"dateFrom": "開始日",
"dateTo": "終了日",
"clearFilters": "クリア",
"clearDateRange": "日付範囲をクリア",
"setDate": "日付を設定",
"dateRangeHint": "開始日を選び、続けて終了日を選んでください",
"typeFilter": "タイプ",
"datePreset": {
"today": "今日",
"yesterday": "昨日",
"lastWeek": "先週",
"lastMonth": "先月",
"last3Months": "直近3ヶ月",
"ytd": "年初来",
"lastYear": "昨年",
"all": "すべて"
},
"currentValue": "現在価値", "currentValue": "現在価値",
"closeLosses": "損失決済", "closeLosses": "損失決済",
"all": "全て", "all": "全て",
@ -283,6 +302,7 @@
"noHistoryFound": "まだ取引履歴がありません", "noHistoryFound": "まだ取引履歴がありません",
"historySideBuy": "買い", "historySideBuy": "買い",
"historySideSell": "売り", "historySideSell": "売り",
"historyTypeTrade": "取引",
"historyTypeSplit": "スプリット", "historyTypeSplit": "スプリット",
"historyTypeMerge": "マージ", "historyTypeMerge": "マージ",
"historyTypeRedeem": "償還", "historyTypeRedeem": "償還",

View File

@ -272,6 +272,25 @@
"openOrders": "미체결", "openOrders": "미체결",
"history": "내역", "history": "내역",
"searchPlaceholder": "검색", "searchPlaceholder": "검색",
"searchEventPlaceholder": "검색",
"date": "날짜",
"dateFrom": "시작일",
"dateTo": "종료일",
"clearFilters": "초기화",
"clearDateRange": "날짜 범위 지우기",
"setDate": "날짜 설정",
"dateRangeHint": "시작일을 선택한 뒤 종료일을 선택하세요",
"typeFilter": "유형",
"datePreset": {
"today": "오늘",
"yesterday": "어제",
"lastWeek": "지난주",
"lastMonth": "지난달",
"last3Months": "최근 3개월",
"ytd": "올해 누적",
"lastYear": "작년",
"all": "전체"
},
"currentValue": "현재 가치", "currentValue": "현재 가치",
"closeLosses": "손실 청산", "closeLosses": "손실 청산",
"all": "전체", "all": "전체",
@ -283,6 +302,7 @@
"noHistoryFound": "아직 거래 내역이 없습니다", "noHistoryFound": "아직 거래 내역이 없습니다",
"historySideBuy": "매수", "historySideBuy": "매수",
"historySideSell": "매도", "historySideSell": "매도",
"historyTypeTrade": "거래",
"historyTypeSplit": "스플릿", "historyTypeSplit": "스플릿",
"historyTypeMerge": "머지", "historyTypeMerge": "머지",
"historyTypeRedeem": "상환", "historyTypeRedeem": "상환",

View File

@ -272,6 +272,25 @@
"openOrders": "未成交", "openOrders": "未成交",
"history": "历史", "history": "历史",
"searchPlaceholder": "搜索", "searchPlaceholder": "搜索",
"searchEventPlaceholder": "搜索",
"date": "日期",
"dateFrom": "开始日期",
"dateTo": "结束日期",
"clearFilters": "清空",
"clearDateRange": "清空日期范围",
"setDate": "设置日期",
"dateRangeHint": "先点开始日期,再点结束日期",
"typeFilter": "类型",
"datePreset": {
"today": "今天",
"yesterday": "昨天",
"lastWeek": "上周",
"lastMonth": "上月",
"last3Months": "近 3 个月",
"ytd": "今年至今",
"lastYear": "去年",
"all": "全部"
},
"currentValue": "当前价值", "currentValue": "当前价值",
"closeLosses": "平仓亏损", "closeLosses": "平仓亏损",
"all": "全部", "all": "全部",
@ -283,6 +302,7 @@
"noHistoryFound": "您还未进行过任何交易", "noHistoryFound": "您还未进行过任何交易",
"historySideBuy": "买入", "historySideBuy": "买入",
"historySideSell": "卖出", "historySideSell": "卖出",
"historyTypeTrade": "交易",
"historyTypeSplit": "拆分", "historyTypeSplit": "拆分",
"historyTypeMerge": "合并", "historyTypeMerge": "合并",
"historyTypeRedeem": "赎回", "historyTypeRedeem": "赎回",

View File

@ -272,6 +272,25 @@
"openOrders": "未成交", "openOrders": "未成交",
"history": "歷史", "history": "歷史",
"searchPlaceholder": "搜尋", "searchPlaceholder": "搜尋",
"searchEventPlaceholder": "搜尋",
"date": "日期",
"dateFrom": "開始日期",
"dateTo": "結束日期",
"clearFilters": "清空",
"clearDateRange": "清空日期範圍",
"setDate": "設定日期",
"dateRangeHint": "先點開始日期,再點結束日期",
"typeFilter": "類型",
"datePreset": {
"today": "今天",
"yesterday": "昨天",
"lastWeek": "上週",
"lastMonth": "上月",
"last3Months": "近 3 個月",
"ytd": "今年至今",
"lastYear": "去年",
"all": "全部"
},
"currentValue": "當前價值", "currentValue": "當前價值",
"closeLosses": "平倉虧損", "closeLosses": "平倉虧損",
"all": "全部", "all": "全部",
@ -283,6 +302,7 @@
"noHistoryFound": "您還未進行過任何交易", "noHistoryFound": "您還未進行過任何交易",
"historySideBuy": "買入", "historySideBuy": "買入",
"historySideSell": "賣出", "historySideSell": "賣出",
"historyTypeTrade": "交易",
"historyTypeSplit": "拆分", "historyTypeSplit": "拆分",
"historyTypeMerge": "合併", "historyTypeMerge": "合併",
"historyTypeRedeem": "贖回", "historyTypeRedeem": "贖回",

View File

@ -49,13 +49,62 @@
<!-- 下方Positions / Open orders / History --> <!-- 下方Positions / Open orders / History -->
<div class="wallet-section"> <div class="wallet-section">
<v-tabs v-model="activeTab" class="wallet-tabs" density="comfortable"> <div class="wallet-toolbar">
<v-tab value="positions">{{ t('wallet.positions') }}</v-tab> <v-tabs v-model="activeTab" class="wallet-tabs" density="comfortable">
<v-tab value="orders">{{ t('wallet.openOrders') }}</v-tab> <v-tab value="positions">{{ t('wallet.positions') }}</v-tab>
<v-tab value="history">{{ t('wallet.history') }}</v-tab> <v-tab value="orders">{{ t('wallet.openOrders') }}</v-tab>
<v-tab value="depositHistory">{{ t('deposit.depositHistory') }}</v-tab> <v-tab value="history">{{ t('wallet.history') }}</v-tab>
<v-tab value="withdrawals">{{ t('wallet.withdrawals') }}</v-tab> <v-tab value="depositHistory">{{ t('deposit.depositHistory') }}</v-tab>
</v-tabs> <v-tab value="withdrawals">{{ t('wallet.withdrawals') }}</v-tab>
</v-tabs>
<div v-if="showListFilters" class="wallet-filters">
<v-text-field
v-model="search"
class="wallet-filter-search"
density="compact"
hide-details
clearable
variant="outlined"
prepend-inner-icon="mdi-magnify"
:placeholder="t('wallet.searchPlaceholder')"
/>
<v-menu v-if="activeTab === 'history'" location="bottom" offset="6">
<template #activator="{ props: typeMenuProps }">
<v-btn
v-bind="typeMenuProps"
class="wallet-filter-chip"
:class="{ 'wallet-filter-chip--active': !!historyTypeFilter }"
variant="outlined"
size="small"
>
<v-icon start size="16">mdi-filter-variant</v-icon>
{{ historyTypeFilterLabel }}
<v-icon end size="16">mdi-chevron-down</v-icon>
</v-btn>
</template>
<v-list class="wallet-filter-menu-list" density="compact" nav>
<v-list-item
v-for="opt in historyTypeOptions"
:key="opt.value || 'all'"
:active="historyTypeFilter === opt.value"
@click="historyTypeFilter = opt.value"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<WalletDateRangePicker v-model:date-from="dateFrom" v-model:date-to="dateTo" />
<v-btn
v-if="hasActiveFilters"
class="wallet-filter-clear"
variant="text"
size="small"
@click="clearListFilters"
>
{{ t('wallet.clearFilters') }}
</v-btn>
</div>
</div>
<v-card class="table-card wallet-design-card" elevation="0" rounded="lg"> <v-card class="table-card wallet-design-card" elevation="0" rounded="lg">
<template v-if="activeTab === 'positions'"> <template v-if="activeTab === 'positions'">
<div class="positions-mobile-list"> <div class="positions-mobile-list">
@ -226,8 +275,12 @@
<span class="order-side-pill" :class="getHistoryTagClass(h)"> <span class="order-side-pill" :class="getHistoryTagClass(h)">
{{ getHistoryTagLabel(h) }} {{ getHistoryTagLabel(h) }}
</span> </span>
<span class="design-history-meta">{{ t('wallet.priceLabel') }}: {{ h.avgPrice || '—' }} · <span class="design-history-meta">
{{ t('wallet.sharesLabel') }}: {{ h.shares || '—' }}</span> <template v-if="h.expiresAt">{{ t('wallet.expirationLabel') }} {{ h.expiresAt }} ·
</template>
{{ t('wallet.priceLabel') }}: {{ h.avgPrice || '—' }} ·
{{ t('wallet.sharesLabel') }}: {{ h.shares || '—' }}
</span>
</div> </div>
</template> </template>
<template v-else> <template v-else>
@ -422,6 +475,7 @@ import { useDisplay } from 'vuetify'
const { t } = useI18n() const { t } = useI18n()
import WalletDepositModule from '../components/WalletDepositModule.vue' import WalletDepositModule from '../components/WalletDepositModule.vue'
import WalletDepositHistoryPanel from '../components/WalletDepositHistoryPanel.vue' import WalletDepositHistoryPanel from '../components/WalletDepositHistoryPanel.vue'
import WalletDateRangePicker from '../components/WalletDateRangePicker.vue'
import WithdrawDialog from '../components/WithdrawDialog.vue' import WithdrawDialog from '../components/WithdrawDialog.vue'
import MarqueeTitle from '../components/MarqueeTitle.vue' import MarqueeTitle from '../components/MarqueeTitle.vue'
import PnlCurveCard from '../components/PnlCurveCard.vue' import PnlCurveCard from '../components/PnlCurveCard.vue'
@ -480,9 +534,12 @@ const localeStore = useLocaleStore()
const portfolioBalance = computed(() => userStore.totalAssetValue) const portfolioBalance = computed(() => userStore.totalAssetValue)
const positionsValueText = computed(() => userStore.positionsValue.toFixed(2)) const positionsValueText = computed(() => userStore.positionsValue.toFixed(2))
const activeTab = ref<'positions' | 'orders' | 'history' | 'depositHistory' | 'withdrawals'>( const activeTab = ref<'positions' | 'orders' | 'history' | 'depositHistory' | 'withdrawals'>(
'positions', 'history',
) )
const search = ref('') const search = ref('')
const dateFrom = ref('')
const dateTo = ref('')
const historyTypeFilter = ref('')
const withdrawStatusFilter = ref<string>('') const withdrawStatusFilter = ref<string>('')
const withdrawStatusOptions = computed(() => [ const withdrawStatusOptions = computed(() => [
{ label: t('wallet.withdrawStatusAll'), value: '' }, { label: t('wallet.withdrawStatusAll'), value: '' },
@ -491,6 +548,77 @@ const withdrawStatusOptions = computed(() => [
{ label: t('wallet.withdrawStatusRejected'), value: WITHDRAW_STATUS.REJECTED }, { label: t('wallet.withdrawStatusRejected'), value: WITHDRAW_STATUS.REJECTED },
{ label: t('wallet.withdrawStatusFailed'), value: WITHDRAW_STATUS.FAILED }, { label: t('wallet.withdrawStatusFailed'), value: WITHDRAW_STATUS.FAILED },
]) ])
const historyTypeOptions = computed(() => [
{ label: t('wallet.all'), value: '' },
{ label: t('wallet.historyTypeTrade'), value: HISTORY_RECORD_TYPE.TRADE },
{ label: t('wallet.historyTypeSplit'), value: HISTORY_RECORD_TYPE.SPLIT },
{ label: t('wallet.historyTypeMerge'), value: HISTORY_RECORD_TYPE.MERGE },
{ label: t('wallet.historyTypeRedeem'), value: HISTORY_RECORD_TYPE.REDEEM },
{ label: t('wallet.historyTypeReward'), value: HISTORY_RECORD_TYPE.REWARD },
{ label: t('wallet.historyTypeConversion'), value: HISTORY_RECORD_TYPE.CONVERSION },
{ label: t('wallet.historyTypeMakerRebate'), value: HISTORY_RECORD_TYPE.MAKER_REBATE },
])
const historyTypeFilterLabel = computed(() => {
const hit = historyTypeOptions.value.find((o) => o.value === historyTypeFilter.value)
return hit?.label ?? t('wallet.all')
})
const showListFilters = computed(
() =>
activeTab.value === 'positions' ||
activeTab.value === 'orders' ||
activeTab.value === 'history',
)
const hasActiveFilters = computed(
() =>
!!search.value.trim() ||
!!dateFrom.value ||
!!dateTo.value ||
(activeTab.value === 'history' && !!historyTypeFilter.value),
)
function clearListFilters() {
search.value = ''
dateFrom.value = ''
dateTo.value = ''
historyTypeFilter.value = ''
}
/** 本地日界转 RFC3339含 Z供 gin 默认 time.Time 绑定 */
function localDayBoundToRfc3339(ymd: string, endOfDay: boolean): string {
const parts = ymd.split('-').map(Number)
const y = parts[0] ?? 1970
const m = parts[1] ?? 1
const d = parts[2] ?? 1
const date = endOfDay
? new Date(y, m - 1, d, 23, 59, 59, 999)
: new Date(y, m - 1, d, 0, 0, 0, 0)
return date.toISOString()
}
/** 时间范围:两端齐全时返回 [start, end]RFC3339仅一端时补全另一端 */
function getCreatedAtRange(): string[] | undefined {
if (!dateFrom.value && !dateTo.value) return undefined
const startYmd = dateFrom.value || '1970-01-01'
const endYmd =
dateTo.value ||
(() => {
const n = new Date()
const y = n.getFullYear()
const m = String(n.getMonth() + 1).padStart(2, '0')
const d = String(n.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
})()
return [localDayBoundToRfc3339(startYmd, false), localDayBoundToRfc3339(endYmd, true)]
}
function getStartEndCreatedAt(): { startCreatedAt?: string; endCreatedAt?: string } {
const range = getCreatedAtRange()
if (!range) return {}
return { startCreatedAt: range[0], endCreatedAt: range[1] }
}
/** 当前展示的持仓列表mock 或 API */ /** 当前展示的持仓列表mock 或 API */
const currentPositionList = computed(() => (USE_MOCK_WALLET ? positions.value : positionList.value)) const currentPositionList = computed(() => (USE_MOCK_WALLET ? positions.value : positionList.value))
/** 未结算项:从持仓列表中筛出可领取的(有 marketID+tokenID且所属市场已关闭 market.closed=true */ /** 未结算项:从持仓列表中筛出可领取的(有 marketID+tokenID且所属市场已关闭 market.closed=true */
@ -645,6 +773,8 @@ interface HistoryItem {
tradeEventId?: string tradeEventId?: string
tradeEventSlug?: string tradeEventSlug?: string
detailMarketId?: string detailMarketId?: string
/** 问题到期时间展示文案 */
expiresAt?: string
} }
function canOpenTradeDetail(opts: { tradeEventId?: string; tradeEventSlug?: string }): boolean { function canOpenTradeDetail(opts: { tradeEventId?: string; tradeEventSlug?: string }): boolean {
@ -807,8 +937,9 @@ async function loadPositionList() {
} }
positionLoading.value = true positionLoading.value = true
try { try {
const { startCreatedAt, endCreatedAt } = getStartEndCreatedAt()
const res = await getPositionList( const res = await getPositionList(
{ page: page.value, pageSize: itemsPerPage.value, userID }, { page: page.value, pageSize: itemsPerPage.value, userID, startCreatedAt, endCreatedAt },
{ headers }, { headers },
) )
if (res.code === 0 || res.code === 200) { if (res.code === 0 || res.code === 200) {
@ -850,12 +981,15 @@ async function loadOpenOrders() {
} }
openOrderLoading.value = true openOrderLoading.value = true
try { try {
const { startCreatedAt, endCreatedAt } = getStartEndCreatedAt()
const res = await getOrderList( const res = await getOrderList(
{ {
page: page.value, page: page.value,
pageSize: itemsPerPage.value, pageSize: itemsPerPage.value,
userID, userID,
status: OrderStatus.Live, status: OrderStatus.Live,
startCreatedAt,
endCreatedAt,
}, },
{ headers }, { headers },
) )
@ -902,12 +1036,16 @@ async function loadHistoryOrders() {
} }
historyLoading.value = true historyLoading.value = true
try { try {
const eventName = search.value.trim()
const res = await getHistoryRecordListClient( const res = await getHistoryRecordListClient(
{ {
page: page.value, page: page.value,
pageSize: itemsPerPage.value, pageSize: itemsPerPage.value,
userId: userID, userId: userID,
isFunding: false, isFunding: false,
keyword: eventName || undefined,
type: historyTypeFilter.value || undefined,
createdAtRange: getCreatedAtRange(),
}, },
{ headers }, { headers },
) )
@ -1067,8 +1205,12 @@ const filteredOpenOrders = computed(() => {
}) })
const filteredHistory = computed(() => { const filteredHistory = computed(() => {
const list = USE_MOCK_WALLET ? history.value : historyList.value const list = USE_MOCK_WALLET ? history.value : historyList.value
const type = historyTypeFilter.value
return list.filter( return list.filter(
(h) => matchSearch(h.market) && (USE_MOCK_WALLET ? !isFundingHistory(h) : true), (h) =>
matchSearch(h.market) &&
(!type || (h.recordType ?? '').toUpperCase() === type) &&
(USE_MOCK_WALLET ? !isFundingHistory(h) : true),
) )
}) })
const page = ref(1) const page = ref(1)
@ -1150,6 +1292,32 @@ watch(withdrawStatusFilter, () => {
page.value = 1 page.value = 1
if (activeTab.value === 'withdrawals') loadWithdrawals() if (activeTab.value === 'withdrawals') loadWithdrawals()
}) })
let searchReloadTimer: ReturnType<typeof setTimeout> | null = null
function reloadFilteredLists() {
if (USE_MOCK_WALLET) return
if (activeTab.value === 'positions') loadPositionList()
if (activeTab.value === 'orders') loadOpenOrders()
if (activeTab.value === 'history') loadHistoryOrders()
}
watch(search, () => {
page.value = 1
if (USE_MOCK_WALLET) return
// History / matchSearch
if (activeTab.value !== 'history') return
if (searchReloadTimer) clearTimeout(searchReloadTimer)
searchReloadTimer = setTimeout(() => loadHistoryOrders(), 300)
})
watch([dateFrom, dateTo], () => {
page.value = 1
reloadFilteredLists()
})
watch(historyTypeFilter, () => {
page.value = 1
if (USE_MOCK_WALLET) return
if (activeTab.value === 'history') loadHistoryOrders()
})
watch([currentListTotal, itemsPerPage], () => { watch([currentListTotal, itemsPerPage], () => {
const maxPage = currentTotalPages.value const maxPage = currentTotalPages.value
if (page.value > maxPage) page.value = Math.max(1, maxPage) if (page.value > maxPage) page.value = Math.max(1, maxPage)
@ -1271,7 +1439,11 @@ watch(
) )
onMounted(() => { onMounted(() => {
if (!USE_MOCK_WALLET && activeTab.value === 'positions') loadPositionList() if (USE_MOCK_WALLET) return
if (activeTab.value === 'positions') loadPositionList()
if (activeTab.value === 'history') loadHistoryOrders()
if (activeTab.value === 'orders') loadOpenOrders()
if (activeTab.value === 'withdrawals') loadWithdrawals()
}) })
function onWithdrawSuccess() { function onWithdrawSuccess() {
@ -1537,8 +1709,18 @@ async function submitAuthorize() {
outline-offset: 2px; outline-offset: 2px;
} }
.wallet-tabs { .wallet-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-bottom: 12px; margin-bottom: 12px;
}
.wallet-tabs {
flex: 0 1 auto;
margin-bottom: 0;
min-width: 0;
min-height: 30px; min-height: 30px;
} }
@ -1569,6 +1751,75 @@ async function submitAuthorize() {
display: none; display: none;
} }
.wallet-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex: 1 1 220px;
min-width: 0;
margin-bottom: 0;
}
.wallet-filter-search {
flex: 1 1 160px;
min-width: 140px;
max-width: 280px;
}
.wallet-filter-search :deep(.v-field) {
border-radius: 999px;
font-size: 13px;
}
.wallet-filter-search :deep(.v-field__outline) {
--v-field-border-opacity: 1;
color: #e5e7eb;
}
/* 搜索图标左侧留白 */
.wallet-filter-search :deep(.v-field__prepend-inner) {
padding-inline-start: 10px;
padding-inline-end: 2px;
}
.wallet-filter-search :deep(.v-field__input) {
padding-inline-start: 4px;
}
.wallet-filter-chip {
text-transform: none;
font-weight: 600;
font-size: 12px;
letter-spacing: 0;
border-radius: 999px !important;
border-color: #e5e7eb !important;
color: #374151;
background: #fff;
min-height: 34px;
padding-inline: 12px;
}
.wallet-filter-chip--active {
border-color: #2d5bff !important;
color: #2d5bff;
background: #eef2ff;
}
.wallet-filter-menu-list {
min-width: 160px;
border-radius: 12px;
padding: 4px;
}
.wallet-filter-clear {
text-transform: none;
min-width: auto;
padding-inline: 4px;
color: #6b7280;
font-weight: 600;
}
.table-card { .table-card {
border: 0; border: 0;
overflow: hidden; overflow: hidden;