7 changed files with 430 additions and 0 deletions
@ -0,0 +1,28 @@ |
|||
import http from './index' |
|||
|
|||
export interface ShopItemDisplay { |
|||
shopItemId: number |
|||
itemType: number |
|||
itemId: number |
|||
itemName: string | null |
|||
price: number |
|||
dailyLimit: number |
|||
purchasedToday: number |
|||
remainingStock: number |
|||
icon: string | null |
|||
itemRarity: number | null |
|||
} |
|||
|
|||
export interface Shop { |
|||
characterId: number |
|||
lastRefreshTime: string |
|||
items: ShopItemDisplay[] |
|||
} |
|||
|
|||
export const getShop = (): Promise<Shop> => { |
|||
return http.get('shop') |
|||
} |
|||
|
|||
export const buyItem = (shopItemId: number): Promise<boolean> => { |
|||
return http.post('shop/buy', { shopItemId }) |
|||
} |
|||
|
After Width: | Height: | Size: 6.4 KiB |
@ -0,0 +1,387 @@ |
|||
<script setup lang="ts"> |
|||
import { ref, onMounted, computed } from 'vue' |
|||
import { useRouter } from 'vue-router' |
|||
import { useCharacterStore } from '@/stores/character' |
|||
import { getShop, buyItem, type Shop, type ShopItemDisplay } from '@/api/shop' |
|||
import { ElMessage } from 'element-plus' |
|||
import StarBorder from '@/components/StarBorder/StarBorder.vue' |
|||
import Particles from '@/components/Particles/Particles.vue' |
|||
import { ArrowLeft } from '@element-plus/icons-vue' |
|||
|
|||
const router = useRouter() |
|||
const characterStore = useCharacterStore() |
|||
|
|||
const shop = ref<Shop | null>(null) |
|||
const loading = ref(true) |
|||
const buying = ref<number | null>(null) |
|||
const error = ref('') |
|||
|
|||
const currentMoney = computed(() => characterStore.currentCharacter?.money ?? 0) |
|||
const characterId = computed(() => characterStore.currentCharacter?.id ?? 0) |
|||
|
|||
const itemTypeMap: Record<number, string> = { |
|||
1: '装备', |
|||
2: '丹药', |
|||
3: '垃圾' |
|||
} |
|||
|
|||
const rarityMap: Record<number, string> = { |
|||
1: '普通', |
|||
2: '稀有', |
|||
3: '史诗', |
|||
4: '传说' |
|||
} |
|||
|
|||
const rarityColorMap: Record<number, string> = { |
|||
1: '#9ca3af', |
|||
2: '#22c55e', |
|||
3: '#3b82f6', |
|||
4: '#f59e0b' |
|||
} |
|||
|
|||
const getItemIcon = (item: ShopItemDisplay) => { |
|||
if (item.icon) { |
|||
const typeStr = item.itemType === 1 ? 'equipment' : item.itemType === 2 ? 'pill' : 'scrap' |
|||
return new URL(`../assets/icons/${typeStr}/${item.icon}`, import.meta.url).href |
|||
} |
|||
return null |
|||
} |
|||
|
|||
const getItemEmoji = (itemType: number) => { |
|||
if (itemType === 1) return '⚔️' |
|||
if (itemType === 2) return '💊' |
|||
return '📦' |
|||
} |
|||
|
|||
const canBuy = (item: ShopItemDisplay) => { |
|||
if (item.remainingStock <= 0) return false |
|||
if (item.dailyLimit > 0 && item.purchasedToday >= item.dailyLimit) return false |
|||
if (currentMoney.value < item.price) return false |
|||
return true |
|||
} |
|||
|
|||
const getBuyButtonText = (item: ShopItemDisplay) => { |
|||
if (item.remainingStock <= 0) return '售罄' |
|||
if (item.dailyLimit > 0 && item.purchasedToday >= item.dailyLimit) return '已达上限' |
|||
if (currentMoney.value < item.price) return '灵石不足' |
|||
return '购买' |
|||
} |
|||
|
|||
const formatRefreshTime = (lastRefreshTime: string) => { |
|||
const date = new Date(lastRefreshTime) |
|||
return date.toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) |
|||
} |
|||
|
|||
const formatNumber = (num: number) => { |
|||
return Math.floor(num).toLocaleString('zh-CN') |
|||
} |
|||
|
|||
const fetchShop = async () => { |
|||
loading.value = true |
|||
error.value = '' |
|||
try { |
|||
shop.value = await getShop() |
|||
} catch (err: any) { |
|||
error.value = err.message || '加载商店失败' |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
const handleBuy = async (item: ShopItemDisplay) => { |
|||
if (!canBuy(item)) return |
|||
|
|||
buying.value = item.shopItemId |
|||
try { |
|||
await buyItem(item.shopItemId) |
|||
ElMessage.success(`购买成功!${item.itemName}`) |
|||
await fetchShop() |
|||
await characterStore.fetchCharacters() |
|||
} catch (err: any) { |
|||
ElMessage.error(err.message || '购买失败') |
|||
} finally { |
|||
buying.value = null |
|||
} |
|||
} |
|||
|
|||
const goBack = () => { |
|||
router.push('/game') |
|||
} |
|||
|
|||
onMounted(async () => { |
|||
await characterStore.fetchCharacters() |
|||
await fetchShop() |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="shop-container"> |
|||
<Particles /> |
|||
<StarBorder /> |
|||
|
|||
<div class="header"> |
|||
<button class="back-btn" @click="goBack"> |
|||
<span class="back-icon">←</span> |
|||
<span>返回</span> |
|||
</button> |
|||
<h1 class="title">商店</h1> |
|||
<div class="money-display"> |
|||
💰 {{ formatNumber(currentMoney) }} 灵石 |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="shop" class="shop-info"> |
|||
<span class="refresh-time">商品刷新时间: {{ formatRefreshTime(shop.lastRefreshTime) }}</span> |
|||
</div> |
|||
|
|||
<div v-if="loading" class="loading"> |
|||
加载中... |
|||
</div> |
|||
|
|||
<div v-else-if="error" class="error"> |
|||
{{ error }} |
|||
<button class="retry-btn" @click="fetchShop">重试</button> |
|||
</div> |
|||
|
|||
<div v-else-if="!shop || shop.items.length === 0" class="empty"> |
|||
商店暂无可购商品,请联系管理员上架商品 |
|||
</div> |
|||
|
|||
<div v-else class="shop-grid"> |
|||
<div |
|||
v-for="item in shop.items" |
|||
:key="item.shopItemId" |
|||
class="shop-item" |
|||
:class="{ 'cannot-buy': !canBuy(item) }" |
|||
> |
|||
<div class="item-icon-wrapper"> |
|||
<img |
|||
v-if="getItemIcon(item)" |
|||
:src="getItemIcon(item)" |
|||
class="item-icon-img" |
|||
:class="'rarity-' + (item.itemRarity || 1)" |
|||
/> |
|||
<div v-else class="item-icon">{{ getItemEmoji(item.itemType) }}</div> |
|||
<div v-if="item.itemRarity" class="rarity-badge" :style="{ color: rarityColorMap[item.itemRarity] }"> |
|||
{{ rarityMap[item.itemRarity] }} |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="item-name">{{ item.itemName }}</div> |
|||
|
|||
<div class="item-price"> |
|||
💰 {{ formatNumber(item.price) }} |
|||
</div> |
|||
|
|||
<div class="item-limit" v-if="item.dailyLimit > 0"> |
|||
今日已购: {{ item.purchasedToday }} / {{ item.dailyLimit }} |
|||
</div> |
|||
|
|||
<button |
|||
class="buy-btn" |
|||
:disabled="!canBuy(item) || buying === item.shopItemId" |
|||
@click="handleBuy(item)" |
|||
> |
|||
{{ buying === item.shopItemId ? '购买中...' : getBuyButtonText(item) }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped lang="css"> |
|||
.shop-container { |
|||
min-height: 100vh; |
|||
padding: 20px; |
|||
position: relative; |
|||
overflow-y: auto; |
|||
} |
|||
|
|||
.header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.back-btn { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
padding: 8px 16px; |
|||
background: rgba(255, 255, 255, 0.05); |
|||
border: 1px solid rgba(255, 255, 255, 0.1); |
|||
border-radius: 8px; |
|||
color: #cccccc; |
|||
cursor: pointer; |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
.back-btn:hover { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
} |
|||
|
|||
.back-icon { |
|||
font-size: 1.2rem; |
|||
} |
|||
|
|||
.title { |
|||
font-size: 1.5rem; |
|||
color: #ffffff; |
|||
margin: 0; |
|||
} |
|||
|
|||
.money-display { |
|||
padding: 8px 16px; |
|||
background: rgba(255, 215, 0, 0.1); |
|||
border: 1px solid rgba(255, 215, 0, 0.3); |
|||
border-radius: 8px; |
|||
color: #ffd700; |
|||
font-size: 1rem; |
|||
} |
|||
|
|||
.shop-info { |
|||
margin-bottom: 20px; |
|||
color: #888888; |
|||
font-size: 0.9rem; |
|||
} |
|||
|
|||
.refresh-time { |
|||
padding: 4px 12px; |
|||
background: rgba(255, 255, 255, 0.05); |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.loading, |
|||
.error, |
|||
.empty { |
|||
text-align: center; |
|||
padding: 60px 20px; |
|||
color: #888888; |
|||
font-size: 1.1rem; |
|||
} |
|||
|
|||
.error { |
|||
color: #ef4444; |
|||
} |
|||
|
|||
.retry-btn { |
|||
display: block; |
|||
margin: 16px auto 0; |
|||
padding: 8px 24px; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
border-radius: 8px; |
|||
color: #cccccc; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.shop-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); |
|||
gap: 16px; |
|||
} |
|||
|
|||
.shop-item { |
|||
background: rgba(255, 255, 255, 0.05); |
|||
border: 1px solid rgba(255, 255, 255, 0.1); |
|||
border-radius: 12px; |
|||
padding: 16px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
text-align: center; |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
.shop-item:hover { |
|||
background: rgba(255, 255, 255, 0.08); |
|||
border-color: rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.shop-item.cannot-buy { |
|||
opacity: 0.6; |
|||
} |
|||
|
|||
.item-icon-wrapper { |
|||
position: relative; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.item-icon-img { |
|||
width: 64px; |
|||
height: 64px; |
|||
object-fit: contain; |
|||
border-radius: 8px; |
|||
} |
|||
|
|||
.item-icon { |
|||
width: 64px; |
|||
height: 64px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
font-size: 2rem; |
|||
background: rgba(255, 255, 255, 0.1); |
|||
border-radius: 8px; |
|||
} |
|||
|
|||
.rarity-1 { filter: grayscale(0.3); } |
|||
.rarity-2 { filter: hue-rotate(80deg) saturate(1.5); } |
|||
.rarity-3 { filter: hue-rotate(200deg) saturate(2); } |
|||
.rarity-4 { filter: hue-rotate(-30deg) saturate(2) brightness(1.2); } |
|||
|
|||
.rarity-badge { |
|||
position: absolute; |
|||
bottom: -8px; |
|||
left: 50%; |
|||
transform: translateX(-50%); |
|||
font-size: 0.7rem; |
|||
font-weight: bold; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
.item-name { |
|||
color: #ffffff; |
|||
font-size: 0.95rem; |
|||
margin-bottom: 8px; |
|||
min-height: 40px; |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
|
|||
.item-price { |
|||
color: #ffd700; |
|||
font-size: 1rem; |
|||
margin-bottom: 8px; |
|||
} |
|||
|
|||
.item-limit { |
|||
color: #888888; |
|||
font-size: 0.8rem; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.buy-btn { |
|||
width: 100%; |
|||
padding: 10px 16px; |
|||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|||
border: none; |
|||
border-radius: 8px; |
|||
color: #ffffff; |
|||
font-size: 0.95rem; |
|||
cursor: pointer; |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
.buy-btn:hover:not(:disabled) { |
|||
transform: scale(1.02); |
|||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); |
|||
} |
|||
|
|||
.buy-btn:disabled { |
|||
background: rgba(255, 255, 255, 0.1); |
|||
cursor: not-allowed; |
|||
opacity: 0.6; |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue