vuepress-theme-vdoing/docs/_posts/随笔/我做了一个手写春联小网页,祝大家虎年暴富.md

811 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: 我做了一个手写春联小网页,祝大家虎年暴富
date: 2022-01-28 14:59:51
permalink: /pages/829589/
titleTag: 原创
sidebar: auto
categories:
- 随笔
tags:
- null
author:
name: xugaoyi
link: https://github.com/xugaoyi
---
手写春联:<https://cl.xugaoyi.com/>
### 前言
虎年春节快到了,首先祝大家新年快乐,轻松暴富。
最近在网上经常看到生成春联的文章不过这些小demo要么功能简陋,要么UI特别程序员满足不了我挑剔的眼光。干脆我自己做一个吧顺便简单体验一下vite+vue3。因为页面相对简单vue组件风格还是使用选项式api重点还是想把产品快速做出来。
<!-- more -->
<p align="center"><img src="https://img-blog.csdnimg.cn/img_convert/185c88180b87ac7277072280a0c144ce.png" width="500" style="cursor: zoom-in;"></p>
### 产品构思
包含`手写春节`和`生成春联`两大功能:
- **手写春联**
- 模拟用笔写字的字迹
- 选择画笔颜色
- 调整画笔大小
- 清空画布
- 撤回笔画
- 切换上、下联、横批、福字
- 随机切换对联提示
- 预览图片和下载
- 贴春联海报和下载
- **生成模式**
- 选择画笔颜色
- 挑选生成的对联
- 输入对联
- 随机切换对联
- 贴春联海报和下载
- **其他**
- 快速切换模式按钮
- 可控制的背景音乐
- 微信分享网页
### 设计
![222.jpg](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/392f2036c0ce4c97b8562e6f17606491~tplv-k3u1fbpfcp-watermark.image?)
### 开发
- **技术栈**
- vite (打包&构建)
- vue3 (页面开发)
- vantui
- sass (css)
- [smooth-signature.js (带笔锋手写库)](https://github.com/linjc/smooth-signature)
``` js
<template>
<div class="wrap" :class="'mode-' + mode" @touchstart="handleTouchstart">
<!-- 切换模式按钮 -->
<div class="toggle-mode-btn" @click="toggleMode">
{{ mode === 1 ? '手写' : '生成' }}
<i class="iconfont icon-qiehuan"></i>
</div>
<!-- 工具栏 -->
<div
class="actions"
:style="{ borderTopRightRadius: colorListVisibility ? '0' : '5px' }"
>
<!-- 手写模式显示 -->
<template v-if="mode === 1">
<!-- 调色板 -->
<div class="palette btn-block">
<div
class="cur-color"
@click="togglePalette"
:style="{ background: colorList[curColorIndex] }"
></div>
<ul class="colorList" v-show="colorListVisibility">
<li
v-for="(item, index) in colorList"
:key="item"
:style="{ background: item }"
@click="selectColor(index)"
></li>
</ul>
</div>
<!-- 滑块 -->
<div class="slider-box btn-block">
<van-slider
v-model="progress"
vertical
@change="changeProgress"
bar-height="28"
active-color="transparent"
:min="50"
:max="150"
>
<template #button>
<div class="custom-button"></div>
</template>
</van-slider>
</div>
<!-- 清空 -->
<div class="btn" @click="handleClear">
<i class="iconfont icon-lajitong"></i>
</div>
<!-- 撤销 -->
<div class="btn" @click="handleUndo">
<i class="iconfont icon-fanhui"></i>
</div>
<div class="line"></div>
<!-- 切换画布的按钮 -->
<div
class="btn"
:class="{ 'cur-active': curCanvasIndex === index }"
v-for="(item, index) in canvasList"
:key="item.name"
@click="changeCanvas(index)"
>
{{ item.name }}
</div>
<div class="line"></div>
<div class="btn prominent" @click="handlePreview">预览</div>
<div class="btn prominent" @click="openPosters">贴联</div>
</template>
<!-- 生成模式显示 -->
<template v-else>
<!-- 选颜色 -->
<div
class="color-list-quick"
:class="{ active: curColorIndex === index }"
v-for="(item, index) in colorList"
:key="item"
:style="{ background: item }"
@click="selectColor(index)"
></div>
<div class="line"></div>
<div class="btn" @click="showPickBox = true">挑选</div>
<div class="btn" @click="showInputBox = true">输入</div>
<!-- 挑选对联弹窗 -->
<van-action-sheet v-model:show="showPickBox" title="请挑选对联">
<ul class="duilian-list">
<li
v-for="(item, index) in duilianList"
:key="index"
@click="handlePickDuilian(item)"
>
<span>{{ item.shang }}</span
> <span>{{ item.xia }}</span
>
<span>{{ item.heng }}</span>
</li>
</ul>
</van-action-sheet>
<!-- 输入对联弹窗 -->
<van-action-sheet v-model:show="showInputBox" title="请输入对联">
<van-form @submit="handleSubmitInput">
<van-cell-group inset>
<van-field
v-model="shanglian"
name="shang"
label="上联"
placeholder="上联"
:rules="[
{
required: true,
message: '请输入7位汉字上联',
pattern: /^[\u4e00-\u9fa5]{7}$/
}
]"
clearable
/>
<van-field
v-model="xialian"
name="xia"
label="下联"
placeholder="下联"
:rules="[
{
required: true,
message: '请输入7位汉字下联',
pattern: /^[\u4e00-\u9fa5]{7}$/
}
]"
clearable
/>
<van-field
v-model="hengpi"
name="heng"
label="横批"
placeholder="横批"
:rules="[
{
required: true,
message: '请输入4位汉字横批',
pattern: /^[\u4e00-\u9fa5]{4}$/
}
]"
clearable
/>
</van-cell-group>
<div style="margin: 16px">
<van-button
round
block
type="primary"
native-type="submit"
color="linear-gradient(to right, #ff6034, #c33825)"
>
完成
</van-button>
</div>
</van-form>
</van-action-sheet>
</template>
</div>
<!-- 模式1-春联画布 -->
<div
v-show="mode === 1"
v-for="(item, index) in canvasList"
:key="item.name"
>
<canvas
class="canvas"
:class="item.className"
v-show="curCanvasIndex === index"
:style="{
marginTop:
item.height < clientHeight
? `${(clientHeight - item.height) / 2}px`
: 0,
marginLeft:
item.width < clientWidth ? `${(clientWidth - item.width) / 2}px` : 0
}"
/>
</div>
<!-- 模式2-春联画布 -->
<div v-show="mode === 2" class="canvas-mode-2">
<div class="row">
<canvas id="canvas-top" :width="200 * scale" :height="60 * scale" />
</div>
<div class="row">
<canvas id="canvas-left" :width="60 * scale" :height="364 * scale" />
<canvas id="canvas-right" :width="60 * scale" :height="364 * scale" />
</div>
</div>
<!-- 贴春联按钮 -->
<Button class="btn-posters" @click="openPosters" />
<!-- footer-当前对联提示 -->
<footer v-if="duilian.shang">
<div class="refresh-btn" @click="handleRefresh(true)">
<i class="iconfont icon-shuaxin" :class="{ rotate: isRotate }"></i>
</div>
<dl class="duilian">
<dt>对联</dt>
<dd>
<div>{{ duilian.shang }}</div>
<div>{{ duilian.xia }}</div>
</dd>
</dl>
<dl>
<dt>横批</dt>
<dd>{{ duilian.heng }}</dd>
</dl>
</footer>
<!-- 分享按钮 -->
<div class="share-btn" v-if="isShowShareBtn" @click="isShowShareTip = true">
<i class="iconfont icon-fenxiang"></i>
</div>
<!-- 微信分享提示语 -->
<div
class="share-tip"
v-if="isShowShareTip"
@click="isShowShareTip = false"
>
点击右上角把这个工具分享给朋友
<div class="hand">👆</div>
</div>
<!-- 保存tip -->
<p v-if="isShowTip" class="download-tip">*长按图片保存或转发</p>
<!-- 版权 -->
<div class="copyright">公众号「有趣研究社」 ©版权所有</div>
<!-- 载入图片元素,用于快速贴图使用, 注意设置crossorigin="anonymous"解决跨域 -->
<div v-if="isReadImages">
<img
crossorigin="anonymous"
v-for="(item, index) in bgList"
:src="item"
:key="item"
class="hide-img"
:id="`bg-img-` + index"
/>
<img
crossorigin="anonymous"
class="hide-img"
id="qrcode"
src="https://cdn.jsdelivr.net/gh/xugaoyi/image_store2@master/img/qrcode.zul0pldsuao.png"
/>
</div>
<!-- 背景音乐 -->
<audio
src="https://cdn.jsdelivr.net/gh/xugaoyi/image_store2@master/cjxq.mp3"
id="bgm"
ref="bgm"
loop
/>
<div
class="play-btn"
:class="{ paused: !isPlay }"
ref="playBtn"
@click="handlePlay"
>
<i class="iconfont icon-yinle"></i>
</div>
</div>
<div class="body-bg-img"></div>
</template>
<script>
import { ImagePreview, Notify } from 'vant'
import { isWX, isMobile } from '@/utils'
import Button from '@/components/Button.vue'
import dl from '@/assets/img/yh/dl.jpeg'
import hp from '@/assets/img/yh/hp.jpeg'
import fz from '@/assets/img/yh/fz.png'
// 对联数据
import duilianList from '@/mock/duilian'
const PROPORTION = 0.37 // 图片缩小比例
const INSTANTIATE_NAME = 'signature' // 实例名称
const MIN_WIDTH = 3 // 画笔最小宽
const MAX_WIDTH = 12 // 画笔最大宽
// 海报背景图大小
const BG_WIDTH = 750
const BG_HEIGHT = 1448
// 贴图定位和大小
const POSITION = [
{ left: 57, top: 510, width: 90, height: 546 }, // 上联
{ left: 600, top: 510, width: 90, height: 546 }, // 下联
{ left: 225, top: 345, width: 300, height: 90 }, // 横幅
{ left: 460, top: 450, width: 130, height: 130 }, // 福字
]
export default {
name: "Home",
components: {
Button
},
data() {
return {
duilianList,
mode: Number(localStorage.getItem('mode')) || 1, // 1 手写2 生成
curCanvasIndex: 0, // 显示哪个画布
progress: 100, // 画笔大小的刻度
clientWidth: document.documentElement.clientWidth,
clientHeight: document.documentElement.clientHeight,
canvasList: [
{
name: '上联',
className: 'canvas-a',
bgImage: dl,
width: 600 * PROPORTION,
height: 3640 * PROPORTION,
},
{
name: '下联',
className: 'canvas-b',
bgImage: dl,
width: 600 * PROPORTION,
height: 3640 * PROPORTION,
},
{
name: '横批',
className: 'canvas-c',
bgImage: hp,
width: 2000 * PROPORTION,
height: 600 * PROPORTION,
},
{
name: '福字',
className: 'canvas-d',
bgImage: fz,
width: 366,
height: 366,
}
],
colorList: ['#000000', '#ffd800', '#e8bd48', '#ddc08c',],
curColorIndex: 0,
colorListVisibility: false, // 画布颜色选择列表可见性
isShowTip: false, // 是否显示底部提示语
duilian: {}, // 当前对联文本对象
isRotate: false, // 刷新icon旋转
bgList: [
'https://cdn.jsdelivr.net/gh/xugaoyi/image_store@master/1.4j8qpdnq80i0.jpeg',
'https://cdn.jsdelivr.net/gh/xugaoyi/image_store@master/4.4460an8ag5o0.jpeg',
'https://cdn.jsdelivr.net/gh/xugaoyi/image_store@master/5.3axtl4xpvy00.jpeg',
'https://cdn.jsdelivr.net/gh/xugaoyi/image_store@master/6.2lnbphdqjaq0.jpeg',
],
isReadImages: false, // 延迟加载图片用
isShowShareBtn: false, // 是否显示分享按钮
isShowShareTip: false, // 是否显示分享提示语
isPlay: false, // 是否在播放
// 模式2
canvasTop: null, // 横批
canvasLeft: null, // 上联
canvasRight: null, // 下联
imgObj1: null, // 横批图片对象
imgObj2: null, // 上下联图片对象
scale: Math.max(window.devicePixelRatio || 1, 2), // 用于增加画布清晰度
showPickBox: false, // 挑选对联的弹框
showInputBox: false, // 输入对联的弹框
shanglian: '', // 输入的上联
xialian: '', // 输入的下联
hengpi: '', // 输入的横批
};
},
computed: {
// 模式1-当前画布实例
curCanvasInstantiate() {
return this[INSTANTIATE_NAME + this.curCanvasIndex]
}
},
created() {
// 微信浏览器显示分享按钮
this.isShowShareBtn = isWX()
},
mounted() {
if (!isMobile()) {
Notify({ type: 'warning', message: '请用移动端打开获得最佳体验', duration: 6000, });
}
this.initMode1();
// 初始化对联提示
this.handleRefresh();
this.initMode2();
// 按钮添加激活时发光效果class
const btnEl = document.querySelectorAll('.btn,.btn-block');
btnEl.forEach((item) => {
item.addEventListener('touchstart', () => {
item.classList.add('btn-active')
})
item.addEventListener('touchend', () => {
setTimeout(() => {
item.classList.remove('btn-active')
}, 100)
})
})
// 延迟加载贴图背景
setTimeout(() => {
this.isReadImages = true
}, 1000)
},
watch: {
// 切换画笔颜色
curColorIndex() {
this.curCanvasInstantiate.color = this.colorList[this.curColorIndex]
if (this.mode === 2) {
this.refreshDuilian()
}
},
// 切换画布时应用当前画笔颜色和大小
curCanvasIndex() {
this.curCanvasInstantiate.color = this.colorList[this.curColorIndex]
this.handleChangeSize()
window.scrollTo(0, 0)
}
},
methods: {
initMode1() {
const { colorList, curColorIndex } = this
this.canvasList.forEach((item, index) => {
const options = {
width: item.width,
height: item.height,
minWidth: MIN_WIDTH, // 画笔最小宽度(px)
maxWidth: MAX_WIDTH, // 画笔最大宽度
minSpeed: 1.8, // 画笔达到最小宽度所需最小速度(px/ms)取值范围1.0-10.0
color: colorList[curColorIndex],
// 新增的配置
bgImage: item.bgImage,
};
this[INSTANTIATE_NAME + index] = new SmoothSignature(document.querySelector('.' + item.className), options);
})
},
initMode2() {
this.canvasTop = document.getElementById('canvas-top').getContext('2d')
this.canvasLeft = document.getElementById('canvas-left').getContext('2d')
this.canvasRight = document.getElementById('canvas-right').getContext('2d')
// 设字体样式
const font = "36px xs, cursive"
this.canvasTop.font = font
this.canvasLeft.font = font
this.canvasRight.font = font
// 增强清晰度
const { scale } = this
this.canvasTop.scale(scale, scale);
this.canvasLeft.scale(scale, scale);
this.canvasRight.scale(scale, scale);
// 设背景图
this.imgObj1 = new Image()
this.imgObj2 = new Image()
this.imgObj1.src = hp
this.imgObj2.src = dl
this.imgObj1.onload = () => {
// 贴背景
this.canvasTop.drawImage(this.imgObj1, 0, 0, 200, 60)
// 字体加载完成后
document.fonts.ready.then(() => {
this.handleTopFillText()
});
}
this.imgObj2.onload = () => {
// 贴背景
this.canvasLeft.drawImage(this.imgObj2, 0, 0, 60, 364)
this.canvasRight.drawImage(this.imgObj2, 0, 0, 60, 364)
// 字体加载完成后
document.fonts.ready.then(() => {
this.handleLRFillText(this.canvasLeft, this.duilian.shang)
this.handleLRFillText(this.canvasRight, this.duilian.xia)
});
}
},
// 模式2-刷新对联
refreshDuilian() {
this.canvasTop.drawImage(this.imgObj1, 0, 0, 200, 60)
this.canvasLeft.drawImage(this.imgObj2, 0, 0, 60, 364)
this.canvasRight.drawImage(this.imgObj2, 0, 0, 60, 364)
this.handleTopFillText()
this.handleLRFillText(this.canvasLeft, this.duilian.shang)
this.handleLRFillText(this.canvasRight, this.duilian.xia)
},
// 模式2-贴横批
handleTopFillText() {
// 贴文本
this.canvasTop.fillStyle = this.colorList[this.curColorIndex]
if (this.duilian.heng) {
this.duilian.heng.split('').forEach((item, index) => {
const left = 42 * (index + 1) - 22
this.canvasTop.fillText(item, left, 40)
})
}
},
// 模式2-贴上下联
handleLRFillText(ctx, text) {
ctx.fillStyle = this.colorList[this.curColorIndex]
if (text) {
text.split('').forEach((item, index) => {
const top = 50 * (index + 1) - 8
ctx.fillText(item, 13, top)
})
}
},
// 切换模式
toggleMode() {
if (this.mode === 1) {
this.mode = 2
this.refreshDuilian()
} else {
this.mode = 1
}
localStorage.setItem('mode', this.mode);
},
// 打开调色板
togglePalette() {
this.colorListVisibility = !this.colorListVisibility
},
// 关闭调色板
handleTouchstart(e) {
// 不是点击选择颜色时
if (e.path[1]?.classList?.value !== 'colorList' && e.target.classList?.value !== 'cur-color') {
this.colorListVisibility = false
}
},
// 选择颜色
selectColor(index) {
this.curColorIndex = index
this.colorListVisibility = false
},
// 切换画布
changeCanvas(index) {
this.curCanvasIndex = index
},
// 清空画布
handleClear() {
this.curCanvasInstantiate.clear();
},
// 撤销笔画
handleUndo() {
this.curCanvasInstantiate.undo();
},
// 预览
handlePreview() {
this.showTopTip();
this.isShowTip = true
const _this = this
ImagePreview({
images: this.getImageList(),
closeable: true,
startPosition: this.curCanvasIndex,
onClose() {
_this.isShowTip = false
},
});
},
// 打开海报预览
openPosters() {
// 创建画布
const canvas = document.createElement('canvas');
canvas.width = BG_WIDTH
canvas.height = BG_HEIGHT
const ctx = canvas.getContext('2d');
const resultImageList = [];
// 是否隐藏福字
const isHideFu = this[INSTANTIATE_NAME + 3].isEmpty()
this.bgList.forEach((item, index) => {
// 贴背景图
ctx.drawImage(document.getElementById('bg-img-' + index), 0, 0, BG_WIDTH, BG_HEIGHT)
// 贴对联
if (this.mode === 1) {
this.canvasList.forEach((item, index) => {
if (index === 3 && isHideFu) return;
const dlCanvas = document.querySelector('.' + item.className)
const { left, top, width, height } = POSITION[index]
ctx.drawImage(dlCanvas, left, top, width, height)
})
} else {
['canvas-left', 'canvas-right', 'canvas-top'].forEach((item, index) => {
const dlCanvas = document.getElementById(item)
const { left, top, width, height } = POSITION[index]
ctx.drawImage(dlCanvas, left, top, width, height)
})
}
// 贴二维码
ctx.drawImage(document.getElementById("qrcode"), 40, 1280, 580, 136)
// 贴文本
ctx.font = "18px sans-serif"
ctx.fillStyle = "#666666"
ctx.fillText('©公众号「有趣研究社」', 550, 1420)
// 导出图片
resultImageList.push(canvas.toDataURL('image/jpeg', 0.8))
})
// 打开图片预览
this.isShowTip = true
const _this = this
ImagePreview({
images: resultImageList,
closeable: true,
onClose() {
_this.isShowTip = false
},
});
this.showTopTip();
},
// 弹出顶部提示语
showTopTip() {
if (!sessionStorage.getItem('showTip')) {
sessionStorage.setItem('showTip', 'true');
Notify({
message: '长按图片可保存到本地',
color: '#c33825',
background: '#eed3ae',
});
}
},
// 获取对联图片列表
getImageList(type = 'image/png') {
const imageList = []
this.canvasList.forEach((item, index) => {
if (index === 3) {
// `福`字必须是png格式
type = 'image/png'
}
imageList.push(this[INSTANTIATE_NAME + index].toDataURL(type, 0.8))
})
return imageList
},
// 进度改变时
changeProgress(progress) {
this.progress = progress
this.handleChangeSize()
},
// 调整画笔大小
handleChangeSize() {
const { progress } = this
this.curCanvasInstantiate.minWidth = MIN_WIDTH * progress / 100
this.curCanvasInstantiate.maxWidth = MAX_WIDTH * progress / 100
},
// 刷新对联
handleRefresh(rotate) {
this.duilian = duilianList[Math.floor(Math.random() * duilianList.length)]
if (rotate) {
if (this.mode === 2) {
this.refreshDuilian()
}
// 使icon旋转
this.isRotate = true
setTimeout(() => {
this.isRotate = false
}, 300)
}
},
// 播放背景音乐
handlePlay() {
const { bgm } = this.$refs
if (bgm.paused) {
bgm.play()
this.isPlay = true
} else {
bgm.pause()
this.isPlay = false
}
},
// 完成输入对联
handleSubmitInput(values) {
this.duilian = values
this.showInputBox = false
this.refreshDuilian()
},
// 完成挑选对联
handlePickDuilian(item) {
this.duilian = item
this.showPickBox = false
this.refreshDuilian()
}
},
};
</script>
```
更多有趣的小网页欢迎关注公众号`有趣研究社`:
> [手写春联](https://cl.xugaoyi.com/)</br>
> [FC在线模拟器](https://game.xugaoyi.com/)</br>
> [爱国头像生成器](https://avatar.xugaoyi.com/)</br>
> [到账语音生成器](https://zfb.xugaoyi.com/)