新增:Api Key,个人中心,搜索页面

This commit is contained in:
ivan 2026-03-20 17:02:36 +08:00
parent 88b7a97e3f
commit effa221907
3 changed files with 1264 additions and 2 deletions

150
src/views/ApiKey.vue Normal file
View File

@ -0,0 +1,150 @@
<template>
<div class="api-key-page">
<div class="api-key-screen">
<header class="api-key-header">
<h1 class="api-key-title">API Key 管理</h1>
<button class="create-btn" type="button">创建 Key</button>
</header>
<section class="api-key-list">
<article v-for="item in apiKeys" :key="item.id" class="api-key-item">
<p class="item-name">{{ item.name }}</p>
<p class="item-value">{{ item.key }}</p>
<div class="item-actions">
<button class="action-btn action-copy" type="button">复制</button>
<button class="action-btn action-delete" type="button">删除</button>
</div>
</article>
</section>
</div>
</div>
</template>
<script setup lang="ts">
interface ApiKeyItem {
id: string
name: string
key: string
}
const apiKeys: ApiKeyItem[] = [
{ id: '1', name: 'Key #1', key: 'pk_live_8f2a9d1c5e7b44a8b1c2d3e4f5a6b7c8' },
{ id: '2', name: 'Key #2', key: 'pk_test_4d8f8a0c2e49f6h0j2k4m8n6p0r2i4v6' },
{ id: '3', name: 'Key #3', key: 'pk_live_1a3c5e7g8i1k3m5o7q9s1u8w5y7z9b1d' },
]
</script>
<style scoped>
.api-key-page {
min-height: 100vh;
background: #fcfcfc;
display: flex;
justify-content: center;
padding: 0;
}
.api-key-screen {
width: 100%;
max-width: 100%;
min-height: 980px;
padding: 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 16px;
font-family: Inter, sans-serif;
}
.api-key-header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.api-key-title {
margin: 0;
color: #111827;
font-size: 24px;
font-weight: 700;
line-height: 1.2;
}
.create-btn {
height: 34px;
padding: 0 12px;
border: 0;
border-radius: 12px;
background: #5b5bd6;
color: #ffffff;
font-size: 12px;
font-weight: 600;
line-height: 1;
cursor: pointer;
}
.api-key-list {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.api-key-item {
width: 100%;
border-radius: 16px;
border: 1px solid #e5e7eb;
background: #ffffff;
padding: 14px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 10px;
}
.item-name {
margin: 0;
color: #6b7280;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.item-value {
margin: 0;
color: #111827;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
overflow-wrap: anywhere;
}
.item-actions {
width: 100%;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.action-btn {
height: 30px;
border-radius: 8px;
padding: 0 10px;
font-size: 11px;
font-weight: 600;
line-height: 1;
cursor: pointer;
}
.action-copy {
border: 1px solid #e5e7eb;
background: #fcfcfc;
color: #111827;
}
.action-delete {
border: 0;
background: #fee2e2;
color: #dc2626;
}
</style>

699
src/views/Profile.vue Normal file
View File

@ -0,0 +1,699 @@
<template>
<div class="profile-page">
<div class="profile-screen">
<section class="card profile-card">
<div class="top-row">
<div class="avatar">
<img v-if="avatarImage" :src="avatarImage" :alt="displayName" class="avatar-img" />
<span v-else>{{ avatarText }}</span>
</div>
<div class="info-col">
<div class="name-text">{{ displayName }}</div>
<div class="acc-text">{{ t('profile.uidLabel', { uid: userIdText }) }}</div>
<div class="tag-text">{{ userTag }}</div>
</div>
<button class="edit-btn" type="button" @click="onEditProfile">{{ t('profile.edit') }}</button>
</div>
</section>
<section class="card wallet-card">
<div class="wallet-head">
<span class="wallet-title">{{ t('profile.walletOverview') }}</span>
<button class="wallet-link" type="button" @click="goWallet">{{ t('profile.walletDetail') }} &gt;</button>
</div>
<div class="wallet-balance">${{ totalBalance }}</div>
<div class="wallet-sub">
{{ t('profile.walletSub', { available: availableBalance, frozen: frozenBalance }) }}
</div>
</section>
<section class="card menu-card">
<div class="menu-title">{{ t('profile.accountSettings') }}</div>
<button v-for="item in settingItems" :key="item.label" class="menu-item" type="button" @click="goSetting(item)">
<span>{{ item.label }}</span>
<span v-if="item.action === 'locale'" class="menu-locale">{{ currentLocaleLabel }}</span>
<span v-else-if="item.action === 'wallet'" class="menu-locale">{{ walletAddressShort }}</span>
<span v-else class="menu-arrow">&gt;</span>
</button>
</section>
<button class="logout-btn" type="button" :disabled="logoutLoading" @click="logout">
{{ t('common.logout') }}
</button>
</div>
<v-dialog v-model="localeDialogOpen" max-width="360">
<v-card class="locale-dialog-card" rounded="xl" elevation="0">
<div class="locale-dialog-title">{{ t('profile.selectLanguage') }}</div>
<button
v-for="opt in localeStore.localeOptions"
:key="opt.value"
class="locale-option"
type="button"
@click="chooseLocale(opt.value)"
>
<span>{{ opt.label }}</span>
<span v-if="opt.value === localeStore.currentLocale" class="locale-selected"></span>
</button>
</v-card>
</v-dialog>
<v-dialog v-model="walletDialogOpen" max-width="420">
<v-card class="wallet-dialog-card" rounded="xl" elevation="0">
<div class="wallet-dialog-title">{{ t('profile.currentWalletAddress') }}</div>
<div class="wallet-dialog-address">{{ walletAddressText }}</div>
<div class="wallet-dialog-actions">
<button class="wallet-copy-btn" type="button" :disabled="!walletAddress" @click="copyWalletAddress">
{{ t('profile.copyAddress') }}
</button>
</div>
</v-card>
</v-dialog>
<v-dialog v-model="editNameDialogOpen" max-width="420">
<v-card class="name-dialog-card" rounded="xl" elevation="0">
<div class="name-dialog-title">{{ t('profile.editNameTitle') }}</div>
<v-text-field
v-model="editingName"
:label="t('profile.newUserName')"
variant="outlined"
density="comfortable"
hide-details="auto"
:error-messages="nameError ? [nameError] : []"
:hint="t('profile.nameFormatHint')"
persistent-hint
/>
<div class="name-dialog-actions">
<button class="name-dialog-cancel-btn" type="button" :disabled="isSaving" @click="closeEditNameDialog">
{{ t('profile.cancel') }}
</button>
<button class="name-dialog-save-btn" type="button" :disabled="isSaving" @click="saveName">
{{ t('profile.save') }}
</button>
</div>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useLocaleStore } from '../stores/locale'
import { useToastStore } from '../stores/toast'
import { useUserStore, type UserInfo } from '../stores/user'
import { setSelfUsername } from '@/api/user'
import type { LocaleCode } from '@/plugins/i18n'
interface SettingItem {
label: string
route?: string
action?: 'locale' | 'wallet'
}
const router = useRouter()
const { t } = useI18n()
const localeStore = useLocaleStore()
const userStore = useUserStore()
const toastStore = useToastStore()
const localeDialogOpen = ref(false)
const walletDialogOpen = ref(false)
const logoutLoading = ref(false)
const editNameDialogOpen = ref(false)
const editingName = ref('')
const nameError = ref<string | null>(null)
const isSaving = ref(false)
const currentLocaleLabel = computed(() => {
return (
localeStore.localeOptions.find((opt) => opt.value === localeStore.currentLocale)?.label ??
String(localeStore.currentLocale)
)
})
function readStringFromUser(keys: string[]): string {
const user = userStore.user as Record<string, unknown> | null
if (!user) return ''
for (const key of keys) {
const value = user[key]
if (typeof value === 'string' && value.trim()) return value.trim()
}
return ''
}
const walletAddress = computed(() => {
const user = userStore.user as Record<string, unknown> | null
if (!user) return ''
const candidateKeys = ['walletAddress', 'address', 'wallet', 'walletAddr', 'ethAddress']
for (const key of candidateKeys) {
const value = user[key]
if (typeof value === 'string' && value.trim()) return value.trim()
}
return ''
})
const walletAddressText = computed(() => walletAddress.value || t('profile.walletAddressUnavailable'))
const walletAddressShort = computed(() => {
const value = walletAddress.value
if (!value) return t('profile.unbound')
if (value.length <= 14) return value
return `${value.slice(0, 6)}...${value.slice(-4)}`
})
const rawUser = computed(() => (userStore.user ?? {}) as Record<string, unknown>)
const userNameRaw = computed(() => {
const v = rawUser.value.userName
return typeof v === 'string' && v.trim() ? v.trim() : ''
})
const displayName = computed(() => userNameRaw.value || t('profile.defaultName'))
const userIdText = computed(() => {
const uid = rawUser.value.id ?? rawUser.value.ID
if (uid == null || uid === '') return '--'
return String(uid)
})
const avatarImage = computed(() => userStore.avatarUrl || '')
const avatarText = computed(() => {
const first = displayName.value.trim().charAt(0)
return first ? first.toUpperCase() : 'U'
})
const hasVip = computed(() => {
const user = rawUser.value
const candidates = [user.isVip, user.vip, user.vipLevel, user.memberLevel]
return candidates.some((v) => {
if (typeof v === 'boolean') return v
if (typeof v === 'number') return v > 0
if (typeof v === 'string') return v.trim() === '1' || v.trim().toLowerCase() === 'vip'
return false
})
})
const userTag = computed(() => (hasVip.value ? t('profile.vipTrader') : t('profile.trader')))
const totalBalance = computed(() => userStore.balance || '0.00')
const availableBalance = computed(() => {
const val = readStringFromUser(['availableBalance', 'available', 'walletAvailable'])
return val || totalBalance.value
})
const frozenBalance = computed(() => {
return readStringFromUser(['frozenBalance', 'frozen', 'walletFrozen']) || '0.00'
})
const settingItems = computed<SettingItem[]>(() => [
{ label: t('profile.walletManage'), action: 'wallet' },
{ label: t('profile.apiKeyManage'), route: '/api-key' },
{ label: t('profile.language'), action: 'locale' },
])
function goSetting(item: SettingItem) {
if (item.action === 'wallet') {
walletDialogOpen.value = true
return
}
if (item.action === 'locale') {
localeDialogOpen.value = true
return
}
if (!item.route) return
router.push(item.route)
}
function chooseLocale(locale: LocaleCode) {
localeStore.setLocale(locale)
localeDialogOpen.value = false
}
async function copyWalletAddress() {
if (!walletAddress.value) return
try {
await navigator.clipboard.writeText(walletAddress.value)
walletDialogOpen.value = false
toastStore.show(t('profile.copySuccess'))
} catch {
toastStore.show(t('profile.copyFailed'), 'error')
}
}
function onEditProfile() {
editNameDialogOpen.value = true
editingName.value = userNameRaw.value
nameError.value = null
}
function goWallet() {
router.push('/wallet')
}
function validateUserName(name: string): string | null {
const v = name.trim()
if (!v) return t('profile.nameRequired')
if (v.length < MIN_USER_NAME_LEN) return t('profile.nameTooShort', { min: MIN_USER_NAME_LEN })
if (v.length > MAX_USER_NAME_LEN) return t('profile.nameTooLong', { max: MAX_USER_NAME_LEN })
const allowedRe = /^[a-z0-9_]+$/i
if (!allowedRe.test(v)) return t('profile.nameInvalidFormat')
return null
}
function closeEditNameDialog() {
if (isSaving.value) return
editNameDialogOpen.value = false
nameError.value = null
}
async function saveName() {
if (isSaving.value) return
const err = validateUserName(editingName.value)
if (err) {
nameError.value = err
return
}
const trimmed = editingName.value.trim()
const token = userStore.token
const u = userStore.user as UserInfo | null
if (!token || !u) {
toastStore.show(t('error.pleaseLogin'), 'error')
return
}
isSaving.value = true
try {
const authHeaders = userStore.getAuthHeaders()
if (!authHeaders) {
toastStore.show(t('error.pleaseLogin'), 'error')
return
}
const res = await setSelfUsername(authHeaders, { username: trimmed })
if (res.code !== 0 && res.code !== 200) {
nameError.value = res.msg || t('error.requestFailed')
return
}
// userName/headerImg
await userStore.fetchUserInfo()
toastStore.show(t('profile.nameSaved'), 'success')
editNameDialogOpen.value = false
} finally {
isSaving.value = false
nameError.value = null
}
}
const MIN_USER_NAME_LEN = 2
const MAX_USER_NAME_LEN = 20
async function logout() {
if (logoutLoading.value) return
logoutLoading.value = true
try {
await userStore.logout()
router.push('/login')
} catch {
toastStore.show(t('error.requestFailed'), 'error')
} finally {
logoutLoading.value = false
}
}
onMounted(() => {
if (!userStore.isLoggedIn) return
userStore.fetchUserInfo()
userStore.fetchUsdcBalance()
})
</script>
<style scoped>
.profile-page {
min-height: 100vh;
background: #ffffff;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 0;
}
.profile-screen {
width: 100%;
max-width: 100%;
min-height: 0;
background: #fcfcfc;
font-family: Inter, sans-serif;
padding: 16px 16px 16px;
display: flex;
flex-direction: column;
gap: 16px;
box-sizing: border-box;
}
.card {
width: 100%;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 16px;
box-sizing: border-box;
}
.profile-card {
padding: 20px;
}
.top-row {
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 56px;
height: 56px;
border-radius: 999px;
background: #5b5bd6;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
flex-shrink: 0;
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.info-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.name-text {
color: #111827;
font-size: 20px;
font-weight: 700;
line-height: 1.1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acc-text {
color: #6b7280;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
}
.tag-text {
color: #5b5bd6;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.edit-btn {
height: 34px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid #e5e7eb;
background: #fcfcfc;
color: #111827;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.wallet-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.wallet-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.wallet-title {
color: #111827;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.wallet-link {
border: 0;
background: transparent;
color: #5b5bd6;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.wallet-balance {
color: #111827;
font-size: 28px;
font-weight: 700;
line-height: 1.1;
}
.wallet-sub {
color: #6b7280;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
}
.wallet-actions {
display: flex;
gap: 10px;
}
.action-btn {
flex: 1;
height: 40px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.action-primary {
border: 0;
background: #5b5bd6;
color: #ffffff;
}
.action-secondary {
border: 1px solid #e5e7eb;
background: #fcfcfc;
color: #111827;
}
.menu-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.menu-title {
color: #111827;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.menu-item {
height: 44px;
padding: 0 4px;
border: 0;
background: transparent;
display: flex;
align-items: center;
justify-content: space-between;
color: #111827;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.menu-arrow {
color: #9ca3af;
font-size: 14px;
font-weight: 600;
}
.menu-locale {
color: #6b7280;
font-size: 13px;
font-weight: 600;
}
.logout-btn {
width: 100%;
height: 48px;
border: 0;
border-radius: 12px;
background: #fee2e2;
color: #dc2626;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.logout-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.locale-dialog-card {
border: 1px solid #e5e7eb;
background: #ffffff;
padding: 8px;
}
.locale-dialog-title {
padding: 10px 10px 6px;
color: #111827;
font-size: 14px;
font-weight: 700;
}
.locale-option {
width: 100%;
height: 42px;
border: 0;
border-radius: 10px;
background: transparent;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
color: #111827;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.locale-option:hover {
background: #f9fafb;
}
.locale-selected {
color: #5b5bd6;
font-weight: 700;
}
.wallet-dialog-card {
border: 1px solid #e5e7eb;
background: #ffffff;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.wallet-dialog-title {
color: #111827;
font-size: 15px;
font-weight: 700;
}
.wallet-dialog-address {
border-radius: 10px;
border: 1px solid #e5e7eb;
background: #fcfcfc;
padding: 12px;
color: #111827;
font-size: 13px;
font-weight: 500;
line-height: 1.35;
overflow-wrap: anywhere;
}
.wallet-dialog-actions {
display: flex;
justify-content: flex-end;
}
.wallet-copy-btn {
height: 34px;
border-radius: 10px;
border: 0;
padding: 0 14px;
background: #5b5bd6;
color: #ffffff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wallet-copy-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.name-dialog-card {
border: 1px solid #e5e7eb;
background: #ffffff;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.name-dialog-title {
color: #111827;
font-size: 15px;
font-weight: 700;
padding: 2px 2px 0;
}
.name-dialog-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
padding-top: 2px;
}
.name-dialog-cancel-btn {
height: 34px;
border-radius: 10px;
border: 1px solid #e5e7eb;
background: #fcfcfc;
color: #111827;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0 14px;
}
.name-dialog-cancel-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.name-dialog-save-btn {
height: 34px;
border-radius: 10px;
border: 0;
background: #5b5bd6;
color: #ffffff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0 14px;
}
.name-dialog-save-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@ -1,8 +1,421 @@
<template> <template>
<Home :initial-search-expanded="true" /> <div class="search-page">
<div class="search-screen">
<h1 class="search-header">搜索</h1>
<form class="search-box" @submit.prevent="onSearch">
<input
v-model.trim="searchKeyword"
class="search-input"
type="search"
enterkeyhint="search"
inputmode="search"
autocomplete="off"
placeholder="搜索市场、话题、地址"
@keydown.enter.prevent="onSearch"
/>
</form>
<template v-if="showResults">
<section class="result-list">
<article
v-for="item in resultItems"
:key="item.id"
class="result-item"
@click="openResult(item)"
>
<div class="result-left">
<div class="result-icon" :class="item.iconClass">{{ item.iconText }}</div>
<div class="result-title">{{ item.title }}</div>
</div>
<div class="result-right">
<div class="result-pct" :class="item.pctClass">{{ item.percent }}</div>
<div class="result-time">{{ item.timeAgo }}</div>
</div>
</article>
</section>
</template>
<template v-else>
<section class="card records-card">
<h2 class="card-title">搜索记录</h2>
<button
v-for="record in searchRecords"
:key="record"
type="button"
class="record-row"
@click="useRecord(record)"
>
<span class="record-text">{{ record }}</span>
<v-icon size="16" class="record-close" @click.stop="removeRecord(record)">mdi-close</v-icon>
</button>
</section>
<section class="card tags-card">
<h2 class="card-title">推荐标签</h2>
<div class="tag-row">
<button v-for="tag in recommendTags" :key="tag" type="button" class="tag-chip" @click="useTag(tag)">
{{ tag }}
</button>
</div>
</section>
</template>
<div v-if="searching" class="search-loading">搜索中...</div>
<div v-if="searchError" class="search-error">{{ searchError }}</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Home from './Home.vue' import { computed, ref } from 'vue'
import { getPmEventPublic, type PmEventListItem } from '../api/event'
interface SearchResultItem {
id: string
title: string
iconText: string
iconClass: string
percent: string
pctClass: string
timeAgo: string
raw: PmEventListItem
}
const searchRecords = ref(['BTC ETF approval odds', 'US election winner', 'ETH above $4k'])
const recommendTags = ref(['ETH', '科技股', '总统大选'])
const searchKeyword = ref('')
const searching = ref(false)
const searchError = ref('')
const hasSearched = ref(false)
const resultItems = ref<SearchResultItem[]>([])
const showResults = computed(() => hasSearched.value)
function removeRecord(record: string) {
searchRecords.value = searchRecords.value.filter((item) => item !== record)
}
function upsertRecord(keyword: string) {
searchRecords.value = [keyword, ...searchRecords.value.filter((item) => item !== keyword)].slice(0, 10)
}
function getTimeAgoLabel(event: PmEventListItem): string {
const source = event.updatedAt || event.createdAt || event.startDate
if (!source) return '1d ago'
const time = new Date(source).getTime()
if (!Number.isFinite(time)) return '1d ago'
const diff = Date.now() - time
if (diff < 60 * 60 * 1000) return `${Math.max(1, Math.floor(diff / (60 * 1000)))}m ago`
if (diff < 24 * 60 * 60 * 1000) return `${Math.max(1, Math.floor(diff / (60 * 60 * 1000)))}h ago`
return `${Math.max(1, Math.floor(diff / (24 * 60 * 60 * 1000)))}d ago`
}
function mapEventToResultItem(event: PmEventListItem, idx: number): SearchResultItem {
const title = event.title || event.slug || 'Untitled Event'
const iconText = title.trim().charAt(0).toUpperCase() || 'M'
const iconClass = idx === 1 ? 'icon-red' : idx === 2 ? 'icon-primary' : 'icon-dark'
const chance = Number(event.markets?.[0]?.outcomePrices?.[0] ?? 0.5)
const percentNum = Number.isFinite(chance) ? Math.round(chance * 100) : 50
return {
id: String(event.ID ?? `${title}-${idx}`),
title,
iconText,
iconClass,
percent: `${percentNum}%`,
pctClass: idx === 0 ? 'pct-primary' : 'pct-default',
timeAgo: getTimeAgoLabel(event),
raw: event,
}
}
function buildFallbackResults(): SearchResultItem[] {
return [
{
id: 'btc',
title: 'BTC > $85k this year',
iconText: 'B',
iconClass: 'icon-dark',
percent: '72%',
pctClass: 'pct-primary',
timeAgo: '2h ago',
raw: { ID: 1, title: 'BTC > $85k this year' } as PmEventListItem,
},
{
id: 'eth',
title: 'ETH above $4k by Q4',
iconText: 'E',
iconClass: 'icon-red',
percent: '61%',
pctClass: 'pct-default',
timeAgo: '5h ago',
raw: { ID: 2, title: 'ETH above $4k by Q4' } as PmEventListItem,
},
{
id: 'us',
title: 'US Election Winner',
iconText: 'U',
iconClass: 'icon-primary',
percent: '54%',
pctClass: 'pct-default',
timeAgo: '1d ago',
raw: { ID: 3, title: 'US Election Winner' } as PmEventListItem,
},
]
}
async function onSearch() {
const keyword = searchKeyword.value.trim()
if (!keyword || searching.value) return
searching.value = true
searchError.value = ''
try {
const res = await getPmEventPublic({ page: 1, pageSize: 10, keyword })
const list = res.data?.list ?? []
resultItems.value = (list.length > 0 ? list : []).slice(0, 12).map(mapEventToResultItem)
if (resultItems.value.length === 0) {
resultItems.value = buildFallbackResults()
}
upsertRecord(keyword)
hasSearched.value = true
} catch {
resultItems.value = buildFallbackResults()
hasSearched.value = true
searchError.value = '搜索失败,已展示示例结果'
} finally {
searching.value = false
}
}
function useRecord(record: string) {
searchKeyword.value = record
onSearch()
}
function useTag(tag: string) {
searchKeyword.value = tag
onSearch()
}
function openResult(_item: SearchResultItem) {
// Placeholder: can route to detail page later.
}
</script> </script>
<style scoped>
.search-page {
min-height: 100vh;
background: #fcfcfc;
display: flex;
justify-content: center;
padding: 0;
}
.search-screen {
width: 100%;
max-width: 100%;
min-height: 980px;
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
box-sizing: border-box;
font-family: Inter, sans-serif;
}
.search-header {
margin: 0;
color: #111827;
font-size: 24px;
font-weight: 700;
line-height: 1.2;
}
.search-box {
height: 42px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: #fff;
padding: 0 12px;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
border: 0;
outline: none;
background: transparent;
color: #111827;
font-size: 13px;
font-weight: 500;
line-height: 1;
font-family: Inter, sans-serif;
}
.search-input::placeholder {
color: #9ca3af;
}
.card {
width: 100%;
border-radius: 16px;
border: 1px solid #e5e7eb;
background: #fff;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
box-sizing: border-box;
}
.card-title {
margin: 0;
color: #111827;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.record-row {
width: 100%;
height: 34px;
border: 0;
background: transparent;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0;
cursor: pointer;
}
.record-text {
color: #6b7280;
font-size: 13px;
font-weight: 500;
line-height: 1;
}
.record-close {
color: #9ca3af;
}
.tag-row {
display: flex;
align-items: center;
gap: 8px;
}
.tag-chip {
height: 30px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: #fcfcfc;
color: #6b7280;
font-size: 12px;
font-weight: 600;
padding: 0 12px;
cursor: pointer;
}
.result-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.result-item {
width: 100%;
border-radius: 16px;
border: 1px solid #e5e7eb;
background: #fff;
padding: 12px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: pointer;
}
.result-left {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
gap: 10px;
}
.result-icon {
width: 38px;
height: 38px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.icon-dark {
background: #111827;
color: #fff;
}
.icon-red {
background: #fee2e2;
color: #dc2626;
}
.icon-primary {
background: #e0e7ff;
color: #5b5bd6;
}
.result-title {
color: #111827;
font-size: 14px;
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
flex-shrink: 0;
}
.result-pct {
font-size: 14px;
font-weight: 700;
}
.pct-primary {
color: #5b5bd6;
}
.pct-default {
color: #111827;
}
.result-time {
color: #9ca3af;
font-size: 11px;
font-weight: 500;
}
.search-loading,
.search-error {
font-size: 12px;
color: #6b7280;
padding: 0 4px;
}
.search-error {
color: #dc2626;
}
</style>