1. 项目概述一个岩点板训练计划管理工具最近在尝试用 Cursor 这个 AI 编程工具边写边学 VueJS顺手做了一个小项目感觉这个学习路径特别适合有点前端基础、想快速上手新框架的朋友。项目叫Hangboard Routines说白了就是一个帮你管理岩点板Hangboard训练计划的单页面应用。对于攀岩爱好者来说自己编排训练计划、记录每次的训练组合是家常便饭但用纸笔或者手机备忘录总归不太方便数据也不好回溯。这个工具就是来解决这个痛点的让你能创建、编辑、保存一套结构化的训练计划下次训练时直接打开就能用所有数据都保存在你本地浏览器里。整个项目麻雀虽小五脏俱全。它基于 Vue 3 的 Composition API 和 TypeScript 构建UI 用了 PrimeVue 组件库路由管理交给 Vue Router构建工具则是 Vite。技术选型上我刻意避开了那些重型、复杂的方案坚持KISSKeep It Simple, Stupid原则在保证代码清晰可维护的前提下尽可能不做过度设计。毕竟对于一个学习兼工具型项目能跑起来、好用、代码看得懂才是第一位的。如果你也在学 Vue 3 或者想看看一个功能完整的小应用是怎么从零搭建的这个项目的思路和代码应该能给你不少直接的参考。2. 技术栈选型与项目初始化2.1 为什么选择这个技术组合在启动任何项目前明确技术栈是至关重要的一步。这次我选择Vue 3 TypeScript PrimeVue Vite的组合背后有非常实际的考量。首先Vue 3是目前 Vue 生态的主流和未来。其 Composition API 提供了比 Options API 更灵活的逻辑组织方式尤其是对于需要复用逻辑的组件script setup语法糖让代码变得极其简洁。对于这个训练计划应用每个训练块Block的编辑表单逻辑可能会复用Composition API 的composables在这里能大显身手。TypeScript是必须的。即使在这样一个小型项目中明确的数据类型也能极大提升开发体验和代码健壮性。项目要求中明确禁止使用any类型这强迫我们在设计之初就必须想清楚每个数据的形状比如一个训练计划Routine到底包含哪些字段一个训练块Block又有哪几种可能。这种约束看似麻烦实则是避免后期出现“这个字段到底是数字还是字符串”这类低级错误的利器。UI 方面我选择了PrimeVue。市面上 Vue 的 UI 库很多如 Element Plus、Ant Design Vue 等。选择 PrimeVue 一是因为它组件丰富、设计相对中性二来它对于表单类组件如SelectButton即按钮式单选组的支持正好契合本项目需求——训练时长参数大多是一组固定的数值选项。自己从头写样式和交互不是这个项目的重点借助成熟的 UI 库能让我们快速搭建出可用的界面把精力集中在核心业务逻辑上。构建工具Vite现在是前端开发的首选其基于原生 ES Module 的极速热更新体验在开发阶段能带来巨大的幸福感。相比传统的 WebpackVite 的配置更简单启动更快非常适合快速迭代的项目。最后Vue Router 4用于管理三个简单的页面路由而数据持久化则直接使用浏览器的localStorage。对于一个完全运行在本地、无需后端同步的训练工具localStorage 足够简单可靠。我们只需要一个轻量的服务层来封装对它的读写操作。2.2 从零搭建项目骨架明确了技术栈接下来就是动手创建项目。我使用 Vite 的官方模板它能一键生成一个配置好 TypeScript 和 Vue 3 的基础项目。npm create vuelatest hangboard-routines # 在创建提示中选择 TypeScript, Vue Router, 其他如ESLint、Prettier按需选择。 cd hangboard-routines npm install创建完成后首先安装我们选定的 UI 库 PrimeVue 及其主题。npm install primevue^3.49.0 primeicons接下来需要全局配置 PrimeVue 和 Vue Router。在src/main.ts中我们引入并注册它们import { createApp } from vue import App from ./App.vue import router from ./router // 导入 PrimeVue import PrimeVue from primevue/config import primevue/resources/themes/lara-light-blue/theme.css // 选择一个主题 import primevue/resources/primevue.min.css import primeicons/primeicons.css const app createApp(App) app.use(router) app.use(PrimeVue) // 注册 PrimeVue app.mount(#app)路由的配置在src/router/index.ts中。根据需求我们需要三个路由并设置一个根路径的重定向。import { createRouter, createWebHistory } from vue-router import MyRoutines from ../pages/MyRoutines.vue import NewRoutine from ../pages/NewRoutine.vue import EditRoutine from ../pages/EditRoutine.vue const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: /, redirect: /my-routines // 根路径重定向到“我的计划”页 }, { path: /my-routines, name: MyRoutines, component: MyRoutines, meta: { pageName: My Routines } // 可选的元信息用于显示页面标题 }, { path: /new-routine, name: NewRoutine, component: NewRoutine, meta: { pageName: New Routine } }, { path: /edit-routine/:id, // 动态路由:id 是计划ID name: EditRoutine, component: EditRoutine, meta: { pageName: Edit Routine }, props: true // 将路由参数 id 作为 prop 传递给组件更方便 } ] }) export default router注意在EditRoutine路由中设置props: true是一个好习惯。这样在EditRoutine.vue组件中你可以直接通过defineProps{ id: string }()来接收id参数而不是通过useRoute().params.id这使得组件逻辑更纯粹更易于测试。项目的基本结构按照常见的 Vue 约定来组织src/ ├── components/ # 可复用的展示组件 ├── composables/ # 使用 Composition API 封装的逻辑复用函数 ├── pages/ # 路由页面组件 ├── router/ # 路由配置 ├── services/ # 数据服务层如 localStorage 操作 ├── types/ # TypeScript 类型定义 └── main.ts这个结构清晰地将不同类型职责的代码分开随着项目增长维护起来会轻松很多。3. 核心数据模型与本地存储服务设计3.1 用 TypeScript 定义清晰的数据契约在写任何业务逻辑之前先定义好数据的“形状”是 TypeScript 开发的核心优势。对于这个应用核心就是“训练计划”Routine和“训练块”RoutineBlock。在src/types/index.ts中我们定义如下类型// 训练块有两种类型迭代悬吊休息和恢复纯休息 export type RoutineBlock | { type: iteration hang: number // 悬吊时间秒 rest: number // 休息时间秒 } | { type: recovery duration: number // 恢复时长秒 } // 一个完整的训练计划 export type Routine { id: string // 唯一标识用于查找和编辑 name: string // 计划名称 countdown: number // 开始训练前的倒计时秒 blocks: RoutineBlock[] // 按顺序排列的训练块数组 }这里RoutineBlock使用了 TypeScript 的联合类型Union Type。它表示一个训练块要么是iteration类型包含hang和rest要么是recovery类型包含duration。这种设计比用一个类型包含所有可能字段并用type字段区分要严谨得多。在后续的代码中我们可以通过判断block.type来安全地访问相应的属性TypeScript 的类型守卫会提供完美的智能提示和类型检查。实操心得在定义这种“类型标签不同属性”的结构时联合类型是首选。它强制你在处理数据时必须先判断类型避免了运行时访问不存在的属性导致的错误。这也是禁止使用any类型带来的一个正面效应——你必须思考数据的精确结构。3.2 封装 localStorage 服务虽然直接使用localStorage.setItem/getItem很简单但将其封装成一个服务有几个好处1) 集中管理存储键名2) 统一数据序列化JSON与反序列化逻辑3) 便于未来更换存储方案如 IndexedDB4) 使业务组件更专注于 UI 和交互不关心存储细节。我们在src/services/storage.service.ts中创建这个服务import type { Routine } from /types const STORAGE_KEY my-hangboard-routines // 获取所有计划 export function getRoutines(): Routine[] { const data localStorage.getItem(STORAGE_KEY) return data ? JSON.parse(data) : [] } // 保存所有计划全量替换 export function saveRoutines(routines: Routine[]): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(routines)) } // 根据ID查找单个计划 export function getRoutineById(id: string): Routine | undefined { const routines getRoutines() return routines.find(r r.id id) } // 创建或更新一个计划 export function saveRoutine(routine: Routine): void { const routines getRoutines() const index routines.findIndex(r r.id routine.id) if (index 0) { // 更新已存在的计划 routines[index] routine } else { // 新增计划 routines.push(routine) } saveRoutines(routines) } // 根据ID删除一个计划 export function deleteRoutineById(id: string): void { const routines getRoutines() const filteredRoutines routines.filter(r r.id ! id) saveRoutines(filteredRoutines) } // 生成一个简单的唯一ID用于新建计划 export function generateId(): string { return Date.now().toString(36) Math.random().toString(36).substring(2) }注意事项localStorage存储的是字符串。所以我们必须用JSON.stringify()将对象数组转为字符串存入取出时再用JSON.parse()转回对象。generateId函数使用了时间戳加随机数的方式生成 ID对于本地小规模应用足够简单且唯一避免了引入 UUID 库的复杂度。这就是KISS 原则的体现。4. “我的计划”页面实现4.1 页面布局与数据加载“我的计划”My Routines页面是应用的首页主要职责是展示所有已保存的训练计划列表并提供编辑和删除入口。首先在src/pages/MyRoutines.vue中搭建基础框架。我们使用 PrimeVue 的Card组件来包裹每个计划项让列表看起来更规整。template div classpage-container h1我的训练计划/h1 div v-ifroutines.length 0 classempty-state p你还没有创建任何训练计划。/p Button label创建新计划 clickgoToNewRoutine / /div div v-else classroutines-list Card v-forroutine in routines :keyroutine.id classroutine-card template #title{{ routine.name }}/template template #content div classroutine-info pstrong倒计时/strong{{ routine.countdown }} 秒/p pstrong训练块数量/strong{{ routine.blocks.length }}/p /div /template template #footer div classcard-footer Button label编辑 clickeditRoutine(routine.id) / Button label删除 severitydanger clickconfirmDelete(routine.id) / /div /template /Card /div /div /template script setup langts import { ref, onMounted } from vue import { useRouter } from vue-router import Card from primevue/card import Button from primevue/button import { getRoutines, deleteRoutineById } from /services/storage.service import type { Routine } from /types import { useConfirm } from primevue/useconfirm // 用于删除确认对话框 const router useRouter() const confirm useConfirm() const routines refRoutine[]([]) // 页面加载时从本地存储读取计划列表 onMounted(() { loadRoutines() }) function loadRoutines() { routines.value getRoutines() } function goToNewRoutine() { router.push(/new-routine) } function editRoutine(id: string) { router.push(/edit-routine/${id}) } function confirmDelete(id: string) { confirm.require({ message: 确定要删除这个训练计划吗此操作不可撤销。, header: 确认删除, icon: pi pi-exclamation-triangle, accept: () { deleteRoutine(id) } }) } function deleteRoutine(id: string) { deleteRoutineById(id) loadRoutines() // 删除后重新加载列表 } /script style scoped .page-container { max-width: 800px; margin: 0 auto; padding: 2rem; } .empty-state { text-align: center; padding: 3rem; border: 2px dashed #ccc; border-radius: 8px; } .routines-list { display: flex; flex-direction: column; gap: 1.5rem; } .routine-card { :deep(.p-card-title) { font-size: 1.25rem; margin-bottom: 0.5rem; } } .routine-info { display: flex; gap: 2rem; margin-bottom: 1rem; } .card-footer { display: flex; justify-content: flex-end; gap: 0.5rem; } /style这段代码有几个关键点响应式数据使用ref来管理routines列表当数据变化时Vue 会自动更新视图。生命周期钩子在onMounted中调用loadRoutines确保组件挂载后立即加载数据。条件渲染使用v-if和v-else来处理空状态当没有计划时显示创建按钮提升用户体验。列表渲染使用v-for遍历routines并为每个Card绑定唯一的:key这里用routine.id这是 Vue 高效更新虚拟 DOM 所必需的。用户交互编辑和删除按钮绑定了对应的方法。删除操作前使用了 PrimeVue 的useConfirm服务弹出一个确认对话框防止误操作。4.2 使用 Composables 抽离列表逻辑上面的代码将数据加载、删除逻辑都写在了页面组件里。对于更复杂的应用我们可以使用 Composition API 的composables来抽离可复用的业务逻辑。即使当前页面逻辑不复杂提前规划也是一个好习惯。我们在src/composables/useRoutines.ts中创建一个 Hookimport { ref } from vue import type { Routine } from /types import * as storageService from /services/storage.service export function useRoutines() { const routines refRoutine[]([]) function loadRoutines() { routines.value storageService.getRoutines() } function deleteRoutine(id: string) { storageService.deleteRoutineById(id) loadRoutines() // 删除后自动刷新列表 } // 初始化时加载一次 loadRoutines() return { routines, loadRoutines, deleteRoutine } }然后在MyRoutines.vue中我们可以简化脚本部分script setup langts import { useRouter } from vue-router import Card from primevue/card import Button from primevue/button import { useConfirm } from primevue/useconfirm import { useRoutines } from /composables/useRoutines const router useRouter() const confirm useConfirm() const { routines, deleteRoutine } useRoutines() // 使用 composable // ... 其他函数如 goToNewRoutine, editRoutine 保持不变 function confirmDelete(id: string) { confirm.require({ message: 确定要删除这个训练计划吗此操作不可撤销。, header: 确认删除, icon: pi pi-exclamation-triangle, accept: () { deleteRoutine(id) // 直接调用 composable 中的方法 } }) } /script这样数据管理的逻辑就被封装到了一个独立的、可测试的函数中页面组件只负责视图渲染和用户交互职责更加清晰。5. “创建/编辑计划”页面实现5.1 表单设计与组件拆分“创建计划”New Routine和“编辑计划”Edit Routine页面的 UI 和逻辑高度相似主要区别在于初始数据的来源空表单 vs. 从存储加载。因此我们可以先实现一个通用的表单组件然后在两个页面中复用。首先我们创建一个用于渲染和编辑单个训练块Block的组件src/components/RoutineBlockEditor.vue。因为训练块有两种类型它的逻辑会稍微复杂。template div classblock-editor Divider alignleft b训练块 {{ index 1 }}/b !-- index 从父组件传入 -- /Divider div classblock-type-selector label类型/label SelectButton v-modellocalBlock.type :optionsblockTypeOptions optionLabellabel optionValuevalue / /div div v-iflocalBlock.type iteration classiteration-fields div classfield label悬吊时间秒/label SelectButton v-modellocalBlock.hang :optionstimeOptions / /div div classfield label休息时间秒/label SelectButton v-modellocalBlock.rest :optionstimeOptions / /div /div div v-else classrecovery-fields div classfield label恢复时长秒/label SelectButton v-modellocalBlock.duration :optionstimeOptions / /div /div Button iconpi pi-trash severitydanger click$emit(remove) / /div /template script setup langts import { computed } from vue import SelectButton from primevue/selectbutton import Button from primevue/button import Divider from primevue/divider import type { RoutineBlock } from /types // 定义组件接收的 props 和 emits const props defineProps{ modelValue: RoutineBlock // 使用 v-model 双向绑定 index: number // 用于显示序号 }() const emit defineEmits{ update:modelValue: [value: RoutineBlock] // v-model 更新事件 remove: [] // 删除事件 }() // 本地副本用于编辑 const localBlock computed({ get: () props.modelValue, set: (value) emit(update:modelValue, value) }) // 常量定义 const blockTypeOptions [ { label: 迭代悬吊休息, value: iteration }, { label: 恢复, value: recovery } ] const timeOptions [10, 20, 30, 40, 50, 60] /script style scoped .block-editor { border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; position: relative; } .block-type-selector { margin-bottom: 1rem; } .field { margin-bottom: 1rem; } .field label { display: inline-block; min-width: 120px; font-weight: 500; } /style这个组件是表单的核心。它通过v-model接收一个RoutineBlock对象并通过计算属性的getter/setter实现双向绑定。当用户在界面上修改类型或时间时localBlock的setter会触发update:modelValue事件通知父组件数据已更新。删除按钮则触发remove事件。技术细节这里使用了 Vue 3 的defineProps和defineEmits宏配合 TypeScript无需运行时声明类型安全且简洁。computed的get/set形式是实现自定义组件v-model绑定的标准做法。5.2 整合表单页面接下来我们创建src/pages/NewRoutine.vue。编辑页面EditRoutine.vue将与之高度相似。template div classpage-container h1创建新训练计划/h1 Card template #content form submit.preventsaveRoutine !-- 计划名称 -- div classfield label forroutine-name计划名称/label InputText idroutine-name v-modelform.name placeholder例如力量耐力训练 / /div !-- 倒计时选择 -- div classfield label开始前倒计时秒/label div SelectButton v-modelform.countdown :optionstimeOptions / /div /div !-- 训练块列表 -- Divider alignleft b训练块序列/b /Divider div v-ifform.blocks.length 0 classempty-blocks p暂无训练块请添加。/p /div RoutineBlockEditor v-for(block, index) in form.blocks :keyindex v-modelform.blocks[index] :indexindex removeremoveBlock(index) / !-- 添加按钮组 -- div classbutton-group Button typebutton label添加迭代块 iconpi pi-plus clickaddIterationBlock / Button typebutton label添加恢复块 iconpi pi-plus severitysecondary clickaddRecoveryBlock / /div !-- 表单操作按钮 -- Divider / div classform-actions Button typebutton label取消 severitysecondary clickgoBack / Button typesubmit label保存计划 / /div /form /template /Card /div /template script setup langts import { ref } from vue import { useRouter } from vue-router import Card from primevue/card import InputText from primevue/inputtext import SelectButton from primevue/selectbutton import Button from primevue/button import Divider from primevue/divider import RoutineBlockEditor from /components/RoutineBlockEditor.vue import { generateId, saveRoutine } from /services/storage.service import type { Routine, RoutineBlock } from /types const router useRouter() const timeOptions [10, 20, 30, 40, 50, 60] // 表单的响应式数据 const form ref({ name: Name of new routine, countdown: 10, blocks: [ { type: iteration, hang: 10, rest: 50 } as RoutineBlock, { type: recovery, duration: 60 } as RoutineBlock ] }) // 添加训练块的方法 function addIterationBlock() { form.value.blocks.push({ type: iteration, hang: 10, rest: 50 }) } function addRecoveryBlock() { form.value.blocks.push({ type: recovery, duration: 60 }) } function removeBlock(index: number) { form.value.blocks.splice(index, 1) } // 保存计划 function saveRoutineHandler() { if (!form.value.name.trim()) { // 简单验证实际项目中可用更完善的方案如 Vuelidate alert(请输入计划名称) return } const newRoutine: Routine { id: generateId(), name: form.value.name, countdown: form.value.countdown, blocks: [...form.value.blocks] // 创建副本避免引用问题 } saveRoutine(newRoutine) router.push(/my-routines) // 保存后跳转回列表页 } // 取消并返回 function goBack() { router.back() } /script style scoped .page-container { max-width: 800px; margin: 0 auto; padding: 2rem; } .field { margin-bottom: 1.5rem; } .field label { display: block; font-weight: 500; margin-bottom: 0.5rem; } .empty-blocks { text-align: center; padding: 1rem; color: #666; border: 1px dashed #ddd; border-radius: 4px; margin-bottom: 1rem; } .button-group { display: flex; gap: 1rem; margin-bottom: 2rem; } .form-actions { display: flex; justify-content: flex-end; gap: 1rem; } /style这个页面整合了所有表单元素。form是一个响应式对象管理着计划名称、倒计时和训练块数组。addIterationBlock和addRecoveryBlock方法向blocks数组推入对应类型的默认块。RoutineBlockEditor组件通过v-for循环渲染并通过v-model与数组中的每个块进行双向绑定。当子组件触发remove事件时调用removeBlock方法从数组中移除对应的块。保存逻辑中我们为新的计划生成一个唯一 ID调用storageService.saveRoutine将其存入 localStorage然后导航回列表页。5.3 实现编辑页面编辑页面EditRoutine.vue与创建页面几乎相同核心区别在于它需要接收一个路由参数id。页面加载时需要根据这个id从 localStorage 中读取已有的计划数据来填充表单。保存时是更新操作而不是创建。我们可以通过复用大部分代码并调整初始化与保存逻辑来实现。!-- EditRoutine.vue 模板部分与 NewRoutine.vue 几乎完全相同故省略 -- script setup langts import { ref, onMounted } from vue import { useRouter } from vue-router // ... 导入组件和类型 import { getRoutineById, saveRoutine } from /services/storage.service // 定义 props 接收路由参数 const props defineProps{ id: string }() const router useRouter() // ... timeOptions 定义 // 表单数据初始为空或默认值 const form ref({ name: , countdown: 10, blocks: [] as RoutineBlock[] }) // 页面加载时根据 ID 获取计划数据 onMounted(() { const routine getRoutineById(props.id) if (routine) { form.value { name: routine.name, countdown: routine.countdown, blocks: [...routine.blocks] // 深拷贝避免直接修改原数据 } } else { // 如果没找到可以跳转回列表页或显示错误信息 alert(未找到该训练计划) router.push(/my-routines) } }) // 添加/删除块的方法与 NewRoutine.vue 完全相同 // ... // 保存计划更新操作 function saveRoutineHandler() { if (!form.value.name.trim()) { alert(请输入计划名称) return } const updatedRoutine: Routine { id: props.id, // 使用传入的 ID而不是生成新的 name: form.value.name, countdown: form.value.countdown, blocks: [...form.value.blocks] } saveRoutine(updatedRoutine) // storageService 中的 saveRoutine 会处理更新逻辑 router.push(/my-routines) } // ... goBack 方法 /script可以看到编辑页面与创建页面的主要差异在于onMounted生命周期钩子中的数据加载以及保存时使用的是已有的props.id。这充分体现了 Vue 组件复用的优势。6. 项目总结与进阶思考至此一个功能完整的岩点板训练计划管理应用就搭建完成了。它涵盖了从项目初始化、技术栈选型、数据建模、服务封装、到组件化开发、路由管理和状态绑定的完整流程。整个过程坚持了KISS和DRY原则代码结构清晰没有过度设计。我个人在实际操作中的几点体会TypeScript 是开发体验的倍增器尤其是在定义RoutineBlock这种联合类型时后续在RoutineBlockEditor组件里写条件渲染 (v-iflocalBlock.type iteration)编辑器能自动推断出该分支下localBlock具有hang和rest属性这种安全感是纯 JavaScript 开发无法提供的。严格遵守“不用any”的规则虽然初期会多花点时间思考类型但极大地减少了运行时潜在的错误。Composition API 让逻辑组织更灵活将数据读取和删除逻辑抽离到useRoutines这个 composable 中使得MyRoutines.vue页面组件非常清爽。如果未来需要添加“分享计划”或“云端同步”功能只需要在这个 composable 中修改或扩展页面组件几乎不用动。这种关注点分离的模式非常适合逻辑复杂的应用。组件设计要权衡复用与复杂度一开始我考虑将整个表单NewRoutine 和 EditRoutine也抽象成一个可复用的组件。但考虑到这两个页面除了数据来源和保存逻辑稍有不同其他几乎完全一致而差异点又足够简单最终决定让编辑页面“继承”创建页面的模板和大部分逻辑只重写脚本部分的关键函数。这比创建一个高度配置化的超级表单组件更简单直接。在简单和抽象之间我选择了简单。这个项目后续还可以从这些方向扩展训练计时器这是最自然的功能延伸。可以新建一个/play-routine/:id页面根据计划中的countdown和blocks序列实现一个视觉和语音提示的计时器真正用于指导训练。数据统计在 localStorage 中记录每次训练的执行情况然后提供一个统计页面展示历史训练数据、图表帮助用户追踪进步。计划分享通过生成一个包含全部计划数据的链接或二维码让用户能够分享自己的训练计划。UI/UX 优化为训练块添加拖拽排序功能为时间选择提供自定义输入增加计划描述字段实现更优雅的空状态和加载状态。通过这个从零到一的项目不仅实践了 Vue 3 的核心特性更重要的是体验了如何将一个产品想法通过合理的技术决策和清晰的代码结构一步步落地为一个可用的工具。这种“边做边学”的方式比单纯看教程要深刻得多。