五件套徒手健身打卡应用:FastAPI+Vue3+PWA

This commit is contained in:
2026-08-13 06:33:56 +08:00
commit d529dad183
915 changed files with 295087 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#f15a29">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="五件套">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icon-192.png">
<title>五件套 · 徒手健身打卡</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1223
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "workout-five-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"vue": "^3.4.27"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.2.11"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"name": "五件套 · 徒手健身打卡",
"short_name": "五件套",
"description": "俯卧撑 · 深蹲 · 卷腹 · 登山跑 · 平板支撑 每日打卡",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#fdfaf3",
"theme_color": "#f15a29",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
+26
View File
@@ -0,0 +1,26 @@
const CACHE = 'workout-five-v1'
const ASSETS = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/apple-touch-icon.png']
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting()))
})
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim())
)
})
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url)
if (e.request.method !== 'GET' || url.pathname.startsWith('/api/')) {
return // API 走网络
}
e.respondWith(
caches.match(e.request).then((hit) => hit || fetch(e.request).then((resp) => {
const clone = resp.clone()
caches.open(CACHE).then((c) => c.put(e.request, clone))
return resp
}))
)
})
+270
View File
@@ -0,0 +1,270 @@
<template>
<div class="app">
<!-- 顶栏 -->
<header class="topbar">
<div class="logo">🐱 五件套</div>
<div class="topbar-right">
<span class="streak-badge" v-if="stats.streak > 0">🔥 {{ stats.streak }} </span>
<span class="weight-badge" v-if="stats.last_weight"> {{ stats.last_weight }}kg</span>
</div>
</header>
<!-- 安装提示 -->
<div class="install-banner" v-if="showInstall">
<span>📱 装到主屏幕打卡更方便</span>
<button class="btn btn-primary btn-sm" @click="installPwa">安装</button>
<button class="btn-close" @click="showInstall = false">×</button>
</div>
<main class="content">
<!-- 今日打卡 -->
<section class="card today-card">
<div class="card-title">
<span class="card-title-icon">💪</span> 今日打卡
<span class="date-hint">{{ todayStr }}</span>
</div>
<div class="action-grid">
<div class="action-item" v-for="a in actions" :key="a.key">
<div class="action-emoji">{{ a.emoji }}</div>
<div class="action-name">{{ a.label }}</div>
<div class="action-input">
<button class="step-btn" @click="dec(a.key)"></button>
<input
type="number"
min="0"
v-model.number="form[a.key]"
class="num-input"
@focus="onFocus($event)"
/>
<button class="step-btn" @click="inc(a.key)"></button>
</div>
<div class="action-unit">{{ a.unit }}</div>
</div>
</div>
<div class="today-prev" v-if="hasPrev">
<span class="prev-hint">昨天</span>
<span class="prev-text">{{ prevSummary }}</span>
</div>
<button class="btn btn-primary btn-save" @click="saveToday" :disabled="saving">
{{ saving ? '保存中…' : '✅ 保存打卡' }}
</button>
<div class="save-tip" v-if="savedTip">已保存哥哥真棒🎉</div>
</section>
<!-- 统计 -->
<section class="card stats-card" v-if="stats.total_days > 0">
<div class="card-title"><span class="card-title-icon">📊</span> 我的进度</div>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-num">{{ stats.streak }}</div>
<div class="stat-label">🔥 连续打卡</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ stats.week_days }}</div>
<div class="stat-label">🗓 本周练了</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ stats.total_days }}</div>
<div class="stat-label">📅 累计天数</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ lastWeightText }}</div>
<div class="stat-label"> 最新体重</div>
</div>
</div>
<div class="weight-trend" v-if="stats.weight_trend">
体重 <b :class="trendClass">{{ trendText }}</b> · 3 天称一次哦
</div>
</section>
<!-- 体重记录 -->
<section class="card weight-card">
<div class="card-title"><span class="card-title-icon"></span> 称体重</div>
<div class="weight-form">
<input type="number" step="0.1" min="0" v-model.number="weightInput" placeholder="今天多少 kg" class="num-input weight-input" />
<button class="btn btn-secondary" @click="saveWeight">记录</button>
</div>
</section>
<!-- 历史记录 -->
<section class="card history-card">
<div class="card-title"><span class="card-title-icon">📜</span> 历史记录</div>
<div v-if="workouts.length === 0" class="empty">
还没有记录今天开始第一练吧 🐾
</div>
<div v-else class="history-list">
<div class="history-item" v-for="w in workouts" :key="w.id">
<div class="history-left">
<div class="history-date">{{ formatDate(w.date) }}</div>
<div class="history-actions">
<span class="history-chips">
<span class="chip" v-for="a in actions" :key="a.key" v-if="w[a.key] > 0">
{{ a.emoji }} {{ w[a.key] }}{{ a.short }}
</span>
<span class="chip-empty" v-if="!hasAny(w)">没练 🥺</span>
</span>
</div>
</div>
<button class="btn-del" @click="delWorkout(w)">🗑</button>
</div>
</div>
</section>
</main>
<footer class="foot">慢慢来比较快 · 软软陪你变强喵 🐾</footer>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const actions = [
{ key: 'pushups', label: '俯卧撑', emoji: '💪', unit: '个', short: '' },
{ key: 'squats', label: '深蹲', emoji: '🦵', unit: '个', short: '' },
{ key: 'crunches', label: '卷腹', emoji: '🐛', unit: '个', short: '' },
{ key: 'mountain', label: '登山跑', emoji: '🏃', unit: '秒', short: 's' },
{ key: 'plank', label: '平板支撑', emoji: '🧘', unit: '秒', short: 's' },
]
const todayStr = new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })
const form = ref({ pushups: 0, squats: 0, crunches: 0, mountain: 0, plank: 0 })
const workouts = ref([])
const stats = ref({ streak: 0, week_days: 0, total_days: 0, last_weight: null, weight_trend: null })
const weights = ref([])
const weightInput = ref(null)
const saving = ref(false)
const savedTip = ref(false)
const showInstall = ref(false)
const hasPrev = ref(false)
const prevSummary = ref('')
function dec(key) {
form.value[key] = Math.max(0, (form.value[key] || 0) - 1)
}
function inc(key) {
form.value[key] = (form.value[key] || 0) + 1
}
function onFocus(e) {
e.target.select()
}
function hasAny(w) {
return actions.some(a => w[a.key] > 0)
}
function formatDate(d) {
const [y, m, day] = d.split('-')
return `${y}/${m}/${day}`
}
const lastWeightText = computed(() =>
stats.value.last_weight ? `${stats.value.last_weight} kg` : '—'
)
const trendText = computed(() => {
const t = stats.value.weight_trend
if (!t) return ''
const d = t.delta
if (d > 0) return `${d} kg`
if (d < 0) return `${Math.abs(d)} kg`
return '持平'
})
const trendClass = computed(() => (stats.value.weight_trend?.delta > 0 ? 'up' : 'down'))
async function api(url, opts = {}) {
const resp = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...opts,
})
if (!resp.ok) throw new Error(`请求失败 ${resp.status}`)
return resp.json()
}
async function loadAll() {
const [ws, st, wts] = await Promise.all([
api('/api/workouts'),
api('/api/stats'),
api('/api/weights'),
])
workouts.value = ws
stats.value = st
weights.value = wts
}
async function loadToday() {
const today = await api('/api/workouts/today')
if (today) {
actions.forEach(a => { form.value[a.key] = today[a.key] || 0 })
}
}
async function loadPrev() {
// 昨天的记录(历史里第二条 = 昨天或更早;第一条可能是今天)
const all = [...workouts.value]
if (all.length === 0) return
const todayISO = new Date().toISOString().slice(0, 10)
const prev = all.find(w => w.date < todayISO)
if (prev) {
hasPrev.value = true
const parts = actions
.filter(a => prev[a.key] > 0)
.map(a => `${a.label} ${prev[a.key]}${a.short}`)
prevSummary.value = parts.join(' · ') || '休息日'
}
}
async function saveToday() {
saving.value = true
try {
await api('/api/workouts', {
method: 'POST',
body: JSON.stringify({ ...form.value }),
})
savedTip.value = true
setTimeout(() => (savedTip.value = false), 2500)
await loadAll()
await loadPrev()
} catch (e) {
alert('保存失败:' + e.message)
} finally {
saving.value = false
}
}
async function delWorkout(w) {
if (!confirm(`删除 ${w.date} 的记录?`)) return
await api(`/api/workouts/${w.id}`, { method: 'DELETE' })
await loadAll()
await loadPrev()
}
async function saveWeight() {
const v = weightInput.value
if (!v || v <= 0) { alert('填个数字嘛~'); return }
await api('/api/weights', { method: 'POST', body: JSON.stringify({ weight: v }) })
weightInput.value = null
await loadAll()
}
let deferredPrompt = null
async function installPwa() {
if (!deferredPrompt) return
deferredPrompt.prompt()
await deferredPrompt.userChoice
deferredPrompt = null
showInstall.value = false
}
onMounted(async () => {
try {
await loadAll()
await loadToday()
await loadPrev()
} catch (e) {
alert('加载失败:' + e.message)
}
window.addEventListener('pwa-install-ready', () => (showInstall.value = true))
window.addEventListener('appinstalled', () => (showInstall.value = false))
})
</script>
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
// PWA install banner
let deferredPrompt = null
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredPrompt = e
window.dispatchEvent(new CustomEvent('pwa-install-ready'))
})
createApp(App).mount('#app')
+244
View File
@@ -0,0 +1,244 @@
:root {
--bg: #fdfaf3;
--card: #ffffff;
--text: #4a3f3a;
--sub: #9e8f84;
--orange: #f15a29;
--orange-light: #fdeee6;
--orange-dark: #d64a1e;
--shadow: 0 2px 14px rgba(241, 90, 41, 0.10);
--radius: 18px;
--radius-sm: 12px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
-webkit-font-smoothing: antialiased;
}
.app { max-width: 720px; margin: 0 auto; padding: 16px 16px 40px; }
/* Topbar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 6px;
}
.logo {
font-size: 22px;
font-weight: 800;
color: var(--orange);
}
.topbar-right { display: flex; gap: 8px; }
.streak-badge, .weight-badge {
background: var(--orange-light);
color: var(--orange-dark);
font-size: 13px;
font-weight: 700;
padding: 6px 12px;
border-radius: 999px;
}
/* Install banner */
.install-banner {
display: flex;
align-items: center;
gap: 10px;
background: var(--card);
border: 2px solid #f8e3d3;
border-radius: var(--radius-sm);
padding: 10px 14px;
margin-bottom: 14px;
font-size: 14px;
box-shadow: var(--shadow);
}
.install-banner .btn-close {
margin-left: auto;
background: none;
border: none;
font-size: 18px;
color: var(--sub);
cursor: pointer;
}
/* Cards */
.card {
background: var(--card);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow);
}
.card-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 17px;
font-weight: 800;
margin-bottom: 14px;
}
.card-title-icon { font-size: 20px; }
.date-hint {
margin-left: auto;
font-size: 13px;
color: var(--sub);
font-weight: 500;
}
/* Action grid */
.action-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
}
.action-item {
background: var(--bg);
border-radius: var(--radius-sm);
padding: 12px 8px;
text-align: center;
}
.action-emoji { font-size: 26px; }
.action-name { font-size: 13px; font-weight: 700; margin: 4px 0 8px; }
.action-input { display: flex; align-items: center; justify-content: center; gap: 4px; }
.step-btn {
width: 28px; height: 28px;
border: 2px solid var(--orange-light);
background: #fff;
color: var(--orange);
border-radius: 8px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.step-btn:active { transform: scale(0.9); }
.num-input {
width: 52px;
text-align: center;
border: 2px solid #f0e2d5;
border-radius: 8px;
padding: 5px 2px;
font-size: 15px;
font-weight: 700;
color: var(--text);
background: #fff;
outline: none;
}
.num-input:focus { border-color: var(--orange); }
.action-unit { font-size: 11px; color: var(--sub); margin-top: 4px; }
.today-prev {
margin-top: 12px;
font-size: 13px;
color: var(--sub);
background: var(--bg);
padding: 8px 12px;
border-radius: var(--radius-sm);
}
.prev-hint { font-weight: 700; color: var(--text); }
.prev-text { color: var(--sub); }
/* Buttons */
.btn {
cursor: pointer;
border: 2px solid transparent;
border-radius: var(--radius-sm);
font-size: 15px;
font-weight: 700;
padding: 11px 20px;
transition: all 0.2s ease;
font-family: inherit;
}
.btn:active { transform: scale(0.95); }
.btn-primary {
background: linear-gradient(135deg, #f15a29, #f48a5e);
color: #fff;
box-shadow: 0 4px 14px rgba(241, 90, 41, 0.3);
}
.btn-primary:hover { transform: translateY(-2px); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary {
background: #fff;
color: var(--orange);
border: 2px solid var(--orange-light);
}
.btn-secondary:hover { background: var(--orange-light); border-color: var(--orange-dark); }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-save { width: 100%; margin-top: 14px; padding: 13px; font-size: 16px; }
.save-tip { text-align: center; margin-top: 10px; color: var(--orange-dark); font-weight: 700; font-size: 14px; }
/* Stats */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
text-align: center;
}
.stat-item {
background: var(--bg);
border-radius: var(--radius-sm);
padding: 12px 4px;
}
.stat-num { font-size: 22px; font-weight: 800; color: var(--orange); }
.stat-label { font-size: 11px; color: var(--sub); margin-top: 4px; }
.weight-trend {
margin-top: 12px;
text-align: center;
font-size: 13px;
color: var(--sub);
}
.weight-trend b.up { color: #2e9e5b; }
.weight-trend b.down { color: var(--orange-dark); }
/* Weight form */
.weight-form { display: flex; gap: 10px; }
.weight-input { flex: 1; width: auto; text-align: left; padding: 11px 14px; font-size: 15px; }
/* History */
.empty { text-align: center; color: var(--sub); padding: 20px 0; font-size: 14px; }
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid #f7efe6;
}
.history-item:last-child { border-bottom: none; }
.history-date { font-size: 14px; font-weight: 700; margin-bottom: 6px; }
.history-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
background: var(--orange-light);
color: var(--orange-dark);
font-size: 12px;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
}
.chip-empty { font-size: 12px; color: var(--sub); }
.btn-del {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
opacity: 0.4;
transition: opacity 0.2s;
}
.btn-del:hover { opacity: 1; }
.foot {
text-align: center;
color: var(--sub);
font-size: 13px;
margin-top: 10px;
}
@media (max-width: 420px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.action-grid { grid-template-columns: repeat(2, 1fr); }
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5175,
proxy: {
'/api': 'http://localhost:8780'
}
}
})