uniapp2
javascript
<template>
<view class="page">
<view class="navBarBox">
<!-- 头部导航 -->
<u-navbar height="100rpx">
<view class="u-nav-slot" slot="left">
<view class="leftBox" @click="_close">
<img src="static/imgs/leftIcon.png" alt="" />
</view>
</view>
<view slot="center">
<view class="title">
<text style="color: red; font-size: 36rpx">XXXX</text>
<text>添加批注</text>
</view>
<u--text
type="error"
text="请您在右侧方框内逐字手写文字,全部写完后点击确认签名!"
size="26rpx"
align="center"
>
</u--text>
</view>
<view class="u-nav-slot flex" slot="right">
<u-button
@click="_checkIsEmpty"
color="#087e6a"
customStyle="font-size: 36rpx;"
type="success"
:disabled="isSubmitting"
iconColor="#fff"
>
确认签名</u-button
>
</view>
</u-navbar>
</view>
<!-- 批注切换标签 -->
<view class="remark-tabs">
<view
v-for="(remark, index) in remarkInfoList"
:key="remark.remarkId"
class="tab-item"
:class="{ active: currentRemarkIndex === index }"
@click="switchRemark(index)"
>
<text class="tab-text">{{ remark.remarkKw }}</text>
<view class="tab-badge" v-if="remark.completed">✓</view>
</view>
</view>
<!-- 主内容区:左中右布局 -->
<view class="main-body">
<!-- 左侧:字符方格 -->
<view class="slots-section">
<view class="slots-header">
<text class="section-title">已写 {{ writtenCount }} 个字</text>
<view class="btn-clear" @click="clearAll">
<text>清 空</text>
</view>
</view>
<scroll-view
scroll-y
class="slots-scroll"
:scroll-into-view="scrollTarget"
>
<view class="slots-grid">
<view
v-for="(slot, index) in charList"
:key="slot.id"
:id="'slot-' + index"
class="char-slot"
:class="{ active: index === currentIndex, done: slot.imgSrc }"
@click="selectSlot(index)"
>
<image
v-if="slot.imgSrc"
:src="slot.imgSrc"
mode="aspectFit"
class="slot-img"
/>
<view v-else class="slot-empty">
<text class="slot-plus">+</text>
</view>
<text class="slot-index">{{ index + 1 }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 中间:手写区域 -->
<view class="canvas-section">
<view class="canvas-info">
<text class="current-pos" v-if="charList.length > 0">
第 {{ currentIndex + 1 }} / {{ charList.length }} 个
</text>
<text class="current-pos" v-else>请开始书写</text>
<text class="current-hint" v-if="isEditing">(修改模式)</text>
</view>
<view class="canvas-wrapper">
<canvas
canvas-id="handwritingCanvas"
class="handwriting-canvas"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
:style="{ width: canvasSize + 'px', height: canvasSize + 'px' }"
disable-scroll="true"
/>
<view v-if="!hasDrawn" class="canvas-placeholder">
<text class="placeholder-text">请书写</text>
</view>
</view>
</view>
</view>
<!-- 隐藏的合成画布 -->
<canvas
canvas-id="compositeCanvas"
class="composite-canvas"
:style="{ width: compositeWidth + 'px', height: compositeHeight + 'px' }"
/>
</view>
</template>
<script>
import { pathToBase64 } from "@/utils/image-tools/index.js";
import {
docmentDetail,
signRemarkInfo,
} from "@/utils/api.js";
export default {
data() {
return {
isSubmitting: false,
charList: [],
currentIndex: 0,
isEditing: false,
canvasSize: 350,
isDrawing: false,
hasDrawn: false,
startX: 0,
startY: 0,
penColor: "#000",
lineWidth: 15,
timer: null,
compositeImage: "",
compositeWidth: 200,
compositeHeight: 40,
idCounter: 0,
scrollTarget: "",
remarkInfoList: [], //批注信息列表
activeRemark: {}, //当前的批注文字
currentRemarkIndex: 0, //当前批注索引
strLength: 12, //最初显示12个字框
};
},
computed: {
writtenCount() {
return this.charList.filter((s) => s.imgSrc).length;
},
},
methods: {
// 处理批注,形成列表
_checkDocData() {
let notesList = [];
for (let i = 0; i < this.strLength; i++) {
const timer = new Date().getTime();
notesList.push({
id: timer + "_" + i,
imgSrc: "",
base64: "",
index: i,
});
}
return notesList;
},
// 切换批注
switchRemark(index) {
if (index === this.currentRemarkIndex) return;
this.saveCurrentRemark();
this.currentRemarkIndex = index;
this.loadRemark(index);
},
// 保存当前批注
saveCurrentRemark() {
if (this.remarkInfoList[this.currentRemarkIndex]) {
this.remarkInfoList[this.currentRemarkIndex].charList = [
...this.charList,
];
this.remarkInfoList[this.currentRemarkIndex].compositeImage =
this.compositeImage;
this.remarkInfoList[this.currentRemarkIndex].completed =
this.charList.some((s) => s.imgSrc);
}
},
// 加载批注
loadRemark(index) {
const remark = this.remarkInfoList[index];
this.activeRemark = remark;
if (remark.charList) {
this.charList = [...remark.charList];
} else {
this.charList = this._checkDocData(remark, false);
}
this.compositeImage = remark.compositeImage || "";
this.currentIndex = remark.charList.length;
this.clearCanvas();
this.isEditing = false;
this.scrollTarget = "";
const firstUndone = this.charList.findIndex((s) => !s.imgSrc);
if (firstUndone !== -1) {
this.currentIndex = firstUndone;
this.scrollTarget = "slot-" + firstUndone;
}
},
// 返回上一页
_close() {
uni.redirectTo({
url:
"/pages/index"
});
},
selectSlot(index) {
if (index === this.currentIndex) return;
this.currentIndex = index;
this.isEditing = !!this.charList[index].imgSrc;
this.clearCanvas();
this.scrollTarget = "slot-" + index;
},
// ========== 画布操作 ==========
onTouchStart(e) {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const touch = e.touches[0];
this.isDrawing = true;
this.hasDrawn = true;
this.startX = touch.x;
this.startY = touch.y;
},
onTouchMove(e) {
if (!this.isDrawing) return;
const touch = e.touches[0];
const ctx = uni.createCanvasContext("handwritingCanvas", this);
ctx.setStrokeStyle(this.penColor);
ctx.setLineWidth(this.lineWidth);
ctx.setLineJoin("round");
ctx.setLineCap("round");
ctx.setLineDash([0, 0], 0);
ctx.beginPath();
ctx.moveTo(this.startX, this.startY);
ctx.lineTo(touch.x, touch.y);
ctx.stroke();
ctx.draw(true);
this.startX = touch.x;
this.startY = touch.y;
},
onTouchEnd() {
if (!this.isDrawing) return;
this.isDrawing = false;
this.timer = setTimeout(() => {
this.confirmChar();
}, 500);
},
clearCanvas() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const ctx = uni.createCanvasContext("handwritingCanvas", this);
ctx.clearRect(0, 0, this.canvasSize, this.canvasSize);
ctx.draw();
this.hasDrawn = false;
},
confirmChar() {
if (!this.hasDrawn) {
uni.showToast({ icon: "none", title: "请先写字" });
return;
}
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
uni.canvasToTempFilePath(
{
canvasId: "handwritingCanvas",
success: (res) => {
this._saveCharImage(res.tempFilePath);
},
},
this,
);
},
_saveCharImage(filePath) {
uni.compressImage(
{
src: filePath,
success: (res) => this._finalizeChar(res.tempFilePath),
fail: () => this._finalizeChar(filePath),
},
this,
);
},
_finalizeChar(imgPath) {
pathToBase64(imgPath)
.then((base64) => {
if (this.currentIndex < this.charList.length) {
this.$set(this.charList[this.currentIndex], "imgSrc", imgPath);
this.$set(
this.charList[this.currentIndex],
"base64",
base64.replace(/[\r\n]/g, ""),
);
} else {
this.charList.push({
id: ++this.idCounter,
imgSrc: imgPath,
base64: base64.replace(/[\r\n]/g, ""),
});
}
// 检查是否写完了倒数第4个字符
if (this.currentIndex === this.charList.length - 4) {
// 自动添加12个新的手写框
for (let i = 0; i < this.strLength; i++) {
const timer = new Date().getTime();
this.charList.push({
id: timer + "_" + (this.charList.length + i),
imgSrc: "",
base64: "",
index: this.charList.length + i,
});
}
}
this.clearCanvas();
this.isEditing = false;
this._goToNext();
this.generateComposite();
})
.catch((err) => {
console.error("base64转换失败:", err);
});
},
_goToNext() {
const next = this.charList.findIndex(
(s, i) => i > this.currentIndex && !s.imgSrc,
);
if (next !== -1) {
this.currentIndex = next;
this.scrollTarget = "slot-" + next;
} else {
this.currentIndex = this.charList.length;
this.scrollTarget = "";
this.$nextTick(() => {
this.scrollTarget = "slot-" + (this.charList.length - 1);
});
}
},
clearAll() {
if (this.charList.length === 0) return;
uni.showModal({
title: "提示",
content: "确定清空当前批注的手写内容吗?",
success: (res) => {
if (res.confirm) {
// 清空当前批注的内容
this.charList = this._checkDocData(
this.remarkInfoList[this.currentRemarkIndex],
false,
);
this.currentIndex = 0;
this.compositeImage = "";
this.isEditing = false;
this.clearCanvas();
// 更新当前批注的状态
if (this.remarkInfoList[this.currentRemarkIndex]) {
this.remarkInfoList[this.currentRemarkIndex].charList = [
...this.charList,
];
this.remarkInfoList[this.currentRemarkIndex].compositeImage = "";
this.remarkInfoList[this.currentRemarkIndex].completed = false;
}
}
},
});
},
// ========== 合成图片 ==========
generateComposite() {
const doneList = this.charList.filter((s) => s.imgSrc);
if (doneList.length === 0) {
this.compositeImage = "";
return;
}
const charSize = 20; //字符大小,增大以提高清晰度
const gap = 1;
const charsPerRow = doneList.length > 20 ? 20 : doneList.length; // 每行最大字符数
const totalRows = Math.ceil(doneList.length / charsPerRow);
this.compositeWidth = charsPerRow * (charSize + gap) - gap;
this.compositeHeight = totalRows * (charSize + gap) - gap;
const ctx = uni.createCanvasContext("compositeCanvas", this);
ctx.clearRect(0, 0, this.compositeWidth, this.compositeHeight);
// 不设置白色背景,保持透明
let drawIndex = 0;
this.charList.forEach((slot) => {
if (!slot.imgSrc) return;
const row = Math.floor(drawIndex / charsPerRow);
const col = drawIndex % charsPerRow;
const x = col * (charSize + gap);
const y = row * (charSize + gap);
ctx.drawImage(slot.imgSrc, x, y, charSize, charSize);
drawIndex++;
});
setTimeout(() => {
ctx.draw(false, () => {
uni.canvasToTempFilePath(
{
canvasId: "compositeCanvas",
success: (res) => {
uni.compressImage(
{
src: res.tempFilePath,
success: (compressRes) => {
pathToBase64(compressRes.tempFilePath)
.then((base64) => {
this.compositeImage = base64.replace(/[\r\n]/g, "");
})
.catch((err) => {
console.error("合成图base64转换失败:", err);
});
},
},
this,
);
},
},
this,
);
});
}, 300);
},
// ========== 导出 ==========
exportImage() {
if (this.charList.length === 0) {
uni.showToast({ icon: "none", title: "还没有写任何字" });
return;
}
this._doExport();
},
_doExport() {
uni.canvasToTempFilePath(
{
canvasId: "compositeCanvas",
success: (res) => {
// #ifdef H5
const link = document.createElement("a");
link.href = res.tempFilePath;
link.download = "handwriting_" + Date.now() + ".png";
link.click();
uni.showToast({ icon: "success", title: "图片已导出" });
// #endif
// #ifndef H5
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
uni.showToast({ icon: "success", title: "已保存到相册" });
},
fail: () => {
uni.showToast({ icon: "none", title: "保存失败,请检查权限" });
},
});
// #endif
},
},
this,
);
},
// 检测是否已书写批注(添加节流功能)
_checkIsEmpty() {
this.saveCurrentRemark();
const allEmpty = this.remarkInfoList.every(
(item) => !item.charList || item.charList.every((s) => !s.imgSrc),
);
if (allEmpty) {
uni.showToast({ icon: "none", title: "还没有写任何批注内容,请先书写批注内容" });
return;
}
this._submitDraw();
},
// 确认提交(添加节流功能)
_submitDraw() {
// 节流:如果在冷却期内,直接返回
if (this.isSubmitting) {
return;
}
// 先保存当前批注的内容
this.saveCurrentRemark();
// 检查是否所有批注都已完成
const uncompletedRemarks = this.remarkInfoList.filter(
(item) => !item.completed,
);
if (uncompletedRemarks.length > 0) {
uni.showToast({
icon: "none",
title: "请完成所有批注的手写后再点击确认签名!",
});
return;
}
// 设置提交状态为true,开始冷却
this.isSubmitting = true;
// 处理批注签名处理
let _signRemarkList = this.remarkInfoList.map((item) => {
const _remarkImage = item.compositeImage.split(
"data:image/png;base64,",
)[1];
const query = {
remarkId: item.remarkId,
remarkKw: item.remarkKw,
remarkImage: _remarkImage,
};
return query;
});
this.signRemarkInfo(_signRemarkList);
// 5秒后重置提交状态
setTimeout(() => {
this.isSubmitting = false;
}, 5000);
},
// 批注签名
async signRemarkInfo(data) {
// 处理批注签名处理
try {
console.log("data", data);
const { data: res } = await signRemarkInfo(data);
if (res.code == 0) {
console.log("批注签名成功", res);
} else {
this.isSubmitting = false;
uni.showToast({
icon: "none",
title: res.msg,
});
}
} catch (e) {
this.isSubmitting = false;
uni.showToast({
icon: "none",
title: e,
});
}
},
// 查询文档详情
async _getDocmentDetail() {
try {
const params = {
id: this.optionQuery.docId,
};
const { data: res } = await docmentDetail(params);
if (res.code == 0) {
const _remarkInfoList = res.data.remarkInfoList || [];
this.remarkInfoList = _remarkInfoList.map((item) => {
const _notes = this._checkDocData(item, false);
console.log("_notes", _notes);
return {
remarkId: item.remarkId,
documentId: item.documentId,
documentSignerId: item.documentSignerId,
remarkKw: item.remarkKw,
remarkImage: "",
charList: _notes,
compositeImage: "",
completed: false,
};
});
console.log("this.remarkInfoList", this.remarkInfoList);
if (this.remarkInfoList.length > 0) {
this.currentRemarkIndex = 0;
this.loadRemark(0);
}
}
} catch (error) {
uni.showToast({
icon: "none",
title: error,
});
}
},
},
onLoad(option) {
this.optionQuery = {
documentSignerId: option.documentSignerId,
docId: option.docId,
};
console.log("queryqueryquery", this.optionQuery);
this._getDocmentDetail();
},
onUnload() {},
};
</script>
<style scoped lang="scss">
.navBarBox {
height: 60rpx;
text-align: center;
.leftBox {
width: 40rpx;
height: 40rpx;
margin-right: 40rpx;
margin-top: -20rpx;
img {
width: 100%;
height: 100%;
}
}
}
page {
background-color: #f5f5f5;
width: 100%;
height: 100%;
}
.page {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f5f5f5;
overflow: hidden;
}
/* 批注切换标签 */
.remark-tabs {
display: flex;
padding: 16rpx;
background-color: #fff;
border-bottom: 1rpx solid #e5e5e5;
flex-shrink: 0;
overflow-x: auto;
}
.tab-item {
display: flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 24rpx;
background-color: #f5f5f5;
border-radius: 30rpx;
cursor: pointer;
flex-shrink: 0;
position: relative;
margin-right: 10rpx;
&.active {
background-color: #087e6a;
color: #fff;
}
}
.tab-text {
font-size: 36rpx;
white-space: nowrap;
}
.tab-badge {
width: 32rpx;
height: 32rpx;
background-color: #087e6a;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20rpx;
position: absolute;
top: -8rpx;
right: -8rpx;
}
/* 批注信息 */
.remark-info {
display: flex;
align-items: center;
gap: 10rpx;
padding: 16rpx;
background-color: #fff;
border-bottom: 1rpx solid #e5e5e5;
flex-shrink: 0;
}
.remark-label {
font-size: 28rpx;
color: #666;
}
.remark-content {
font-size: 32rpx;
color: #333;
font-weight: bold;
}
.remark-progress {
font-size: 24rpx;
color: #999;
margin-left: auto;
}
/* 自定义导航栏 - 横屏时紧凑 */
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10rpx 24rpx;
background-color: #ffffff;
border-bottom: 1rpx solid #e5e5e5;
flex-shrink: 0;
height: 80rpx;
}
.nav-left {
display: flex;
align-items: center;
padding-left: 10rpx;
}
.nav-icon {
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.nav-center {
display: flex;
align-items: center;
gap: 20rpx;
}
.title-text {
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.title-hint {
font-size: 22rpx;
color: #999;
}
.nav-right {
display: flex;
align-items: center;
gap: 16rpx;
}
.btn-clear {
padding: 10rpx 20rpx;
color: red;
font-size: 30rpx;
border: 2rpx solid red;
border-radius: 8rpx;
}
.btn-export {
padding: 8rpx 24rpx;
background-color: #087e6a;
color: #fff;
font-size: 24rpx;
border-radius: 8rpx;
}
/* 主内容区:横屏左中右布局 */
.main-body {
flex: 1;
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 16rpx;
gap: 16rpx;
overflow: hidden;
}
/* 左侧:字符方格 */
.slots-section {
width: 60%;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: 12rpx;
padding: 16rpx;
flex-shrink: 0;
}
.slots-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.section-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 12rpx;
white-space: nowrap;
}
.slots-scroll {
flex: 1;
overflow: hidden;
}
.slots-grid {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.char-slot {
position: relative;
width: 120rpx;
height: 120rpx;
border: 4rpx solid #e5e5e5;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #fafafa;
margin-right: 5rpx;
margin-bottom: 5rpx;
&.active {
border-color: #087e6a;
box-shadow: 0 0 0 4rpx rgba(8, 126, 106, 0.2);
}
&.done {
background-color: #fff;
}
}
.slot-img {
width: 90%;
height: 90%;
}
.slot-empty {
display: flex;
align-items: center;
justify-content: center;
}
.slot-plus {
font-size: 40rpx;
color: #ddd;
}
.slot-index {
position: absolute;
top: 2rpx;
left: 4rpx;
font-size: 18rpx;
color: #bbb;
}
/* 中间:手写区域 */
.canvas-section {
width: 40%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #fff;
border-radius: 12rpx;
}
.canvas-info {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
}
.current-pos {
font-size: 44rpx;
color: #666;
}
.current-hint {
font-size: 44rpx;
color: #d41535;
}
.canvas-wrapper {
position: relative;
border: 4rpx dashed #ccc;
border-radius: 12rpx;
background-color: transparent;
overflow: hidden;
}
.handwriting-canvas {
touch-action: none;
}
.canvas-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.placeholder-text {
font-size: 200rpx;
color: rgba(200, 200, 200, 0.4);
}
/* 右侧:合成预览 */
.composite-section {
width: 30%;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: 12rpx;
padding: 16rpx;
flex-shrink: 0;
}
.composite-preview {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 12rpx;
background-color: #fafafa;
border-radius: 8rpx;
overflow: auto;
}
.composite-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 24rpx;
color: #ccc;
}
.composite-img {
max-width: 100%;
}
.composite-canvas {
position: fixed;
left: -9999px;
top: -9999px;
z-index: -1;
}
</style>