大道至简 大道至简
首页
  • 前端文章

    • JavaScript
  • 学习笔记

    • 《JavaScript教程》
    • 《JavaScript高级程序设计》
    • 《ES6 教程》
    • 《Vue》
    • 《React》
    • 《TypeScript 从零实现 axios》
    • 《Git》
    • TypeScript
    • JS设计模式总结
  • HTML
  • CSS
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档

Evan Xu

前端界的小学生
首页
  • 前端文章

    • JavaScript
  • 学习笔记

    • 《JavaScript教程》
    • 《JavaScript高级程序设计》
    • 《ES6 教程》
    • 《Vue》
    • 《React》
    • 《TypeScript 从零实现 axios》
    • 《Git》
    • TypeScript
    • JS设计模式总结
  • HTML
  • CSS
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
  • 我做了一个手写春联小网页,祝大家虎年暴富

xugaoyi
2022-01-28
随笔
目录

我做了一个手写春联小网页,祝大家虎年暴富原创

手写春联:https://cl.xugaoyi.com/ (opens new window)

# 前言

虎年春节快到了,首先祝大家新年快乐,轻松暴富。 最近在网上经常看到生成春联的文章,不过这些小demo要么功能简陋,要么UI特别‘程序员’,满足不了我挑剔的眼光。干脆我自己做一个吧,顺便简单体验一下vite+vue3。(因为页面相对简单,vue组件风格还是使用选项式api,重点还是想把产品快速做出来。)

# 产品构思

包含手写春节和生成春联两大功能:

  • 手写春联

    • 模拟用笔写字的字迹
    • 选择画笔颜色
    • 调整画笔大小
    • 清空画布
    • 撤回笔画
    • 切换上、下联、横批、福字
    • 随机切换对联提示
    • 预览图片和下载
    • 贴春联海报和下载
  • 生成模式

    • 选择画笔颜色
    • 挑选生成的对联
    • 输入对联
    • 随机切换对联
    • 贴春联海报和下载
  • 其他

    • 快速切换模式按钮
    • 可控制的背景音乐
    • 微信分享网页

# 设计

222.jpg

# 开发

  • 技术栈
    • vite (打包&构建)
    • vue3 (页面开发)
    • vant(ui)
    • sass (css)
    • smooth-signature.js (带笔锋手写库) (opens new window)
<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.staticaly.com/gh/xugaoyi/image_store2@master/img/qrcode.zul0pldsuao.png"
      />
    </div>

    <!-- 背景音乐 -->
    <audio
      src="https://cdn.staticaly.com/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.staticaly.com/gh/xugaoyi/image_store@master/1.4j8qpdnq80i0.jpeg',
        'https://cdn.staticaly.com/gh/xugaoyi/image_store@master/4.4460an8ag5o0.jpeg',
        'https://cdn.staticaly.com/gh/xugaoyi/image_store@master/5.3axtl4xpvy00.jpeg',
        'https://cdn.staticaly.com/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>

更多有趣的小网页欢迎关注公众号有趣研究社:

手写春联 (opens new window)
FC在线模拟器 (opens new window)
爱国头像生成器 (opens new window)
到账语音生成器 (opens new window)

上次更新: 2023/02/20, 07:11:28
最近更新
01
Git修改分支名
08-11
02
CSS给table的tbody添加滚动条
06-29
03
一行代码“黑”掉任意网站 原创
11-25
更多文章>
Theme by Vdoing | Copyright © 2019-2023 Evan Xu | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式