feat: 添加签名功能及图片上传逻辑

实现签名确认功能,支持生成签名图片并上传至服务器
添加图片预览和保存功能,优化用户交互体验
重构页面布局和样式,增加时间戳显示
集成API调用处理签名数据的获取和提交
This commit is contained in:
王创世 2025-05-26 17:00:36 +08:00
parent 8de96d1fb0
commit 6c0af29edc
15 changed files with 744 additions and 62 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
unpackage/

114
App.vue
View File

@ -1,32 +1,106 @@
<script>
import Vue from 'vue'
import store from './store/index.js'
export default {
onLaunch: function() {
console.warn('当前组件仅支持 uni_modules 目录结构 ,请升级 HBuilderX 到 3.1.0 版本以上!')
console.log('App Launch')
uni.getSystemInfo({
success: function(e) {
// #ifndef H5
//
const system = e.system.toLowerCase()
const platform = e.platform.toLowerCase()
// ios
if (platform.indexOf('ios') != -1 && (system.indexOf('ios') != -1 || system.indexOf(
'macos') != -1)) {
Vue.prototype.SystemPlatform = 'apple'
} else if (platform.indexOf('android') != -1 && (system.indexOf('android') != -1)) {
Vue.prototype.SystemPlatform = 'android'
} else {
Vue.prototype.SystemPlatform = 'devtools'
}
// #endif
}
})
//
// store.dispatch('updateCustomBarInfo')
// updateCustomBarInfo().then((res) => {
// store.commit('$tStore', {
// name: 'vuex_status_bar_height',
// value: res.statusBarHeight
// })
// store.commit('$tStore', {
// name: 'vuex_custom_bar_height',
// value: res.customBarHeight
// })
// })
// #ifdef MP-WEIXIN
//
if (wx.canIUse('getUpdateManager')) {
const updateManager = wx.getUpdateManager();
updateManager && updateManager.onCheckForUpdate((res) => {
if (res.hasUpdate) {
updateManager.onUpdateReady(() => {
uni.showModal({
title: '更新提示',
content: '新版本已经准备就绪,是否需要重新启动应用?',
success: (res) => {
if (res.confirm) {
uni.clearStorageSync() // storage
updateManager.applyUpdate()
}
}
})
})
updateManager.onUpdateFailed(() => {
uni.showModal({
title: '已有新版本上线',
content: '小程序自动更新失败,请删除该小程序后重新搜索打开哟~~~',
showCancel: false
})
})
} else {
//
}
})
} else {
uni.showModal({
title: '提示',
content: '当前微信版本过低,无法使用该功能,请更新到最新的微信后再重试。',
showCancel: false
})
}
// #endif
},
onShow: function() {
console.log('App Show')
// console.log('App Show')
},
onHide: function() {
console.log('App Hide')
// console.log('App Hide')
},
methods: {
addWidthToImages(html) {
// style img
html = html.replace(/(<img\b[^>]*\bstyle\s*=\s*['"])([^'"]*)(['"][^>]*>)/g,
function(match, p1, p2, p3) {
return p1 + ';width: 100%;margin:0 auto;' + p3;
});
// style style img
html = html.replace(/(<img\b(?![^>]*\bstyle\s*=)[^>]*>)/g,
function(match, p1) {
return p1.replace(/\/?>$/, ' style="width: 100%;margin:0 auto;" />');
});
return html;
},
}
}
</script>
<style lang="scss">
/*每个页面公共css */
@import '@/uni_modules/uni-scss/index.scss';
/* #ifndef APP-NVUE */
@import '@/static/customicons.css';
//
page {
background-color: #f5f5f5;
}
/* #endif */
.example-info {
font-size: 14px;
color: #333;
padding: 10px;
}
</style>
/* 注意要写在第一行同时给style标签加入lang="scss"属性 */
</style>

17
main.js
View File

@ -1,8 +1,7 @@
// #ifndef VUE3
import Vue from 'vue'
import App from './App'
import mixin from '@/utils/mixin.js'
Vue.mixin(mixin);
Vue.config.productionTip = false
App.mpType = 'app'
@ -11,15 +10,3 @@ const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import { createSSRApp } from 'vue'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}
// #endif

View File

@ -56,5 +56,13 @@
},
"usingComponents" : true
},
"vueVersion" : "2"
"vueVersion" : "2",
"h5" : {
"router" : {
"base" : "/h5/"
},
"devServer" : {
"https" : false
}
}
}

View File

@ -1,25 +1,27 @@
<template>
<view>
<view v-if="!showSignature">
<view v-if="!url">
<view v-if="!showSignature" style="padding-bottom: 100rpx;">
<view v-if="!zhen_url">
<view @click="openImg()">
<l-painter ref="painter">
<l-painter-image src="/static/img/1.jpg" css="width: 100%; height: 100%" />
<l-painter-image :src="imgUrl+info.yuan_image" css="width: 100%; height: 100%" />
<l-painter-image :src="signatureBase64"
css="width: 20%;position:absolute;bottom:20rpx;left:100rpx" />
css="width: 18%;position:absolute;bottom:20rpx;left:110rpx" />
<l-painter-text
css="font-size:10px;width: 100%;position:absolute;bottom:24rpx;left:300rpx">{{getFormattedTime()}}</l-painter-text>
</l-painter>
</view>
<view>
<button @click="showSignature = true"
style="margin-top: 20px;background-color:#0066FF;border: #0066FF;color: #fff;width: 80%;">签名确认</button>
<!-- <button @click="insImg()"
style="margin-top: 20px;background-color:#0066FF;border: #0066FF;color: #fff;width: 80%;">图片上传</button> -->
</view>
</view>
<view v-if="url">
<image :src="url" style="width: 100%;" mode="widthFix"></image>
<view v-if="zhen_url">
<image @click="openImg()" :src="zhen_url" style="width: 100%;" mode="widthFix"></image>
<view style="text-align: center;margin-top: 10rpx;font-size: 20px;font-weight: 600;">长按图片保存</view>
</view>
<view v-if="info.nlinesignature_image=='' || info.nlinesignature_image==null">
<button @click="showSignature = true"
style="margin-top: 20px;background-color:#0066FF;border: #0066FF;color: #fff;width: 80%;">{{zhen_url?'重签':'签名确认'}}</button>
<button v-if="zhen_url" @click="submit()"
style="margin-top: 20px;background-color:#00CC33;border: #00CC33;color: #fff;width: 80%;">确认提交</button>
</view>
</view>
<view class="wrapper" v-if="showSignature">
@ -40,31 +42,101 @@
</template>
<script>
import {
getInfo,getUpdate
} from '@/utils/api.js';
export default {
data() {
return {
imgUrl: "http://qz.hschool.com.cn",
signatureBase64: '',
showSignature: false,
url: '',
info: {},
token: '',
time: '',
zhen_url: '',
}
},
onLoad() {
uni.setNavigationBarTitle({
title: '新的标题'
});
onLoad(t) {
console.log(t);
this.token = t.token;
this.getContentInfo();
},
methods: {
submit() {
getUpdate({
token: this.token,
nlinesignature_image: this.zhen_url
})
.then(res => {
console.log(res);
if(res.code==1){
uni.showToast({
icon:'none',
title: '提交成功!',
duration: 2000
});
}
this.getContentInfo();
})
.catch(error => {
uni.showToast({
title: error,
duration: 2000
});
})
},
getContentInfo() {
getInfo({
token: this.token,
})
.then(res => {
console.log(res);
this.info = res.data;
this.zhen_url=res.data.nlinesignature_image;
uni.setNavigationBarTitle({
title: res.data.name
});
})
.catch(error => {
uni.showToast({
title: error,
duration: 2000
});
})
},
insImg() {
this.$refs.painter.canvasToTempFilePathSync({
// nvuejpeg
fileType: "jpg",
fileType: "png",
quality: 1,
success: (res) => {
console.log(res.tempFilePath);
this.url = res.tempFilePath;
//this.url = res.tempFilePath;
uni.uploadFile({
url: 'http://qz.hschool.com.cn/api/common/upload', //
filePath: res.tempFilePath,
name: 'file',
formData: {
'token': this.token
},
success: (uploadFileRes) => {
console.log(uploadFileRes);
var data = JSON.parse(uploadFileRes.data);
this.zhen_url = data.data.fullurl;
setTimeout(()=>{
uni.hideLoading();
}, 3000);
},
fail(re) {
console.log(re);
}
});
},
complete: () => {
uni.hideLoading();
fail: (re) => {
console.log(re);
},
});
},
@ -72,7 +144,7 @@
this.$refs.signatureRef.canvasToTempFilePath({
success: (res) => {
this.signatureBase64 = res.tempFilePath;
console.log(res);
//console.log(res);
this.showSignature = false;
uni.showLoading({
title: '签名生成中...',
@ -92,11 +164,36 @@
this.$refs.signatureRef.clear();
},
openImg() {
uni.previewImage({
urls: ['/static/img/1.jpg'],
current: 0
});
if (this.zhen_url != '') {
uni.previewImage({
urls: [this.zhen_url],
current: 0
});
} else {
if (this.info.nlinesignature_image != '') {
uni.previewImage({
urls: [this.info.nlinesignature_image],
current: 0
});
} else {
uni.previewImage({
urls: [this.imgUrl + this.info.yuan_image],
current: 0
});
}
}
},
getFormattedTime() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0'); //
const day = String(now.getDate()).padStart(2, '0'); //
const hours = String(now.getHours()).padStart(2, '0'); //
const minutes = String(now.getMinutes()).padStart(2, '0'); //
return `${year}-${month}-${day} ${hours}${minutes}`;
}
}
}
</script>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

28
store/$tn.mixin.js Normal file
View File

@ -0,0 +1,28 @@
import { mapState } from 'vuex'
import store from '@/store'
// 尝试将用户在根目录中的store/index.js的vuex的state变量加载到全局变量中
let $tStoreKey = []
try {
$tStoreKey = store.state ? Object.keys(store.state) : []
} catch(e) {
}
module.exports = {
beforeCreate() {
// 将vuex方法挂在在$t中
// 使用方法:
// 修改vuex的state中的user.name变量为图鸟小菜 => this.$tn.vuex('user.name', '图鸟小菜')
// 修改vuexde state中的version变量为1.0.1 => this.$tn.vuex('version', 1.0.1)
this.$tn.vuex = (name, value) => {
this.$store.commit('$tStore', {
name, value
})
}
},
computed: {
// 将vuex的state中的变量结构到全局混入mixin中
...mapState($tStoreKey)
}
}

76
store/index.js Normal file
View File

@ -0,0 +1,76 @@
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
let lifeData = {}
// 尝试获取本地是否存在lifeData变量第一次启动时不存在
try {
lifeData = uni.getStorageSync('lifeData')
} catch (e) {
}
// 标记需要永久存储的变量在每次启动时取出在state中的变量名
let saveStateKeys = ['vuex_user']
// 保存变量到本地存储
const saveLifeData = function(key, value) {
// 判断变量是否在存储数组中
if (saveStateKeys.indexOf(key) != -1) {
// 获取本地存储的lifeData对象将变量添加到对象中
let tmpLifeData = uni.getStorageSync('lifeData')
// 第一次启动时不存在,则放一个空对象
tmpLifeData = tmpLifeData ? tmpLifeData : {},
tmpLifeData[key] = value
// 将变量再次放回本地存储中
uni.setStorageSync('lifeData', tmpLifeData)
}
}
const store = new Vuex.Store({
state: {
// 如果上面从本地获取的lifeData对象下有对应的属性就赋值给state中对应的变量
// 加上vuex_前缀是防止变量名冲突也让人一目了然
vuex_user: lifeData.vuex_user ? lifeData.vuex_user : {
name: '灵睿'
},
// 如果vuex_version无需保存到本地永久存储无需lifeData.vuex_version方式
// app版本
vuex_version: "1.0.3",
// 是否使用自定义导航栏
vuex_custom_nav_bar: true,
// 状态栏高度
vuex_status_bar_height: 0,
// 自定义导航栏的高度
vuex_custom_bar_height: 0
},
mutations: {
$tStore(state, payload) {
// 判断是否多层调用state中为对象存在的情况例如user.info.score = 1
let nameArr = payload.name.split('.')
let saveKey = ''
let len = nameArr.length
if (len >= 2) {
let obj = state[nameArr[0]]
for (let i = 1; i < len - 1; i++) {
obj = obj[nameArr[i]]
}
obj[nameArr[len - 1]] = payload.value
saveKey = nameArr[0]
} else {
// 单层级变量
state[payload.name] = payload.value
saveKey = payload.name
}
// 保存变量到本地中
saveLifeData(saveKey, state[saveKey])
}
},
actions: {}
})
export default store

3
utils/api.js Normal file
View File

@ -0,0 +1,3 @@
import request from '@/utils/request';
export const getInfo = data => request.post('/api/signature/getTkenFind', data, false);
export const getUpdate = data => request.post('/api/signature/update', data, false);

59
utils/mixin.js Normal file
View File

@ -0,0 +1,59 @@
function v(a, b) {
return +((1000 * a - 1000 * b) / 1000).toFixed(1)
}
module.exports = {
created() {
if (this._setTransform) {
this._setTransform = (x, y, scale, source = '', r, o) => {
if (!(x !== null && x.toString() !== 'NaN' && typeof x === 'number')) {
x = this._translateX || 0
}
if (!(y !== null && y.toString() !== 'NaN' && typeof y === 'number')) {
y = this._translateY || 0
}
x = Number(x.toFixed(1))
y = Number(y.toFixed(1))
scale = Number(scale.toFixed(1))
if (!(this._translateX === x && this._translateY === y)) {
if (!r) {
this.$trigger('change', {}, {
x: v(x, this._scaleOffset.x),
y: v(y, this._scaleOffset.y),
source: source
})
}
}
if (!this.scale) {
scale = this._scale
}
scale = this._adjustScale(scale)
scale = +scale.toFixed(3)
if (o && scale !== this._scale) {
this.$trigger('scale', {}, {
x: x,
y: y,
scale: scale
})
}
var transform = 'translateX(' + x + 'px) translateY(' + y + 'px) scale(' + scale + ')'
this.$el.style.transform = transform
this.$el.style.webkitTransform = transform
this._translateX = x
this._translateY = y
this._scale = scale
}
}
},
destroyed() {
//解决预览模式关闭后和重复开关预览模式this._setTransform函数无限次执行导致手机卡顿的问题
if (this._FA) {
this._FA.cancel()
}
if (this._SFA) {
this._SFA.cancel()
}
},
methods: {
}
}

57
utils/request.js Normal file
View File

@ -0,0 +1,57 @@
import {
toast,
clearStorageSync,
getStorageSync,
useRouter
} from './utils'
import RequestManager from '@/utils/requestManager.js'
let BASE_URL = 'http://qz.hschool.com.cn';
const baseRequest = async (url, method, data = {}, loading = true) => {
//const u = getStorageSync('u');
// let requestId = manager.generateId(method, url, data)
// if (!requestId) {
// console.log('重复请求')
// }
// if (!requestId) return false;
return new Promise((reslove, reject) => {
loading && uni.showLoading({
title: '加载中...'
})
uni.request({
url: BASE_URL + url,
method: method || 'GET',
header: {
'content-type': 'application/json'
},
timeout: 10000,
data: data || {},
complete: () => {
uni.hideLoading()
},
success: (successData) => {
const res = successData.data;
if (successData.statusCode == 200) {
reslove(res)
} else {
//toast('网络连接失败,请稍后重试')
reject(res)
}
},
fail: (msg) => {
toast('网络连接失败,请稍后重试')
reject(msg)
}
})
})
}
const request = {};
['options', 'get', 'post', 'put', 'head', 'delete', 'trace', 'connect'].forEach((method) => {
request[method] = (api, data, loading) => baseRequest(api, method, data, loading)
})
export default request

66
utils/requestManager.js Normal file
View File

@ -0,0 +1,66 @@
class RequestManager {
constructor() {
this.idMap = new Map()
}
/**
* 生成唯一ID并将ID和请求信息存储到map对象中
* @param {string} method - 请求方法
* @param {string} url - 请求URL
* @param {object} params - 请求参数
* @returns {string|boolean} - 生成的唯一ID如果存在相同请求则返回false
*/
generateId(method, url, params) {
const id = this.generateUniqueId(method, url, params)
if (this.idMap.has(id)) {
return false
}
this.idMap.set(id, { method, url, params })
return id
}
/**
* 根据ID删除map对象中的请求信息
* @param {string} id - 要删除的唯一ID
*/
deleteById(id) {
this.idMap.delete(id)
}
/**
* 生成唯一ID的方法
* @param {string} method - 请求方法
* @param {string} url - 请求URL
* @param {object} params - 请求参数
* @returns {string} - 生成的唯一ID
*/
generateUniqueId(method, url, params) {
const idString = `${method}-${url}-${this.serializeObject(params)}`
let id = 0;
for (let i = 0; i < idString.length; i++) {
id = ((id << 5) - id) + idString.charCodeAt(i)
id |= 0;
}
return id.toString()
}
/**
* 序列化对象为字符串
* @param {object} obj - 要序列化的对象
* @returns {string} - 序列化后的字符串
*/
serializeObject(obj) {
const keys = Object.keys(obj).sort()
const serializedObj = {}
for (let key of keys) {
const value = obj[key]
if (value !== null && typeof value === 'object') {
serializedObj[key] = this.serializeObject(value)
} else {
serializedObj[key] = value
}
}
return JSON.stringify(serializedObj)
}
}
export default RequestManager

136
utils/utils.js Normal file
View File

@ -0,0 +1,136 @@
/**
* 提示方法
* @param {String} title 提示文字
* @param {String} icon icon图片
* @param {Number} duration 提示时间
*/
export function toast(title, icon = 'none', duration = 1500) {
if(title) {
uni.showToast({
title,
icon,
duration
})
}
}
/**
* 设置缓存
* @param {String} key 键名
* @param {String} data
*/
export function setStorageSync(key, data) {
uni.setStorageSync(key, data)
}
/**
* 获取缓存
* @param {String} key 键名
*/
export function getStorageSync(key) {
return uni.getStorageSync(key)
}
/**
* 删除缓存
* @param {String} key 键名
*/
export function removeStorageSync(key) {
return uni.removeStorageSync(key)
}
/**
* 清空缓存
* @param {String} key 键名
*/
export function clearStorageSync() {
return uni.clearStorageSync()
}
/**
* 页面跳转
* @param {'navigateTo' | 'redirectTo' | 'reLaunch' | 'switchTab' | 'navigateBack' | number } url 转跳路径
* @param {String} params 跳转时携带的参数
* @param {String} type 转跳方式
**/
export function useRouter(url, params = {}, type = 'navigateTo') {
try {
if (Object.keys(params).length) url = `${url}?data=${encodeURIComponent(JSON.stringify(params))}`
if (type === 'navigateBack') {
uni[type]({ delta: url })
} else {
uni[type]({ url })
}
} catch (error) {
console.error(error)
}
}
/**
* 预览图片
* @param {Array} urls 图片链接
*/
export function previewImage(urls, itemList = ['发送给朋友', '保存图片', '收藏']) {
uni.previewImage({
urls,
longPressActions: {
itemList,
fail: function (error) {
console.error(error,'===previewImage')
}
}
})
}
/**
* 保存图片到本地
* @param {String} filePath 图片临时路径
**/
export function saveImage(filePath) {
if (!filePath) return false
uni.saveImageToPhotosAlbum({
filePath,
success: (res) => {
toast('图片保存成功', 'success')
},
fail: (err) => {
if (err.errMsg === 'saveImageToPhotosAlbum:fail:auth denied' || err.errMsg === 'saveImageToPhotosAlbum:fail auth deny') {
uni.showModal({
title: '提示',
content: '需要您授权保存相册',
showCancel: false,
success: (modalSuccess) => {
uni.openSetting({
success(settingdata) {
if (settingdata.authSetting['scope.writePhotosAlbum']) {
uni.showModal({
title: '提示',
content: '获取权限成功,再次点击图片即可保存',
showCancel: false
})
} else {
uni.showModal({
title: '提示',
content: '获取权限失败,将无法保存到相册哦~',
showCancel: false
})
}
},
fail(failData) {
console.log('failData', failData)
}
})
}
})
}
}
})
}
/**
* 深拷贝
* @param {Object} data
**/
export const clone = (data) => JSON.parse(JSON.stringify(data))

77
utils/weapp-jwt.js Normal file
View File

@ -0,0 +1,77 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
var b64re = /^(?:[A-Za-z\d+\/]{4})*?(?:[A-Za-z\d+\/]{2}(?:==)?|[A-Za-z\d+\/]{3}=?)?$/;
exports.weBtoa = function (string) {
string = String(string);
var bitmap, a, b, c, result = "", i = 0, rest = string.length % 3;
for (; i < string.length;) {
if ((a = string.charCodeAt(i++)) > 255 ||
(b = string.charCodeAt(i++)) > 255 ||
(c = string.charCodeAt(i++)) > 255)
throw new TypeError("Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.");
bitmap = (a << 16) | (b << 8) | c;
result += b64.charAt(bitmap >> 18 & 63) + b64.charAt(bitmap >> 12 & 63) +
b64.charAt(bitmap >> 6 & 63) + b64.charAt(bitmap & 63);
}
return rest ? result.slice(0, rest - 3) + "===".substring(rest) : result;
};
exports.weAtob = function (string) {
string = String(string).replace(/[\t\n\f\r ]+/g, "");
if (!b64re.test(string))
throw new TypeError("Failed to execute 'atob' on 'Window': The string to be decoded is not correctly encoded.");
string += "==".slice(2 - (string.length & 3));
var bitmap, result = "", r1, r2, i = 0;
for (; i < string.length;) {
bitmap = b64.indexOf(string.charAt(i++)) << 18 | b64.indexOf(string.charAt(i++)) << 12 |
(r1 = b64.indexOf(string.charAt(i++))) << 6 | (r2 = b64.indexOf(string.charAt(i++)));
result += r1 === 64 ? String.fromCharCode(bitmap >> 16 & 255) :
r2 === 64 ? String.fromCharCode(bitmap >> 16 & 255, bitmap >> 8 & 255) :
String.fromCharCode(bitmap >> 16 & 255, bitmap >> 8 & 255, bitmap & 255);
}
return result;
};
function b64DecodeUnicode(str) {
return decodeURIComponent(exports.weAtob(str).replace(/(.)/g, function (p) {
var code = p.charCodeAt(0).toString(16).toUpperCase();
if (code.length < 2) {
code = "0" + code;
}
return "%" + code;
}));
}
function base64_url_decode(str) {
var output = str.replace(/-/g, "+").replace(/_/g, "/");
switch (output.length % 4) {
case 0:
break;
case 2:
output += "==";
break;
case 3:
output += "=";
break;
default:
throw "Illegal base64url string!";
}
try {
return b64DecodeUnicode(output);
}
catch (err) {
return exports.weAtob(output);
}
}
function weappJwtDecode(token, options) {
if (typeof token !== "string") {
throw ("Invalid token specified");
}
options = options || {};
var pos = options.header === true ? 0 : 1;
try {
return JSON.parse(base64_url_decode(token.split(".")[pos]));
}
catch (e) {
throw ("Invalid token specified: " + e.message);
}
}
exports.default = weappJwtDecode;

13
vue.config.js Normal file
View File

@ -0,0 +1,13 @@
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://qz.hschool.com.cn/',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
}
}
}
}