新增:交易详情

This commit is contained in:
ivan 2026-02-08 11:41:39 +08:00
parent e882c74449
commit 6f633178de
8 changed files with 702 additions and 39 deletions

32
package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"echarts": "^6.0.0",
"ethers": "^6.16.0", "ethers": "^6.16.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"siwe": "^3.0.0", "siwe": "^3.0.0",
@ -4196,6 +4197,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/editorconfig": { "node_modules/editorconfig": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmmirror.com/editorconfig/-/editorconfig-1.0.4.tgz", "resolved": "https://registry.npmmirror.com/editorconfig/-/editorconfig-1.0.4.tgz",
@ -9163,6 +9180,21 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
} }
} }
} }

View File

@ -19,6 +19,7 @@
"dependencies": { "dependencies": {
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"echarts": "^6.0.0",
"ethers": "^6.16.0", "ethers": "^6.16.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"siwe": "^3.0.0", "siwe": "^3.0.0",

View File

@ -48,7 +48,7 @@ const progressPercentage = computed(() => {
width: 100%; width: 100%;
height: 8px; height: 8px;
overflow: hidden; overflow: hidden;
border-radius: 4px; border-radius: 0;
} }
.progress-track { .progress-track {
@ -67,6 +67,6 @@ const progressPercentage = computed(() => {
height: 100%; height: 100%;
background-color: #0066cc; background-color: #0066cc;
transition: width 0.3s ease; transition: width 0.3s ease;
border-radius: 4px; border-radius: 0;
} }
</style> </style>

View File

@ -1,12 +1,12 @@
<template> <template>
<v-card class="market-card" elevation="0" :rounded="'lg'"> <v-card class="market-card" elevation="0" :rounded="'lg'" @click="navigateToDetail">
<div class="market-card-content"> <div class="market-card-content">
<!-- Top Section --> <!-- Top Section -->
<div class="top-section"> <div class="top-section">
<!-- Market Image and Title --> <!-- Market Image and Title -->
<div class="image-title-container"> <div class="image-title-container">
<v-avatar class="market-image" :size="40" :color="'#f0f0f0'" :rounded="'sm'"> <v-avatar class="market-image" :size="40" color="#f0f0f0" rounded="sm">
<!-- Placeholder for market image --> <v-img v-if="props.imageUrl" :src="props.imageUrl" cover />
</v-avatar> </v-avatar>
<v-card-title class="market-title" :text="true" :shrink="true"> <v-card-title class="market-title" :text="true" :shrink="true">
{{ marketTitle }} {{ marketTitle }}
@ -55,6 +55,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const props = defineProps({ const props = defineProps({
marketTitle: { marketTitle: {
@ -69,6 +72,25 @@ const props = defineProps({
type: String, type: String,
default: '$155k Vol.', default: '$155k Vol.',
}, },
id: {
type: String,
default: '1',
},
/** 市场图片 URL由卡片传入供详情页展示 */
imageUrl: {
type: String,
default: '',
},
/** 分类标签,如 "Economy · World" */
category: {
type: String,
default: '',
},
/** 结算/到期日期,如 "Mar 31, 2026" */
expiresAt: {
type: String,
default: '',
},
}) })
// (0%)绿(100%) // (0%)绿(100%)
@ -77,6 +99,21 @@ const progressColor = computed(() => {
const hue = (props.chanceValue / 100) * 120 const hue = (props.chanceValue / 100) * 120
return `hsl(${hue}, 100%, 50%)` return `hsl(${hue}, 100%, 50%)`
}) })
// query
const navigateToDetail = () => {
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),
},
})
}
</script> </script>
<style scoped> <style scoped>
@ -88,6 +125,14 @@ const progressColor = computed(() => {
border-radius: 8px; border-radius: 8px;
border: 1px solid #e7e7e7; border: 1px solid #e7e7e7;
padding: 12px; padding: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.market-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
border-color: #d0d0d0;
} }
.market-card-content { .market-card-content {

View File

@ -395,8 +395,11 @@ const maxBidsTotal = computed(() => {
gap: 12px; gap: 12px;
} }
/* 深度图:固定宽度,进度条长度表示深度比例,不占满整行 */
.order-progress { .order-progress {
flex: 1; flex: 0 0 auto;
width: 120px;
min-width: 120px;
} }
.asks-price { .asks-price {
@ -454,11 +457,8 @@ const maxBidsTotal = computed(() => {
} }
.order-progress { .order-progress {
flex: 2;
}
.depth-chart {
width: 80px; width: 80px;
min-width: 80px;
} }
.order-list-header-price { .order-list-header-price {

View File

@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue' import Home from '../views/Home.vue'
import Trade from '../views/Trade.vue' import Trade from '../views/Trade.vue'
import Login from '../views/Login.vue' import Login from '../views/Login.vue'
import TradeDetail from '../views/TradeDetail.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -20,6 +21,11 @@ const router = createRouter({
path: '/login', path: '/login',
name: 'login', name: 'login',
component: Login component: Login
},
{
path: '/trade-detail/:id',
name: 'trade-detail',
component: TradeDetail
} }
], ],
}) })

View File

@ -126,6 +126,8 @@ const connectWithWallet = async () => {
const issuedAt = new Date().toISOString() const issuedAt = new Date().toISOString()
const provider = new BrowserProvider(window.ethereum) const provider = new BrowserProvider(window.ethereum)
const signer = await provider.getSigner() const signer = await provider.getSigner()
const nonce = new Date().getTime().toString()
const siwe = new SiweMessage({ const siwe = new SiweMessage({
scheme, scheme,
domain, domain,
@ -134,6 +136,7 @@ const connectWithWallet = async () => {
uri: origin, uri: origin,
version: '1', version: '1',
chainId: chainId, chainId: chainId,
nonce,
}) })
const message = siwe.prepareMessage() const message = siwe.prepareMessage()
// message http:// // message http://
@ -145,7 +148,7 @@ const connectWithWallet = async () => {
// console.log('Cleaned Siwe message:', cleanedMessage) // console.log('Cleaned Siwe message:', cleanedMessage)
// Generate nonce for security // Generate nonce for security
const nonce = siwe.nonce //Math.floor(Math.random() * 1000000).toString() // const nonce = siwe.nonce //Math.floor(Math.random() * 1000000).toString()
// Construct Siwe message according to EIP-4361 standard // Construct Siwe message according to EIP-4361 standard
// const message = `${domain} wants you to sign in with your Ethereum account:\n${walletAddress}\n\n${statement}\n\nURI: ${origin}\nVersion: ${version}\nChain ID: ${parseInt(chainId, 16)}\nNonce: ${nonce}\nIssued At: ${issuedAt}` // const message = `${domain} wants you to sign in with your Ethereum account:\n${walletAddress}\n\n${statement}\n\nURI: ${origin}\nVersion: ${version}\nChain ID: ${parseInt(chainId, 16)}\nNonce: ${nonce}\nIssued At: ${issuedAt}`
@ -205,34 +208,10 @@ const connectWithWallet = async () => {
min-height: calc(100vh - 80px); min-height: calc(100vh - 80px);
} }
/* PC端样式 - 固定大小 */ .login-card {
@media (min-width: 600px) { border-radius: 12px;
.login-card { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-radius: 12px; padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 24px;
max-width: 480px;
margin: 0 auto;
}
}
/* 手机端样式 - 铺满页面 */
@media (max-width: 599px) {
.login-container {
padding: 0;
}
.login-row {
min-height: 100vh;
}
.login-card {
border-radius: 0;
box-shadow: none;
padding: 32px 24px;
min-height: 100vh;
margin: 0;
}
} }
.login-title { .login-title {

600
src/views/TradeDetail.vue Normal file
View File

@ -0,0 +1,600 @@
<template>
<v-container class="trade-detail-container">
<v-row>
<v-col cols="12">
<!-- 分时图卡片Polymarket 样式 -->
<v-card class="chart-card polymarket-chart" elevation="0" rounded="lg">
<!-- 顶部标题当前概率Past / 日期 -->
<div class="chart-header">
<h1 class="chart-title">{{ marketTitle }}</h1>
<div class="chart-controls-row">
<v-btn variant="text" size="small" class="past-btn">Past </v-btn>
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
</div>
<div class="chart-chance">{{ currentChance }}% chance</div>
</div>
<!-- 图表区域 -->
<div class="chart-wrapper">
<div ref="chartContainerRef" class="chart-container"></div>
</div>
<!-- 底部成交量/到期日 | 时间粒度 -->
<div class="chart-footer">
<div class="chart-footer-left">
<span class="chart-volume">{{ marketVolume }} Vol.</span>
<span v-if="marketExpiresAt" class="chart-expires">| {{ marketExpiresAt }}</span>
</div>
<div class="chart-time-ranges">
<v-btn
v-for="r in timeRanges"
:key="r.value"
:class="['time-range-btn', { active: selectedTimeRange === r.value }]"
variant="text"
size="small"
@click="selectTimeRange(r.value)"
>
{{ r.label }}
</v-btn>
</div>
</div>
</v-card>
<!-- Order Book Section -->
<v-card class="order-book-card" elevation="0" rounded="lg" style="margin-top: 32px">
<OrderBook />
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import OrderBook from '../components/OrderBook.vue'
/**
* 分时图服务端推送数据格式约定
*
* 图表所需单点格式 ECharts 时间轴一致
* [timestamp, value]
* - timestamp: numberUnix 毫秒时间戳
* - value: number当前概率/价格 0100 的百分比
*
* 服务端可选用两种推送方式
*
* 1) 全量快照切换时间粒度或首次加载时
* { range?: string, data: [number, number][] }
* - range: 可选'1H' | '6H' | '1D' | '1W' | '1M' | 'ALL'
* - data: 按时间升序的 [timestamp, value] 数组
*
* 2) 增量点实时推送最新点
* { point: [number, number] }
* - point: 单点 [timestamp, value]前端会追加并按当前 range 保留最近 N 个点
*
* 示例WebSocket 消息体
* 全量: { "data": [[1707206400000, 25.5], [1707210000000, 26.1], ...] }
* 增量: { "point": [1707213600000, 27.2] }
*/
export type ChartPoint = [number, number]
export type ChartSnapshot = { range?: string; data: ChartPoint[] }
export type ChartIncrement = { point: ChartPoint }
const route = useRoute()
//
const marketTitle = computed(
() => (route.query.title as string) || 'U.S. anti-cartel ground operation in Mexico by March 31?'
)
const marketVolume = computed(() => (route.query.marketInfo as string) || '$398,719')
const marketExpiresAt = computed(() => (route.query.expiresAt as string) || 'Mar 31, 2026')
const resolutionDate = computed(() => {
const s = marketExpiresAt.value
return s ? s.replace(/,?\s*\d{4}$/, '').trim() || 'Mar 31' : 'Mar 31'
})
//
const selectedTimeRange = ref('ALL')
const timeRanges = [
{ label: '1H', value: '1H' },
{ label: '6H', value: '6H' },
{ label: '1D', value: '1D' },
{ label: '1W', value: '1W' },
{ label: '1M', value: '1M' },
{ label: 'ALL', value: 'ALL' },
]
// [timestamp, value][]
function generateData(range: string): [number, number][] {
const now = Date.now()
const data: [number, number][] = []
let stepMs: number
let count: number
switch (range) {
case '1H':
stepMs = 60 * 1000
count = 60
break
case '6H':
stepMs = 10 * 60 * 1000
count = 36
break
case '1D':
stepMs = 60 * 60 * 1000
count = 24
break
case '1W':
stepMs = 24 * 60 * 60 * 1000
count = 7
break
case '1M':
case 'ALL':
stepMs = 24 * 60 * 60 * 1000
count = 30
break
default:
stepMs = 60 * 60 * 1000
count = 24
}
let value = 15 + Math.random() * 25
for (let i = count; i >= 0; i--) {
const t = now - i * stepMs
value = Math.max(10, Math.min(90, value + (Math.random() - 0.5) * 6))
data.push([t, Math.round(value * 10) / 10])
}
return data
}
const chartContainerRef = ref<HTMLElement | null>(null)
const data = ref<[number, number][]>([])
let chartInstance: ECharts | null = null
let dynamicInterval: number | undefined
const currentChance = computed(() => {
const d = data.value
const last = d.length > 0 ? d[d.length - 1] : undefined
return last != null ? last[1] : 20
})
const lineColor = '#2563eb'
function buildOption(chartData: [number, number][]) {
const lastIndex = chartData.length - 1
return {
animation: false,
tooltip: {
trigger: 'axis',
formatter: function (params: unknown) {
const p = (Array.isArray(params) ? params[0] : params) as { name: string | number; value: unknown }
const date = new Date(p.name as number)
const val = Array.isArray(p.value) ? p.value[1] : p.value
return (
date.getDate() +
'/' +
(date.getMonth() + 1) +
'/' +
date.getFullYear() +
' : ' +
val +
'%'
)
},
axisPointer: { animation: false },
},
grid: { left: 16, right: 48, top: 16, bottom: 28, containLabel: false },
xAxis: {
type: 'time',
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#6b7280', fontSize: 11 },
axisTick: { show: false },
splitLine: { show: false },
},
yAxis: {
type: 'value',
position: 'right',
boundaryGap: [0, '100%'],
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#6b7280', fontSize: 11, formatter: '{value}%' },
splitLine: {
show: true,
lineStyle: { type: 'dashed', color: '#e5e7eb' },
},
},
series: [
{
name: 'probability',
type: 'line',
showSymbol: true,
symbol: 'circle',
symbolSize: (_: unknown, params: { dataIndex?: number }) =>
params?.dataIndex === lastIndex ? 8 : 0,
data: chartData,
smooth: true,
lineStyle: { width: 2, color: lineColor },
itemStyle: { color: lineColor, borderColor: '#fff', borderWidth: 2 },
},
],
}
}
function initChart() {
if (!chartContainerRef.value) return
data.value = generateData(selectedTimeRange.value)
chartInstance = echarts.init(chartContainerRef.value)
chartInstance.setOption(buildOption(data.value))
}
function updateChartData() {
data.value = generateData(selectedTimeRange.value)
if (chartInstance) chartInstance.setOption(buildOption(data.value), { replaceMerge: ['series'] })
}
function selectTimeRange(range: string) {
selectedTimeRange.value = range
updateChartData()
}
function getMaxPoints(range: string): number {
switch (range) {
case '1H': return 60
case '6H': return 36
case '1D': return 24
case '1W': return 7
case '1M':
case 'ALL': return 30
default: return 24
}
}
function startDynamicUpdate() {
dynamicInterval = window.setInterval(() => {
const list = [...data.value]
const last = list[list.length - 1]
if (!last) return
const nextVal = Math.max(10, Math.min(90, last[1] + (Math.random() - 0.5) * 4))
const nextT = Date.now()
list.push([nextT, Math.round(nextVal * 10) / 10])
const max = getMaxPoints(selectedTimeRange.value)
data.value = list.slice(-max)
if (chartInstance) chartInstance.setOption(buildOption(data.value), { replaceMerge: ['series'] })
}, 3000)
}
function stopDynamicUpdate() {
if (dynamicInterval) {
clearInterval(dynamicInterval)
dynamicInterval = undefined
}
}
watch(selectedTimeRange, () => updateChartData())
const handleResize = () => chartInstance?.resize()
onMounted(() => {
initChart()
startDynamicUpdate()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
stopDynamicUpdate()
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
chartInstance = null
})
</script>
<style scoped>
.trade-detail-container {
padding: 24px;
min-height: 100vh;
}
.back-btn {
margin-bottom: 16px;
}
.trade-detail-card {
padding: 32px;
background-color: #ffffff;
border-radius: 12px;
border: 1px solid #e7e7e7;
}
.trade-title {
font-size: 1.5rem;
font-weight: 600;
color: #000000;
margin-bottom: 24px;
text-align: center;
}
.market-image-container {
display: flex;
justify-content: center;
margin-bottom: 32px;
}
.market-image {
border: 2px solid #e7e7e7;
}
.chance-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
}
.progress-bar {
margin-bottom: 8px;
}
.chance-value {
font-size: 24px;
font-weight: 700;
color: #000000;
}
.chance-label {
font-size: 16px;
font-weight: normal;
color: #808080;
margin-top: 8px;
}
.options-section {
display: flex;
gap: 16px;
margin-bottom: 32px;
}
.option-yes {
flex: 1;
background-color: #e6f9e6;
height: 56px;
font-size: 18px;
}
.option-text-yes {
font-size: 18px;
font-weight: 600;
color: #008000;
}
.option-no {
flex: 1;
background-color: #ffe6e6;
height: 56px;
font-size: 18px;
}
.option-text-no {
font-size: 18px;
font-weight: 600;
color: #ff0000;
}
.market-info-section {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 32px;
padding: 24px;
background-color: #f9f9f9;
border-radius: 8px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 14px;
font-weight: normal;
color: #808080;
}
.info-value {
font-size: 14px;
font-weight: 500;
color: #000000;
}
.description-section {
margin-bottom: 32px;
}
.description-title {
font-size: 16px;
font-weight: 600;
color: #000000;
margin-bottom: 12px;
}
.description-text {
font-size: 14px;
font-weight: normal;
color: #333333;
line-height: 1.6;
}
.action-buttons {
margin-top: 16px;
}
.action-btn {
height: 56px;
font-size: 16px;
font-weight: 600;
}
/* Polymarket 样式分时图卡片 */
.chart-card.polymarket-chart {
margin-top: 32px;
padding: 20px 24px 16px;
background-color: #ffffff;
border: 1px solid #e7e7e7;
border-radius: 12px;
}
.chart-header {
margin-bottom: 16px;
}
.chart-title {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0 0 12px 0;
line-height: 1.3;
}
.chart-controls-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.past-btn {
font-size: 13px;
color: #6b7280;
text-transform: none;
}
.date-pill {
background-color: #111827 !important;
color: #fff !important;
font-size: 12px;
font-weight: 500;
text-transform: none;
}
.chart-chance {
font-size: 1.5rem;
font-weight: 700;
color: #2563eb;
}
.chart-wrapper {
width: 100%;
margin-bottom: 12px;
}
.chart-container {
width: 100%;
height: 320px;
min-height: 260px;
}
.chart-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.chart-footer-left {
font-size: 12px;
color: #6b7280;
}
.chart-expires {
margin-left: 4px;
}
.chart-time-ranges {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.time-range-btn {
font-size: 12px;
text-transform: none;
min-width: 36px;
color: #6b7280;
}
.time-range-btn.active {
color: #111827;
font-weight: 600;
}
.time-range-btn:hover {
color: #111827;
}
/* Order Book Card Styles */
.order-book-card {
padding: 0;
background-color: #ffffff;
border: 1px solid #e7e7e7;
}
/* Responsive adjustments */
@media (max-width: 599px) {
.trade-detail-container {
padding: 16px;
}
.trade-detail-card {
padding: 24px;
}
.trade-title {
font-size: 1.25rem;
}
.market-image-container {
margin-bottom: 24px;
}
.chance-section {
margin-bottom: 24px;
}
.options-section {
margin-bottom: 24px;
}
.market-info-section {
margin-bottom: 24px;
padding: 16px;
}
.description-section {
margin-bottom: 24px;
}
.chart-container {
height: 260px;
}
.chart-card.polymarket-chart {
padding: 16px;
}
.chart-title {
font-size: 1.125rem;
}
}
/* Responsive order book adjustments */
@media (max-width: 600px) {
.order-book-card {
margin-top: 24px;
}
}
</style>