毕业设计总结
HCloud 是一个全栈 JavaScript 开发的项目也是一个从零开始的项目,前端采用的是 uni-app 跨端框架,后端 EggJS,前端在 uni-app nvue (weex) 纯原生渲染技术的加持下,功能和流畅度方面和原生 app 差距不大。
项目 CODE 和成品链接:
功能思维导图
空格鼠标拖动移动视图,滚轮放大放小
移动端不支持思维导图
- Hcloud
- 登录/注册/找回
- 账号密码登录
- 短信登录/注册/找回
- 首页
- 最近动态列表
- 预览
- 长按进入选择模式(可单/多选)
- 打开所在目录
- 下载
- 预览
- 搜索文件
- 上传文件
- 传输列表
- 点击切换
- 已完成
- 上传
- 跳转到所在目录
- 下载
- 打开文件
- 长按进入选择模式(可单/多选)
- 继续
- 暂停
- 取消
- 文件列表
- 查找文件(同首页相同,两个入口)
- 点击文件预览
- 视图规则(过滤、排序、切换展示模式)
- 长按进入选择模式(可单/多选)
- 重命名
- 移动
- 下载
- 分享
- 删除
- 相册
- 九宫格布局
- 右侧上下拖动快速定位到所选日期
- 长按进入选择模式(和文件页相同)
- 点击预览
- 个人中心
- 用户基本信息
- 点击进入用户详情
- 空间使用情况
- 分享管理页面
- 点击进入分享详情页
- 查看文件
- 取消分享
- 设置提取码
- 复制链接
- 长按进入选择模式(可单/多选)
- 取消分享
- 复制链接
- 回收站页面
- 点击/长按进入选择模式
- 恢复文件
- 彻底删除文件
- 关于页面
- 二维码扫描
- 退出登录
- 添加
- 扫一扫
- 新建文件夹
- 本地文件上传
- 相册文件上传
- 解析分享链接
- fresh-purple
- filetree
知识点&难点总结
前端
本项目前端技术栈使用的是 Uni-app Weex Native Vue Vuex Scss 等,是一个纯 NVue 开发的 APP
全局弹窗
在 NVUE 中,由于有些组件是 webView 渲染,有些则是原生渲染,这两种的的存在就造成了 webView 的组件无法覆盖原生渲染的组件,官方虽说在纯 NVUE 的渲染页面不存在覆盖不了的问题,但是导航栏和 tabbar 等原生组件在纯 NVUE 渲染的情况下依旧无法覆盖
解决覆盖问题的方法有多个 如下:
- SubNVue(盖上一个新的 webView 窗口,缺点: 使用方式有所限制,扩展性不够,只支持 APP 端)
参考链接 - 自定义导航栏和 tabbar(缺点:页面跳转性能方面变差,因为只能改成组件路由的切换模式来跳转页面)
- 新健一个背景透明的 Nvue 页面(能覆盖所有组件但是只支持 APP 端)
- 自行绘制原生控件(利用官方的 H5PLUS API 绘制,自定义强,但交互、动画等效果比较难实现)
{
"style": {
"navigationStyle": "custom",
"backgroundColor": "transparent",
"app-plus": {
"background": "transparent",
"titleNView": false
}
}
}在布局方面,nvue 只支持 flex 弹性布局,px/rpx 单位,不支持百分比,并且也不支持 DOM 操作,局限性比较大,以下是传统布局在 nvue 中的实现方式
flex
在 nvue 中自带 display:flex css 设置,不用手动添加该样式,默认 column 垂直方向,手动设置可覆盖,也可修改全局默认设置 manifest.json -> app-plus -> nvue -> flex-direction 节点下修改
manifest.jsonlink"nvue" : { "flex-direction" : "row" }
撑满屏幕/w100% h100%
// 1
view {
display: flex;
flex: 1;
}
// 2
view {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
//3 在uni-app中,750rpx即可以当做 width:100% 来使用,但在宽大于一定程度后,不在自动撑满,该方案只能自动撑满宽度,高度只能自行获取屏幕高度设置
view {
width: 750rpx;
}3 参考链接:
js动态获取屏幕宽高linkconst { windowWidth, windowHeight, navigationBarHeight, statusBarHeight } = uni.getSystemInfoSync() // 屏幕高度 = 原生NavigationBar高度(含状态栏高度)+ 可使用窗口高度 + 原生TabBar高度 // windowHeight不包含NavigationBar和TabBar的高度 // H5端,windowTop等于NavigationBar高度,windowBottom等于TabBar高度 // App端,windowTop等于透明状态NavigationBar高度,windowBottom等于透明状态TabBar高度 // 高度相关信息,要放在 onReady 里获取
自定义字体图标导入
在 webView 中,可以使用传统的方式导入字体图标,但在 Nvue 中传统的方式无效,得使用官方提供的插件 api 导入,以下是导入代码 参考链接
const domModule = uni.requireNativePlugin('dom')
domModule.addRule('fontFace', {
fontFamily: 'iconfont',
src: `url('file:/${plus.io.convertLocalFileSystemURL('_www/static/icon/iconfont/iconfont.ttf')}')` // android 读取本地文件路径,通常得加上前缀 file:///
})动画
在 APP 端, 动画的实现方式也有所限制和不同
| 技术关键词 | transition | @keyframes | plugin-animation | plugin-bindingx | lottie |
|---|---|---|---|---|---|
| 支持性 | YES | NO | YES | YES | YES |
以下是本项目对 plugin-animation 的封装和调用方式
util/animation.jsconst animation = uni.requireNativePlugin('animation') // 对单 export function animate(target, options) { return new Promise((resolve, reject) => { if (!target || !options) return reject('params error') animation.transition(target, options, () => { resolve(true) }) }) } // 对群 export async function animates(multiple) { if (Object.prototype.toString.call(multiple) !== '[object Array]') return console.error('Not an Array') for (let item of multiple) { await animate(item.target, item.options) } }
common/js/mixins/dragpanel.jsanimate(this.$refs['ctx'], { styles: { transform: 'translateY(100%)' }, duration: 200, timingFunction: 'ease', delay: 0, needLayout: true }).then(() => { uni.navigateBack() })
交互
一般交互动画应用场景如: 点击 拖动 元素增删改查过渡 初始化等等… , 在交互上做一些功夫能使整个 APP 无论是视觉上还是体验上都有很好的感光提升,能加强引导用户操作,也能在页面数据还未加载完成时做一个替代品,但想要实现并落地一个交互动画还是比较困难的(对于全部都由我自己来做 o(╥﹏╥)o 的酷比来说), 因为要考虑之间融不融洽、过渡是否顺畅等等视觉感官上的设计,还要考虑这个交互的动画实现成本,不能太占资源而导致整个 app 卡顿
在 uni-app 中,使用传统的 JS 方式来做交互的话,性能会比较差,为什么呢? 这里引用官方文档的话: 逻辑层和视图层分离的。此时会产生两层通信成本。比如拖动视图层的元素,如果在逻辑层不停接收事件,因为通信损耗会产生不顺滑的体验,因而在本项目中我采用的是 Weex 提供的 Bindingx 插件 来做交互动画
uni-app 官方说明 > Bindingx 官网文档 > Weex BindingX 尝鲜
首页

pages/tabbar/index/index.nvuebindingx.bind({ eventType: 'scroll', // 容器 anchor: getEl(this.$refs['index']), props: [ { // 要执行动画的元素 element: getEl(this.$refs['top']), // 要执行动画的属性 property: 'opacity', // 每次执行的运算表达式 expression: '1-y/' + this.dynamicTop } ] })
我的

pages/tabbar/personal/personal.nvuelet circleY_targetY = uni.upx2px( size.top + (this.navHeight - statusBarHeight) + (this.navHeight - statusBarHeight - uni.upx2px(100) * 0.6) / 2 ), qs_targetY = uni.upx2px( size.top + (this.navHeight - statusBarHeight) + (this.navHeight - statusBarHeight - uni.upx2px(60) * 0.9) / 2 ), list_header_height = uni.upx2px(300) const userOpacity = `max(1-min(y,${uni.upx2px(100)})/${uni.upx2px(100)},0)`, navOpacity = `min(y,${uni.upx2px(list_header_height + this.navHeight)})/${uni.upx2px( list_header_height + this.navHeight )}`, userY = `y>${uni.upx2px(100)}?-500:0`, circleY = `(0-min(y,${list_header_height})/${list_header_height})*${circleY_targetY}`, circleX = `(0-min(y,${list_header_height})/${list_header_height})*${uni.upx2px(100)}`, circleScale = `(y-${this.navHeight})<=0?1:(1-min(.4,1*(y-${this.navHeight})/${list_header_height}))`, qsY = `(0-min(y,${list_header_height})/${list_header_height})*${qs_targetY}`, qsX = `min(y,${list_header_height})/${list_header_height}*${uni.upx2px(90)}`, qsScale = `(y-${this.navHeight})<=0?1:(1-min(.1,1*(y-${this.navHeight})/${list_header_height}))` bindingx.bind({ eventType: 'scroll', anchor: getEl(this.$refs['personal']), config: { transformOrigin: 'center' }, props: [ { element: getEl(this.$refs['nav']), property: 'opacity', expression: navOpacity }, { element: getEl(this.$refs['user']), property: 'opacity', expression: userOpacity }, { element: getEl(this.$refs['user']), property: 'transform.translateY', expression: userY }, { element: getEl(this.$refs['qs']), property: 'transform.translateY', expression: qsY }, { element: getEl(this.$refs['qs']), property: 'transform.scale', expression: qsScale }, { element: getEl(this.$refs['qs']), property: 'transform.translateX', expression: qsX }, { element: getEl(this.$refs['circle']), property: 'transform.translateY', expression: circleY }, { element: getEl(this.$refs['circle']), property: 'transform.translateX', expression: circleX }, { element: getEl(this.$refs['circle']), property: 'transform.scale', expression: circleScale } ] })
拖动式抽屉组件

components/custom/custom-dragPanel/custom-dragPanel.vuebindingx.bind( { eventType: 'pan', anchor: el, props: [ { element: el, property: 'transform.translateY', expression: 'max(y+' + this.y + ',0)' } ] }, e => { if (e.state === 'end') { if (this.autoTop) { if (e.deltaY > this.ctxHeight / 2) return this.back() else { this.show() } } else { this.y += e.deltaY if (this.y < 0) this.y = 0 if (this.y > this.ctxHeight / 2) return this.back() } } } )
绘制原生导航栏
在本项目中,长按选择文件会显示当前已选的个数和取消、全选按钮的导航栏窗口,并且会覆盖原生自带的窗口,实现了类似百度云盘等 app 选择模式下的同等 UI,采用官方提供的H5PLUS -> nativeObj 原生控件绘制 API 绘制,这里也涉及到了 JS 和 native 之间的通信
util/pages.jsfunction createSelectNav() { // 获取坐标 const { statusBarHeight, windowWidth } = uni.getSystemInfoSync() // 初始化控件参数 let nav = new plus.nativeObj.View('nav', { top: `${statusBarHeight}px`, left: '0px', height: `44px`, width: '100%', backgroundColor: '#4070ff', statusbar: { background: '#4070ff' } }) // 绘制导航栏里的的内容 nav.draw([ { tag: 'font', id: 'title', text: '', textStyles: { size: '16px', color: 'white' }, position: { bottom: '0px', height: '44px' } }, { tag: 'font', id: 'cancel', text: '取消', textStyles: { size: '16px', color: 'white' }, position: { left: '43%' } }, { tag: 'font', id: 'all', text: '全选', textStyles: { size: '16px', color: 'white' }, position: { right: '84%' } } ]) // 绑定点击事件 nav.addEventListener('touchstart', function (e) { let itemWidth = windowWidth * 0.2 if (e.screenX < itemWidth) { uni.$emit('select-all') } else if (e.screenX > windowWidth - itemWidth) { uni.$emit('cancel-all') } }) return nav }
前端直传 OSS
后端
本项目后端技术栈使用的是 EggJS Mysql Redis Sequelize AliYun_SMS JWT,文件存储在 AliYun_OSS,文件上传下载传输采用的是直传 OSS 的方式,省掉了中转到 OSS 的流量
Tips: 手机端视窗太小不适合 mermaid 图展示,看不太清楚,请到 pc 端查看
用户登录/注册/找回
在本项目中,用户注册找回等操作是采用短信验证方式进行限制的,技术栈方面 使用了 redis 替代了 session、 jwt 生成 token、 接入了阿里云大鱼短信验证
用户操作除了账号密码登录不需要短信验证外,其他情况都需要,整个过程如下:
graph TB
A[注册/找回/登录 流程] -->|发送短信| C[用户是否存在?参数是否正确?]
C -->|验证通过| D[发出短信]
C -->|验证失败| E[返回错误提示]
D -->|用户收到短信,提交验证码|F[短信验证]
F --> G[是否通过?]
G --> |通过,跳转到注册或找回密码页面| H[表单页面]
G --> |短信登录| K[登录成功,清除登录错误记录]
G --> |失败| E
H --> |提交表单,验证参数是否合法|I[是否通过?]
I --> |通过| J[注册/找回成功]
I --> |失败| H
A --> |账号密码登录| L[校验账号信息]
L --> |通过| K
L --> |失败| Z[记录账号错误次数,达到一定量的时候自动锁定,需等30分钟解锁]文件管理 CURD
文件管理是本项目中的难点之一,也是比较核心的一部分,下面一部分一部分讲解
请求授权流程
graph LR
A[请求授权流程] -->|携带token| C[校验token]
C -->|校验通过| B[利用token获取用户信息]
C -->|校验失败| E[返回错误提示]
B --> E1[参数校验]
E1 --> E2[通过]
E1 --> E
E2 --> END[响应成功]文件存储结构
stateDiagram-v2
文件夹
state 文件夹 {
子文件夹
state 子文件夹 {
文件4
...子文件1
子文件夹2
state 子文件夹2 {
子文件2
}
子文件夹2
state 子文件夹3 {
子文件3
...N
}
}
...子文件
}由上面的 mermaid(状态图)可以看出,文件夹里有子文件和子文件夹,子文件夹里在有子文件,无限嵌套,这种结构必然得加上递归的方式来进行增删改查处理,之间的逻辑会交纵错杂,经常尝生一条线好了另一条线坏了的情况
在本项目中,文件的结构和文件夹由 mysql 来模拟,使用到了自联查询和 CTE 递归查询,目前这种方案算不上好,但能实现需求,递归查询非常耗性能,文件存储在阿里云 OSS 上,速度方面可以跑满带宽
文件上传
在本项目,文件上传流程: 前端授权并直传 OSS 完成后,OSS 会携带秘钥请求服务端,服务端验证后入库,完成文件上传
sequenceDiagram
autonumber
前端 ->> 服务端: 请求签名授权
服务端 ->> 服务端: 生成唯一签名
服务端 ->> 前端: 完成签名响应
前端 -->> OSS: 携带签名,前端直传OSS
OSS ->> 服务端: 直传完成后,携带签名,请求服务端回调接口
服务端 ->> 服务端: 校验参数合法性,通过则入库
服务端 ->> 前端: 文件上传请求,响应完成重命名
重命名相对比较容易实现,只要检查同目录下是否有相同名称的文件即可
graph LR
A[重命名] --> |token校验通过| B[参数校验]
B --> C[当前目录是否有同名文件存在]
C --> |YES| D[查库,自动在名称上面加序号]
C --> |NO| F[success]
D --> F
G[对单]移动
由于数据库的字段设计,移动文件不用去递归查询子文件,子文件父 ID 未改动会自动跟随,只要改动顶层的文件夹即可
graph TB
A[移动] --> |token校验通过| B[参数校验]
B --> B2[查库校验,文件是否存在]
B2 --> |NO| E
B2 -->|YES| C[检查移动目标位置是否在已选择的文件夹下]
C --> |YES| E[Return Error]
C --> |NO| F[检查目标位置是否有相同的文件名]
F --> |YES| J[对重复的文件名自动加上序号]
F --> |NO| K[success]
J --> K
G[对多/单]删除
在本项目,删除有软删除和硬删除,前者还可以在回收站恢复,后者直接在数据库删除,属于真删除
在删除文件后自动递归删除其下所有子文件的查询,是采用Sequelize Hooks 完成的
graph TB
A[删除] --> |token校验通过| B[参数校验]
B --> B2[查库校验,文件是否存在]
B2 --> |NO| E
B2 --> |YES| D[params mode]
D --> |true| D2[硬删除]
D --> |false| D3[软删除]
D2 --> D5[更新用户空间信息]
D5 --> L
D3 --> L
L --> L2[触发删除HOOK]
L2 --> L3[检查删除的文件有没有文件夹类型]
L3 -->|YES|D
L3 -->|NO|L
G[对多/单]
E[Return Error]
L[success]恢复
在恢复文件后,恢复文件如果存储在一个已删除的目录下,那恢复的文件依旧找不到,这时得递归恢复其父目录,把原来的路径的文件夹也一并恢复
在恢复文件后,同样,自动递归其所有父文件,检查是否是删除状态,是则也跟着恢复,一直顶层文件夹或存在的时候停止,采用Sequelize Hooks 完成的
graph TB
A[恢复] --> |token校验通过| B[参数校验]
B --> B2[查库校验,文件是否存在]
B2 --> |NO|E
B2 -->|YES| B3[检查目录是否有相同的文件名]
B3 --> |YES|B4[查库,重复文件名自动加上序号]
B3 --> |NO|L
B4 --> L
L --> L2[触发恢复HOOK]
L2 --> L3[检查恢复的文件有没有文件夹类型]
L3 -->|YES|B3
L3 -->|NO|L
G[对多/单]
E[Return Error]
L[success]预览/获取文件数据
在选择技术栈的时候,考虑带宽速度之类的体验问题,文件选择存储在阿里云的 OSS 上,其优点比较快,但是缺点是流量、费用比较贵 o(╥﹏╥)o,由于文件不是存储在服务端本地,这时需要去 OSS 获取一个临时的授权链接,来进行预览
graph TB
A[预览/获取文件数据] --> |token校验通过| B[参数校验]
B --> B2[查库校验,文件是否存在]
B2 --> |NO|E
B2 --> |YES| B3[检查OSS文件是否存在]
B3 --> |NO| E
B3 --> |YES| B4[是否有缓存]
B4 --> |NO| B6["文件类型是否属于可预览类型(图片,视频)"]
B4 --> |YES| B5[从缓存获取]
B5 --> L
B6 --> |YES| B7[请求OSS,返回一个处理过的缩略图]
B6 --> |NO| B8[请求OSS,返回原文件]
B7 --> C
B8 --> C
C --> L
E[Return Error]
C[缓存内容]
L[success,返回一个临时的签名链接,过期失效]