ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue3+ThinkPHP打造适老化志愿者服务平台:从数据库到部署的全栈实践

Vue3+ThinkPHP打造适老化志愿者服务平台:从数据库到部署的全栈实践 1. 这个平台到底要解决什么问题老年志愿服务的真实困境先别急着谈技术我们得搞清楚一件事老年志愿者服务智慧平台核心不在智慧而在老年志愿者这几个字。我做这个项目的起因挺直接。社区那边一直用Excel表格管理志愿者信息活动报名靠微信群接龙服务时长靠手工登记年底评优的时候要从聊天记录里翻几个月前的截图。最痛的是社区里大部分活跃志愿者是55岁到70岁的退休老人——他们不是不会用手机而是受不了那些花里胡哨的App界面。字体太小、按钮太隐蔽、操作路径太长用过一次就再也不碰了。所以这个平台从立项开始就定了三个硬目标一是让社区工作者能高效管理志愿者、活动、服务记录和积分二是让老年志愿者能用最简单的操作完成报名、签到、查积分三是让整个服务过程有据可查年底能自动生成评优数据。技术栈选型没纠结太久前端Vue3 后端ThinkPHP前后端完全分离。Vue3负责页面交互ThinkPHP提供API接口。为什么会选这套组合后面我会详细讲先记住一个结论这个组合对于一个社区级项目来说性价比相当高。如果你是做社区服务、居家养老、公益组织管理系统这类项目的开发者或者你正在给某个单位做类似的志愿者管理平台这篇文章的完整落地过程应该能帮你少走不少弯路。2. 技术选型背后的思考Vue3和ThinkPHP为什么是一对务实组合2.1 前端选Vue3的真实理由我在选前端框架时其实在React、Vue之间犹豫过。但最终选Vue3核心原因有三条。第一项目复杂度摆在那里。这个平台主要包含管理员端和志愿者端两个角色核心页面也就十来个——登录注册、活动列表、活动详情、报名签到、个人中心、积分记录、后台管理、数据统计。这种体量用React有点大炮打蚊子Vue3的组合式APIComposition API写起来逻辑复用特别顺手一个useActivity函数就能把活动相关的所有逻辑都收进去。第二生态成熟度。Element Plus在后台管理系统里几乎是事实标准组件齐全文档清晰改主题色也方便。Vue Router、Pinia这些周边库也都是官方维护版本兼容性不用操心。第三团队上手成本。说实话国内招Vue开发比招React开发容易得多而且我团队里几个人都是Vue熟手没必要为了追求技术时髦去切换框架。2.2 后端选ThinkPHP的理由ThinkPHP作为国产老牌PHP框架经常被人低估。但放在这个项目里它有几个非常实际的优势。首先是部署门槛极低。很多社区和街道的服务器就是一台普通的云主机装的是Linux Nginx MySQL PHP环境。ThinkPHP跑在这种环境上几乎是零成本适配。你不需要像Java那样装一堆中间件也不需要像Node.js那样考虑进程守护PHP天生就是为这种场景准备的。其次是开发效率。ThinkPHP 6的架构足够清晰app目录下按controller、model、middleware分类路由定义直观数据库操作有查询构造器和模型关联写起来非常顺手。像这个项目里志愿者和活动之间的多对多关系用模型关联一句话就能查到报名记录不用写一堆join。最关键是ThinkPHP的文档和社区在国内足够丰富。遇到问题时搜一下解决方案遍地都是。对于时间紧、预算有限的政务类项目来说这种确定性比技术先进性重要得多。2.3 前后端分离的代价与收益这个项目我采用了前后端完全分离的架构Vue3构建的纯静态页面部署在Nginx上通过HTTP请求访问ThinkPHP提供的API接口。这么做的好处很明显前端开发和后端开发完全解耦我可以并行推进页面改版不用碰后端代码接口能被小程序端或者其他第三方系统复用。代价也有——跨域问题、接口鉴权问题、部署时多一个Nginx配置工作。但这些都有成熟解决方案后面我会讲到跨域到底怎么配才不出幺蛾子。3. 数据库建模按老人能用的标准倒推业务逻辑3.1 核心表结构与设计思路数据库是整个平台的地基。我设计了6张核心表admin管理员、volunteer志愿者、activity活动、activity_registration报名记录、service_record服务记录、integral_record积分记录。其中几个关键设计值得展开说。志愿者表中的年龄字段不是简单存一个数字而是同时存birthday出生日期和age_group年龄段分组。这是因为平台后续要按年龄段做统计分析比如60-70岁活跃志愿者占比多少、70岁以上志愿者主要集中在哪些服务类型。如果只存年龄每年都要跑脚本批量更新而存出生日期后年龄随时可以算出来年龄段通过MySQL的CASE WHEN语句分组即可。报名记录表设计了一个status字段取值范围是0、1、2、3——0代表已取消1代表已报名待签到2代表已签到3代表已签退。为什么要区分签到和签退因为有些服务活动是全天制的志愿者上午到场签到、下午结束签退两个时间点都要记录这样才能准确计算服务时长。而服务时长恰恰是整个积分体系的计算基础。服务记录表我特意做成了单独表而不是直接挂在报名记录下面。因为一次活动可能产生多种服务类型比如一个社区义诊活动有的志愿者负责量血压有的负责登记信息有的负责引导老人。虽然活动是同一个但服务类型不同积分计算标准也不同。把服务记录独立出来以后扩展志愿服务类型分析这类报表时就不需要改动现有表结构。3.2 积分体系的核心算法积分体系是这类平台最容易设计失败的地方。很多平台一股脑地把积分规则写死在代码里结果活动中途要调规则代码改得面目全非。我的做法是建一张integral_rule表把积分规则数据化。每条规则包含rule_type规则类型如服务时长积分、活动报名奖励、推荐新人奖励、rule_value计算参数比如每小时积分数、status是否启用。积分计算逻辑封装在后端Service层核心算法如下// 服务时长积分计算每满30分钟计0.5积分不足30分钟按0.5计 public function calculateServiceIntegral($volunteerId, $serviceMinutes) { // 查询当前的积分规则 $rule IntegralRule::where(rule_type, service_time)-where(status, 1)-find(); if (!$rule) { return [code 0, msg 积分规则未配置]; } // 每30分钟为一个计分单位不足30分钟按30分钟计 $units (int)ceil($serviceMinutes / 30); $integral $units * $rule[rule_value]; // 写入积分记录 IntegralRecord::create([ volunteer_id $volunteerId, integral $integral, source_type service_time, description 服务时长积分 . $serviceMinutes . 分钟 ]); // 更新志愿者积分汇总 $volunteer Volunteer::find($volunteerId); $volunteer-total_integral $integral; $volunteer-save(); return [code 1, msg 积分计算成功, integral $integral]; }这个逻辑看着简单但有个细节特别容易踩坑——积分的幂等性。如果管理员误操作重复点击了签退按钮或者网络抖动导致签退请求发了两次服务时长就会算两次积分也翻倍。所以我给service_record表加了唯一索引(activity_id, volunteer_id)数据库层面直接拦住重复数据代码里再用try-catch捕获重复提交异常。两层保险万无一失。3.3 适老化设计对数据结构的反向要求这就是前面提到的按老人能用标准倒推的体现。我们设计数据库时特意加了几个适老化字段。比如volunteer表里有is_voice_assist是否需要语音辅助和font_size_pref字体大小偏好这两个字段直接影响前端页面的渲染方式。老人登录后前端读到font_size_pref large所有页面自动切换到大字号模式按钮尺寸也同步放大。这些字段在普通管理平台里根本不存在但在适老系统里是刚需。再比如activity表里有registration_deadline报名截止时间和min_age、max_age年龄限制因为有些活动对志愿者年龄有要求比如搬运物资的活动不适合75岁以上老人参加。报名接口在写入activity_registration前要先校验年龄是否符合限制条件——这个逻辑必须放后端不能只靠前端隐藏按钮否则绕过前端直接调接口就能非法报名。4. 后端接口设计ThinkPHP如何支撑完整业务流转4.1 路由定义与版本控制ThinkPHP 6的路由定义我用的是Route门面方式全部集中在route/app.php文件里。为了不给后续维护挖坑我从一开始就做了两件事。第一所有API都以/api/前缀开头方便在Nginx层做区分和拦截。第二接口按模块分组管理每个模块用group方法包起来一目了然。// 活动模块路由 Route::group(activity, function () { Route::get(list, Activity/list); // 活动列表 Route::get(detail, Activity/detail); // 活动详情 Route::post(register, Activity/register); // 报名活动 Route::post(cancel, Activity/cancel); // 取消报名 Route::post(signIn, Activity/signIn); // 签到 Route::post(signOut, Activity/signOut); // 签退 })-middleware(AuthMiddleware::class); // 管理员模块路由 Route::group(admin, function () { Route::post(login, Admin/login); // 管理员登录 Route::get(statistics, Admin/statistics); // 数据仪表盘 Route::get(volunteerList, Volunteer/list); // 志愿者管理列表 Route::post(auditVolunteer, Volunteer/audit); // 志愿者审核 Route::post(activityPublish, Activity/publish); // 活动发布 Route::get(exportData, Report/export); // 数据导出 })-middleware(AdminAuthMiddleware::class);这里有个值得注意的设计。活动相关接口和志愿者列表导出接口都加了中间件做鉴权但鉴权的力度不同。activity模块的注册、签到接口任何登录用户都能访问只是要求携带有效的token而admin模块的接口还要额外校验操作者身份是不是管理员。我用两个不同的中间件处理了不同等级的权限需求。4.2 Token鉴权与刷新机制项目前后端分离后最麻烦的问题就是会话保持。PHP端的Session在跨域场景下用起来很别扭我直接选了JWTJSON Web Token方案。ThinkPHP里用JWT我选择了firebase/php-jwt这个库composer安装即可不用自己造轮子。登录成功后后端生成token返回给前端前端每次请求在Authorization头带上token后端中间件解析验证。踩了一个比较深的坑JWT默认的有效期一般不会设太长我设的是2小时但老年志愿者用手机操作时经常页面开着不动填个表填了半小时回头提交时token已经过期了。这时候如果直接弹登录已过期请重新登录对年轻人倒没什么对老人来说就可能直接放弃操作了。我的解决方式是双token机制。登录时同时返回access_token2小时有效和refresh_token7天有效。前端封装Axios拦截器当接口返回401时自动用refresh_token换新的access_token整个过程用户无感知。如果refresh_token也过期了才强制跳转到登录页。// Axios响应拦截器核心逻辑 service.interceptors.response.use( (response) { if (response.data.code 401) { // 尝试刷新token return refreshTokenFunc() .then((newToken) { // 更新本地token重发原请求 localStorage.setItem(access_token, newToken.access_token); localStorage.setItem(refresh_token, newToken.refresh_token); const config response.config; config.headers[Authorization] Bearer newToken.access_token; return service(config); }) .catch(() { // 刷新失败跳转登录页 router.push(/login); return Promise.reject(登录已过期); }); } return response; }, (error) { return Promise.reject(error); } );4.3 活动发布到签退的完整业务闭环这是整个平台最核心的流程我拆成四步来说。发布活动管理员填写活动名称、地点、时间、服务类型、活动介绍、人数上限、报名截止时间、年龄限制提交后存库状态为招募中。管理员可以设置活动是否需要审核志愿者有些活动确实需要筛选默认是免审直接报名成功。志愿者报名志愿者在活动列表页看到活动点击报名。后端接口做几个校验——活动是否存在、活动状态是否为招募中、是否过了报名截止时间、年龄是否符合、是否已报名过。所有校验通过后写入activity_registration表。这里有个容易疏忽的地方并发报名超员问题。微信小程序端和App端同时发起报名可能两个人同时通过人数校验最后报名人数超过上限。解决方式是在activity表加一个registered_count字段插入报名记录时用ThinkPHP的where条件加上registered_count max_people利用数据库的行锁特性避免超员$result Activity::where(id, $activityId) -where(registered_count, , $maxPeople) -inc(registered_count) -update(); if (!$result) { return json([code 0, msg 活动名额已满]); }这种原子更新方式比先查再写要可靠得多也是我在并发场景下踩过坑后总结的经验。扫码或报号签到活动当天志愿者到达现场后签到。我做了两种方式——线上微信扫码签到二维码里带上活动ID和志愿者ID的加密串和线下管理员代签防止老人不会扫码。签退与时长核算活动结束志愿者签退。后端计算签到到签退之间的分钟数写入服务记录表。如果是跨天活动比如两天一夜的环保活动签退时间会检测小于签到时间这时候直接按24小时制取模计算。4.4 管理端数据看板的SQL统计管理端首页需要展示几个关键数据总志愿者数、本月新增志愿者数、本月活动数、累计服务时长、积分发放总数。这些统计直接用ThinkPHP配合MySQL聚合查询实现。// 总志愿者数含审核通过率 $totalVolunteer Volunteer::count(); $pendingAudit Volunteer::where(audit_status, 0)-count(); // 本月新增志愿者 $monthStart date(Y-m-01 00:00:00); $monthEnd date(Y-m-t 23:59:59); $monthNewVolunteer Volunteer::whereBetween(create_time, [$monthStart, $monthEnd])-count(); // 累计服务时长 $totalServiceMinutes ServiceRecord::sum(service_minutes); $totalServiceHours round($totalServiceMinutes / 60, 1); // 服务类型分布 $serviceTypeStats ServiceRecord::alias(sr) -join(activity a, sr.activity_id a.id) -field(a.service_type, COUNT(*) as activity_count, SUM(sr.service_minutes) as total_minutes) -group(a.service_type) -select();这里我特意在service_record表上给volunteer_id和activity_id都建了索引否则数据量到了几万条后统计接口的响应时间会从几百毫秒飙升到几秒用户体感会非常明显。5. 前端落地Vue3组件化与适老化交互设计5.1 项目初始化和工程结构设计前端工程我用Vite创建没有用Vue CLI。Vite的启动速度、热更新体验比Webpack系好太多了。初始化命令就一行npm create vitelatest volunteer-frontend -- --template vue工程目录结构我按模块和组织层级两个维度划分避免后续组件多了乱成一锅粥src/ ├── api/ # 接口请求封装按模块拆文件 │ ├── activity.js │ ├── user.js │ ├── admin.js │ └── request.js # Axios实例封装 ├── assets/ # 静态资源 ├── components/ # 全局通用组件 │ ├── BaseCard.vue # 通用卡片 │ ├── FontSizeControl.vue # 字体大小调节组件 │ └── EmptyState.vue # 空状态组件 ├── router/ # 路由配置 │ └── index.js ├── stores/ # Pinia状态管理 │ ├── user.js │ └── app.js ├── views/ # 页面 │ ├── volunteer/ # 志愿者端页面 │ │ ├── ActivityList.vue │ │ ├── ActivityDetail.vue │ │ ├── MyIntegral.vue │ │ └── Profile.vue │ └── admin/ # 管理端页面 │ ├── Dashboard.vue │ ├── ActivityManage.vue │ └── VolunteerManage.vue └── App.vue5.2 组合式API落地的几个关键点Vue3的组合式API写起来最大的感受是——代码复用变得特别清爽。举个例子志愿者端和管理端都有活动列表页面但展示字段和操作按钮差异很大。我抽了一个useActivityList组合函数把获取列表数据、分页、搜索、状态切换这些通用逻辑都放在里面两个页面各自调用再补充自己特有的逻辑。// useActivityList.js import { ref, onMounted } from vue; import { getActivityList } from /api/activity; export function useActivityList() { const list ref([]); const loading ref(false); const page ref(1); const total ref(0); const pageSize ref(10); const keyword ref(); const fetchList async () { loading.value true; try { const res await getActivityList({ page: page.value, pageSize: pageSize.value, keyword: keyword.value }); list.value res.data.list; total.value res.data.total; } finally { loading.value false; } }; const search () { page.value 1; fetchList(); }; onMounted(fetchList); return { list, loading, page, total, pageSize, keyword, fetchList, search }; }除了逻辑复用组合式API对复杂组件的可维护性提升也很明显。比如活动详情页报名按钮、签到打卡、积分预览、活动地图这几个区域相互独立但又共用同一个活动的数据。我用ref定义活动数据然后在onMounted里一次性加载再用computed派生出各个区域的展示数据结构非常清晰。5.3 适老化交互改造从能用到好用这部分是平台和普通后台管理系统最不一样的地方也是我觉得整个项目中最有温度的部分。字号全局控制在app.js这个Pinia store里维护一个fontSizeScale状态默认为1。老人登录后如果偏好设置为大字号就在根组件上动态加一个font-large的class配合CSS的rem单位所有字体和组件尺寸同步放大。整个实现并不复杂但需要UI组件库配合——Element Plus的样式变量支持覆盖我用CSS变量重新赋值了主要字号变量。大按钮和充裕的间距适老化设计里按钮的点击区域不能只看视觉大小。我在全局样式中把Element Plus按钮最小高度调到了48px正常人机工程标准是44px确保手指点击不容易误触。列表项的行高也加大到56px以上让老人不会点错。语音辅助报名成功、签到成功这类关键操作通过SpeechSynthesisUtterance做语音播报。代码很简洁在关键操作回调函数里加几行const speak (text) { if (speechSynthesis in window) { const utterance new SpeechSynthesisUtterance(text); utterance.lang zh-CN; utterance.rate 0.9; // 稍微放慢语速 window.speechSynthesis.speak(utterance); } }; // 使用示例签到成功后播报 speak(签到成功您本次服务已记录感谢您的付出);登录页的点线动态背景也是一个不错的细节。纯CSS或Canvas实现一个缓慢流动的点线粒子动画让整个登录页看起来不那么死板同时也不会因为动画过于花哨分散老人注意力。我用了Canvas实现每帧绘制点并用线连接近距离的点动态效果比较柔和。5.4 打印模板与报表导出活动结束后管理员常常需要打印签到表、服务证明。这个场景我用的是Vue3的打印组件方案——vue3-print-nb这个库通过指令方式把指定区域的内容送到打印机。服务证明打印页面是一个独立的Vue组件里面不引入任何多余的元素只展示志愿者的姓名、服务日期、服务时长、服务类型加上平台的落款和印章用CSS画了一个圆形的红色印章。打印前用v-print指令绑定打印区域外的按钮和导航自动被隐藏。template div div idprintArea refprintArea !-- 服务证明内容 -- div classcertificate h2志愿服务证明/h2 p兹证明 strong{{ volunteerName }}/strong 同志/p p于 {{ serviceDate }} 参加 {{ serviceType }} 志愿服务活动/p p累计服务时长{{ totalHours }} 小时/p div classsealXX社区志愿者服务站/div /div /div button v-print#printArea打印证明/button /div /template打印样式通过media print单独定义确保纸质输出格式规整不会被页面上其他CSS干扰。5.5 富文本对比活动公告的编辑场景管理员编辑活动公告时我用了独立的富文本编辑器但一开始遇到了一个具体问题——版本迭代时不同浏览器渲染富文本HTML的结果有差异导致同一个公告在Chrome和Edge里显示不一致。我在项目里用diff方式实现了富文本差异对比插件发布前能预览两个版本的编辑差异避免误操作。这个看着不起眼但实际用起来非常解决问题尤其是有多个管理员协作编辑活动文案时。6. 部署实战小皮面板、Nginx路由与跨域配置全记录6.1 本地开发环境搭建项目开发阶段我用的是小皮面板phpstudy做本地环境Windows上集成了PHP、MySQL、Nginx一键启动非常省心。这里有一个关键的坑要提醒ThinkPHP项目在Nginx环境下的运行目录必须指向public目录。很多新手直接把网站根目录设置成项目根目录访问http://localhost/volunteer时直接报404或者直接暴露了项目源码目录这是非常危险的。正确做法是在小皮面板创建网站时运行目录选择public这样Nginx的root指向public外部只能访问到入口文件其他内部代码文件完全不可见。Nginx配置里核心是location的rewrite规则让所有非真实文件的请求都交给index.php处理server { listen 80; server_name volunteer.localhost; root /www/wwwroot/volunteer/public; index index.php index.html; # 前端静态文件Vue打包产物 location / { try_files $uri $uri/ /index.html; } # 后端API接口 location /api/ { if (!-e $request_filename) { rewrite ^/api/(.*)$ /index.php?s$1 last; } } location ~ \.php(.*)$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_split_path_info ^((?U).\.php)(/?.)$; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; include fastcgi_params; } }6.2 跨域问题的正确解法前后端分离项目跨域是绕不开的。开发环境我用Vite的代理解决配置一行搞定// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:8080, // ThinkPHP地址 changeOrigin: true } } } }生产环境的跨域就得靠后端配合了。我写了一个全局的跨域中间件在app/middleware.php里注册class CorsMiddleware { public function handle($request, \Closure $next) { $origin $request-header(origin, *); header(Access-Control-Allow-Origin: . $origin); header(Access-Control-Allow-Credentials: true); header(Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS); header(Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With, Accept); // 处理预检请求 if ($request-method() OPTIONS) { return response(, 204); } return $next($request); } }这里有个细节Access-Control-Allow-Origin不建议直接写*因为*不支持携带凭证Credentials而我们的Authorization头需要凭证支持。用$request-header(origin)回显来源域名安全性也能接受。6.3 Vue3项目部署后白屏问题排查项目第一次部署上线时遇到一个典型的白屏问题——首页加载后页面空白控制台报错。排查过程不复杂但很典型打开开发者工具看到Uncaught SyntaxError: Unexpected token 这个报错基本能确定是index.html引用的JS文件加载了HTML内容而不是JS。原因很明确Vue Router使用的是history模式前端路由的路径比如/activity/listNginx没有把请求返回到index.html而是返回了404页面。那个404页面是HTML格式浏览器把HTML内容当JS解析自然报错。解决方式就是前面Nginx配置里那行location / { try_files $uri $uri/ /index.html; }这行配置让所有不存在的文件请求都回退到index.html由前端Router接管后续路由。配置改完重启Nginx刷新页面秒开。6.4 前端静态资源的缓存策略部署后的另一个优化点是前端静态资源的缓存。Vite构建时文件名会带上hash值比如index-abc123.js内容变了文件名就变。所以我可以放心给静态资源设置长缓存max-age2592000即30天用户再次访问时直接走浏览器缓存不用重新下载。难点在index.html本身它是入口文件不能被长缓存。如果浏览器缓存了旧的index.html里面引用的还是旧hash的JS下次发版后就不会更新。我设置location /index.html { add_header Cache-Control no-cache, no-store, must-revalidate; }这样index.html每次请求都会向后端确认保证拿到最新版本。6.5 Edge浏览器的一个诡异问题在兼容性测试时发现一个很怪的现象项目在Edge浏览器中有时会出现页面卡顿甚至浏览器右上角的最小化按钮点击没有反应。排查很久才发现问题出在Vue3的一个动画库上——transition-group配合大量absolute定位的元素时动画执行期间会阻塞主线程导致浏览器按钮响应延迟。解决方式很简单给transition-group加上move-class并把动画时间缩短、改用GPU加速属性transform和opacity而不是top和left。改完之后动画流畅性不仅没降反而更好。7. 上线后的真实反馈与持续迭代方向平台上线试运行了三个月我拿到了一些很有意思的反馈。用数据说话注册志愿者482人其中60岁以上占比67%累计发布活动86场服务总时长超过2300小时。最让社区主任意外的是之前用Excel经常漏记的零散服务终于有了完整记录。但也暴露了一些前期没有预料到的问题。最大的一个很多老年志愿者子女不在身边手机流量套餐很少活动详情页里面的照片加载太慢经常打开一个页面要等五六秒。后来我在前端给图片加了懒加载v-lazy指令并且把活动封面图统一缩放到指定尺寸压缩到200KB以内再上传加载速度才有明显改善。另一个反馈是老人希望看到周围最近的志愿活动而不是按时间排列表格。这个功能目前平台还没做需要结合高德地图API做距离排序和地图展示算是一个比较明确的迭代方向。最后说一个我个人的教训这类面向老年群体的平台千万不要在高频操作路径上设置需要精确点击的交互组件。比如日期选择器Element Plus自带的日历选择对老人来说太精细了换成按钮式选项今天、明天、本周末之后报名转化率直接提高了20%这是上线前做用户测试时完全没预料到的。这个项目最大的价值不在于用了多新的技术而在于它验证了一件事Vue3和ThinkPHP的组合完全有能力支撑起一个有温度、有实际业务价值的社区服务平台。技术只是基础真正的难点在于你怎么理解你的用户并把理解转化为功能和交互上的细节。如果你也在做类似的平台希望这篇文章里的经验和踩坑记录能帮你把路走得更稳。
返回列表