xtraderClient/src/components/MarketCard.vue
2026-02-11 21:25:50 +08:00

669 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<v-card class="market-card" elevation="0" :rounded="'lg'" @click="navigateToDetail">
<div class="market-card-content">
<!-- Top Section头像 + 标题 + 半圆概率 / 多选项时仅标题 -->
<div class="top-section">
<div class="image-title-container">
<v-avatar class="market-image" :size="40" color="#f0f0f0" rounded="sm">
<v-img v-if="props.imageUrl" :src="props.imageUrl" cover />
</v-avatar>
<v-card-title class="market-title" :text="true" :shrink="true">
{{ marketTitle }}
</v-card-title>
</div>
<!-- 单一类型右侧半圆进度条 -->
<div v-if="!isMulti" class="chance-container">
<div class="semi-progress-wrap">
<svg class="semi-progress-svg" viewBox="0 0 60 36" preserveAspectRatio="xMidYMin meet">
<path
class="semi-progress-track"
d="M 4 30 A 26 26 0 0 1 56 30"
fill="none"
stroke="#e8e8e8"
stroke-width="5"
stroke-linecap="round"
/>
<path
class="semi-progress-fill"
d="M 4 30 A 26 26 0 0 1 56 30"
fill="none"
:stroke="semiProgressColor"
stroke-width="5"
stroke-linecap="round"
stroke-dasharray="81.68"
:stroke-dashoffset="semiProgressOffset"
/>
</svg>
<div class="semi-progress-inner">
<span class="chance-value">{{ chanceValue }}%</span>
<span class="chance-label">chance</span>
</div>
</div>
</div>
</div>
<!-- 单一类型中间 Yes/No Up/Down -->
<template v-if="!isMulti">
<div class="options-section">
<v-btn
class="option-yes"
:color="'#b8e0b8'"
:rounded="'sm'"
:text="true"
elevation="0"
@click.stop="openTradeSingle('yes')"
>
<span class="option-text-yes">{{ yesLabel }}</span>
</v-btn>
<v-btn
class="option-no"
:color="'#f0b8b8'"
:rounded="'sm'"
:text="true"
elevation="0"
@click.stop="openTradeSingle('no')"
>
<span class="option-text-no">{{ noLabel }}</span>
</v-btn>
</div>
</template>
<!-- 多选项类型左右滑动轮播每页一项 = 左侧 title+% 右侧 Yes/No -->
<div v-else class="multi-section">
<v-carousel
v-model="currentSlide"
class="outcome-carousel"
:continuous="true"
:show-arrows="false"
hide-delimiters
height="35"
>
<v-carousel-item
v-for="(outcome, idx) in props.outcomes"
:key="idx"
class="outcome-slide"
>
<div class="outcome-slide-inner">
<div class="outcome-row">
<div class="outcome-item">
<span class="outcome-title">{{ outcome.title }}</span>
<span class="outcome-chance-value">{{ outcome.chanceValue }}%</span>
</div>
<div class="outcome-buttons">
<v-btn
class="option-yes option-yes-no-compact"
:color="'#b8e0b8'"
:rounded="'sm'"
:text="true"
elevation="0"
@click.stop="openTradeMulti('yes', outcome)"
>
<span class="option-text-yes">{{ outcome.yesLabel ?? 'Yes' }}</span>
</v-btn>
<v-btn
class="option-no option-yes-no-compact"
:color="'#f0b8b8'"
:rounded="'sm'"
:text="true"
elevation="0"
@click.stop="openTradeMulti('no', outcome)"
>
<span class="option-text-no">{{ outcome.noLabel ?? 'No' }}</span>
</v-btn>
</div>
</div>
</div>
</v-carousel-item>
</v-carousel>
<div class="carousel-controls" @mousedown.stop @touchstart.stop>
<button
type="button"
class="carousel-arrow carousel-arrow--left"
:disabled="outcomeCount <= 1"
aria-label="上一项"
@click.stop="prevSlide"
>
<v-icon size="18">mdi-chevron-left</v-icon>
</button>
<div class="carousel-dots">
<button
v-for="(_, idx) in (props.outcomes ?? [])"
:key="idx"
type="button"
:class="['carousel-dot', { active: currentSlide === idx }]"
:aria-label="`选项 ${idx + 1}`"
@click.stop="currentSlide = idx"
/>
</div>
<button
type="button"
class="carousel-arrow carousel-arrow--right"
:disabled="outcomeCount <= 1"
aria-label="下一项"
@click.stop="nextSlide"
>
<v-icon size="18">mdi-chevron-right</v-icon>
</button>
</div>
</div>
<!-- Bottom Section -->
<div class="bottom-section">
<span class="market-info">
<span v-if="props.isNew" class="new-badge">+ NEW</span>
{{ marketInfo }}
</span>
<div class="icons-container">
<v-icon class="gift-icon" size="16">mdi-gift</v-icon>
<v-icon class="bookmark-icon" size="16">mdi-bookmark</v-icon>
</div>
</div>
</div>
</v-card>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { EventCardOutcome } from '../api/event'
const router = useRouter()
const emit = defineEmits<{
openTrade: [
side: 'yes' | 'no',
market?: { id: string; title: string; marketId?: string; outcomeTitle?: string; clobTokenIds?: string[] }
]
}>()
const props = withDefaults(
defineProps<{
marketTitle: string
chanceValue: number
marketInfo: string
id: string
/** Event slug用于 findPmEvent 传参 */
slug?: string
imageUrl?: string
category?: string
expiresAt?: string
/** 展示类型single 单一 Yes/Nomulti 多选项左右滑动 */
displayType?: 'single' | 'multi'
/** 多选项时的选项列表(用于滑动) */
outcomes?: EventCardOutcome[]
/** 单一类型时左按钮文案,如 Up */
yesLabel?: string
/** 单一类型时右按钮文案,如 Down */
noLabel?: string
/** 是否显示 + NEW */
isNew?: boolean
/** 当前市场 ID单 market 时供交易/Split 使用) */
marketId?: string
/** 用于下单 tokenId单 market 时 */
clobTokenIds?: string[]
}>(),
{
marketTitle: 'Mamdan opens city-owned grocery store b...',
chanceValue: 17,
marketInfo: '$155k Vol.',
id: '1',
imageUrl: '',
category: '',
expiresAt: '',
displayType: 'single',
outcomes: () => [],
yesLabel: 'Yes',
noLabel: 'No',
isNew: false,
marketId: undefined,
}
)
const isMulti = computed(
() => props.displayType === 'multi' && (props.outcomes?.length ?? 0) > 1
)
const currentSlide = ref(0)
const outcomeCount = computed(() => (props.outcomes ?? []).length)
function prevSlide() {
if (outcomeCount.value <= 1) return
currentSlide.value = (currentSlide.value - 1 + outcomeCount.value) % outcomeCount.value
}
function nextSlide() {
if (outcomeCount.value <= 1) return
currentSlide.value = (currentSlide.value + 1) % outcomeCount.value
}
// 半圆进度条stroke-dashoffset半圆弧长 ≈ π*26左底 0° 顺时针至右底 180°
const SEMI_CIRCLE_LENGTH = Math.PI * 26
const semiProgressOffset = computed(() => {
const p = Math.min(100, Math.max(0, props.chanceValue)) / 100
return SEMI_CIRCLE_LENGTH * (1 - p)
})
// 进度条纯色0% 红 → 50% 图片橙黄 #FFBB5C → 100% 绿(两段 RGB 插值)
const COLOR_RED = { r: 239, g: 68, b: 68 }
const COLOR_ORANGE_YELLOW = { r: 255, g: 187, b: 92 }
const COLOR_GREEN = { r: 22, g: 163, b: 74 }
function rgbToHex(r: number, g: number, b: number): string {
const toByte = (v: number) => Math.min(255, Math.max(0, Math.round(v)))
return `#${toByte(r).toString(16).padStart(2, '0')}${toByte(g).toString(16).padStart(2, '0')}${toByte(b).toString(16).padStart(2, '0')}`
}
const semiProgressColor = computed(() => {
const t = Math.min(1, Math.max(0, props.chanceValue / 100))
if (t <= 0.5) {
const u = t * 2
return rgbToHex(
COLOR_RED.r + (COLOR_ORANGE_YELLOW.r - COLOR_RED.r) * u,
COLOR_RED.g + (COLOR_ORANGE_YELLOW.g - COLOR_RED.g) * u,
COLOR_RED.b + (COLOR_ORANGE_YELLOW.b - COLOR_RED.b) * u
)
}
const u = (t - 0.5) * 2
return rgbToHex(
COLOR_ORANGE_YELLOW.r + (COLOR_GREEN.r - COLOR_ORANGE_YELLOW.r) * u,
COLOR_ORANGE_YELLOW.g + (COLOR_GREEN.g - COLOR_ORANGE_YELLOW.g) * u,
COLOR_ORANGE_YELLOW.b + (COLOR_GREEN.b - COLOR_ORANGE_YELLOW.b) * u
)
})
const navigateToDetail = () => {
if (props.displayType === 'multi' && (props.outcomes?.length ?? 0) > 1) {
router.push({
path: `/event/${props.id}/markets`,
query: { ...(props.slug && { slug: props.slug }) },
})
return
}
router.push({
path: `/trade-detail/${props.id}`,
query: {
title: props.marketTitle,
imageUrl: props.imageUrl || undefined,
category: props.category || undefined,
marketInfo: props.marketInfo || undefined,
expiresAt: props.expiresAt || undefined,
chance: String(props.chanceValue),
...(props.marketId && { marketId: props.marketId }),
...(props.slug && { slug: props.slug }),
},
})
}
function openTradeSingle(side: 'yes' | 'no') {
emit('openTrade', side, {
id: props.id,
title: props.marketTitle,
marketId: props.marketId,
clobTokenIds: props.clobTokenIds,
})
}
function openTradeMulti(side: 'yes' | 'no', outcome: EventCardOutcome) {
emit('openTrade', side, {
id: props.id,
title: outcome.title,
marketId: outcome.marketId,
outcomeTitle: outcome.title,
clobTokenIds: outcome.clobTokenIds,
})
}
</script>
<style scoped>
/* 单 market 与多 market 统一高度 */
.market-card {
position: relative;
width: 310px;
height: 176px;
background-color: #ffffff;
border-radius: 8px;
border: 1px solid #e7e7e7;
padding: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.market-card:hover {
border-color: #d0d0d0;
}
.market-card-content {
display: flex;
flex-direction: column;
height: 100%;
gap: 10px;
}
/* 扁平化:卡片内所有按钮无阴影 */
.market-card :deep(.v-btn),
.market-card .carousel-arrow {
box-shadow: none !important;
}
.market-card :deep(.v-btn::before) {
box-shadow: none;
}
/* Top Section */
.top-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.image-title-container {
display: flex;
align-items: flex-start;
gap: 12px;
flex: 1;
min-width: 0;
}
.market-image {
flex-shrink: 0;
}
/* 覆盖 Vuetify v-card-title 的 padding、white-space保证两行完整显示且不露第三行 */
.market-title {
flex: 1;
min-width: 0;
padding: 0;
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
color: #000000;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
/* 仅文字区域两行高,无 padding 占用 */
max-height: 2.8em;
}
.chance-container {
flex-shrink: 0;
width: 60px;
}
.semi-progress-wrap {
position: relative;
width: 60px;
height: 44px;
}
.semi-progress-svg {
display: block;
width: 60px;
height: 36px;
overflow: visible;
}
.semi-progress-track,
.semi-progress-fill {
transition: stroke-dashoffset 0.25s ease;
}
/* 半圆弧 viewBox 0 0 60 36弧的视觉中心约 y=17百分比文字的 top 对齐该中心 */
.semi-progress-inner {
position: absolute;
left: 50%;
top: 17px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
pointer-events: none;
}
.chance-value {
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 700;
color: #000000;
line-height: 1.2;
}
.chance-label {
font-family: 'Inter', sans-serif;
font-size: 10px;
font-weight: normal;
color: #9ca3af;
margin-top: 2px;
}
/* Options Section单 market 时靠底对齐 */
.options-section {
display: flex;
gap: 12px;
margin-top: auto;
}
.option-yes {
flex: 1;
background-color: #b8e0b8;
border-radius: 6px;
height: 40px;
}
.option-text-yes {
font-family: 'Inter', sans-serif;
font-size: 16px;
font-weight: 500;
color: #006600;
}
.option-no {
flex: 1;
background-color: #f0b8b8;
border-radius: 6px;
height: 40px;
}
.option-text-no {
font-family: 'Inter', sans-serif;
font-size: 16px;
font-weight: 500;
color: #cc0000;
}
/* Bottom Section禁止被挤压避免与 multi-section 重叠 */
.bottom-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
flex-shrink: 0;
}
.market-info {
font-family: 'Inter', sans-serif;
font-size: 12px;
font-weight: normal;
color: #808080;
}
.icons-container {
display: flex;
gap: 16px;
}
.gift-icon {
color: #808080;
}
.bookmark-icon {
color: #808080;
}
/* 多选项:左右滑动区域,整体靠底对齐;预留底部 padding 避免与 bottom-section 重叠 */
.multi-section {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1 1 auto;
min-height: 0;
margin-top: auto;
padding-bottom: 14px;
box-sizing: border-box;
overflow: hidden;
}
.outcome-carousel {
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.outcome-carousel :deep(.v-carousel__container) {
touch-action: pan-y pinch-zoom;
}
.outcome-slide {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
box-sizing: border-box;
}
.outcome-slide-inner {
width: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
padding: 6px 12px 6px 10px;
}
.outcome-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.outcome-item {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.outcome-title {
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 500;
color: #000000;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outcome-chance-value {
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 700;
color: #000000;
flex-shrink: 0;
}
.outcome-buttons {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.outcome-buttons .option-yes-no-compact {
min-width: 44px;
height: 28px;
padding: 0 10px;
}
.outcome-buttons .option-text-yes,
.outcome-buttons .option-text-no {
font-size: 13px;
}
.carousel-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0;
margin-bottom: 10px;
flex-shrink: 0;
}
.carousel-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background-color: #f0f0f0;
color: #333;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
box-shadow: none;
}
.carousel-arrow:hover:not(:disabled) {
background-color: #e0e0e0;
color: #000;
}
.carousel-arrow:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.carousel-dots {
display: flex;
justify-content: center;
gap: 6px;
padding: 0;
flex-shrink: 0;
}
.carousel-dot {
width: 6px;
height: 6px;
border-radius: 50%;
border: none;
background-color: #e0e0e0;
cursor: pointer;
transition: background-color 0.2s ease;
padding: 0;
}
.carousel-dot:hover {
background-color: #bdbdbd;
}
.carousel-dot.active {
background-color: rgb(var(--v-theme-primary));
width: 8px;
height: 6px;
border-radius: 3px;
}
.new-badge {
display: inline-block;
margin-right: 6px;
padding: 1px 6px;
font-size: 11px;
font-weight: 600;
color: #000;
background-color: #fef08a;
border-radius: 4px;
}
</style>