五件套徒手健身打卡应用:FastAPI+Vue3+PWA
This commit is contained in:
@@ -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>
|
||||
Generated
+1223
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user