普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月7日首页

介绍一个手势识别库——AlloyFinger

作者 codingWhat
2026年3月7日 18:30

移动端触摸手势库 AlloyFinger,配上 Vue 的 v-finger 指令,让「点、滑、捏、转」都能用声明式写法搞定,一起看看吧。


一、为什么需要 AlloyFinger?

在 H5 里,原生 touchstart / touchmove / touchend 只能告诉你「手指动了」,至于用户是单击、双击、长按、滑动、双指缩放还是旋转,都要自己算时间差、距离、角度——既难写又容易出 bug。

AlloyFinger 是腾讯 AlloyTeam 开源的轻量级手势库,把这些常见手势都封装好了,并且提供了 Vue 插件,以自定义指令 v-finger 的形式在模板里绑定,写法清晰、易维护。


二、安装依赖

在项目根目录执行:

npm install alloyfinger

三、在入口文件中注册插件

Vue 入口文件(如 src/main.js)中做两件事:

  1. 引入 AlloyFinger 本体和其 Vue 插件;
  2. 使用 Vue.use(AlloyFingerPlugin, { AlloyFinger }) 注册。

这样全局就可以在任意组件的模板里使用 v-finger 指令。

// 引入 alloy-finger
import AlloyFinger from 'alloyfinger'
import AlloyFingerPlugin from 'alloyfinger/vue/alloy_finger_vue'
Vue.use(AlloyFingerPlugin, {
  AlloyFinger
})

注意:

  • 插件路径是 alloyfinger/vue/alloy_finger_vue
  • 必须把 AlloyFinger 通过 Vue.use 的第二个参数传进去,插件内部会用它来创建手势实例。

四、在模板里使用 v-finger

注册完成后,在任意 Vue 组件的模板中,给需要绑定手势的单个根元素写上 v-finger:事件名="方法名" 即可。

4.1 语法形式

<div
  v-finger:tap="onTap"
  v-finger:swipe="onSwipe"
  v-finger:long-tap="onLongTap"
>
  可触摸区域
</div>
  • 指令名v-finger
  • 修饰符:冒号后面是事件类型,如 tapswipelong-tappinchrotate 等。
  • :当前 Vue 实例上的方法名,与普通 @click 一样写在 methods 里即可。

4.2 支持的事件

事件名 说明
tap 单击
double-tap 双击
single-tap 单击(与 double-tap 区分时用)
long-tap 长按
swipe 滑动手势(可结合 evt.direction)
pinch 双指缩放(evt.zoom)
rotate 双指旋转(evt.angle)
press-move 按住拖动(evt.deltaX / deltaY)
multipoint-start 多指开始
multipoint-end 多指结束
touch-start / touch-move / touch-end / touch-cancel 原生触摸事件封装

需要传参时,在方法里接收事件对象即可(如 swipe(evt) 中的 evt.directionpinch(evt) 中的 evt.zoom)。

4.3 完整示例

模板:

<template>
  <div
    class="touch-area"
    v-finger:tap="tap"
    v-finger:long-tap="longTap"
    v-finger:swipe="swipe"
    v-finger:pinch="pinch"
    v-finger:rotate="rotate"
    v-finger:double-tap="doubleTap"
    v-finger:single-tap="singleTap"
  >
    <div>点我、长按、滑动或双指操作</div>
  </div>
</template>

脚本:

export default {
  methods: {
    tap() {
      console.log('单击')
    },
    longTap() {
      console.log('长按')
    },
    swipe(evt) {
      console.log('滑动方向:', evt.direction)
    },
    pinch(evt) {
      console.log('缩放比例:', evt.zoom)
    },
    rotate(evt) {
      console.log('旋转角度:', evt.angle)
    },
    doubleTap() {
      console.log('双击')
    },
    singleTap() {
      console.log('单击(与双击区分)')
    }
  }
}

按需绑定自己用到的几个事件即可,不必全部写上。


五、用法很简单,那AlloyFinger是怎么实现的呢?

了解实现原理,有助于我们更放心地使用、排查问题,甚至做简单扩展。
AlloyFinger 的实现可以拆成两层:底层手势识别(alloy_finger.js)Vue 指令封装(alloy_finger_vue.js)

5.1 底层:基于原生 Touch 事件 + 向量运算

AlloyFinger 不依赖任何框架,核心就是给一个 DOM 元素绑定四个原生事件:

this.element.addEventListener("touchstart", this.start, false);
this.element.addEventListener("touchmove", this.move, false);
this.element.addEventListener("touchend", this.end, false);
this.element.addEventListener("touchcancel", this.cancel, false);

start 里:

  • 记录第一个触点的坐标 (x1, y1)和当前时间戳;
  • 用「上次 tap 的时间」和「两次点击的位移」判断是否构成双击(例如 250ms 内、位移 30px 以内);
  • 若检测到多指(evt.touches.length > 1),则计算两指构成的向量长度,作为后续 pinch 缩放的基准,并触发 multipointStart
  • 同时启动一个 750ms 的定时器,到时即触发 longTap

move 里:

  • 若是单指,则用当前点与上一帧点的差值得到 deltaXdeltaY,触发 pressMove
  • 若移动距离超过约 10px,会置位 _preventTap,避免误触 tap
  • 若是双指,则用两指构成的向量做向量长度比得到 evt.zoom(pinch),用向量夹角得到 evt.angle(rotate),这里用到简单的向量数学(点积、叉积、夹角),核心逻辑类似:
// 向量长度
function getLen(v) {
  return Math.sqrt(v.x * v.x + v.y * v.y);
}
// 缩放:当前两指距离 / 起始两指距离
evt.zoom = getLen(v) / this.pinchStartLen;
// 旋转:当前向量相对上一帧向量的角度
evt.angle = getRotateAngle(v, preV);

end 里:

  • 若「起点到终点的位移」超过约 30px,则根据 x、y 方向位移谁更大来判定 swipe 方向(Left/Right/Up/Down),并触发 swipe
  • 否则在下一个「事件循环」里触发 tap,并根据之前的双击标记决定是否再触发 doubleTap 或延迟 250ms 触发 singleTap
  • 同时会清除 longTap 定时器、重置双指相关的状态。

也就是说:tap / longTap / doubleTap / swipe / pinch / rotate / pressMove 等,都是在同一套 touch 生命周期里,用「时间差 + 位移 + 向量运算」推导出来的,没有黑魔法。

5.2 回调管理:HandlerAdmin

每种手势对应一个「回调列表」,用 HandlerAdmin 统一管理:add 注册、del 移除、dispatch 时对该元素上的所有回调依次 apply。这样同一个元素上可以挂多个监听(例如 Vue 插件里对同一元素绑定多个 v-finger:xxx),彼此也不会互相覆盖。

5.3 Vue 插件层:v-finger 如何挂到 DOM 上

插件在 install 时执行 Vue.directive('finger', directiveOpts),因此模板里的 v-finger 会变成对自定义指令 finger 的调用。

  • 事件名映射:模板里写的是 kebab-case(如 v-finger:long-tap),插件里用 EVENTMAP 转成 AlloyFinger 的 camelCase(如 longTap),再交给底层。
  • 一元素一实例:用一个全局 CACHE 数组,按 DOM 元素存 { elem, alloyFinger }。同一元素上多条 v-finger:tapv-finger:swipe 等,共用一个 AlloyFinger 实例;第一次绑定时 new AlloyFinger(elem, options),之后同元素再绑其他事件时,不再 new,而是 alloyFinger.on(eventName, func) 往该实例上追加回调。
  • 指令生命周期:Vue2 下 bind / update 时执行 doBindEvent(绑定或更新回调),unbind 时从 CACHE 里取出实例并调用 alloyFinger.destroy(),移除原生事件监听和所有定时器,避免内存泄漏。

核心片段:

// 同一元素多次 v-finger:xxx 共用一个 AlloyFinger 实例
var cacheObj = CACHE[getElemCacheIndex(elem)];
if (cacheObj && cacheObj.alloyFinger) {
  if (oldFunc) cacheObj.alloyFinger.off(eventName, oldFunc);
  if (func) cacheObj.alloyFinger.on(eventName, func);
} else {
  CACHE.push({
    elem: elem,
    alloyFinger: new AlloyFinger(elem, { [eventName]: func })
  });
}

5.4 小结

  • 手势识别:完全基于 touchstart / touchmove / touchend,用时间、位移和向量运算区分 tap、doubleTap、longTap、swipe、pinch、rotate、pressMove 等。
  • Vue 层:通过自定义指令 v-finger 和元素级 AlloyFinger 实例缓存,把「模板里的 v-finger:事件名」映射到「底层 AlloyFinger 的 on/off」,实现声明式绑定与组件销毁时的清理。

参考

手撸一个「能打」的 React Table 组件

作者 codingWhat
2026年3月7日 17:43

业务里的表格从来不是「行 + 列」那么简单:要分页(前端分页 / 服务端分页)、多选、分组合并、固定列、列宽拖拽、斑马纹、空状态……现成的 Table 要么过重,要么缺能力。所以这篇文章想和大家聊一聊怎么手撸一个配置驱动、能打业务的 React Table 组件。


一、先想清楚:我们要解决什么问题?

一个「能打」的 Table 至少要覆盖这些场景:

  • 配置驱动:用一份 options 描述表头、列、分页、选择行为,而不是在 JSX 里写死一堆 <th> / <td>
  • 真假分页:前端切片分页(假分页)和服务端请求分页(真分页)共用一套表格逻辑
  • 行选择:单选/多选、全选/当页全选、禁用某些行、分组下勾选联动
  • 合并与分组:按某一维度的 groupKey 做行分组,并支持 rowSpan / colSpan
  • 固定列:左右固定列 + 横向滚动时表头与 body 对齐
  • 列宽拖拽:表头边缘拖拽改变列宽(固定列不参与)
  • 体验细节:斑马纹、空数据提示、最大高度滚动、底部合计等

二、核心设计:用 options 驱动整张表

表头、列、分页、选择、是否可拖拽等,全部收口到 options,组件内部通过 setOptions 统一解析并挂到实例上,方便在 render 和生命周期里复用。

setOptions(options) {
  const {
    th = [],
    tbody,
    trAttr,
    type = '',
    key = 'sbTable',
    rowSelection = {},
    operations = {},
    scrollable = false,
    groupKey = '',
    emptyText,
    // ....
  } = options;

  this.th = th;
  this.tbody = tbody;
  this.type = type;           // 'checkbox' | 'normal'
  this.ref = key;
  this.rowSelection = rowSelection;
  this.scrollable = scrollable;
  this.groupKey = groupKey;
  this.emptyText = emptyText;
  // ...
}

这样,使用方只需要传 options + dataSource,表格长什么样、怎么分页、选不选,都由配置决定。


三、表头与列

表头支持「多行」,所以用二维数组 th:每一行是一个 tr,每个元素是 th 的配置(title、rowSpan、colSpan、width、align、fixed 等)。body 列用一维数组 tbody,每项描述一列:key 直接取数据字段,或不用 key 而用 render(data, index, rowNum, groupIndex) 自定义渲染。

表头渲染时顺带把「固定列」的 class 打好(sticky-left / sticky-right),为后面的固定列布局做准备:

// 表头:支持多行 th,每行一个 tr
this.th.map((ths, index) => (
  <tr key={`th-tr-${index}`}>
    {this.type === 'checkbox' && index === 0 && (
      <th className={`${this.ref}thh`} name="checkallbox" rowSpan={this.th.length}>
        {!!this.state.dataSource.length && this.renderCheckAllBox()}
      </th>
    )}
    {ths && ths.length && ths.map((th, ins) =>
      (!th.skip || !th.skip()) && (
        <th
          key={`th-${index}-${ins}`}
          rowSpan={th.rowSpan}
          width={th.width || 'unset'}
          colSpan={th.colSpan}
          style={{ textAlign: th.align || 'center' }}
          className={`${this.ref}thh ${
            th.fixed && !!this.state.dataSource.length && th.fixed === 'right'
              ? 'sticky-column sticky-right'
              : ''
          } ${
            th.fixed && !!this.state.dataSource.length && th.fixed === 'left'
              ? 'sticky-column sticky-left'
              : ''
          }`}
        >
          {th.title}
        </th>
      )
    )}
  </tr>
))

body 的每一列则根据 td 配置决定是走 key 还是 render,并统一处理 rowSpan、对齐、固定列 class:

this.tbody.map((td, ins) => {
  const rowSpan = this.rowSpanRender(td, data);
  return (!td.skip || !td.skip()) && rowSpan !== 0 && (
    <td
      key={`tb-td-${index}-${ins}`}
      {...(td.tdAttr && td.tdAttr(data, index))}
      rowSpan={rowSpan}
      width={td.width || 'unset'}
      colSpan={td.colSpan}
      style={{ textAlign: td.align || 'center', ...td.style }}
      title={this.isShowTitle ? data[td.key] : ''}
      className={`${td.fixed === 'right' ? 'sticky-column sticky-right' : ''} ${
        td.fixed === 'left' ? 'sticky-column sticky-left' : ''
      }`}
    >
      {td.key
        ? (data[td.key] ?? td.emptyText)
        : td.render.call(this, data, index, rowNum, this.computeGroupKeyIndex(data))}
    </td>
  );
})

有了「表头二维 + body 列描述」这一层,复杂表头、固定列、自定义单元格就都能在一份配置里表达清楚了。


四、数据与分页:visibleList 是「当前要渲染的那一页」

数据源是 dataSource,但真正参与渲染的是「当前页」的数据。组件里用 setVisibleList 根据是否分组、是否分页、真假分页,算出 visibleList 再 setState,这样 render 里只遍历 state.dataSource 即可。

分组时,先用 groupKey(以及可选的 groupKey2)把数据按维度聚合成 groupDataMap,再按分页截取;非 xhr 时直接对当前页做 slice,xhr 时通常整份 dataSource 就是当前页,只做分组展开即可:

setVisibleList() {
  let visibleList = [];
  let _list = this.data;

  if (this.groupKey) {
    let result = Util.prototype.Array.groupBy(this.data, this.groupKey);
    this.state.result = result;
    this.groupIndexMap = result.indexMap;
    this.groupDataMap = result.dataMap;
    this.groupKeyList = result.keyList;
    _list = Util.prototype.Array.map2Array(this.groupDataMap);
  }
  // groupKey2 可再做一层分组...

  let totalCount = this.xhr ? this.pagination.totalSize : _list.length;
  if (this.pagination) {
    let current = this.pagination.current || 1;
    let pageSize = this.pagination.pageSize || 10;
    if (!this.xhr) {
      let start = (current - 1) * pageSize;
      let end = current === pageCount ? totalCount : current * pageSize;
      visibleList = _list.slice(start, end);
    } else {
      visibleList = _list;
    }
  } else {
    visibleList = _list;
  }
  if (this.groupKey) visibleList = [].concat(...visibleList);

  this.setState({ dataSource: visibleList, total: totalCount });
}

分页切换时,需要区分「假分页」和「真分页」:

  • 假分页只改 pagination.current/pageSize,然后再调一次 setVisibleList即可;
  • 真分页则交给父组件 onPaginationChange 拉新数据,再更新 paginationdataSource,最 后同样走 setVisibleList
    这样一套表格逻辑同时支持真假分页

五、行选择与分组下的勾选联动

type === 'checkbox' 时,表头有「全选」框,每一行根据 rowSelection.disableCheck(row) 决定是否可勾选;支持「仅当页全选」和「全量全选」。分组时,同一组内勾选要联动(一组算一个「逻辑行」,checkbox 只在该组首行渲染,并设 rowSpan):

renderCheckBox(row, index) {
  let rowSpan = 1;
  if (this.groupKey) {
    let groupData = this.groupDataMap[this.groupKey(row)];
    rowSpan = groupData[0] === row ? groupData.length : 0;
  }
  if (rowSpan === 0) return null;

  const { disableCheck, checkboxToolTip, isShowHj } = this.rowSelection;
  const disabled = disableCheck && disableCheck(row);
  const checkbox = (
    <td name="checkbox" width="32px" rowSpan={rowSpan}>
      <Checkbox
        checked={row.checked}
        disabled={disabled}
        onChange={(e) => this.onCheck(e.target.checked, row)}
      />
    </td>
  );
  return !(isShowHj && isShowHj(row))
    ? (checkboxToolTip ? <Tooltip title={checkboxToolTip(row)}>{checkbox}</Tooltip> : checkbox)
    : <td name="checkbox" width="32px">合计</td>;
}

勾选/取消勾选时,若存在 groupKey,需要把同组所有行的 checked 同步,再根据是否 xhr 更新「全选」状态,并回调 rowSelection.onSelect / onSelectPage / onSelectAll。这样分组 + 多选 + 全选/当页全选都在一套逻辑里闭环。


六、rowSpan 与分组合并

除了 checkbox 的 rowSpan,普通列也支持「按分组合并」。rowSpanRender(td, data) 根据 td.rowSpantd.combine(对应 groupKey)、td.combine2(对应 groupKey2)计算当前单元格应该占几行,同组非首行返回 0 表示不渲染该格(由首行的 rowSpan 占位):

rowSpanRender(td, data) {
  if (td.rowSpan) return td.rowSpan;
  let rowSpan = 1;
  if (td.combine && this.groupKey) {
    let groupData = this.groupDataMap[this.groupKey(data)];
    rowSpan = groupData[0] === data ? groupData.length : 0;
  }
  if (td.combine2 && this.groupKey2) {
    let groupData2 = this.groupDataMap2[this.groupKey2(data)];
    rowSpan = groupData2[0] === data ? groupData2.length : 0;
  }
  return rowSpan;
}

这样,表头可以多行多列,body 可以按业务分组做合并,行列跨度都由配置 + 数据推导,无需手写一堆 rowSpan/colSpan。


七、固定列与列宽拖拽

固定列用 CSS position: sticky 实现,表头与 body 的对应列都加上 sticky-left / sticky-right。关键是要在滚动或列宽变化时,把「左侧宽度累加」和「右侧宽度累加」算准,赋给 left / right,这样多列固定时不会错位。在 setColumnStyle 里遍历表头行和 body 每一行的 cells,按索引累加左侧/右侧宽度并写回 style:

setOffset(elements) {
  for (let i = 0; i < elements.length; i++) {
    let Right = 0, Left = 0;
    for (let r = i + 1; r < elements.length; r++) Right += elements[r].offsetWidth;
    for (let l = 0; l < i; l++) Left += elements[l].offsetWidth;
    if (elements[i].className.includes('sticky-right')) {
      elements[i].setAttribute('style', `${elements[i].getAttribute('style')} right:${Right || -1}px;`);
    } else if (elements[i].className.includes('sticky-left')) {
      elements[i].setAttribute('style', `${elements[i].getAttribute('style')} left:${Left || -1}px;`);
    }
  }
}

列宽拖拽:在表头单元格上监听 mousedown / mousemove,靠近边缘(如 4px)时认为进入了「可拖拽」状态,按下后根据 evt.screenX 差值计算新宽度,并限制最小宽度 dragMinWidth;固定列不绑定拖拽。拖拽过程中可再次调用 setColumnStyle 让固定列的 left/right 跟着变,表格就不会「错位」。


八、斑马纹与空状态

斑马纹按「当前页」的行下标或按 stripeRowNum 为步长取奇偶,在 isStripe(index) 里返回不同的 backgroundColor,在 <tr style={this.isStripe(index)}> 上使用即可。无数据时 渲染一行 colSpan 覆盖整表的「空状态」,文案用 emptyText 或默认文案:

renderEmptydata() {
  return (
    <tr>
      <td
        className="empty-panal"
        colSpan={this.tbody.length + (this.type === 'checkbox' ? 1 : 0)}
      >
        {this.emptyText ? this.emptyText.call(this) : '没有数据'}
      </td>
    </tr>
  );
}

九、使用示例:配置即文档

业务侧只要组好 optionsdataSource,表格就能跑起来。下面是一个「带复选框 + 分页 + 自定义列」的简化示例:

tableOptions = {
  type: 'checkbox',
  key: 'sbTable',
  maxHeight: 385,
  th: [[
    { title: '序号', name: 'xh' },
    { title: '编号', name: 'num' },
    { title: '名称', name: 'name' },
    { title: '处理状态', name: 'status' },
  ]],
  tbody: [
    { align: 'center', key: 'xh' },
    { align: 'center', key: 'num' },
    { key: 'name' },
    {
      align: 'center',
      render(row) {
        return <span className={`status-${row.status}`}>{row.status}</span>;
      },
    },
  ],
  pagination: {
    current: 1,
    pageSize: 10,
    showSizeChanger: true,
    showQuickJumper: true,
  },
  rowSelection: {
    onSelect: (selectedRows) => { /* ... */ },
    disableCheck: (row) => row.disabled,
  },
};

// 使用
<Table
  options={this.tableOptions}
  dataSource={state.tableData}
  onPaginationChange={({ pageNumber, pageSize }) => this.loadData({ pageNum: pageNumber - 1, pageSize })}
/>

真分页时,父组件在 onPaginationChange 里请求接口,把 pagination.totalSize 和新的 dataSource 更新后再传回 Table,组件内部会据此重新 setVisibleList 并刷新全选状态。


十、小结

这样实现的 Table 不一定「大而全」,但能覆盖业务里最常见的一批需求,且易于在一个文件里维护和扩展。如果你也在为复杂表格发愁,不妨从「配置驱动 + 可见数据单一来源」这两点开始,手撸一版属于自己的 Table,说不定打一打业务需求会更顺手呢!

昨天以前首页

如何实现一个「万能」的通用打印组件?

作者 codingWhat
2026年3月5日 12:22

在我们组开发的业务系统中,存在文书种类多、格式不一的场景,但又要求保持一致的打印体验,怎么办呢?难道每次加一种新文书就写一套打印逻辑?不存在的。用「配置 + 动态模板 + iframe 打印」的思路,可以搭出一套一个组件打天下的通用打印方案。


一、先想清楚:我们要解决什么问题?

  • 多种文书:不同业务对应不同的文书模板,字段、布局、样式都不一样。
  • 统一入口:希望小伙伴调用时只关心「打开打印、传文书类型和业务单号」,不用关心具体模板和接口。
  • 可编辑再打:部分文书需要在预览里编辑或填充后再打印,而不是纯静态展示。
  • 打印体验:要能控制打印样式(页眉页脚、分页、字体),并且不把整页 UI 一起打出去。

二、整体架构:三层拆解

可以把通用打印拆成三层,逻辑会非常清晰:

  1. 主组件:包含组件状态提示、调用 iframe 执行打印等功能;
  2. 配置层:文书类型与文书模版要一一对应;
  3. 模板层:每种文书一个 Vue 模板组件,负责展示、编辑字段,同时提供方法给壳层拿去保存/打印。

三、配置层:文书类型与模板的映射

用一份配置集中维护,后续扩展新文书主要就是:加一条配置 + 加一个模板组件。

export const DOC_TYPE = {
  FORM_A: 'FORM_A',  // 例如:某登记表
  FORM_B: 'FORM_B',  // 例如:某告知书
  // ...
};

export const documentTemplates = {
  [DOC_TYPE.FORM_A]: {
    title: '某登记表',
  },
  [DOC_TYPE.FORM_B]: {
    title: '某告知书',
  },
};

export function getTemplateConfig(docType) {
  const config = documentTemplates[docType];
  if (!config) {
    console.warn(`未找到文书类型 ${docType} 的模板配置`);
    return null;
  }
  return config;
}

主组件里使用 getTemplateConfig(docType) 拿配置,这样「加新文书」对主组件来说就是多一个配置键和对应的模板组件啦。


四、壳层:动态组件 + 打印流程

主组件只认「当前 docType 对应哪个模板组件」,用 component :is 动态渲染,这样无需在壳里写一长串 if/else 或 v-if。

4.1 模板区域与动态组件

<!-- 打印区域:唯一 id 便于后面克隆到 iframe -->
<div id="commonPrintArea" class="print-area">
  <component
    :is="templateComponent"
    ref="templateRef"
    :data="printData"
    :numb="numb"
    :template-config="templateConfig"
  />
</div>
computed: {
  templateComponent() {
    const componentMap = {
      FORM_A: 'FormATemplate',
      FORM_B: 'FormBTemplate',
      // 新文书:加一行即可
    };
    return componentMap[this.docType] || null;
  },
},

printData 由你在 init/loadCommonData 里请求接口或直接使用外部传入的数据;templateConfig 来自 getTemplateConfig(this.docType)

4.2 从模板组件拿数据:约定 getData()

打印或保存前,主组件需要拿到当前模板里用户可能改过的内容,所以约定:每个模板组件暴露 getData()方法,返回要落库/打印的纯数据。

// 主组件 methods
getTemplateData() {
  const templateComponent = this.$refs.templateRef;
  if (!templateComponent || typeof templateComponent.getData !== 'function') {
    return null;
  }
  return templateComponent.getData();
},

async handlePrint() {
  const templateData = this.getTemplateData();
  if (!templateData) return;

  const saved = await this.savePrintRecord(templateData);
  if (!saved) return;

  this.executePrint();
  this.$emit('print-success', { docType: this.docType, numb: this.numb, printData: templateData });
}

这样无论是「先保存再打」还是「仅打印」,数据源都统一来自模板的 getData()


五、模板层:可编辑字段与 getData()

模板里会有大量「看起来像下划线填空」的格子,既要可编辑又要打印时样式干净,我们的做法是,用一个可编辑字段的子组件包一层,再在模板里用 v-model 绑定 editableData对象,最后 getData() 直接返回这个对象。

5.1 可编辑字段的子组件(EditableField组件)

用 HTML5的contenteditable属性做内联编辑,通过 v-model和父组件同步;输入法期间用 compositionstart/end 防抖。

<template>
  <span
    ref="editableElement"
    :class="['editable-field', customClass]"
    :contenteditable="editable"
    :data-placeholder="placeholder"
    @blur="handleBlur"
    @input="handleInput"
    @compositionstart="isComposing = true"
    @compositionend="isComposing = false; handleInput($event)"
  />
</template>

<script>
export default {
  name: 'EditableField',
  props: ['value', 'editable', 'placeholder', 'customClass', 'maxlength'],
  data() {
    return { isComposing: false, innerValue: '' };
  },
  watch: {
    value: {
      immediate: true,
      handler(newVal) {
        if (!this.isComposing && newVal !== this.innerValue) {
          this.innerValue = newVal || '';
          if (this.$refs.editableElement) this.$refs.editableElement.innerText = this.innerValue;
        }
      },
    },
  },
  methods: {
    handleBlur(e) {
      const text = e.target.innerText.trim();
      this.innerValue = text;
      this.$emit('input', text);
    },
    handleInput(e) {
      if (this.isComposing) return;
      let text = e.target.innerText;
      if (this.maxlength && text.length > this.maxlength) {
        text = text.substring(0, this.maxlength);
        this.$refs.editableElement.innerText = text;
      }
      this.innerValue = text;
      this.$emit('input', text);
    },
  },
};
</script>

模板里用法示例:

<editable-field v-model="editableData.name" placeholder="请输入" custom-class="inline-underline-field" />

打印样式里对 .editable-field.inline-underline-field 等做「无边框、无背景、保下划线」的覆盖,即可做到「屏幕可编辑、纸上像填空」。


六、iframe 打印:只打「这一块」且样式可控

直接 window.print() 会连侧边栏、导航、按钮一起打。我们的做法是:把要打印的那块 DOM 克隆到隐藏的 iframe 里,在 iframe 里注入完整打印样式,再对 iframe 执行 print()

6.1 克隆 + 处理特殊节点(如复选框)

克隆时注意:像 Element UI 的 checkbox,在 iframe 里可能不会按「勾选状态」渲染,所以克隆后先把这类控件转成「勾选用 ☑ / 未勾选用 ☐」的纯文本,再塞进 iframe,这样打印出来稳定一致。

processCheckboxes(container) {
  container.querySelectorAll('.el-checkbox').forEach((el) => {
    const input = el.querySelector('input[type="checkbox"]');
    const isChecked = input && input.checked;
    const checkmark = document.createElement('span');
    checkmark.textContent = isChecked ? '☑' : '☐';
    // 若有 .el-checkbox__label,可把 label 文本和 checkmark 拼成新节点替换 el
    el.parentNode.replaceChild(checkmark, el);
  });
}

6.2 创建 iframe 并写入 HTML + 样式

executePrint() {
  const printArea = document.getElementById('commonPrintArea');
  if (!printArea) return;

  const cloned = printArea.cloneNode(true);
  this.processCheckboxes(cloned);

  const iframe = document.createElement('iframe');
  iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:none';
  document.body.appendChild(iframe);

  const printStyles = this.getPrintStyles(); // 见下一小节

  const doc = iframe.contentWindow.document;
  doc.open();
  doc.write(`
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8">
        <title>${this.templateConfig.title}</title>
        <style>
          * { margin: 0; padding: 0; box-sizing: border-box; }
          body { font-family: "Microsoft YaHei", Arial, sans-serif; line-height: 1.5; color: #000; background: #fff; }
          ${printStyles}
        </style>
      </head>
      <body>${cloned.innerHTML}</body>
    </html>
  `);
  doc.close();

  iframe.onload = () => {
    iframe.contentWindow.focus();
    setTimeout(() => {
      iframe.contentWindow.print();
      setTimeout(() => document.body.removeChild(iframe), 500);
    }, 100);
  };
}

这样只有 iframe 里的 body 被打印,且样式完全由你注入的 printStyles 控制。


七、打印样式:基础 + 按文书类型扩展

拆成「基础样式(所有文书共用)」和「按 docType 的扩展样式」,主组件里根据 docType 拼成最终样式字符串。

getPrintStyles() {
  const baseStyles = `
    @page { margin: 0; size: A4; }
    body { margin: 10mm 10mm 15mm 10mm; font-family: "仿宋", serif; }
    .form-table { width: 100%; border-collapse: collapse; border: 2px solid #000; }
    .form-table th, .form-table td { border: 1px solid #000; padding: 6px 8px; }
    .form-table tr { page-break-inside: avoid; }
    .editable-field { border: none !important; background: transparent !important; box-shadow: none !important; }
    .inline-underline-field { border-bottom: 1px solid #333 !important; min-height: 1.2em; }
  `;
  const docTypeStyles = this.getDocTypeSpecificStyles(); // 从 styleMap[docType] 取
  return `${baseStyles}\n${docTypeStyles}`;
}

新增文书时,如需单独调表格列宽、标题字号等,在 getDocTypeSpecificStyles() 的 styleMap 里加一条即可,主组件逻辑不用改。


结尾:按这套思路实现后,业务侧只需要「传 docType + 外部数据) + 监听事件」,就能接住多种文书、可编辑、可保存的通用打印能力啦;后续加新文书也不会再在主组件里堆逻辑,维护成本也会低很多。

❌
❌