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