669 lines
16 KiB
Vue
669 lines
16 KiB
Vue
<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/No,multi 多选项左右滑动 */
|
||
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>
|