Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Steam 成就追踪器</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
17 changes: 17 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "games-tracker",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"vite": "^6.3.5"
}
}
171 changes: 171 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<template>
<div class="app" :data-theme="theme">
<aside class="nav-rail">
<button
v-for="item in navItems"
:key="item.key"
:class="['rail-btn', { active: currentPage === item.key }]"
:title="item.label"
@click="currentPage = item.key"
>
<span>{{ item.icon }}</span>
</button>
</aside>

<main class="main-layout">
<header class="command-bar">
<h1>{{ currentPageLabel }}</h1>
<div class="search-wrap">
<span>🔎</span>
<input v-model="query" placeholder="搜索游戏、标签或备注" />
<button v-if="query" class="ghost" @click="query = ''">清除</button>
</div>
<button class="primary">
<span v-if="syncing">⏳</span>
{{ currentPage === 'wishlist' ? '添加游戏' : syncing ? '同步中…' : '同步我的库' }}
</button>
Comment on lines +23 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire primary command button to sync/add action

Attach a click handler that mutates state for the primary command. The button text and spinner depend on syncing, but there is no @click or other state transition here, so users cannot trigger sync/add behavior and the 同步中… branch is effectively dead in normal use.

Useful? React with 👍 / 👎.

</header>

<section class="content">
<div v-if="currentPage === 'sync'" class="panel">
<h2>同步中心</h2>
<div class="sync-item" v-for="task in syncTasks" :key="task.name">
<div>
<strong>{{ task.name }}</strong>
<p>{{ task.stage }}</p>
</div>
<div class="progress"><div :style="{ width: task.progress + '%' }"></div></div>
<button class="ghost">取消</button>
</div>
</div>

<div v-else-if="currentPage === 'settings'" class="panel settings">
<h2>设置</h2>
<div class="setting-group">
<h3>外观</h3>
<label>主题
<select v-model="theme">
<option value="system">跟随系统</option>
<option value="light">浅色</option>
<option value="dark">深色</option>
</select>
</label>
<label class="switch">
<input type="checkbox" v-model="showEnglish" /> 显示英文名(副标题)
</label>
</div>
</div>

<div v-else class="grid">
<article v-for="game in filteredGames" :key="game.id" class="card" @click="openDrawer(game)">
<div class="left-icon">🎮</div>
<div class="mid">
<h3>{{ game.zhName || game.enName }}</h3>
<p v-if="showEnglish && game.zhName">{{ game.enName }}</p>
<small v-if="currentPage === 'library'">游玩:{{ game.playtime }} 小时 · 最近:{{ game.lastPlayed }}</small>
</div>
<div class="right-ach">
<strong>{{ game.ach?.done ?? '—' }} / {{ game.ach?.total ?? '—' }}</strong>
<div class="progress"><div :style="{ width: (game.ach?.percent || 0) + '%' }"></div></div>
<small>{{ game.ach ? game.ach.percent + '%' : '未同步' }}</small>
</div>
<div class="chips">
<span class="chip state">{{ game.state }}</span>
<span class="chip" v-for="tag in game.tags.slice(0, 3)" :key="tag">{{ tag }}</span>
<span class="chip" v-if="game.tags.length > 3">+{{ game.tags.length - 3 }}</span>
</div>
</article>
</div>
</section>
</main>

<div class="mask" v-if="selectedGame" @click="selectedGame = null"></div>
<aside class="drawer" v-if="selectedGame">
<header>
<div>
<h3>{{ selectedGame.zhName || selectedGame.enName }}</h3>
<p>{{ selectedGame.ach?.done ?? '—' }} / {{ selectedGame.ach?.total ?? '—' }} · {{ selectedGame.ach?.percent ?? '—' }}%</p>
</div>
<button class="ghost" @click="selectedGame = null">关闭</button>
</header>
<nav class="segmented">
<button :class="{ active: drawerTab === 'overview' }" @click="drawerTab = 'overview'">概览</button>
<button :class="{ active: drawerTab === 'ach' }" @click="drawerTab = 'ach'">成就</button>
</nav>
<div class="drawer-content" v-if="drawerTab === 'overview'">
<section class="panel">
<h4>你的管理</h4>
<label>状态
<select v-model="selectedGame.state">
<option>想玩</option><option>在玩</option><option>已通关</option><option>搁置</option><option>已弃坑</option>
</select>
</label>
<label>标签
<input v-model="tagDraft" placeholder="输入标签后回车" @keydown.enter.prevent="addTag" />
</label>
<div class="chips"><span class="chip" v-for="tag in selectedGame.tags" :key="tag">{{ tag }}</span></div>
<label>备注
<textarea v-model="selectedGame.note" placeholder="例如:进度、计划、易错点、刷成就思路…"></textarea>
</label>
</section>
</div>
<div class="drawer-content" v-else>
<section class="panel">
<h4>成就</h4>
<div class="search-wrap"><input placeholder="搜索成就" /></div>
<p v-if="!selectedGame.ach">成就尚未同步。点击“刷新成就”以获取该游戏的成就列表与解锁状态。</p>
<ul v-else>
<li v-for="n in 8" :key="n">未解锁成就 {{ n }}</li>
</ul>
</section>
</div>
</aside>
</div>
</template>

<script setup>
import { computed, ref } from 'vue'

const currentPage = ref('library')
const query = ref('')
const syncing = ref(false)
const showEnglish = ref(true)
const theme = ref('system')
const drawerTab = ref('overview')
const selectedGame = ref(null)
const tagDraft = ref('')

const navItems = [
{ key: 'library', label: '游戏库', icon: '📚' },
{ key: 'wishlist', label: '我的清单', icon: '⭐' },
{ key: 'sync', label: '同步中心', icon: '🔄' },
{ key: 'settings', label: '设置', icon: '⚙️' }
]

const games = ref([
{ id: 1, zhName: '空洞骑士', enName: 'Hollow Knight', playtime: 41, lastPlayed: '2026-05-12', state: '在玩', tags: ['高难度', '银河恶魔城'], note: '', ach: { done: 34, total: 63, percent: 54 } },
{ id: 2, zhName: '哈迪斯', enName: 'Hades', playtime: 19, lastPlayed: '2026-05-10', state: '想玩', tags: ['肉鸽', '动作'], note: '', ach: null },
{ id: 3, zhName: '', enName: 'Portal 2', playtime: 12, lastPlayed: '2026-05-01', state: '搁置', tags: ['解谜', '剧情', '合作', '经典'], note: '补双人', ach: { done: 20, total: 51, percent: 39 } }
])

const syncTasks = ref([
{ name: '哈迪斯', stage: '下载图标', progress: 35 },
{ name: '空洞骑士', stage: '写入本地数据', progress: 92 }
])

const currentPageLabel = computed(() => navItems.find((i) => i.key === currentPage.value)?.label ?? '')
const filteredGames = computed(() => games.value.filter((g) => `${g.zhName} ${g.enName} ${g.tags.join(' ')} ${g.note}`.toLowerCase().includes(query.value.toLowerCase())))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter wishlist entries by page state

Update the game list computation to include currentPage criteria, not just the search text. As written, filteredGames only checks query, so switching to 我的清单 still renders the full library (including non-wishlist states), which breaks the expected separation between Library and Wishlist views.

Useful? React with 👍 / 👎.


const openDrawer = (game) => {
selectedGame.value = game
drawerTab.value = 'overview'
}

const addTag = () => {
const next = tagDraft.value.trim()
if (next && selectedGame.value && !selectedGame.value.tags.includes(next)) {
selectedGame.value.tags.push(next)
}
tagDraft.value = ''
}
</script>
5 changes: 5 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles.css'

createApp(App).mount('#app')
35 changes: 35 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
:root { font-family: 'Segoe UI Variable', 'Microsoft YaHei UI', sans-serif; color-scheme: light dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #f4f6fb; color: #1f2937; }
.app { display: flex; min-height: 100vh; }
.nav-rail { width: 72px; padding: 16px 12px; display: flex; flex-direction: column; gap: 12px; border-right: 1px solid #e5e7eb; background: #fbfcff; }
.rail-btn,.primary,.ghost,select,input,textarea { height: 36px; border-radius: 10px; border: 1px solid #d1d5db; background: white; }
.rail-btn { display:grid; place-items:center; }
.rail-btn.active { border-color: #3b82f6; color: #3b82f6; }
.main-layout { flex:1; display:flex; flex-direction:column; }
.command-bar { height: 56px; display:flex; align-items:center; gap:16px; padding: 0 24px; border-bottom:1px solid #e5e7eb; background: #fff; }
.command-bar h1 { font-size:22px; margin:0; min-width: 120px; }
.search-wrap { display:flex; align-items:center; gap:8px; width:min(520px, 60vw); border:1px solid #d1d5db; border-radius: 10px; padding: 0 10px; background:#fff; height: 36px; }
.search-wrap input { flex:1; border:0; outline:none; background:transparent; height: 30px; }
.content { padding:24px; overflow:auto; }
.grid { display:grid; grid-template-columns: repeat(auto-fill,minmax(320px,1fr)); gap:16px; }
.card { background:#fff; border-radius:12px; padding:16px; display:grid; grid-template-columns:64px 1fr auto; gap:12px; box-shadow: 0 1px 3px rgba(0,0,0,.08); cursor:pointer; }
.left-icon { width:64px; height:64px; border-radius:12px; background:#eef2ff; display:grid; place-items:center; }
.mid h3 { font-size:16px; margin:0; }
.mid p { margin:2px 0; font-size:12px; color:#64748b; }
.mid small { font-size:12px; color:#6b7280; }
.right-ach { text-align:right; }
.progress { width:108px; height:6px; border-radius:999px; background:#e5e7eb; overflow:hidden; }
.progress>div { height:100%; background:#3b82f6; }
.chips { grid-column:1 / -1; display:flex; gap:8px; flex-wrap:wrap; }
.chip { border-radius:999px; padding:2px 10px; font-size:12px; background:#eef2f7; }
.chip.state { background:#dbeafe; color:#1e3a8a; }
.mask { position:fixed; inset:0; background:rgba(15,23,42,.28); }
.drawer { position:fixed; top:0; right:0; width:480px; height:100vh; background:#fff; box-shadow:-8px 0 20px rgba(0,0,0,.12); padding:16px; display:flex; flex-direction:column; }
.segmented { display:flex; gap:8px; background:#f1f5f9; border-radius:999px; padding:4px; }
.segmented button { flex:1; height:32px; border-radius:999px; border:none; }
.segmented .active { background:#fff; color:#2563eb; }
.drawer-content { overflow:auto; padding-top:12px; }
.panel { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:16px; }
.settings .setting-group { display:flex; flex-direction:column; gap:12px; }
textarea { min-height:120px; width:100%; }
6 changes: 6 additions & 0 deletions vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
plugins: [vue()]
})
Loading