核心代码
数据获取
// composables/useSteamAPIGet.ts
import blogConfig from '~~/blog.config'
// ==================== 基础类型定义 ====================
export type SteamStatus =
| 'offline'
| 'online'
| 'away'
| 'snooze'
| 'busy'
| 'trading'
| 'playing'
export interface SteamAvatar {
small: string
medium: string
large: string
}
export interface SteamCurrentGame {
appid: number
name: string
}
export interface SteamPlaytimeStats {
totalForever: number
totalTwoWeeks: number
}
export interface SteamUser {
steamid: string
username: string
profileUrl: string
avatar: SteamAvatar
status: SteamStatus
statusMessage: string
currentGame?: SteamCurrentGame
playtimeStats: SteamPlaytimeStats
}
export interface SteamGameAchievement {
total: number
unlocked: number
percentage: number
}
export interface SteamGamePrice {
amount: number
currency: string
displayPrice: string
}
export interface SteamGameImages {
icon: string
logo: string
headerImage: string
heroImage: string
libraryHeroImage: string
}
export interface SteamGameDetail {
appid: number
name: string
playtimeForever: number
playtimeTwoWeeks: number
price: SteamGamePrice
images: SteamGameImages
releaseDate: string
shortDescription: string
achievements?: SteamGameAchievement
}
export interface SteamGameTwoWeekSummary extends SteamGameDetail {}
export interface SteamGameAllTimeSummary extends SteamGameDetail {}
export interface SteamGamesList {
totalCount: number
recentCount: number
recentGames: SteamGameTwoWeekSummary[]
allGames: SteamGameAllTimeSummary[]
}
export interface SteamAchievementItem {
name: string
description: string
unlocked: boolean
unlockTime: number
images: {
icon: string
iconGray: string
}
}
export interface SteamGameAchievements {
appid: number
gameName: string
total: number
unlocked: number
percentage: number
items: SteamAchievementItem[]
}
export interface SteamAchievementsData {
totalCount: number
unlockedCount: number
unlockedPercentage: number
byGame: SteamGameAchievements[]
}
// ==================== API 响应类型 ====================
export interface SteamApiMetadata {
cached: boolean
cachedAt: string
cacheExpiry: string
fetchDuration: string
}
export interface SteamApiResponse<T> {
success: boolean
data?: T
metadata?: SteamApiMetadata
error?: string
code?: string
}
export interface SteamUserResponse {
user: SteamUser
}
export interface SteamGamesResponse {
games: SteamGamesList
}
export interface SteamGameResponse {
game: SteamGameDetail
}
export interface SteamAchievementsResponse {
achievements: SteamAchievementsData
}
// ==================== Composable 返回类型 ====================
export interface SteamDataResult {
user?: SteamUser
games?: SteamGamesList
achievements?: SteamAchievementsData
}
export interface SteamAllMetadata {
user?: SteamApiMetadata
games?: SteamApiMetadata
achievements?: SteamApiMetadata
gameDetail?: SteamApiMetadata
}
// ==================== 常量定义 ====================
export const steamStatusTextMap: Record<SteamStatus, string> = {
offline: '离线',
online: '在线',
away: '离开',
snooze: '打盹',
busy: '忙碌',
trading: '交易中',
playing: '游戏中',
} as const
export const steamStatusColorMap: Record<SteamStatus, string> = {
offline: '#90a0a6',
online: '#4fc951',
away: '#ffc72c',
snooze: '#ffc72c',
busy: '#ff6554',
trading: '#6495ed',
playing: '#26d07c',
} as const
// ==================== 工具函数 ====================
export const formatPlaytime = (hours: number): string => {
return hours < 1 ? '< 1 小时' : `${Math.round(hours)} 小时`
}
export const formatSteamTime = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString('zh-CN')
}
// ==================== 内部缓存类型 ====================
type CacheEntry<T> = {
data: SteamApiResponse<T>
expiresAt: number
}
type FetchOptions = {
force?: boolean
ttl?: number
}
const DEFAULT_TTL = 60 * 1000
const GAME_DETAIL_TTL = 5 * 60 * 1000
// ==================== Composable 主函数 ====================
export const useSteamAPIGet = () => {
// 共享状态:多个组件调用 composable 时复用同一份状态
const loadingCount = useState<number>('steam-loading-count', () => 0)
const error = useState<Error | null>('steam-error', () => null)
const userData = useState<SteamUser | null>('steam-user-data', () => null)
const gamesData = useState<SteamGamesList | null>('steam-games-data', () => null)
const achievementsData = useState<SteamAchievementsData | null>('steam-achievements-data', () => null)
const gameDetailMap = useState<Record<number, SteamGameDetail>>('steam-game-detail-map', () => ({}))
const allMetadata = useState<SteamAllMetadata | null>('steam-all-metadata', () => null)
// 内存缓存 + 请求去重
const responseCache = useState<Record<string, CacheEntry<any>>>('steam-response-cache', () => ({}))
const pendingRequests = useState<Record<string, Promise<any>>>('steam-pending-requests', () => ({}))
const loading = computed(() => loadingCount.value > 0)
const baseURL = computed(() => {
return (blogConfig.Steam.status as string) || 'https://steam-api-profile-palomiku.netlify.app/api'
})
const endpoints = computed(() => ({
user: `${baseURL.value}/steam-user`,
games: `${baseURL.value}/steam-games`,
game: (appid: number) => `${baseURL.value}/steam-game?appid=${appid}`,
achievements: `${baseURL.value}/steam-achievements`,
}))
const setLoading = (value: boolean) => {
if (value) {
loadingCount.value += 1
} else {
loadingCount.value = Math.max(0, loadingCount.value - 1)
}
}
const isCacheValid = (key: string) => {
const entry = responseCache.value[key]
return !!entry && entry.expiresAt > Date.now()
}
const getCache = <T>(key: string): SteamApiResponse<T> | null => {
if (!isCacheValid(key)) return null
return responseCache.value[key].data as SteamApiResponse<T>
}
const setCache = <T>(key: string, data: SteamApiResponse<T>, ttl = DEFAULT_TTL) => {
responseCache.value[key] = {
data,
expiresAt: Date.now() + ttl,
}
}
const clearCache = (key?: string) => {
if (key) {
delete responseCache.value[key]
return
}
responseCache.value = {}
}
const fetchWithDedupe = async <T>(
key: string,
url: string,
options: FetchOptions = {},
): Promise<SteamApiResponse<T> | null> => {
const { force = false, ttl = DEFAULT_TTL } = options
// 1. 优先返回缓存
if (!force) {
const cached = getCache<T>(key)
if (cached) return cached
}
// 2. 有相同请求正在进行时,直接复用 Promise
if (!force && pendingRequests.value[key]) {
return pendingRequests.value[key] as Promise<SteamApiResponse<T> | null>
}
const requestPromise = (async () => {
try {
const response = await $fetch<SteamApiResponse<T>>(url, {
method: 'GET',
headers: {
Accept: 'application/json',
},
// 不再强制 no-store,让服务端/CDN/浏览器仍有机会利用缓存策略
})
if (response?.success) {
setCache(key, response, ttl)
}
return response
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error('[Steam] Fetch error:', message)
return null
} finally {
delete pendingRequests.value[key]
}
})()
pendingRequests.value[key] = requestPromise
return requestPromise
}
const mergeMetadata = (patch: Partial<SteamAllMetadata>) => {
allMetadata.value = {
...(allMetadata.value || {}),
...patch,
}
}
const fetchUser = async (options: FetchOptions = {}) => {
const res = await fetchWithDedupe<SteamUserResponse>(
'steam:user',
endpoints.value.user,
{ ttl: DEFAULT_TTL, ...options },
)
if (res?.success && res.data?.user) {
userData.value = res.data.user
mergeMetadata({ user: res.metadata })
}
return res
}
const fetchGames = async (limit?: number, options: FetchOptions = {}) => {
const validLimit = limit && limit > 0 && limit <= 100 ? limit : undefined
const url = validLimit
? `${endpoints.value.games}?limit=${validLimit}`
: endpoints.value.games
const key = `steam:games:${validLimit ?? 'default'}`
const res = await fetchWithDedupe<SteamGamesResponse>(
key,
url,
{ ttl: DEFAULT_TTL, ...options },
)
if (res?.success && res.data?.games) {
gamesData.value = res.data.games
mergeMetadata({ games: res.metadata })
}
return res
}
const fetchAchievements = async (options: FetchOptions = {}) => {
const res = await fetchWithDedupe<SteamAchievementsResponse>(
'steam:achievements',
endpoints.value.achievements,
{ ttl: DEFAULT_TTL, ...options },
)
if (res?.success && res.data?.achievements) {
achievementsData.value = res.data.achievements
mergeMetadata({ achievements: res.metadata })
}
return res
}
const fetchSteamData = async (
limit?: number,
options: FetchOptions = {},
): Promise<SteamApiResponse<SteamDataResult> & { allMetadata?: SteamAllMetadata }> => {
setLoading(true)
error.value = null
try {
const [userRes, gamesRes, achievementsRes] = await Promise.all([
fetchUser(options),
fetchGames(limit, options),
fetchAchievements(options),
])
if (!userRes?.success || !gamesRes?.success) {
const errMsg = 'Failed to fetch required Steam data'
error.value = new Error(errMsg)
return {
success: false,
error: errMsg,
code: 'FETCH_ERROR',
}
}
return {
success: true,
data: {
user: userRes.data?.user,
games: gamesRes.data?.games,
achievements: achievementsRes?.data?.achievements,
},
allMetadata: allMetadata.value || undefined,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error('[Steam] Fetch error:', message)
const errorObj = err instanceof Error ? err : new Error(message)
error.value = errorObj
return {
success: false,
error: `Fetch failed: ${message}`,
code: 'FETCH_ERROR',
}
} finally {
setLoading(false)
}
}
const fetchGameDetail = async (
appid: number,
options: FetchOptions = {},
): Promise<SteamApiResponse<SteamGameDetail>> => {
setLoading(true)
try {
const res = await fetchWithDedupe<SteamGameResponse>(
`steam:game:${appid}`,
endpoints.value.game(appid),
{ ttl: GAME_DETAIL_TTL, ...options },
)
if (res?.success && res.data?.game) {
gameDetailMap.value[appid] = res.data.game
mergeMetadata({ gameDetail: res.metadata })
return {
success: true,
data: res.data.game,
metadata: res.metadata,
}
}
return {
success: false,
error: res?.error || 'Failed to fetch game details',
code: 'FETCH_ERROR',
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error('[Steam] Fetch game detail error:', message)
return {
success: false,
error: `Fetch failed: ${message}`,
code: 'FETCH_ERROR',
}
} finally {
setLoading(false)
}
}
const refreshUser = () => fetchUser({ force: true })
const refreshGames = (limit?: number) => fetchGames(limit, { force: true })
const refreshAchievements = () => fetchAchievements({ force: true })
const refreshGameDetail = (appid: number) => fetchGameDetail(appid, { force: true })
const getGameDetail = computed(() => {
return (appid: number) => gameDetailMap.value[appid] || null
})
return {
// 状态
loading: readonly(loading),
error: readonly(error),
userData: readonly(userData),
gamesData: readonly(gamesData),
achievementsData: readonly(achievementsData),
gameDetailMap: readonly(gameDetailMap),
allMetadata: readonly(allMetadata),
// 计算
endpoints: readonly(endpoints),
baseURL: readonly(baseURL),
getGameDetail,
// 方法
fetchSteamData,
fetchGameDetail,
refreshUser,
refreshGames,
refreshAchievements,
refreshGameDetail,
clearCache,
formatPlaytime,
formatSteamTime,
// 常量
statusTextMap: steamStatusTextMap,
statusColorMap: steamStatusColorMap,
}
}
// ==================== 向后兼容别名导出 ====================
export const a = steamStatusTextMap
export const f = formatPlaytime
export const s = steamStatusColorMap
export const u = useSteamAPIGet
export default useSteamAPIGet
页面组件
<script setup lang="ts">
const {
fetchSteamData,
fetchGameDetail,
userData,
statusTextMap,
statusColorMap
} = useSteamAPIGet()
// 初始加载数据
onMounted(async () => {
// 获取基础数据
await fetchSteamData(10)
// 如果用户正在游戏中,获取游戏详情
if (userData.value?.currentGame) {
await fetchGameDetail(userData.value.currentGame.appid)
}
})
</script>
<template>
<div class="SteamUser">
<div class="SteamUserHeader">
<NuxtImg class="UserHeaderAvatar" :src="`${userData?.avatar.large}`" />
<div class="UserHeaderInfo">
<div class="HeaderInfoRow">
<h2 class="RowUserName">
{{ userData?.username }}
</h2>
<div class="RowBadgeGroup">
<div class="RowBadgeCard" :style="`--status-color: ${statusColorMap[userData?.status]}`">
<span class="BadgeCardDot" />
{{ statusTextMap[userData?.status] }}
</div>
</div>
</div>
<p class="StatusInfoText" v-show="userData?.status === 'offline'">当前该用户已{{ statusTextMap[userData?.status] }}</p>
<a class="StatusInfoUrl" :href="userData?.profileUrl" target="_blank"> 访问 Steam 个人资料 → </a>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.SteamUser {
background: var(--ld-bg-card);
border: 1px solid var(--c-border);
border-radius: .8em;
padding: 1em;
transition: border-color .3s ease;
@media (max-width: 767px) {
padding: .75em;
}
.SteamUserHeader {
align-items: flex-start;
display: flex;
gap: 1em;
@media (max-width: 767px) {
gap: .75em;
}
.UserHeaderAvatar {
border: 2px solid var(--c-primary);
border-radius: 50%;
flex-shrink: 0;
height: 100px;
-o-object-fit: cover;
object-fit: cover;
width: 100px;
@media (max-width: 767px) {
height: 80px;
width: 80px;
}
@media (max-width: 480px) {
height: 70px;
width: 70px;
}
}
.UserHeaderInfo {
flex: 1;
min-width: 0;
.HeaderInfoRow {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: .75em;
margin-bottom: .5em;
@media (max-width: 767px) {
gap: .5em;
margin-bottom: .4em;
}
.RowUserName {
color: var(--c-text);
font-size: 1.25em;
font-weight: 600;
margin: 0;
word-break: break-word;
@media (max-width: 480px) {
font-size: 1em;
}
@media (max-width: 767px) {
font-size: 1.1em;
}
}
.RowBadgeGroup {
align-items: center;
display: flex;
gap: .5em;
.RowBadgeCard {
align-items: center;
background: color-mix(in srgb, var(--status-color) 15%, transparent);
border-radius: 1em;
color: var(--status-color);
display: inline-flex;
font-size: .875em;
font-weight: 500;
gap: .5em;
padding: .25em .75em;
white-space: nowrap;
@media (max-width: 767px) {
font-size: .8em;
padding: .2em .6em;
}
.BadgeCardDot {
animation: pulse-a9cdcf99 1.5s ease-in-out infinite;
background: var(--status-color);
border-radius: 50%;
display: inline-block;
height: 6px;
width: 6px;
}
}
}
}
.StatusInfoText {
color: var(--c-text-2);
font-size: .85em;
font-weight: 400;
margin: .125em 0 0;
@media (max-width: 480px) {
font-size: .8em;
}
}
.StatusInfoUrl {
color: var(--c-primary);
display: inline-block;
font-size: .875em;
font-weight: 500;
margin-top: .5em;
text-decoration: none;
transition: opacity .2s;
}
}
}
}
</style>
更新日志
V0.20260327.78999.68.90WER_PRE
- 1.对
最近游戏模块增加滚动功能,可以通过Shift + 空格或者滑动来查看 - 2.对
顶部标题模块的右侧新增tip显示,可以通过写入sub-tip配置项来进行调用
V0.20260323.14599.11.0_PRE
- 1.优化后端API获取模块中对于链接请求重复过多的问题
V0.20260322.7688.8.0_PRE
- 1.优化
用户面板、信息面板、最近游戏、全部游戏四个模块的移动端不同尺寸的适配 - 2.分离
最近游戏、全部游戏两个模块的顶部信息栏,添加到顶部标题的模块中,并且进行特殊化配置项(即defineProps写法) - 3.对
最近游戏、全部游戏的成就显示与具体百分比显示,并且在最近游戏中新增价格显示(即price配置)
V0.20260322.6788.7.0_PRE
- 1.对
游戏面板进行分离模块,新增最近游戏与全游戏的模块,并且优化两个逻辑
V0.20260321.6358.2.0_PRE
- 1.优化
用户面板、信息面板、游戏面板三个模块的加载与逻辑运算,并且接入后端API获取模块(V0.20260321.6358.1.0_PRE更新内容第二项) - 2.优化
用户面板、信息面板、游戏面板三个模块的样式,并且重构vue template与scss的具体写法 - 3.修复在获取API的过程中出现的样式错乱
- 4.优化后端API获取模块中的数据
V0.20260321.6358.1.0_PRE
- 1.新增
用户面板、信息面板、游戏面板模块的基础框架(用于测试数据是否正常) - 2.新增后端API获取模块

评论区
评论加载中...