From df1af33a03056ce9fba70abb7f1e7894c38a0194 Mon Sep 17 00:00:00 2001 From: priority3 Date: Thu, 26 Feb 2026 20:08:40 +0800 Subject: [PATCH 1/5] docs(ui): add glass + mint UI spec --- docs/UI_SPEC.md | 206 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 docs/UI_SPEC.md diff --git a/docs/UI_SPEC.md b/docs/UI_SPEC.md new file mode 100644 index 0000000..d048b8b --- /dev/null +++ b/docs/UI_SPEC.md @@ -0,0 +1,206 @@ +# RunPaceFlow UI 规范(Glass + Mint + 灰阶) + +> 目标:让 UI 从“模块拼装”变成“同一个系统里长出来的仪表盘”。 +> 风格:Apple Fitness / iOS 原生质感的 **玻璃拟态仪表盘**。 +> 约束:**单主色(mint)+ iOS 灰阶**;不引入额外 WebFont(使用系统字体栈)。 + +--- + +## 1. 一句话定位(产品语言) + +RunPaceFlow 是一个 Apple Fitness 风格的跑步仪表盘:暗色氛围底 + Glass 面板承载信息;地图是舞台背景,数字是主角。全站唯一强调色 **mint** 贯穿选中态、交互态、图表与路线高亮,其余全部使用 iOS 灰阶。 + +--- + +## 2. 设计原则(防碎片化) + +1. **只有两种 Surface**:Base(背景)与 Glass(信息层)。禁止出现第三种卡片风格。 +2. **数字优先**:指标数字的字号/字重/行高/对齐必须全站一致。 +3. **单主色**:mint 只用于 selection / interaction / visualization / progress。 +4. **同一状态驱动一切**:时间范围与选中活动必须全局联动(地图、列表、指标同时响应)。 +5. **动效少而准**:两档时长即可;避免夸张动效和“到处发光”。 + +--- + +## 3. 颜色系统(Single Accent + Grayscale) + +### 3.1 灰阶(来自 UIKit Tokens) + +项目已使用 `tailwindcss-uikit-colors`,灰阶直接使用以下语义 token(不再自造): + +- 文本:`text-label` / `text-secondary-label` / `text-tertiary-label` +- 背景:`bg-system-background` / `bg-secondary-system-background` +- 分隔:`border-separator` +- 填充(hover/chip):`bg-secondary-fill` / `bg-tertiary-fill` + +> 规则:能用 `separator` 分隔就不要用重阴影;能用灰阶表达就不要引入第二强调色。 + +### 3.2 主强调色:mint(唯一) + +mint 只允许出现于: + +1. **Selection**:segmented/tab/列表当前行/已选路线 +2. **Interaction**:focus ring、hover 边框、主要按钮强调 +3. **Visualization**:图表主线、地图高亮路线 +4. **Progress**:目标达成/进度提醒(尽量使用“soft mint”,不做高饱和大片铺色) + +禁止项: + +- 不允许把“距离/配速/心率”等指标各自染色(会立刻碎片化)。 +- “发光效果”只允许用于选中路线/选中行,且强度极低(更像系统 highlight)。 + +--- + +## 4. 字体策略(不引入 WebFont) + +### 4.1 字体栈 + +- 使用系统字体栈(iOS/macOS 优先 SF,Windows/Android 自动 fallback)。 +- 中文由系统字体接管(苹方/微软雅黑等),避免中英文混排割裂。 + +### 4.2 数字排版(必须全站统一) + +所有指标数字、表格数字列必须启用: + +- `tabular-nums`(数字等宽,列表/表格不跳) +- `tracking-tight`(更像仪表盘读数) +- `leading-none` 或接近 `1.05` 的紧凑行高 + +单位与标签统一: + +- 单位:小号、低对比(例如 `text-tertiary-label text-xs font-medium`) +- 标签:次级对比(例如 `text-secondary-label text-sm font-medium`) + +--- + +## 5. Typography Scale(全站仅三档层级) + +> 规则:不要在不同模块里随意发明字号/字重;只允许使用这三档。 + +### 5.1 Title(标题) + +- 页面标题 / 主模块标题 +- 建议:`text-xl font-semibold text-label` + +### 5.2 Metric(指标) + +- Hero 数字 / 卡片关键读数 +- 建议:`tabular-nums tracking-tight leading-none font-semibold text-label` +- 大小建议: + - Hero:`text-3xl` 或 `text-4xl`(仅首页核心指标) + - 常规模块:`text-2xl` + +### 5.3 Label(标签/说明) + +- 单位 / 说明 / 表头 +- 建议:`text-xs font-medium text-tertiary-label` + +--- + +## 6. Surface(材质)规则 + +### 6.1 Base(背景层) + +- 深色底 + 轻微渐变作为氛围(强度要克制) +- 地图属于背景舞台:整体降饱和/降对比,不抢主指标 + +### 6.2 Glass(信息层) + +Glass 面板必须统一: + +- 半透明底(灰阶) +- 轻微 blur(backdrop) +- 细边框(separator) +- 统一圆角:建议 `16px`(全站只用一个大圆角) +- 统一内边距:建议 16/20/24 三档 + +禁止项: + +- 禁止重阴影卡片(玻璃质感靠边框与高光,不靠大投影)。 +- 禁止出现多种圆角体系(会瞬间变“拼装风”)。 + +--- + +## 7. 布局骨架(Dashboard Layout) + +推荐总览页结构(桌面/移动均适配): + +1. **Top Bar** + - Large Title(例如 Running / Overview) + - Segmented Control(Week / Month / Year 或 Year Tabs) +2. **Hero** + - Summary(Glass):距离/次数/配速/心率/连续天数等核心指标 + - Stage(Base 或 Glass):地图/路线(舞台) +3. **Feed** + - Activity List(Glass):活动列表(可选中) +4. **Detail** + - Bottom Sheet / Side Panel:详情(路线、splits、心率、AI insight) + +核心原则: + +- “范围切换”是唯一全局入口;所有模块只响应它,不各自维护筛选。 +- 页面主线只有一条:先看 Summary,再看 Stage,再下钻 Detail。 + +--- + +## 8. 状态联动(cool 的关键) + +最少需要三个跨模块状态(概念即可,具体实现方式不限): + +- `range`:时间范围(week/month/year 或 year) +- `selectedActivityId`:选中活动 +- `hoverActivityId`:hover 活动(桌面端增强) + +联动规则(建议强制落实): + +1. hover 列表行 → 地图高亮路线(mint)+ tooltip 展示关键指标 +2. hover 地图路线 → 列表行高亮(mint 左侧条/边框) +3. click 任意一边 → 锁定 selection → 打开 detail sheet(统一动效) +4. 切换 range/year → summary、map、list **同步更新 + 同步过渡** + +选中态视觉统一(不要各模块自创样式): + +- mint 边框 / 左侧条 / 轻 glow(强度很低) +- 文字不必染色,尽量保持灰阶;用边框/环/线条表达选中 + +--- + +## 9. 动效(Motion) + +> 原则:少而准,两档时长全站统一。 + +时长建议: + +- Hover / micro interaction:`180ms` +- 页面切换 / sheet 开关 / range 切换:`280ms` + +动效形式建议: + +- Glass 面板:`fade + translateY(4px)`(很轻) +- 指标更新:`crossfade`(或轻量数字滚动,后续再做) +- 路线高亮:线宽/透明度过渡(避免复杂粒子特效) + +--- + +## 10. 执行清单(每加一个模块都要过一遍) + +- 是否只使用 Base / Glass 两类容器? +- 指标数字是否统一使用 Metric 规则(`tabular-nums`、紧凑行高、单位低对比)? +- 交互态是否只使用 mint? +- 是否出现第二强调色或“彩虹指标”? +- range/selection 是否全局联动? +- hover/click 是否地图与列表互相响应? +- 动效时长是否只使用两档(180/280)? + +--- + +## 11. 建议的组件抽象(工程落地) + +为避免“每个页面手写一套”,建议优先抽两个基础组件: + +1. `GlassPanel` + - 统一:圆角 / 边框 / blur / padding / hover 样式 +2. `Metric` + - 统一:数字样式(tabular-nums + tight)/ 单位 / label 的 baseline 对齐 + +> 后续所有页面优先组合这些基础组件,而不是复制粘贴 className。 From 988db1cbbc5e03e5fcb785c01af2d11197505c4f Mon Sep 17 00:00:00 2001 From: priority3 Date: Thu, 26 Feb 2026 20:20:03 +0800 Subject: [PATCH 2/5] fix(db): use /tmp sqlite fallback on Vercel --- src/lib/db/client.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/lib/db/client.ts b/src/lib/db/client.ts index 5f07686..dfdfebc 100644 --- a/src/lib/db/client.ts +++ b/src/lib/db/client.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs' import path from 'node:path' import { createClient } from '@libsql/client' @@ -7,8 +8,11 @@ import * as schema from './schema' /** * 数据库配置 - * 默认使用 data/activities.db,支持 Git 持久化 - * 可通过 DATABASE_URL 环境变量覆盖 + * 可通过 DATABASE_URL 环境变量覆盖(推荐:Turso / libSQL 远程数据库) + * + * Notes: + * - Vercel Serverless 运行时文件系统基本只读(除了 /tmp)。 + * - 预览环境(Preview)如果未配置 DATABASE_URL,也不应在构建阶段因 SQLite 路径不可写而失败。 */ const getDatabaseUrl = () => { // 如果有环境变量,直接使用 @@ -16,14 +20,20 @@ const getDatabaseUrl = () => { return process.env.DATABASE_URL } - // 在 Vercel 环境中,使用绝对路径 + // 在 Vercel 环境中,优先使用 /tmp(可写),避免构建/运行时因为 data 目录不存在而失败 if (process.env.VERCEL) { - // Vercel 会将项目文件部署到 /var/task - return `file:${path.join(process.cwd(), 'data', 'activities.db')}` + return 'file:/tmp/runpaceflow.db' } - // 本地开发环境 - return 'file:./data/activities.db' + // 本地开发环境:默认落到 data/activities.db(若目录不存在则自动创建) + const dataDir = path.join(process.cwd(), 'data') + try { + fs.mkdirSync(dataDir, { recursive: true }) + } catch { + // Best-effort: directory creation failure will surface as connection error later. + } + + return `file:${path.join(dataDir, 'activities.db')}` } const client = createClient({ From 43bed446a96ba056856d137a3e68d4d58031d03e Mon Sep 17 00:00:00 2001 From: priority3 Date: Thu, 26 Feb 2026 21:03:36 +0800 Subject: [PATCH 3/5] feat(ui): apply Glass + Mint interactions --- src/app/activity/[id]/page.tsx | 40 ++--- src/app/page.tsx | 48 ++++-- src/components/activity/ActivityActionBar.tsx | 38 ++--- src/components/activity/ActivityCard.tsx | 2 +- src/components/activity/ActivityHeatmap.tsx | 46 ++--- src/components/activity/ActivityTable.tsx | 51 ++++-- src/components/activity/HeartRateZones.tsx | 18 +- src/components/activity/PersonalRecords.tsx | 16 +- src/components/activity/StatsCard.tsx | 70 +++----- src/components/layout/Header.tsx | 2 +- src/components/map/KilometerMarkers.tsx | 2 +- src/components/map/RouteLayer.tsx | 159 +++++++++++++----- src/components/map/RunMap.tsx | 42 +++-- src/components/ui/ThemeToggle.tsx | 2 +- src/components/ui/animated-button.tsx | 4 +- src/components/ui/button-variants.ts | 12 +- src/components/ui/glass-panel.tsx | 67 ++++++++ src/components/ui/metric.tsx | 36 ++++ src/components/ui/tabs.tsx | 10 +- src/styles/globals.css | 2 +- 20 files changed, 444 insertions(+), 223 deletions(-) create mode 100644 src/components/ui/glass-panel.tsx create mode 100644 src/components/ui/metric.tsx diff --git a/src/app/activity/[id]/page.tsx b/src/app/activity/[id]/page.tsx index 521039c..d004d8b 100644 --- a/src/app/activity/[id]/page.tsx +++ b/src/app/activity/[id]/page.tsx @@ -199,7 +199,7 @@ export default function ActivityDetailPage() { if (isLoading || !isMounted) { return (
-
+
@@ -214,7 +214,7 @@ export default function ActivityDetailPage() { if (error || !data) { return (
-
+
{/* Subtle gradient overlay for glassmorphic depth */} -
+
{/* Compact Header */} @@ -274,7 +274,7 @@ export default function ActivityDetailPage() {
@@ -293,7 +293,7 @@ export default function ActivityDetailPage() { {animationProgress > 0 && ( -
+
{/* Title and date */} @@ -407,10 +407,10 @@ export default function ActivityDetailPage() {
{activity.averagePace && (
- + {formatPace(activity.averagePace)} - 配速 + 配速
)} {activity.elevationGain !== null && activity.elevationGain > 0 && ( @@ -423,10 +423,10 @@ export default function ActivityDetailPage() { )} {activity.averageHeartRate && (
- + ❤{activity.averageHeartRate} - 心率 + 心率
)} {activity.weatherData && } @@ -452,7 +452,7 @@ export default function ActivityDetailPage() { {chartSplits.length > 0 ? (
-
+

每公里配速

@@ -462,7 +462,7 @@ export default function ActivityDetailPage() { />
) : ( -
+
暂无配速数据
)} @@ -474,7 +474,7 @@ export default function ActivityDetailPage() {
{/* Heart Rate Chart */} {heartRateData.length > 0 && ( -
+

心率变化

{chartSplits.length > 0 ? ( -
+

分段数据

) : ( -
+
暂无分段数据
)} @@ -529,11 +529,11 @@ export default function ActivityDetailPage() { {/* More Data Tab - Calories and other stats */} {activity.calories && ( -
+

其他数据

{activity.calories && ( -
+
卡路里
{activity.calories} @@ -542,7 +542,7 @@ export default function ActivityDetailPage() {
)} {activity.bestPace && ( -
+
最快配速
{formatPace(activity.bestPace)} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3971e29..40e5ba2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,7 +10,8 @@ import { AnimatePresence, motion } from 'framer-motion' import { Activity, Calendar, Clock, MapPin } from 'lucide-react' import dynamic from 'next/dynamic' -import { useMemo, useState } from 'react' +import type { MapLayerMouseEvent } from 'react-map-gl/maplibre' +import { useCallback, useMemo, useState } from 'react' import { ActivityTable } from '@/components/activity/ActivityTable' import { StatsCard } from '@/components/activity/StatsCard' @@ -26,7 +27,7 @@ const RunMap = dynamic( { ssr: false, loading: () => ( -
+
), }, ) @@ -68,6 +69,19 @@ export default function HomePage() { // UI state const [statsPeriod, setStatsPeriod] = useState('week') + const [hoverActivityId, setHoverActivityId] = useState(null) + + // Map → list hover linking (keep state updates minimal) + const handleMapMouseMove = useCallback((event: MapLayerMouseEvent) => { + const feature = event.features?.[0] + const id = feature?.properties?.id + const nextId = typeof id === 'string' ? id : null + setHoverActivityId((prev) => (prev === nextId ? prev : nextId)) + }, []) + + const handleMapMouseLeave = useCallback(() => { + setHoverActivityId((prev) => (prev === null ? prev : null)) + }, []) const activities = useMemo( () => activitiesData?.pages.flatMap((page) => page.activities) ?? [], @@ -130,7 +144,7 @@ export default function HomePage() { return (
{/* Subtle gradient overlay for glassmorphic depth */} -
+
@@ -140,22 +154,23 @@ export default function HomePage() { {/* Period Toggle */}

数据概览

-
+
{(['week', 'month'] as const).map((period) => (
-
+
- - {routes.length > 0 && } + + {routes.length > 0 && ( + + )}
@@ -308,6 +332,8 @@ export default function HomePage() { hasMore={!!hasNextPage} isLoadingMore={isFetchingNextPage} onLoadMore={() => fetchNextPage()} + hoveredActivityId={hoverActivityId} + onHoverActivity={setHoverActivityId} /> )} diff --git a/src/components/activity/ActivityActionBar.tsx b/src/components/activity/ActivityActionBar.tsx index 057bf11..b8adcd9 100644 --- a/src/components/activity/ActivityActionBar.tsx +++ b/src/components/activity/ActivityActionBar.tsx @@ -100,7 +100,7 @@ export function ActivityActionBar({ {/* Fixed bottom action bar - only visible on mobile */} - + 分享 @@ -126,10 +126,10 @@ export function ActivityActionBar({ setShowExportMenu(!showExportMenu) setShowShareMenu(false) }} - className="flex flex-1 flex-col items-center gap-1 rounded-xl py-2 transition-colors active:bg-white/50 dark:active:bg-white/10" + className="active:bg-secondary-system-fill/60 flex flex-1 flex-col items-center gap-1 rounded-xl py-2 transition-colors" whileTap={{ scale: 0.95 }} > - + 导出
@@ -141,12 +141,12 @@ export function ActivityActionBar({ - + {/* Export button */} @@ -156,12 +156,12 @@ export function ActivityActionBar({ setShowExportMenu(!showExportMenu) setShowShareMenu(false) }} - className="flex h-12 w-12 items-center justify-center rounded-full border border-white/20 bg-white/80 shadow-lg backdrop-blur-xl transition-colors hover:bg-white dark:border-white/10 dark:bg-black/60 dark:hover:bg-black/80" + className="border-separator/40 bg-secondary-system-background/80 hover:bg-secondary-system-background/90 flex h-12 w-12 items-center justify-center rounded-full border shadow-lg shadow-black/10 backdrop-blur-xl transition-colors" whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} title="导出" > - +
@@ -169,14 +169,14 @@ export function ActivityActionBar({ {showShareMenu && ( e.stopPropagation()} > -
+
分享活动
@@ -204,14 +204,14 @@ export function ActivityActionBar({ {showExportMenu && ( e.stopPropagation()} > -
+
导出格式 diff --git a/src/components/activity/ActivityHeatmap.tsx b/src/components/activity/ActivityHeatmap.tsx index de65c93..3f58084 100644 --- a/src/components/activity/ActivityHeatmap.tsx +++ b/src/components/activity/ActivityHeatmap.tsx @@ -39,12 +39,12 @@ interface StreakData { * Get color intensity based on distance */ function getIntensityColor(distance: number): string { - if (distance === 0) return 'bg-black/5 dark:bg-white/5' - if (distance < 3000) return 'bg-green/20' - if (distance < 5000) return 'bg-green/40' - if (distance < 8000) return 'bg-green/60' - if (distance < 10000) return 'bg-green/80' - return 'bg-green' + if (distance === 0) return 'bg-secondary-system-fill/40' + if (distance < 3000) return 'bg-mint/15' + if (distance < 5000) return 'bg-mint/30' + if (distance < 8000) return 'bg-mint/45' + if (distance < 10000) return 'bg-mint/60' + return 'bg-mint' } /** @@ -218,8 +218,8 @@ function DayCell({ className={cn( 'h-3 w-3 rounded-sm transition-all duration-150', getIntensityColor(day.distance), - isToday && 'ring-blue ring-1 ring-offset-1', - hasActivities && 'hover:ring-blue/50 cursor-pointer hover:scale-125 hover:ring-2', + isToday && 'ring-mint ring-1 ring-offset-1', + hasActivities && 'hover:ring-mint/40 cursor-pointer hover:scale-125 hover:ring-2', !hasActivities && 'cursor-default', )} disabled={!hasActivities} @@ -235,8 +235,8 @@ function DayCell({ initial={{ opacity: 0, scale: 0.95, y: -4 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: -4 }} - transition={{ duration: 0.15, ease: 'easeOut' }} - className="w-64 rounded-xl border border-white/20 bg-white/90 p-4 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-black/90" + transition={{ duration: 0.18, ease: 'easeOut' }} + className="border-separator bg-secondary-system-background/85 w-64 rounded-2xl border p-4 shadow-lg shadow-black/10 backdrop-blur-xl" > {/* Header */}
@@ -332,7 +332,7 @@ export function ActivityHeatmap({ activities, className }: ActivityHeatmapProps) return (
@@ -347,10 +347,10 @@ export function ActivityHeatmap({ activities, className }: ActivityHeatmapProps) - - {streaks.current}天连续 + + {streaks.current}天连续 )} {streaks.longest > 0 && streaks.longest > streaks.current && ( @@ -358,10 +358,10 @@ export function ActivityHeatmap({ activities, className }: ActivityHeatmapProps) initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.1 }} - className="bg-yellow/10 flex items-center gap-1 rounded-full px-2 py-0.5" + className="bg-mint/8 flex items-center gap-1 rounded-full px-2 py-0.5" > - - 最长{streaks.longest}天 + + 最长{streaks.longest}天 )}
@@ -423,12 +423,12 @@ export function ActivityHeatmap({ activities, className }: ActivityHeatmapProps) {/* Legend */}
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/src/components/activity/ActivityTable.tsx b/src/components/activity/ActivityTable.tsx index 3285eec..8f1bf33 100644 --- a/src/components/activity/ActivityTable.tsx +++ b/src/components/activity/ActivityTable.tsx @@ -27,6 +27,7 @@ import { RippleContainer } from '@/components/ui/ripple' import { springs } from '@/lib/animation' import { calculatePace, formatDuration, formatPace } from '@/lib/pace/calculator' import { trpc } from '@/lib/trpc/client' +import { cn } from '@/lib/utils' import type { ActivityListItem } from '@/types/activity' /** @@ -149,7 +150,7 @@ function calculateAchievements(activities: ActivityListItem[]): Map, - color: 'bg-yellow/20 text-yellow', + color: 'bg-mint/12 text-mint', }) achievements.set(longestActivity.id, existing) } @@ -160,7 +161,7 @@ function calculateAchievements(activities: ActivityListItem[]): Map, - color: 'bg-green/20 text-green', + color: 'bg-mint/16 text-mint', }) achievements.set(fastestActivity.id, existing) } @@ -171,7 +172,7 @@ function calculateAchievements(activities: ActivityListItem[]): Map, - color: 'bg-orange/20 text-orange', + color: 'bg-mint/10 text-mint', }) achievements.set(mostElevationActivity.id, existing) } @@ -185,6 +186,10 @@ export interface ActivityTableProps { hasMore?: boolean isLoadingMore?: boolean onLoadMore?: () => void + /** Hovered activity id for map/list linking */ + hoveredActivityId?: string | null + /** Callback when hovered activity changes */ + onHoverActivity?: (activityId: string | null) => void } export function ActivityTable({ @@ -193,6 +198,8 @@ export function ActivityTable({ hasMore = false, isLoadingMore = false, onLoadMore, + hoveredActivityId, + onHoverActivity, }: ActivityTableProps) { const achievements = useMemo(() => calculateAchievements(activities), [activities]) @@ -200,7 +207,7 @@ export function ActivityTable({ const trpcUtils = trpc.useUtils() // Prefetch activity data on hover for faster navigation - const handleMouseEnter = useCallback( + const handleRowMouseEnter = useCallback( (activityId: string) => { // Prefetch activity with splits data trpcUtils.activities.getWithSplits.prefetch({ id: activityId }) @@ -211,12 +218,12 @@ export function ActivityTable({ if (activities.length === 0) { return ( -
+

还没有活动记录

@@ -283,6 +290,7 @@ export function ActivityTable({ {virtualItems.map((virtualRow) => { const isLoaderRow = virtualRow.index >= activities.length const activity = activities[virtualRow.index] + const isHovered = !!activity && hoveredActivityId === activity.id return (
handleMouseEnter(activity.id)} + onMouseEnter={() => { + handleRowMouseEnter(activity.id) + onHoverActivity?.(activity.id) + }} + onMouseLeave={() => onHoverActivity?.(null)} > - + {activity.isIndoor && ( - + 室内 @@ -360,15 +375,25 @@ export function ActivityTable({
- - + + {activity.averagePace ? formatPace(activity.averagePace) : formatPace( calculatePace(activity.distance, activity.duration), )} - /km + /km
{activity.elevationGain && activity.elevationGain > 0 && ( diff --git a/src/components/activity/HeartRateZones.tsx b/src/components/activity/HeartRateZones.tsx index 3b3e18c..afd311e 100644 --- a/src/components/activity/HeartRateZones.tsx +++ b/src/components/activity/HeartRateZones.tsx @@ -18,11 +18,11 @@ export interface HeartRateZonesProps { * Heart rate zone definitions based on max heart rate percentage */ const ZONES = [ - { name: 'Z1 恢复', min: 50, max: 60, color: 'bg-gray', description: '轻松恢复' }, - { name: 'Z2 有氧', min: 60, max: 70, color: 'bg-blue', description: '基础耐力' }, - { name: 'Z3 有氧耐力', min: 70, max: 80, color: 'bg-green', description: '提升耐力' }, - { name: 'Z4 乳酸阈值', min: 80, max: 90, color: 'bg-orange', description: '提升速度' }, - { name: 'Z5 无氧', min: 90, max: 100, color: 'bg-red', description: '最大强度' }, + { name: 'Z1 恢复', min: 50, max: 60, color: 'bg-mint/15', description: '轻松恢复' }, + { name: 'Z2 有氧', min: 60, max: 70, color: 'bg-mint/25', description: '基础耐力' }, + { name: 'Z3 有氧耐力', min: 70, max: 80, color: 'bg-mint/35', description: '提升耐力' }, + { name: 'Z4 乳酸阈值', min: 80, max: 90, color: 'bg-mint/50', description: '提升速度' }, + { name: 'Z5 无氧', min: 90, max: 100, color: 'bg-mint/65', description: '最大强度' }, ] /** @@ -66,7 +66,7 @@ export function HeartRateZones({ averageHeartRate, maxHeartRate, className }: He return (
@@ -82,7 +82,7 @@ export function HeartRateZones({ averageHeartRate, maxHeartRate, className }: He return (
@@ -101,7 +101,7 @@ export function HeartRateZones({ averageHeartRate, maxHeartRate, className }: He {zone.min}-{zone.max}% · {percentage}%
-
+
{/* Summary stats */} -
+
平均心率

diff --git a/src/components/activity/PersonalRecords.tsx b/src/components/activity/PersonalRecords.tsx index 0037eeb..d4f933a 100644 --- a/src/components/activity/PersonalRecords.tsx +++ b/src/components/activity/PersonalRecords.tsx @@ -45,7 +45,7 @@ function calculatePersonalRecords(activities: ActivityListItem[]): PersonalRecor unit: 'km', date: new Date(longestRun.startTime), icon: , - color: 'text-blue', + color: 'text-mint', activityId: longestRun.id, }) } @@ -65,7 +65,7 @@ function calculatePersonalRecords(activities: ActivityListItem[]): PersonalRecor unit: '/km', date: new Date(fastestRun.startTime), icon: , - color: 'text-green', + color: 'text-mint', activityId: fastestRun.id, }) } @@ -80,7 +80,7 @@ function calculatePersonalRecords(activities: ActivityListItem[]): PersonalRecor unit: '', date: new Date(longestDuration.startTime), icon: , - color: 'text-purple', + color: 'text-mint', activityId: longestDuration.id, }) } @@ -98,7 +98,7 @@ function calculatePersonalRecords(activities: ActivityListItem[]): PersonalRecor unit: 'm', date: new Date(mostElevation.startTime), icon: , - color: 'text-orange', + color: 'text-mint', activityId: mostElevation.id, }) } @@ -116,7 +116,7 @@ function calculatePersonalRecords(activities: ActivityListItem[]): PersonalRecor unit: '', date: new Date(best5k.startTime), icon: , - color: 'text-yellow', + color: 'text-mint', activityId: best5k.id, }) } @@ -135,7 +135,7 @@ function calculatePersonalRecords(activities: ActivityListItem[]): PersonalRecor unit: '', date: new Date(best10k.startTime), icon: , - color: 'text-red', + color: 'text-mint', activityId: best10k.id, }) } @@ -153,7 +153,7 @@ export function PersonalRecords({ activities, className }: PersonalRecordsProps) return (

@@ -163,7 +163,7 @@ export function PersonalRecords({ activities, className }: PersonalRecordsProps) {records.map((record) => (
{record.icon}
{record.title}
diff --git a/src/components/activity/StatsCard.tsx b/src/components/activity/StatsCard.tsx index 4bfe7c4..f85ac1d 100644 --- a/src/components/activity/StatsCard.tsx +++ b/src/components/activity/StatsCard.tsx @@ -11,6 +11,8 @@ import { TrendingDown, TrendingUp } from 'lucide-react' import { useMemo } from 'react' import { AnimatedNumber } from '@/components/ui/animated-number' +import { GlassPanel } from '@/components/ui/glass-panel' +import { Metric } from '@/components/ui/metric' import { cn } from '@/lib/utils' export interface StatsCardProps { @@ -34,7 +36,7 @@ export interface StatsCardProps { goalUnit?: string /** Sparkline data points (7 days trend) */ sparklineData?: number[] - /** Sparkline color - defaults to blue */ + /** Sparkline color - defaults to mint */ sparklineColor?: string } @@ -51,7 +53,7 @@ function calculateTrend(current: number, previous: number): number | null { */ function Sparkline({ data, - color = 'var(--color-blue)', + color = 'var(--color-mint)', width = 80, height = 24, }: { @@ -186,45 +188,30 @@ export function StatsCard({ const decimals = typeof value === 'string' && value.includes('.') ? value.split('.')[1]?.length || 0 : 0 - // Determine sparkline color based on trend - const effectiveSparklineColor = - sparklineColor || - (isTrendPositive - ? 'var(--color-green)' - : isTrendNegative - ? 'var(--color-red)' - : 'var(--color-blue)') + // Single-accent style: mint is the only highlight color (unless overridden). + const effectiveSparklineColor = sparklineColor || 'var(--color-mint)' return ( - {/* Header */}
- {title} - {icon && {icon}} + + {title} + + {icon && {icon}}
{/* Value with trend and sparkline */}
-
- - {isNumeric ? : value} - - {unit && {unit}} -
+ : value} + unit={unit} + /> {/* Sparkline */} {sparklineData && sparklineData.length >= 2 && ( @@ -240,9 +227,11 @@ export function StatsCard({
{trend > 0 ? ( @@ -252,7 +241,7 @@ export function StatsCard({ ) : null} {Math.abs(Math.round(trend))}%
- {subtitle && {subtitle}} + {subtitle && {subtitle}}
)} @@ -269,10 +258,7 @@ export function StatsCard({
= 100 ? 'bg-green' : goalProgress >= 50 ? 'bg-blue' : 'bg-orange', - )} + className={cn('h-full rounded-full', goalProgress >= 100 ? 'bg-mint' : 'bg-mint/70')} initial={{ width: '0%' }} animate={{ width: `${goalProgress}%` }} transition={{ duration: 0.8, ease: 'easeOut' }} @@ -282,7 +268,7 @@ export function StatsCard({ )} {/* Subtitle (when no trend) */} - {subtitle && trend === null &&

{subtitle}

} -
+ {subtitle && trend === null &&

{subtitle}

} + ) } diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 64ed039..ee678b0 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -13,7 +13,7 @@ import { ThemeToggle } from '@/components/ui/ThemeToggle' export function Header() { return ( -
+
{marker.kilometer} diff --git a/src/components/map/RouteLayer.tsx b/src/components/map/RouteLayer.tsx index d2c0a8d..1a4fa38 100644 --- a/src/components/map/RouteLayer.tsx +++ b/src/components/map/RouteLayer.tsx @@ -2,7 +2,8 @@ * RouteLayer Component * * Displays running routes on the map with glow effect - * Optimized: Combines all routes into a single MultiLineString for better performance + * Optimized: Uses a single GeoJSON Source with multiple LineString features, + * allowing hover/click interactions per route without mounting N Sources. */ 'use client' @@ -14,67 +15,133 @@ import type { RouteData } from '@/types/map' export interface RouteLayerProps { routes: RouteData[] + /** Highlight a single route on hover/selection */ + highlightRouteId?: string | null + /** Highlight color (defaults to iOS mint) */ + highlightColor?: string } -export function RouteLayer({ routes }: RouteLayerProps) { - // Combine all routes into a single MultiLineString GeoJSON - const combinedGeoJson = useMemo(() => { +const DEFAULT_HIGHLIGHT = '#00C7BE' + +export function RouteLayer({ + routes, + highlightRouteId, + highlightColor = DEFAULT_HIGHLIGHT, +}: RouteLayerProps) { + const routesGeoJson = useMemo(() => { if (!routes || routes.length === 0) return null const validRoutes = routes.filter((route) => route.coordinates && route.coordinates.length >= 2) - if (validRoutes.length === 0) return null - const geojson: GeoJSON.Feature = { - type: 'Feature', - properties: {}, - geometry: { - type: 'MultiLineString', - coordinates: validRoutes.map((route) => - route.coordinates.map((coord) => [coord.longitude, coord.latitude]), - ), - }, + const geojson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: validRoutes.map((route) => ({ + type: 'Feature', + properties: { id: route.id }, + geometry: { + type: 'LineString', + coordinates: route.coordinates.map((coord) => [coord.longitude, coord.latitude]), + }, + })), } return geojson }, [routes]) - if (!combinedGeoJson) { + const highlightFilter = useMemo(() => { + if (!highlightRouteId) return null + return ['==', ['get', 'id'], highlightRouteId] as const + }, [highlightRouteId]) + + if (!routesGeoJson) { return null } return ( - - {/* Glow effect layer (behind main line) */} - + <> + + {/* Wide hitbox layer for hover interactions (invisible) */} + + + {/* Base routes (subtle) */} + + + {/* Main route line */} + - {/* Main route line */} - - + {/* Highlight route overlay */} + {highlightFilter && ( + <> + + + + )} + + ) } diff --git a/src/components/map/RunMap.tsx b/src/components/map/RunMap.tsx index e6a5964..46e7434 100644 --- a/src/components/map/RunMap.tsx +++ b/src/components/map/RunMap.tsx @@ -17,7 +17,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { MapPin, Maximize2, Minimize2 } from 'lucide-react' import type { StyleSpecification } from 'maplibre-gl' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { MapRef } from 'react-map-gl/maplibre' +import type { MapLayerMouseEvent, MapRef } from 'react-map-gl/maplibre' import Map from 'react-map-gl/maplibre' import { cn } from '@/lib/utils' @@ -67,6 +67,12 @@ export interface RunMapProps { enableFullscreen?: boolean /** Auto-load map without requiring user click (default: true) */ autoLoad?: boolean + /** Map hover interactivity (forwarded to react-map-gl) */ + interactiveLayerIds?: string[] + /** Mouse move handler (forwarded to react-map-gl) */ + onMouseMove?: (event: MapLayerMouseEvent) => void + /** Mouse leave handler (forwarded to react-map-gl) */ + onMouseLeave?: (event: MapLayerMouseEvent) => void } // Use environment variable or fallback to a clean, minimal style @@ -106,6 +112,9 @@ export function RunMap({ showSkeleton = true, enableFullscreen = true, autoLoad = true, + interactiveLayerIds, + onMouseMove, + onMouseLeave, }: RunMapProps) { const mapRef = useRef(null) const containerRef = useRef(null) @@ -323,9 +332,9 @@ export function RunMap({ if (webglUnavailable) { return (
-
-
- +
+
+

地图无法显示

@@ -340,7 +349,7 @@ export function RunMap({ if (!shouldMount) { return (
-
+
{/* Grid pattern background */}
setShouldMount(true)} - className="flex items-center gap-2 rounded-xl border border-white/20 bg-white/60 px-5 py-2.5 text-sm font-medium backdrop-blur-xl transition-colors hover:bg-white/80 dark:border-white/10 dark:bg-black/40 dark:hover:bg-black/60" + className="border-separator/40 bg-secondary-system-background/70 hover:bg-secondary-system-background/80 focus-visible:ring-mint/40 flex items-center gap-2 rounded-xl border px-5 py-2.5 text-sm font-medium backdrop-blur-xl transition-colors focus-visible:ring-2 focus-visible:outline-none" > - + 加载地图 点击加载路线地图 @@ -378,7 +387,7 @@ export function RunMap({ stroke="currentColor" strokeWidth="2" strokeLinecap="round" - className="text-blue" + className="text-mint" />
@@ -390,7 +399,7 @@ export function RunMap({ <> {/* Map skeleton loading state */} {showSkeleton && !isMapLoaded && ( -
+
{/* Animated gradient background */} - + +
底图加载失败,已切换到简洁模式{lastStyleError ? '(网络受限)' : ''}
@@ -500,7 +512,7 @@ export function RunMap({ diff --git a/src/components/ui/ThemeToggle.tsx b/src/components/ui/ThemeToggle.tsx index 28389e2..332b4a8 100644 --- a/src/components/ui/ThemeToggle.tsx +++ b/src/components/ui/ThemeToggle.tsx @@ -41,7 +41,7 @@ export function ThemeToggle() { , 're } const variants = { - default: 'bg-blue text-white hover:bg-blue/90 shadow-sm', + default: 'bg-mint text-white hover:bg-mint/90 shadow-sm', ghost: 'bg-transparent hover:bg-white/20 dark:hover:bg-white/10', outline: 'border border-white/20 bg-white/40 hover:bg-white/60 dark:border-white/10 dark:bg-black/20 dark:hover:bg-black/30', @@ -44,7 +44,7 @@ export const AnimatedButton = ({ ref={ref} className={cn( 'inline-flex items-center justify-center gap-2 rounded-xl font-medium transition-colors', - 'focus-visible:ring-blue/50 focus-visible:ring-2 focus-visible:outline-none', + 'focus-visible:ring-mint/40 focus-visible:ring-2 focus-visible:outline-none', 'disabled:pointer-events-none disabled:opacity-50', 'backdrop-blur-xl', variants[variant], diff --git a/src/components/ui/button-variants.ts b/src/components/ui/button-variants.ts index 05e8dcd..779ed70 100644 --- a/src/components/ui/button-variants.ts +++ b/src/components/ui/button-variants.ts @@ -1,15 +1,17 @@ import { cva } from 'class-variance-authority' export const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-2xl text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-2xl text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-mint/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { - default: 'bg-primary text-white shadow-sm hover:bg-primary/90', + default: 'bg-mint text-white shadow-sm hover:bg-mint/90', destructive: 'bg-red text-white shadow-sm hover:bg-red/90', - outline: 'border border-separator bg-background shadow-sm hover:bg-fill hover:text-text', - secondary: 'bg-fill text-text shadow-sm hover:bg-fill-secondary', - ghost: 'hover:bg-fill hover:text-text', + outline: + 'border border-separator bg-secondary-system-background/50 text-label shadow-sm hover:bg-secondary-system-background/70', + secondary: + 'bg-secondary-system-fill/60 text-label shadow-sm hover:bg-secondary-system-fill/80', + ghost: 'text-label hover:bg-secondary-system-fill/60', link: 'text-link underline-offset-4 hover:underline', }, size: { diff --git a/src/components/ui/glass-panel.tsx b/src/components/ui/glass-panel.tsx new file mode 100644 index 0000000..5696719 --- /dev/null +++ b/src/components/ui/glass-panel.tsx @@ -0,0 +1,67 @@ +'use client' + +import { motion } from 'framer-motion' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +export interface GlassPanelProps extends React.HTMLAttributes { + ref?: React.RefObject + /** + * Enable subtle hover/tap animation. + * @default false + */ + interactive?: boolean + /** + * Enable entrance fade-in animation. + * @default false + */ + fadeIn?: boolean + /** + * Visual density. + * - default: main glass surface + * - subtle: lighter chrome for nested items + * @default "default" + */ + tone?: 'default' | 'subtle' +} + +const base = + 'rounded-2xl border border-separator/40 backdrop-blur-xl backdrop-saturate-150 shadow-sm shadow-black/5' + +const tones = { + default: 'bg-secondary-system-background/70', + subtle: 'bg-secondary-system-background/55', +} as const + +export function GlassPanel({ + ref, + className, + interactive = false, + fadeIn = false, + tone = 'default', + onAnimationStart, + onAnimationEnd, + ...props +}: GlassPanelProps) { + if (interactive || fadeIn) { + return ( + + ) + } + + return
+} diff --git a/src/components/ui/metric.tsx b/src/components/ui/metric.tsx new file mode 100644 index 0000000..835704d --- /dev/null +++ b/src/components/ui/metric.tsx @@ -0,0 +1,36 @@ +'use client' + +import * as React from 'react' + +import { cn } from '@/lib/utils' + +export interface MetricProps extends React.HTMLAttributes { + label?: React.ReactNode + value: React.ReactNode + unit?: React.ReactNode + size?: 'hero' | 'default' +} + +const sizes = { + hero: 'text-3xl sm:text-4xl', + default: 'text-2xl', +} as const + +export function Metric({ label, value, unit, size = 'default', className, ...props }: MetricProps) { + return ( +
+ {label &&
{label}
} +
+ + {value} + + {unit && {unit}} +
+
+ ) +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 73c4c9c..fa42159 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -137,7 +137,7 @@ const TabsList = ({ listRef.current = node }} className={cn( - 'relative inline-flex items-center gap-1 rounded-xl border border-white/20 bg-white/40 p-1 backdrop-blur-xl dark:border-white/10 dark:bg-black/20', + 'border-separator/40 bg-secondary-system-fill/50 relative inline-flex items-center gap-1 rounded-xl border p-1 backdrop-blur-xl backdrop-saturate-150', className, )} {...props} @@ -145,7 +145,7 @@ const TabsList = ({ {/* Sliding indicator */} {indicatorStyle.width > 0 && ( Date: Fri, 27 Feb 2026 10:40:26 +0800 Subject: [PATCH 4/5] fix(db): support TURSO_ env vars on Vercel --- src/lib/db/client.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/lib/db/client.ts b/src/lib/db/client.ts index dfdfebc..8cc28ae 100644 --- a/src/lib/db/client.ts +++ b/src/lib/db/client.ts @@ -14,10 +14,18 @@ import * as schema from './schema' * - Vercel Serverless 运行时文件系统基本只读(除了 /tmp)。 * - 预览环境(Preview)如果未配置 DATABASE_URL,也不应在构建阶段因 SQLite 路径不可写而失败。 */ +const getEnvDatabaseUrl = () => process.env.DATABASE_URL || process.env.TURSO_DATABASE_URL + +const getEnvDatabaseAuthToken = () => + process.env.DATABASE_AUTH_TOKEN || + process.env.TURSO_DATABASE_TOKEN || + process.env.TURSO_AUTH_TOKEN + const getDatabaseUrl = () => { // 如果有环境变量,直接使用 - if (process.env.DATABASE_URL) { - return process.env.DATABASE_URL + const envUrl = getEnvDatabaseUrl() + if (envUrl) { + return envUrl } // 在 Vercel 环境中,优先使用 /tmp(可写),避免构建/运行时因为 data 目录不存在而失败 @@ -38,7 +46,7 @@ const getDatabaseUrl = () => { const client = createClient({ url: getDatabaseUrl(), - authToken: process.env.DATABASE_AUTH_TOKEN, + authToken: getEnvDatabaseAuthToken(), }) /** From 95486cfe3caf76f6e7e70a1ace86df789e701461 Mon Sep 17 00:00:00 2001 From: priority3 Date: Sat, 28 Feb 2026 17:29:11 +0800 Subject: [PATCH 5/5] revert: Revert "feat(ui): apply Glass + Mint UI spec" (#17) Reverts priority3/runPaceFlow#17 This reverts the following commits from PR #17: - d05236c fix(db): support TURSO_ env vars on Vercel - 43bed44 feat(ui): apply Glass + Mint interactions - 988db1c fix(db): use /tmp sqlite fallback on Vercel - df1af33 docs(ui): add glass + mint UI spec Removes the Glass + Mint + grayscale UI system including: - docs/UI_SPEC.md - GlassPanel and Metric UI primitives - Dashboard + activity page Glass/Mint refactor - RouteLayer FeatureCollection + hitbox hover approach - RunMap interactiveLayerIds forwarding and UIKit chrome - /tmp SQLite fallback and TURSO_ env var support on Vercel Co-Authored-By: Claude Sonnet 4.6 --- docs/UI_SPEC.md | 206 ------------------ src/app/activity/[id]/page.tsx | 40 ++-- src/app/page.tsx | 48 +--- src/components/activity/ActivityActionBar.tsx | 38 ++-- src/components/activity/ActivityCard.tsx | 2 +- src/components/activity/ActivityHeatmap.tsx | 46 ++-- src/components/activity/ActivityTable.tsx | 51 ++--- src/components/activity/HeartRateZones.tsx | 18 +- src/components/activity/PersonalRecords.tsx | 16 +- src/components/activity/StatsCard.tsx | 70 +++--- src/components/layout/Header.tsx | 2 +- src/components/map/KilometerMarkers.tsx | 2 +- src/components/map/RouteLayer.tsx | 159 ++++---------- src/components/map/RunMap.tsx | 42 ++-- src/components/ui/ThemeToggle.tsx | 2 +- src/components/ui/animated-button.tsx | 4 +- src/components/ui/button-variants.ts | 12 +- src/components/ui/glass-panel.tsx | 67 ------ src/components/ui/metric.tsx | 36 --- src/components/ui/tabs.tsx | 10 +- src/lib/db/client.ts | 38 +--- src/styles/globals.css | 2 +- 22 files changed, 233 insertions(+), 678 deletions(-) delete mode 100644 docs/UI_SPEC.md delete mode 100644 src/components/ui/glass-panel.tsx delete mode 100644 src/components/ui/metric.tsx diff --git a/docs/UI_SPEC.md b/docs/UI_SPEC.md deleted file mode 100644 index d048b8b..0000000 --- a/docs/UI_SPEC.md +++ /dev/null @@ -1,206 +0,0 @@ -# RunPaceFlow UI 规范(Glass + Mint + 灰阶) - -> 目标:让 UI 从“模块拼装”变成“同一个系统里长出来的仪表盘”。 -> 风格:Apple Fitness / iOS 原生质感的 **玻璃拟态仪表盘**。 -> 约束:**单主色(mint)+ iOS 灰阶**;不引入额外 WebFont(使用系统字体栈)。 - ---- - -## 1. 一句话定位(产品语言) - -RunPaceFlow 是一个 Apple Fitness 风格的跑步仪表盘:暗色氛围底 + Glass 面板承载信息;地图是舞台背景,数字是主角。全站唯一强调色 **mint** 贯穿选中态、交互态、图表与路线高亮,其余全部使用 iOS 灰阶。 - ---- - -## 2. 设计原则(防碎片化) - -1. **只有两种 Surface**:Base(背景)与 Glass(信息层)。禁止出现第三种卡片风格。 -2. **数字优先**:指标数字的字号/字重/行高/对齐必须全站一致。 -3. **单主色**:mint 只用于 selection / interaction / visualization / progress。 -4. **同一状态驱动一切**:时间范围与选中活动必须全局联动(地图、列表、指标同时响应)。 -5. **动效少而准**:两档时长即可;避免夸张动效和“到处发光”。 - ---- - -## 3. 颜色系统(Single Accent + Grayscale) - -### 3.1 灰阶(来自 UIKit Tokens) - -项目已使用 `tailwindcss-uikit-colors`,灰阶直接使用以下语义 token(不再自造): - -- 文本:`text-label` / `text-secondary-label` / `text-tertiary-label` -- 背景:`bg-system-background` / `bg-secondary-system-background` -- 分隔:`border-separator` -- 填充(hover/chip):`bg-secondary-fill` / `bg-tertiary-fill` - -> 规则:能用 `separator` 分隔就不要用重阴影;能用灰阶表达就不要引入第二强调色。 - -### 3.2 主强调色:mint(唯一) - -mint 只允许出现于: - -1. **Selection**:segmented/tab/列表当前行/已选路线 -2. **Interaction**:focus ring、hover 边框、主要按钮强调 -3. **Visualization**:图表主线、地图高亮路线 -4. **Progress**:目标达成/进度提醒(尽量使用“soft mint”,不做高饱和大片铺色) - -禁止项: - -- 不允许把“距离/配速/心率”等指标各自染色(会立刻碎片化)。 -- “发光效果”只允许用于选中路线/选中行,且强度极低(更像系统 highlight)。 - ---- - -## 4. 字体策略(不引入 WebFont) - -### 4.1 字体栈 - -- 使用系统字体栈(iOS/macOS 优先 SF,Windows/Android 自动 fallback)。 -- 中文由系统字体接管(苹方/微软雅黑等),避免中英文混排割裂。 - -### 4.2 数字排版(必须全站统一) - -所有指标数字、表格数字列必须启用: - -- `tabular-nums`(数字等宽,列表/表格不跳) -- `tracking-tight`(更像仪表盘读数) -- `leading-none` 或接近 `1.05` 的紧凑行高 - -单位与标签统一: - -- 单位:小号、低对比(例如 `text-tertiary-label text-xs font-medium`) -- 标签:次级对比(例如 `text-secondary-label text-sm font-medium`) - ---- - -## 5. Typography Scale(全站仅三档层级) - -> 规则:不要在不同模块里随意发明字号/字重;只允许使用这三档。 - -### 5.1 Title(标题) - -- 页面标题 / 主模块标题 -- 建议:`text-xl font-semibold text-label` - -### 5.2 Metric(指标) - -- Hero 数字 / 卡片关键读数 -- 建议:`tabular-nums tracking-tight leading-none font-semibold text-label` -- 大小建议: - - Hero:`text-3xl` 或 `text-4xl`(仅首页核心指标) - - 常规模块:`text-2xl` - -### 5.3 Label(标签/说明) - -- 单位 / 说明 / 表头 -- 建议:`text-xs font-medium text-tertiary-label` - ---- - -## 6. Surface(材质)规则 - -### 6.1 Base(背景层) - -- 深色底 + 轻微渐变作为氛围(强度要克制) -- 地图属于背景舞台:整体降饱和/降对比,不抢主指标 - -### 6.2 Glass(信息层) - -Glass 面板必须统一: - -- 半透明底(灰阶) -- 轻微 blur(backdrop) -- 细边框(separator) -- 统一圆角:建议 `16px`(全站只用一个大圆角) -- 统一内边距:建议 16/20/24 三档 - -禁止项: - -- 禁止重阴影卡片(玻璃质感靠边框与高光,不靠大投影)。 -- 禁止出现多种圆角体系(会瞬间变“拼装风”)。 - ---- - -## 7. 布局骨架(Dashboard Layout) - -推荐总览页结构(桌面/移动均适配): - -1. **Top Bar** - - Large Title(例如 Running / Overview) - - Segmented Control(Week / Month / Year 或 Year Tabs) -2. **Hero** - - Summary(Glass):距离/次数/配速/心率/连续天数等核心指标 - - Stage(Base 或 Glass):地图/路线(舞台) -3. **Feed** - - Activity List(Glass):活动列表(可选中) -4. **Detail** - - Bottom Sheet / Side Panel:详情(路线、splits、心率、AI insight) - -核心原则: - -- “范围切换”是唯一全局入口;所有模块只响应它,不各自维护筛选。 -- 页面主线只有一条:先看 Summary,再看 Stage,再下钻 Detail。 - ---- - -## 8. 状态联动(cool 的关键) - -最少需要三个跨模块状态(概念即可,具体实现方式不限): - -- `range`:时间范围(week/month/year 或 year) -- `selectedActivityId`:选中活动 -- `hoverActivityId`:hover 活动(桌面端增强) - -联动规则(建议强制落实): - -1. hover 列表行 → 地图高亮路线(mint)+ tooltip 展示关键指标 -2. hover 地图路线 → 列表行高亮(mint 左侧条/边框) -3. click 任意一边 → 锁定 selection → 打开 detail sheet(统一动效) -4. 切换 range/year → summary、map、list **同步更新 + 同步过渡** - -选中态视觉统一(不要各模块自创样式): - -- mint 边框 / 左侧条 / 轻 glow(强度很低) -- 文字不必染色,尽量保持灰阶;用边框/环/线条表达选中 - ---- - -## 9. 动效(Motion) - -> 原则:少而准,两档时长全站统一。 - -时长建议: - -- Hover / micro interaction:`180ms` -- 页面切换 / sheet 开关 / range 切换:`280ms` - -动效形式建议: - -- Glass 面板:`fade + translateY(4px)`(很轻) -- 指标更新:`crossfade`(或轻量数字滚动,后续再做) -- 路线高亮:线宽/透明度过渡(避免复杂粒子特效) - ---- - -## 10. 执行清单(每加一个模块都要过一遍) - -- 是否只使用 Base / Glass 两类容器? -- 指标数字是否统一使用 Metric 规则(`tabular-nums`、紧凑行高、单位低对比)? -- 交互态是否只使用 mint? -- 是否出现第二强调色或“彩虹指标”? -- range/selection 是否全局联动? -- hover/click 是否地图与列表互相响应? -- 动效时长是否只使用两档(180/280)? - ---- - -## 11. 建议的组件抽象(工程落地) - -为避免“每个页面手写一套”,建议优先抽两个基础组件: - -1. `GlassPanel` - - 统一:圆角 / 边框 / blur / padding / hover 样式 -2. `Metric` - - 统一:数字样式(tabular-nums + tight)/ 单位 / label 的 baseline 对齐 - -> 后续所有页面优先组合这些基础组件,而不是复制粘贴 className。 diff --git a/src/app/activity/[id]/page.tsx b/src/app/activity/[id]/page.tsx index d004d8b..521039c 100644 --- a/src/app/activity/[id]/page.tsx +++ b/src/app/activity/[id]/page.tsx @@ -199,7 +199,7 @@ export default function ActivityDetailPage() { if (isLoading || !isMounted) { return (
-
+
@@ -214,7 +214,7 @@ export default function ActivityDetailPage() { if (error || !data) { return (
-
+
{/* Subtle gradient overlay for glassmorphic depth */} -
+
{/* Compact Header */} @@ -274,7 +274,7 @@ export default function ActivityDetailPage() {
@@ -293,7 +293,7 @@ export default function ActivityDetailPage() { {animationProgress > 0 && ( -
+
{/* Title and date */} @@ -407,10 +407,10 @@ export default function ActivityDetailPage() {
{activity.averagePace && (
- + {formatPace(activity.averagePace)} - 配速 + 配速
)} {activity.elevationGain !== null && activity.elevationGain > 0 && ( @@ -423,10 +423,10 @@ export default function ActivityDetailPage() { )} {activity.averageHeartRate && (
- + ❤{activity.averageHeartRate} - 心率 + 心率
)} {activity.weatherData && } @@ -452,7 +452,7 @@ export default function ActivityDetailPage() { {chartSplits.length > 0 ? (
-
+

每公里配速

@@ -462,7 +462,7 @@ export default function ActivityDetailPage() { />
) : ( -
+
暂无配速数据
)} @@ -474,7 +474,7 @@ export default function ActivityDetailPage() {
{/* Heart Rate Chart */} {heartRateData.length > 0 && ( -
+

心率变化

{chartSplits.length > 0 ? ( -
+

分段数据

) : ( -
+
暂无分段数据
)} @@ -529,11 +529,11 @@ export default function ActivityDetailPage() { {/* More Data Tab - Calories and other stats */} {activity.calories && ( -
+

其他数据

{activity.calories && ( -
+
卡路里
{activity.calories} @@ -542,7 +542,7 @@ export default function ActivityDetailPage() {
)} {activity.bestPace && ( -
+
最快配速
{formatPace(activity.bestPace)} diff --git a/src/app/page.tsx b/src/app/page.tsx index 40e5ba2..3971e29 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,8 +10,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { Activity, Calendar, Clock, MapPin } from 'lucide-react' import dynamic from 'next/dynamic' -import type { MapLayerMouseEvent } from 'react-map-gl/maplibre' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { ActivityTable } from '@/components/activity/ActivityTable' import { StatsCard } from '@/components/activity/StatsCard' @@ -27,7 +26,7 @@ const RunMap = dynamic( { ssr: false, loading: () => ( -
+
), }, ) @@ -69,19 +68,6 @@ export default function HomePage() { // UI state const [statsPeriod, setStatsPeriod] = useState('week') - const [hoverActivityId, setHoverActivityId] = useState(null) - - // Map → list hover linking (keep state updates minimal) - const handleMapMouseMove = useCallback((event: MapLayerMouseEvent) => { - const feature = event.features?.[0] - const id = feature?.properties?.id - const nextId = typeof id === 'string' ? id : null - setHoverActivityId((prev) => (prev === nextId ? prev : nextId)) - }, []) - - const handleMapMouseLeave = useCallback(() => { - setHoverActivityId((prev) => (prev === null ? prev : null)) - }, []) const activities = useMemo( () => activitiesData?.pages.flatMap((page) => page.activities) ?? [], @@ -144,7 +130,7 @@ export default function HomePage() { return (
{/* Subtle gradient overlay for glassmorphic depth */} -
+
@@ -154,23 +140,22 @@ export default function HomePage() { {/* Period Toggle */}

数据概览

-
+
{(['week', 'month'] as const).map((period) => (
-
+
- - {routes.length > 0 && ( - - )} + + {routes.length > 0 && }
@@ -332,8 +308,6 @@ export default function HomePage() { hasMore={!!hasNextPage} isLoadingMore={isFetchingNextPage} onLoadMore={() => fetchNextPage()} - hoveredActivityId={hoverActivityId} - onHoverActivity={setHoverActivityId} /> )} diff --git a/src/components/activity/ActivityActionBar.tsx b/src/components/activity/ActivityActionBar.tsx index b8adcd9..057bf11 100644 --- a/src/components/activity/ActivityActionBar.tsx +++ b/src/components/activity/ActivityActionBar.tsx @@ -100,7 +100,7 @@ export function ActivityActionBar({ {/* Fixed bottom action bar - only visible on mobile */} - + 分享 @@ -126,10 +126,10 @@ export function ActivityActionBar({ setShowExportMenu(!showExportMenu) setShowShareMenu(false) }} - className="active:bg-secondary-system-fill/60 flex flex-1 flex-col items-center gap-1 rounded-xl py-2 transition-colors" + className="flex flex-1 flex-col items-center gap-1 rounded-xl py-2 transition-colors active:bg-white/50 dark:active:bg-white/10" whileTap={{ scale: 0.95 }} > - + 导出
@@ -141,12 +141,12 @@ export function ActivityActionBar({ - + {/* Export button */} @@ -156,12 +156,12 @@ export function ActivityActionBar({ setShowExportMenu(!showExportMenu) setShowShareMenu(false) }} - className="border-separator/40 bg-secondary-system-background/80 hover:bg-secondary-system-background/90 flex h-12 w-12 items-center justify-center rounded-full border shadow-lg shadow-black/10 backdrop-blur-xl transition-colors" + className="flex h-12 w-12 items-center justify-center rounded-full border border-white/20 bg-white/80 shadow-lg backdrop-blur-xl transition-colors hover:bg-white dark:border-white/10 dark:bg-black/60 dark:hover:bg-black/80" whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} title="导出" > - +
@@ -169,14 +169,14 @@ export function ActivityActionBar({ {showShareMenu && ( e.stopPropagation()} > -
+
分享活动
@@ -204,14 +204,14 @@ export function ActivityActionBar({ {showExportMenu && ( e.stopPropagation()} > -
+
导出格式 diff --git a/src/components/activity/ActivityHeatmap.tsx b/src/components/activity/ActivityHeatmap.tsx index 3f58084..de65c93 100644 --- a/src/components/activity/ActivityHeatmap.tsx +++ b/src/components/activity/ActivityHeatmap.tsx @@ -39,12 +39,12 @@ interface StreakData { * Get color intensity based on distance */ function getIntensityColor(distance: number): string { - if (distance === 0) return 'bg-secondary-system-fill/40' - if (distance < 3000) return 'bg-mint/15' - if (distance < 5000) return 'bg-mint/30' - if (distance < 8000) return 'bg-mint/45' - if (distance < 10000) return 'bg-mint/60' - return 'bg-mint' + if (distance === 0) return 'bg-black/5 dark:bg-white/5' + if (distance < 3000) return 'bg-green/20' + if (distance < 5000) return 'bg-green/40' + if (distance < 8000) return 'bg-green/60' + if (distance < 10000) return 'bg-green/80' + return 'bg-green' } /** @@ -218,8 +218,8 @@ function DayCell({ className={cn( 'h-3 w-3 rounded-sm transition-all duration-150', getIntensityColor(day.distance), - isToday && 'ring-mint ring-1 ring-offset-1', - hasActivities && 'hover:ring-mint/40 cursor-pointer hover:scale-125 hover:ring-2', + isToday && 'ring-blue ring-1 ring-offset-1', + hasActivities && 'hover:ring-blue/50 cursor-pointer hover:scale-125 hover:ring-2', !hasActivities && 'cursor-default', )} disabled={!hasActivities} @@ -235,8 +235,8 @@ function DayCell({ initial={{ opacity: 0, scale: 0.95, y: -4 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: -4 }} - transition={{ duration: 0.18, ease: 'easeOut' }} - className="border-separator bg-secondary-system-background/85 w-64 rounded-2xl border p-4 shadow-lg shadow-black/10 backdrop-blur-xl" + transition={{ duration: 0.15, ease: 'easeOut' }} + className="w-64 rounded-xl border border-white/20 bg-white/90 p-4 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-black/90" > {/* Header */}
@@ -332,7 +332,7 @@ export function ActivityHeatmap({ activities, className }: ActivityHeatmapProps) return (
@@ -347,10 +347,10 @@ export function ActivityHeatmap({ activities, className }: ActivityHeatmapProps) - - {streaks.current}天连续 + + {streaks.current}天连续 )} {streaks.longest > 0 && streaks.longest > streaks.current && ( @@ -358,10 +358,10 @@ export function ActivityHeatmap({ activities, className }: ActivityHeatmapProps) initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.1 }} - className="bg-mint/8 flex items-center gap-1 rounded-full px-2 py-0.5" + className="bg-yellow/10 flex items-center gap-1 rounded-full px-2 py-0.5" > - - 最长{streaks.longest}天 + + 最长{streaks.longest}天 )}
@@ -423,12 +423,12 @@ export function ActivityHeatmap({ activities, className }: ActivityHeatmapProps) {/* Legend */}
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/src/components/activity/ActivityTable.tsx b/src/components/activity/ActivityTable.tsx index 8f1bf33..3285eec 100644 --- a/src/components/activity/ActivityTable.tsx +++ b/src/components/activity/ActivityTable.tsx @@ -27,7 +27,6 @@ import { RippleContainer } from '@/components/ui/ripple' import { springs } from '@/lib/animation' import { calculatePace, formatDuration, formatPace } from '@/lib/pace/calculator' import { trpc } from '@/lib/trpc/client' -import { cn } from '@/lib/utils' import type { ActivityListItem } from '@/types/activity' /** @@ -150,7 +149,7 @@ function calculateAchievements(activities: ActivityListItem[]): Map, - color: 'bg-mint/12 text-mint', + color: 'bg-yellow/20 text-yellow', }) achievements.set(longestActivity.id, existing) } @@ -161,7 +160,7 @@ function calculateAchievements(activities: ActivityListItem[]): Map, - color: 'bg-mint/16 text-mint', + color: 'bg-green/20 text-green', }) achievements.set(fastestActivity.id, existing) } @@ -172,7 +171,7 @@ function calculateAchievements(activities: ActivityListItem[]): Map, - color: 'bg-mint/10 text-mint', + color: 'bg-orange/20 text-orange', }) achievements.set(mostElevationActivity.id, existing) } @@ -186,10 +185,6 @@ export interface ActivityTableProps { hasMore?: boolean isLoadingMore?: boolean onLoadMore?: () => void - /** Hovered activity id for map/list linking */ - hoveredActivityId?: string | null - /** Callback when hovered activity changes */ - onHoverActivity?: (activityId: string | null) => void } export function ActivityTable({ @@ -198,8 +193,6 @@ export function ActivityTable({ hasMore = false, isLoadingMore = false, onLoadMore, - hoveredActivityId, - onHoverActivity, }: ActivityTableProps) { const achievements = useMemo(() => calculateAchievements(activities), [activities]) @@ -207,7 +200,7 @@ export function ActivityTable({ const trpcUtils = trpc.useUtils() // Prefetch activity data on hover for faster navigation - const handleRowMouseEnter = useCallback( + const handleMouseEnter = useCallback( (activityId: string) => { // Prefetch activity with splits data trpcUtils.activities.getWithSplits.prefetch({ id: activityId }) @@ -218,12 +211,12 @@ export function ActivityTable({ if (activities.length === 0) { return ( -
+

还没有活动记录

@@ -290,7 +283,6 @@ export function ActivityTable({ {virtualItems.map((virtualRow) => { const isLoaderRow = virtualRow.index >= activities.length const activity = activities[virtualRow.index] - const isHovered = !!activity && hoveredActivityId === activity.id return (
{ - handleRowMouseEnter(activity.id) - onHoverActivity?.(activity.id) - }} - onMouseLeave={() => onHoverActivity?.(null)} + onMouseEnter={() => handleMouseEnter(activity.id)} > - + {activity.isIndoor && ( - + 室内 @@ -375,25 +360,15 @@ export function ActivityTable({
- - + + {activity.averagePace ? formatPace(activity.averagePace) : formatPace( calculatePace(activity.distance, activity.duration), )} - /km + /km
{activity.elevationGain && activity.elevationGain > 0 && ( diff --git a/src/components/activity/HeartRateZones.tsx b/src/components/activity/HeartRateZones.tsx index afd311e..3b3e18c 100644 --- a/src/components/activity/HeartRateZones.tsx +++ b/src/components/activity/HeartRateZones.tsx @@ -18,11 +18,11 @@ export interface HeartRateZonesProps { * Heart rate zone definitions based on max heart rate percentage */ const ZONES = [ - { name: 'Z1 恢复', min: 50, max: 60, color: 'bg-mint/15', description: '轻松恢复' }, - { name: 'Z2 有氧', min: 60, max: 70, color: 'bg-mint/25', description: '基础耐力' }, - { name: 'Z3 有氧耐力', min: 70, max: 80, color: 'bg-mint/35', description: '提升耐力' }, - { name: 'Z4 乳酸阈值', min: 80, max: 90, color: 'bg-mint/50', description: '提升速度' }, - { name: 'Z5 无氧', min: 90, max: 100, color: 'bg-mint/65', description: '最大强度' }, + { name: 'Z1 恢复', min: 50, max: 60, color: 'bg-gray', description: '轻松恢复' }, + { name: 'Z2 有氧', min: 60, max: 70, color: 'bg-blue', description: '基础耐力' }, + { name: 'Z3 有氧耐力', min: 70, max: 80, color: 'bg-green', description: '提升耐力' }, + { name: 'Z4 乳酸阈值', min: 80, max: 90, color: 'bg-orange', description: '提升速度' }, + { name: 'Z5 无氧', min: 90, max: 100, color: 'bg-red', description: '最大强度' }, ] /** @@ -66,7 +66,7 @@ export function HeartRateZones({ averageHeartRate, maxHeartRate, className }: He return (
@@ -82,7 +82,7 @@ export function HeartRateZones({ averageHeartRate, maxHeartRate, className }: He return (
@@ -101,7 +101,7 @@ export function HeartRateZones({ averageHeartRate, maxHeartRate, className }: He {zone.min}-{zone.max}% · {percentage}%
-
+
{/* Summary stats */} -
+
平均心率

diff --git a/src/components/activity/PersonalRecords.tsx b/src/components/activity/PersonalRecords.tsx index d4f933a..0037eeb 100644 --- a/src/components/activity/PersonalRecords.tsx +++ b/src/components/activity/PersonalRecords.tsx @@ -45,7 +45,7 @@ function calculatePersonalRecords(activities: ActivityListItem[]): PersonalRecor unit: 'km', date: new Date(longestRun.startTime), icon: , - color: 'text-mint', + color: 'text-blue', activityId: longestRun.id, }) } @@ -65,7 +65,7 @@ function calculatePersonalRecords(activities: ActivityListItem[]): PersonalRecor unit: '/km', date: new Date(fastestRun.startTime), icon: , - color: 'text-mint', + color: 'text-green', activityId: fastestRun.id, }) } @@ -80,7 +80,7 @@ function calculatePersonalRecords(activities: ActivityListItem[]): PersonalRecor unit: '', date: new Date(longestDuration.startTime), icon: , - color: 'text-mint', + color: 'text-purple', activityId: longestDuration.id, }) } @@ -98,7 +98,7 @@ function calculatePersonalRecords(activities: ActivityListItem[]): PersonalRecor unit: 'm', date: new Date(mostElevation.startTime), icon: , - color: 'text-mint', + color: 'text-orange', activityId: mostElevation.id, }) } @@ -116,7 +116,7 @@ function calculatePersonalRecords(activities: ActivityListItem[]): PersonalRecor unit: '', date: new Date(best5k.startTime), icon: , - color: 'text-mint', + color: 'text-yellow', activityId: best5k.id, }) } @@ -135,7 +135,7 @@ function calculatePersonalRecords(activities: ActivityListItem[]): PersonalRecor unit: '', date: new Date(best10k.startTime), icon: , - color: 'text-mint', + color: 'text-red', activityId: best10k.id, }) } @@ -153,7 +153,7 @@ export function PersonalRecords({ activities, className }: PersonalRecordsProps) return (

@@ -163,7 +163,7 @@ export function PersonalRecords({ activities, className }: PersonalRecordsProps) {records.map((record) => (
{record.icon}
{record.title}
diff --git a/src/components/activity/StatsCard.tsx b/src/components/activity/StatsCard.tsx index f85ac1d..4bfe7c4 100644 --- a/src/components/activity/StatsCard.tsx +++ b/src/components/activity/StatsCard.tsx @@ -11,8 +11,6 @@ import { TrendingDown, TrendingUp } from 'lucide-react' import { useMemo } from 'react' import { AnimatedNumber } from '@/components/ui/animated-number' -import { GlassPanel } from '@/components/ui/glass-panel' -import { Metric } from '@/components/ui/metric' import { cn } from '@/lib/utils' export interface StatsCardProps { @@ -36,7 +34,7 @@ export interface StatsCardProps { goalUnit?: string /** Sparkline data points (7 days trend) */ sparklineData?: number[] - /** Sparkline color - defaults to mint */ + /** Sparkline color - defaults to blue */ sparklineColor?: string } @@ -53,7 +51,7 @@ function calculateTrend(current: number, previous: number): number | null { */ function Sparkline({ data, - color = 'var(--color-mint)', + color = 'var(--color-blue)', width = 80, height = 24, }: { @@ -188,30 +186,45 @@ export function StatsCard({ const decimals = typeof value === 'string' && value.includes('.') ? value.split('.')[1]?.length || 0 : 0 - // Single-accent style: mint is the only highlight color (unless overridden). - const effectiveSparklineColor = sparklineColor || 'var(--color-mint)' + // Determine sparkline color based on trend + const effectiveSparklineColor = + sparklineColor || + (isTrendPositive + ? 'var(--color-green)' + : isTrendNegative + ? 'var(--color-red)' + : 'var(--color-blue)') return ( - {/* Header */}
- - {title} - - {icon && {icon}} + {title} + {icon && {icon}}
{/* Value with trend and sparkline */}
- : value} - unit={unit} - /> +
+ + {isNumeric ? : value} + + {unit && {unit}} +
{/* Sparkline */} {sparklineData && sparklineData.length >= 2 && ( @@ -227,11 +240,9 @@ export function StatsCard({
{trend > 0 ? ( @@ -241,7 +252,7 @@ export function StatsCard({ ) : null} {Math.abs(Math.round(trend))}%
- {subtitle && {subtitle}} + {subtitle && {subtitle}}
)} @@ -258,7 +269,10 @@ export function StatsCard({
= 100 ? 'bg-mint' : 'bg-mint/70')} + className={cn( + 'h-full rounded-full', + goalProgress >= 100 ? 'bg-green' : goalProgress >= 50 ? 'bg-blue' : 'bg-orange', + )} initial={{ width: '0%' }} animate={{ width: `${goalProgress}%` }} transition={{ duration: 0.8, ease: 'easeOut' }} @@ -268,7 +282,7 @@ export function StatsCard({ )} {/* Subtitle (when no trend) */} - {subtitle && trend === null &&

{subtitle}

} - + {subtitle && trend === null &&

{subtitle}

} +
) } diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index ee678b0..64ed039 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -13,7 +13,7 @@ import { ThemeToggle } from '@/components/ui/ThemeToggle' export function Header() { return ( -
+
{marker.kilometer} diff --git a/src/components/map/RouteLayer.tsx b/src/components/map/RouteLayer.tsx index 1a4fa38..d2c0a8d 100644 --- a/src/components/map/RouteLayer.tsx +++ b/src/components/map/RouteLayer.tsx @@ -2,8 +2,7 @@ * RouteLayer Component * * Displays running routes on the map with glow effect - * Optimized: Uses a single GeoJSON Source with multiple LineString features, - * allowing hover/click interactions per route without mounting N Sources. + * Optimized: Combines all routes into a single MultiLineString for better performance */ 'use client' @@ -15,133 +14,67 @@ import type { RouteData } from '@/types/map' export interface RouteLayerProps { routes: RouteData[] - /** Highlight a single route on hover/selection */ - highlightRouteId?: string | null - /** Highlight color (defaults to iOS mint) */ - highlightColor?: string } -const DEFAULT_HIGHLIGHT = '#00C7BE' - -export function RouteLayer({ - routes, - highlightRouteId, - highlightColor = DEFAULT_HIGHLIGHT, -}: RouteLayerProps) { - const routesGeoJson = useMemo(() => { +export function RouteLayer({ routes }: RouteLayerProps) { + // Combine all routes into a single MultiLineString GeoJSON + const combinedGeoJson = useMemo(() => { if (!routes || routes.length === 0) return null const validRoutes = routes.filter((route) => route.coordinates && route.coordinates.length >= 2) + if (validRoutes.length === 0) return null - const geojson: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: validRoutes.map((route) => ({ - type: 'Feature', - properties: { id: route.id }, - geometry: { - type: 'LineString', - coordinates: route.coordinates.map((coord) => [coord.longitude, coord.latitude]), - }, - })), + const geojson: GeoJSON.Feature = { + type: 'Feature', + properties: {}, + geometry: { + type: 'MultiLineString', + coordinates: validRoutes.map((route) => + route.coordinates.map((coord) => [coord.longitude, coord.latitude]), + ), + }, } return geojson }, [routes]) - const highlightFilter = useMemo(() => { - if (!highlightRouteId) return null - return ['==', ['get', 'id'], highlightRouteId] as const - }, [highlightRouteId]) - - if (!routesGeoJson) { + if (!combinedGeoJson) { return null } return ( - <> - - {/* Wide hitbox layer for hover interactions (invisible) */} - - - {/* Base routes (subtle) */} - - - {/* Main route line */} - + + {/* Glow effect layer (behind main line) */} + - {/* Highlight route overlay */} - {highlightFilter && ( - <> - - - - )} - - + {/* Main route line */} + + ) } diff --git a/src/components/map/RunMap.tsx b/src/components/map/RunMap.tsx index 46e7434..e6a5964 100644 --- a/src/components/map/RunMap.tsx +++ b/src/components/map/RunMap.tsx @@ -17,7 +17,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { MapPin, Maximize2, Minimize2 } from 'lucide-react' import type { StyleSpecification } from 'maplibre-gl' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { MapLayerMouseEvent, MapRef } from 'react-map-gl/maplibre' +import type { MapRef } from 'react-map-gl/maplibre' import Map from 'react-map-gl/maplibre' import { cn } from '@/lib/utils' @@ -67,12 +67,6 @@ export interface RunMapProps { enableFullscreen?: boolean /** Auto-load map without requiring user click (default: true) */ autoLoad?: boolean - /** Map hover interactivity (forwarded to react-map-gl) */ - interactiveLayerIds?: string[] - /** Mouse move handler (forwarded to react-map-gl) */ - onMouseMove?: (event: MapLayerMouseEvent) => void - /** Mouse leave handler (forwarded to react-map-gl) */ - onMouseLeave?: (event: MapLayerMouseEvent) => void } // Use environment variable or fallback to a clean, minimal style @@ -112,9 +106,6 @@ export function RunMap({ showSkeleton = true, enableFullscreen = true, autoLoad = true, - interactiveLayerIds, - onMouseMove, - onMouseLeave, }: RunMapProps) { const mapRef = useRef(null) const containerRef = useRef(null) @@ -332,9 +323,9 @@ export function RunMap({ if (webglUnavailable) { return (
-
-
- +
+
+

地图无法显示

@@ -349,7 +340,7 @@ export function RunMap({ if (!shouldMount) { return (
-
+
{/* Grid pattern background */}
setShouldMount(true)} - className="border-separator/40 bg-secondary-system-background/70 hover:bg-secondary-system-background/80 focus-visible:ring-mint/40 flex items-center gap-2 rounded-xl border px-5 py-2.5 text-sm font-medium backdrop-blur-xl transition-colors focus-visible:ring-2 focus-visible:outline-none" + className="flex items-center gap-2 rounded-xl border border-white/20 bg-white/60 px-5 py-2.5 text-sm font-medium backdrop-blur-xl transition-colors hover:bg-white/80 dark:border-white/10 dark:bg-black/40 dark:hover:bg-black/60" > - + 加载地图 点击加载路线地图 @@ -387,7 +378,7 @@ export function RunMap({ stroke="currentColor" strokeWidth="2" strokeLinecap="round" - className="text-mint" + className="text-blue" />
@@ -399,7 +390,7 @@ export function RunMap({ <> {/* Map skeleton loading state */} {showSkeleton && !isMapLoaded && ( -
+
{/* Animated gradient background */} - + +
底图加载失败,已切换到简洁模式{lastStyleError ? '(网络受限)' : ''}
@@ -512,7 +500,7 @@ export function RunMap({ diff --git a/src/components/ui/ThemeToggle.tsx b/src/components/ui/ThemeToggle.tsx index 332b4a8..28389e2 100644 --- a/src/components/ui/ThemeToggle.tsx +++ b/src/components/ui/ThemeToggle.tsx @@ -41,7 +41,7 @@ export function ThemeToggle() { , 're } const variants = { - default: 'bg-mint text-white hover:bg-mint/90 shadow-sm', + default: 'bg-blue text-white hover:bg-blue/90 shadow-sm', ghost: 'bg-transparent hover:bg-white/20 dark:hover:bg-white/10', outline: 'border border-white/20 bg-white/40 hover:bg-white/60 dark:border-white/10 dark:bg-black/20 dark:hover:bg-black/30', @@ -44,7 +44,7 @@ export const AnimatedButton = ({ ref={ref} className={cn( 'inline-flex items-center justify-center gap-2 rounded-xl font-medium transition-colors', - 'focus-visible:ring-mint/40 focus-visible:ring-2 focus-visible:outline-none', + 'focus-visible:ring-blue/50 focus-visible:ring-2 focus-visible:outline-none', 'disabled:pointer-events-none disabled:opacity-50', 'backdrop-blur-xl', variants[variant], diff --git a/src/components/ui/button-variants.ts b/src/components/ui/button-variants.ts index 779ed70..05e8dcd 100644 --- a/src/components/ui/button-variants.ts +++ b/src/components/ui/button-variants.ts @@ -1,17 +1,15 @@ import { cva } from 'class-variance-authority' export const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-2xl text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-mint/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-2xl text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { - default: 'bg-mint text-white shadow-sm hover:bg-mint/90', + default: 'bg-primary text-white shadow-sm hover:bg-primary/90', destructive: 'bg-red text-white shadow-sm hover:bg-red/90', - outline: - 'border border-separator bg-secondary-system-background/50 text-label shadow-sm hover:bg-secondary-system-background/70', - secondary: - 'bg-secondary-system-fill/60 text-label shadow-sm hover:bg-secondary-system-fill/80', - ghost: 'text-label hover:bg-secondary-system-fill/60', + outline: 'border border-separator bg-background shadow-sm hover:bg-fill hover:text-text', + secondary: 'bg-fill text-text shadow-sm hover:bg-fill-secondary', + ghost: 'hover:bg-fill hover:text-text', link: 'text-link underline-offset-4 hover:underline', }, size: { diff --git a/src/components/ui/glass-panel.tsx b/src/components/ui/glass-panel.tsx deleted file mode 100644 index 5696719..0000000 --- a/src/components/ui/glass-panel.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client' - -import { motion } from 'framer-motion' -import * as React from 'react' - -import { cn } from '@/lib/utils' - -export interface GlassPanelProps extends React.HTMLAttributes { - ref?: React.RefObject - /** - * Enable subtle hover/tap animation. - * @default false - */ - interactive?: boolean - /** - * Enable entrance fade-in animation. - * @default false - */ - fadeIn?: boolean - /** - * Visual density. - * - default: main glass surface - * - subtle: lighter chrome for nested items - * @default "default" - */ - tone?: 'default' | 'subtle' -} - -const base = - 'rounded-2xl border border-separator/40 backdrop-blur-xl backdrop-saturate-150 shadow-sm shadow-black/5' - -const tones = { - default: 'bg-secondary-system-background/70', - subtle: 'bg-secondary-system-background/55', -} as const - -export function GlassPanel({ - ref, - className, - interactive = false, - fadeIn = false, - tone = 'default', - onAnimationStart, - onAnimationEnd, - ...props -}: GlassPanelProps) { - if (interactive || fadeIn) { - return ( - - ) - } - - return
-} diff --git a/src/components/ui/metric.tsx b/src/components/ui/metric.tsx deleted file mode 100644 index 835704d..0000000 --- a/src/components/ui/metric.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client' - -import * as React from 'react' - -import { cn } from '@/lib/utils' - -export interface MetricProps extends React.HTMLAttributes { - label?: React.ReactNode - value: React.ReactNode - unit?: React.ReactNode - size?: 'hero' | 'default' -} - -const sizes = { - hero: 'text-3xl sm:text-4xl', - default: 'text-2xl', -} as const - -export function Metric({ label, value, unit, size = 'default', className, ...props }: MetricProps) { - return ( -
- {label &&
{label}
} -
- - {value} - - {unit && {unit}} -
-
- ) -} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index fa42159..73c4c9c 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -137,7 +137,7 @@ const TabsList = ({ listRef.current = node }} className={cn( - 'border-separator/40 bg-secondary-system-fill/50 relative inline-flex items-center gap-1 rounded-xl border p-1 backdrop-blur-xl backdrop-saturate-150', + 'relative inline-flex items-center gap-1 rounded-xl border border-white/20 bg-white/40 p-1 backdrop-blur-xl dark:border-white/10 dark:bg-black/20', className, )} {...props} @@ -145,7 +145,7 @@ const TabsList = ({ {/* Sliding indicator */} {indicatorStyle.width > 0 && ( process.env.DATABASE_URL || process.env.TURSO_DATABASE_URL - -const getEnvDatabaseAuthToken = () => - process.env.DATABASE_AUTH_TOKEN || - process.env.TURSO_DATABASE_TOKEN || - process.env.TURSO_AUTH_TOKEN - const getDatabaseUrl = () => { // 如果有环境变量,直接使用 - const envUrl = getEnvDatabaseUrl() - if (envUrl) { - return envUrl + if (process.env.DATABASE_URL) { + return process.env.DATABASE_URL } - // 在 Vercel 环境中,优先使用 /tmp(可写),避免构建/运行时因为 data 目录不存在而失败 + // 在 Vercel 环境中,使用绝对路径 if (process.env.VERCEL) { - return 'file:/tmp/runpaceflow.db' - } - - // 本地开发环境:默认落到 data/activities.db(若目录不存在则自动创建) - const dataDir = path.join(process.cwd(), 'data') - try { - fs.mkdirSync(dataDir, { recursive: true }) - } catch { - // Best-effort: directory creation failure will surface as connection error later. + // Vercel 会将项目文件部署到 /var/task + return `file:${path.join(process.cwd(), 'data', 'activities.db')}` } - return `file:${path.join(dataDir, 'activities.db')}` + // 本地开发环境 + return 'file:./data/activities.db' } const client = createClient({ url: getDatabaseUrl(), - authToken: getEnvDatabaseAuthToken(), + authToken: process.env.DATABASE_AUTH_TOKEN, }) /** diff --git a/src/styles/globals.css b/src/styles/globals.css index 1cc26f1..5d798b8 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -7,7 +7,7 @@ } body { - @apply bg-system-background text-label tabular-nums antialiased; + @apply bg-system-background text-label antialiased; } }