基于Canvas的原生签名组件,兼容小程序和H5

说明

基于Tarojs+NutUI做移动签名时,发现原生Signature组件无法兼容UI的设计要求,事件无法自定义处理,同时又要兼容微信小程序和H5的展示。于是重新使用Canvas来生成签名。

代码示例

独立一个签名组件,实现基本的签名、重置、图片生成,需要使用的地方通过组件引用的方式使用。

签名组件代码如下:

javascript 复制代码
<template>
  <view class="canvas-signature" @catchtouchmove.stop.prevent>
    <canvas
      :canvas-id="canvasId"
      :id="canvasId"
      :width="canvasWidth"
      :height="canvasHeight"
      class="signature-canvas"
      :style="{ width: displayWidth + 'px', height: displayHeight + 'px' }"
      @touchstart.stop="handleTouchStart"
      @touchmove.stop="handleTouchMove"
      @touchend.stop="handleTouchEnd"
      disable-scroll
      @catchtouchmove.stop.prevent
    ></canvas>
  </view>
</template>

<script setup lang="ts">
import { ref, onMounted, nextTick, computed, watch } from 'vue';
import Taro from '@tarojs/taro';

interface Props {
  width?: number;
  height?: number;
  lineWidth?: number;
  lineColor?: string;
}

const props = withDefaults(defineProps<Props>(), {
  width: 0, // 0表示自适应
  height: 0, // 0表示自适应
  lineWidth: 2,
  lineColor: '#000000',
});

const emit = defineEmits<{
  confirm: [data: string];
  clear: [];
}>();

const canvasId = ref(`signatureCanvas_${Date.now()}`);
const canvasWidth = ref(props.width || 750); // canvas实际绘制宽度(默认与展示一致)
const canvasHeight = ref(props.height || 344); // canvas实际绘制高度(默认与展示一致)
const displayWidth = ref(props.width || 750); // canvas显示宽度
const displayHeight = ref(props.height || 344); // canvas显示高度
const ctx = ref<any>(null);
const isDrawing = ref(false);
const lastPoint = ref<{ x: number; y: number } | null>(null);
const paths = ref<Array<Array<{ x: number; y: number }>>>([]); // 用于撤销功能
let drawTimer: any = null; // 用于节流draw调用
const normalizedLineWidth = computed(() => Math.max(1, props.lineWidth));
const hasStartedDrawing = ref(false); // 标记是否已经开始签名

// 初始化canvas
const initCanvas = () => {
  nextTick(() => {
    // 获取 canvas 尺寸(用 canvas 自身而不是外层容器,避免 padding 导致导出缩放/错位)
    const query = Taro.createSelectorQuery();
    query
      .select(`#${canvasId.value}`)
      .boundingClientRect((rect: any) => {
        if (rect && rect.width > 0 && rect.height > 0) {
          // 设置显示尺寸(单位为 px,保证 DOM 展示尺寸明确)
          displayWidth.value = props.width && props.width > 0 ? props.width : rect.width;
          displayHeight.value = props.height && props.height > 0 ? props.height : rect.height;

          // 内部绘制尺寸与显示尺寸一致,避免实际绘图被缩放
          canvasWidth.value = Math.max(1, Math.round(displayWidth.value));
          canvasHeight.value = Math.max(1, Math.round(displayHeight.value));

          // 更新canvas位置缓存
          canvasRect = {
            left: rect.left,
            top: rect.top,
            width: displayWidth.value,
            height: displayHeight.value,
          };
        }

        // 初始化canvas上下文
        try {
          ctx.value = Taro.createCanvasContext(canvasId.value);
          if (ctx.value) {
            // 先设置白色背景(这是画布的底色,不会被clearRect清除)
            ctx.value.setFillStyle('#ffffff');
            ctx.value.fillRect(0, 0, canvasWidth.value, canvasHeight.value);

            // 设置绘制样式
            ctx.value.setStrokeStyle(props.lineColor);
            ctx.value.setLineWidth(normalizedLineWidth.value);
            ctx.value.setLineCap('round');
            ctx.value.setLineJoin('round');
            ctx.value.setLineDash([]);

            // 绘制提示文字
            drawHintText();

            ctx.value.draw(true);
            console.log('Canvas初始化成功', {
              width: canvasWidth.value,
              height: canvasHeight.value,
              displayWidth: displayWidth.value,
              displayHeight: displayHeight.value,
            });
          }
        } catch (error) {
          console.error('Canvas初始化失败:', error);
        }
      })
      .exec();
  });
};

watch(
  () => [props.width, props.height],
  ([width, height], [prevWidth, prevHeight]) => {
    const shouldResize = (width && width > 0 && width !== prevWidth) || (height && height > 0 && height !== prevHeight);
    if (!shouldResize) {
      return;
    }

    if (width && width > 0) {
      displayWidth.value = width;
      canvasWidth.value = Math.max(1, Math.round(width));
    }
    if (height && height > 0) {
      displayHeight.value = height;
      canvasHeight.value = Math.max(1, Math.round(height));
    }

    nextTick(() => {
      initCanvas();
    });
  },
);

// 缓存canvas的位置信息,避免频繁查询
let canvasRect: { left: number; top: number; width: number; height: number } | null = null;

// 绘制提示文字
const drawHintText = () => {
  if (!ctx.value || hasStartedDrawing.value) return;

  const text = '请在此处手写签名';
  const fontSize = Math.min(canvasWidth.value / 15, 16); // 根据画布宽度自适应字体大小,最大20px

  ctx.value.setFillStyle('#666666'); // 浅灰色提示文字
  ctx.value.setFontSize(fontSize);
  ctx.value.setTextAlign('center');
  ctx.value.setTextBaseline('middle');

  // 在画布中心绘制文字
  const centerX = canvasWidth.value / 2;
  const centerY = canvasHeight.value / 2;
  ctx.value.fillText(text, centerX, centerY);
};

// 清除提示文字
const clearHintText = () => {
  if (!ctx.value) return;

  // 重新填充白色背景,清除提示文字
  ctx.value.setFillStyle('#ffffff');
  ctx.value.fillRect(0, 0, canvasWidth.value, canvasHeight.value);

  // 重新设置绘制样式
  ctx.value.setStrokeStyle(props.lineColor);
  ctx.value.setLineWidth(normalizedLineWidth.value);
  ctx.value.setLineCap('round');
  ctx.value.setLineJoin('round');
};

// 说明:canvasRect 在 initCanvas 中已基于 canvas 节点一次性设置;如后续需要支持旋转/布局变化,可再加更新逻辑

// 获取触摸点坐标(同步版本,提高性能)
// 关键:weapp 真机下优先用 touches[0].x/y(通常已是 canvas 内坐标),并在缺失时使用 pageX/pageY - rect 兜底。
const getTouchPointSync = (e: any): { x: number; y: number } | null => {
  const t = e.touches?.[0] || e.changedTouches?.[0];

  // weapp:优先使用 x/y(很多机型/版本直接给 canvas 内坐标)
  if (process.env.TARO_ENV === 'weapp') {
    if (t && typeof t.x === 'number' && typeof t.y === 'number') {
      // 若 x/y 看起来已经是 canvas 内坐标,则直接用
      if (t.x >= 0 && t.y >= 0 && t.x <= canvasWidth.value && t.y <= canvasHeight.value) {
        return {
          x: Math.max(0, Math.min(canvasWidth.value, t.x)),
          y: Math.max(0, Math.min(canvasHeight.value, t.y)),
        };
      }
      return {
        x: Math.max(0, Math.min(canvasWidth.value, t.x)),
        y: Math.max(0, Math.min(canvasHeight.value, t.y)),
      };
    }
    if (e.detail && typeof e.detail.x === 'number' && typeof e.detail.y === 'number') {
      return {
        x: Math.max(0, Math.min(canvasWidth.value, e.detail.x)),
        y: Math.max(0, Math.min(canvasHeight.value, e.detail.y)),
      };
    }
    // 兜底:用 pageX/pageY(或 clientX/clientY)减 rect(得到的是显示坐标 px)
    if (canvasRect && canvasRect.width > 0 && canvasRect.height > 0 && t) {
      const pageX = (t.pageX ?? t.clientX ?? 0) as number;
      const pageY = (t.pageY ?? t.clientY ?? 0) as number;
      const relativeX = pageX - canvasRect.left;
      const relativeY = pageY - canvasRect.top;
      return {
        x: Math.max(0, Math.min(canvasWidth.value, relativeX)),
        y: Math.max(0, Math.min(canvasHeight.value, relativeY)),
      };
    }
    return null;
  }

  // H5:用 client 坐标 + rect 转换
  const clientX = (t?.clientX ?? t?.x ?? 0) as number;
  const clientY = (t?.clientY ?? t?.y ?? 0) as number;
  if (canvasRect && canvasRect.width > 0 && canvasRect.height > 0) {
    const relativeX = clientX - canvasRect.left;
    const relativeY = clientY - canvasRect.top;
    return {
      x: Math.max(0, Math.min(canvasWidth.value, relativeX)),
      y: Math.max(0, Math.min(canvasHeight.value, relativeY)),
    };
  }
  return null;
};

// 获取触摸点坐标(异步版本,仅在必要时使用)
const getTouchPoint = (e: any): Promise<{ x: number; y: number }> => {
  const syncPoint = getTouchPointSync(e);
  if (syncPoint) {
    return Promise.resolve(syncPoint);
  }

  // 如果没有缓存,查询并返回
  let x = 0;
  let y = 0;
  if (e.detail && typeof e.detail.x === 'number' && typeof e.detail.y === 'number') {
    x = e.detail.x;
    y = e.detail.y;
  } else {
    const touch = e.touches?.[0] || e.changedTouches?.[0];
    if (touch) {
      x = touch.clientX || touch.x || 0;
      y = touch.clientY || touch.y || 0;
    }
  }

  return new Promise<{ x: number; y: number }>(resolve => {
    const query = Taro.createSelectorQuery();
    query
      .select(`#${canvasId.value}`)
      .boundingClientRect((rect: any) => {
        if (rect && rect.width > 0 && rect.height > 0) {
          canvasRect = {
            left: rect.left,
            top: rect.top,
            width: rect.width,
            height: rect.height,
          };
          const relativeX = x - rect.left;
          const relativeY = y - rect.top;
          const scaleX = canvasWidth.value / rect.width;
          const scaleY = canvasHeight.value / rect.height;
          resolve({
            x: Math.max(0, Math.min(canvasWidth.value, relativeX * scaleX)),
            y: Math.max(0, Math.min(canvasHeight.value, relativeY * scaleY)),
          });
        } else {
          resolve({ x, y });
        }
      })
      .exec();
  });
};

// 触摸开始
const handleTouchStart = (e: any) => {
  e.preventDefault();
  isDrawing.value = true;

  // 如果是第一次开始签名,清除提示文字
  if (!hasStartedDrawing.value && ctx.value) {
    clearHintText();
    hasStartedDrawing.value = true;
    ctx.value.draw(true);
  }

  // 优先使用同步方法获取坐标
  const point = getTouchPointSync(e);
  if (point) {
    // 起笔在画布外:直接不开始绘制
    if (point.x < 0 || point.y < 0 || point.x > canvasWidth.value || point.y > canvasHeight.value) {
      isDrawing.value = false;
      lastPoint.value = null;
      return;
    }
    lastPoint.value = point;
    // 开始新的路径
    paths.value.push([point]);
    if (ctx.value) {
      // 画一个"起始点",保证轻微移动/单击也能看到笔迹
      drawLine(point, point);
    }
  } else {
    // 如果缓存不存在,异步获取
    getTouchPoint(e).then(p => {
      if (isDrawing.value && ctx.value) {
        // 如果是第一次开始签名,清除提示文字
        if (!hasStartedDrawing.value) {
          clearHintText();
          hasStartedDrawing.value = true;
          ctx.value.draw(true);
        }
        lastPoint.value = p;
        paths.value.push([p]);
        ctx.value.beginPath();
        ctx.value.moveTo(p.x, p.y);
      }
    });
  }
};

// 计算两点之间的距离
const getDistance = (p1: { x: number; y: number }, p2: { x: number; y: number }) => {
  const dx = p2.x - p1.x;
  const dy = p2.y - p1.y;
  return Math.sqrt(dx * dx + dy * dy);
};

// 触摸移动(优化版本,使用同步获取坐标和节流绘制)
const handleTouchMove = (e: any) => {
  e.preventDefault();
  if (!isDrawing.value || !lastPoint.value || !ctx.value) return;

  // 使用同步方法获取坐标,避免异步延迟
  const point = getTouchPointSync(e);
  if (!point) {
    // 如果缓存不存在,异步获取(这种情况应该很少)
    getTouchPoint(e).then(p => {
      if (isDrawing.value && lastPoint.value && ctx.value) {
        drawLine(lastPoint.value, p);
        lastPoint.value = p;
        if (paths.value.length > 0) {
          paths.value[paths.value.length - 1].push(p);
        }
      }
    });
    return;
  }

  // 手指滑出画布:停止本次会话(防止继续绘制/保存异常)
  if (point.x < 0 || point.y < 0 || point.x > canvasWidth.value || point.y > canvasHeight.value) {
    handleTouchEnd(e);
    return;
  }

  // 绘制线条
  if (lastPoint.value) {
    drawLine(lastPoint.value, point);
  }

  // 更新当前路径
  if (paths.value.length > 0) {
    paths.value[paths.value.length - 1].push(point);
  }

  lastPoint.value = point;
};

// 绘制线条(优化版本,减少draw调用频率)
const drawLine = (from: { x: number; y: number }, to: { x: number; y: number }) => {
  if (!ctx.value) return;

  // 小程序 createCanvasContext:每次 draw() 后当前 path 会被清空
  // 所以必须每次都 beginPath + moveTo,再 lineTo + stroke
  ctx.value.beginPath();
  ctx.value.moveTo(from.x, from.y);

  const distance = getDistance(from, to);

  // 轻微移动也要出线:只要 distance > 0 就 lineTo;插值阈值降低,提高跟手
  if (distance > 1) {
    const steps = Math.min(Math.ceil(distance / 2), 12); // 每2像素一个点,最多12步
    for (let i = 1; i <= steps; i++) {
      const ratio = i / steps;
      const interpolatedX = from.x + (to.x - from.x) * ratio;
      const interpolatedY = from.y + (to.y - from.y) * ratio;
      ctx.value.lineTo(interpolatedX, interpolatedY);
    }
  } else {
    ctx.value.lineTo(to.x, to.y);
  }

  ctx.value.stroke();

  // 使用节流,减少 draw 调用频率(每 16ms 最多一次),但不"反复重置计时器"(否则手指一直动会延迟到最后才 draw)
  if (drawTimer) return;
  drawTimer = setTimeout(() => {
    drawTimer = null;
    if (ctx.value) ctx.value.draw(true);
  }, 16);
};

// 触摸结束
const handleTouchEnd = (e: any) => {
  e.preventDefault();

  // 清除节流定时器
  if (drawTimer) {
    clearTimeout(drawTimer);
    drawTimer = null;
  }

  // 确保最后一点也被绘制
  if (isDrawing.value && ctx.value && lastPoint.value) {
    ctx.value.draw(true);
  }

  isDrawing.value = false;
  lastPoint.value = null;
};

// 清空画布(只清空绘制内容,保留白色背景)
const clear = () => {
  if (ctx.value) {
    // 重新填充白色背景(clearRect会清空所有内容,包括背景色)
    ctx.value.setFillStyle('#ffffff');
    ctx.value.fillRect(0, 0, canvasWidth.value, canvasHeight.value);

    // 重新设置绘制样式
    ctx.value.setStrokeStyle(props.lineColor);
    ctx.value.setLineWidth(normalizedLineWidth.value);
    ctx.value.setLineCap('round');
    ctx.value.setLineJoin('round');

    // 重置绘制状态,重新绘制提示文字
    hasStartedDrawing.value = false;
    drawHintText();

    ctx.value.draw(true);
  }
  paths.value = [];
  emit('clear');
};

// 撤销上一步
const undo = () => {
  if (paths.value.length === 0) {
    Taro.showToast({
      title: '没有可撤销的操作',
      icon: 'none',
    });
    return;
  }

  // 移除最后一条路径
  paths.value.pop();

  // 清空画布并重绘所有路径(保留白色背景)
  if (ctx.value) {
    // 先填充白色背景
    ctx.value.setFillStyle('#ffffff');
    ctx.value.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
    // 再清空绘制内容
    ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
    // 重新设置绘制样式
    ctx.value.setStrokeStyle(props.lineColor);
    ctx.value.setLineWidth(normalizedLineWidth.value);
    ctx.value.setLineCap('round');
    ctx.value.setLineJoin('round');

    // 重绘所有路径
    paths.value.forEach(path => {
      if (path.length > 0) {
        ctx.value.beginPath();
        ctx.value.moveTo(path[0].x, path[0].y);
        path.forEach(point => {
          ctx.value.lineTo(point.x, point.y);
        });
        ctx.value.stroke();
      }
    });

    // 如果撤销后没有路径了,重新显示提示文字
    if (paths.value.length === 0) {
      hasStartedDrawing.value = false;
      drawHintText();
    }

    ctx.value.draw(true);
  }
};

// 保存为图片(返回base64或临时文件路径)
const saveAsImage = async (format: 'base64' | 'tempFilePath' = 'base64'): Promise<string> => {
  return new Promise((resolve, reject) => {
    if (!ctx.value) {
      reject(new Error('Canvas未初始化'));
      return;
    }

    // 检查是否有绘制内容
    if (paths.value.length === 0) {
      // Taro.showToast({
      //   title: '请先绘制签名',
      //   icon: 'error',
      // });
      reject(new Error('没有绘制内容'));
      return;
    }

    // 导出时保持导出尺寸与显示尺寸一致,避免放大/缩小
    const exportDestWidth = canvasWidth.value;
    const exportDestHeight = canvasHeight.value;

    if (format === 'base64') {
      // 使用 canvasToTempFilePath 然后读取为 base64
      Taro.canvasToTempFilePath({
        canvasId: canvasId.value,
        x: 0,
        y: 0,
        width: canvasWidth.value,
        height: canvasHeight.value,
        destWidth: exportDestWidth,
        destHeight: exportDestHeight,
        fileType: 'png',
        success: res => {
          // 在小程序中,需要读取临时文件并转换为base64
          if (process.env.TARO_ENV === 'weapp') {
            try {
              const fs = Taro.getFileSystemManager();
              fs.readFile({
                filePath: res.tempFilePath,
                encoding: 'base64',
                success: (fileRes: any) => {
                  const base64 = `data:image/png;base64,${fileRes.data}`;
                  resolve(base64);
                },
                fail: reject,
              });
            } catch (error) {
              reject(error);
            }
          } else {
            // H5环境,tempFilePath 通常已经是 base64
            if (res.tempFilePath && res.tempFilePath.startsWith('data:')) {
              resolve(res.tempFilePath);
            } else {
              // 兜底方案:如果不是 base64,尝试通过 selectorQuery 获取节点(注意:H5下可能需要特定处理)
              const query = Taro.createSelectorQuery();
              query
                .select(`#${canvasId.value}`)
                .node((nodeRes: any) => {
                  const canvas = nodeRes?.node;
                  if (canvas && canvas.toDataURL) {
                    const base64 = canvas.toDataURL('image/png');
                    resolve(base64);
                  } else {
                    // 再次兜底:尝试直接通过原生 DOM 获取(仅 H5)
                    const nativeCanvas = document.getElementById(canvasId.value) as HTMLCanvasElement;
                    if (nativeCanvas && nativeCanvas.toDataURL) {
                      resolve(nativeCanvas.toDataURL('image/png'));
                    } else {
                      reject(new Error('无法获取canvas节点'));
                    }
                  }
                })
                .exec();
            }
          }
        },
        fail: reject,
      });
    } else {
      // 返回临时文件路径
      Taro.canvasToTempFilePath({
        canvasId: canvasId.value,
        x: 0,
        y: 0,
        width: canvasWidth.value,
        height: canvasHeight.value,
        destWidth: exportDestWidth,
        destHeight: exportDestHeight,
        fileType: 'png',
        success: res => {
          resolve(res.tempFilePath);
        },
        fail: reject,
      });
    }
  });
};

// 确认并返回图片数据
const confirm = async () => {
  try {
    const imageData = await saveAsImage('base64');
    emit('confirm', imageData);
    return imageData;
  } catch (error) {
    console.error('保存签名失败:', error);
    // Taro.showToast({
    //   title: '保存失败',
    //   icon: 'error',
    // });
    throw error;
  }
};

// 暴露方法给父组件
defineExpose({
  clear,
  undo,
  saveAsImage,
  confirm,
});

onMounted(() => {
  initCanvas();
});
</script>

<style lang="scss" scoped>
.canvas-signature {
  width: 100%;
  height: 100%;
  padding: 2px;
  background: #fff;
  border-radius: 0.6rem;
  box-shadow: 0rem 0rem 0.25rem 0rem rgba(207, 207, 207, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  position: relative;
  touch-action: none; // 阻止触摸默认行为

  .signature-canvas {
    width: 100%;
    height: 100%;
    touch-action: none; // 阻止触摸默认行为
    border-radius: 0.6rem;
    position: relative;
  }
}
</style>

主页面示例:

javascript 复制代码
<template>
  <view class="signature_page bg" @catchtouchmove.stop.prevent>
    <page-nav title="我的签名"></page-nav>
    <view class="signature_content" @catchtouchmove.stop.prevent>
      <view class="signature_canvas" id="signature_canvas_container" @catchtouchmove.stop.prevent>
        <CanvasSignature
          :width="canvasWidth"
          :height="canvasHeight"
          ref="signatureRef"
          class="signImg"
        ></CanvasSignature>
      </view>
      <view class="signature_btns">
        <nut-button type="primary" @click="handleClear" class="signature_btn" plain>重置</nut-button>
        <nut-button type="info" @click="handleConfirm" class="signature_btn signature_save">确认</nut-button>
      </view>
      <image v-show="imageUrl" :src="imageUrl" class="signImg" />
    </view>
  </view>
</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue';
import Taro from '@tarojs/taro';
import CanvasSignature from './CanvasSignature.vue';

const canvasHeight = ref(300);
const canvasWidth = ref(0);

// 获取签名组件实例
const signatureRef = ref<InstanceType<typeof CanvasSignature> | null>(null);
const imageUrl = ref('');


onMounted(() => {});

//手动触发重置按钮点击事件
const handleClear = () => {
  if (signatureRef.value) {
    signatureRef.value.clear();
  }
};

//手动触发确认按钮点击事件
const handleConfirm = async () => {
  if (!signatureRef.value) {
    return;
  }
  try {
    const base64Data = await signatureRef.value.confirm();
    if (!base64Data) {
      Taro.showToast({
        title: '没有签名内容',
        icon: 'error',
      });
      return;
    }
    imageUrl.value = base64Data;
    console.log('签名数据:', base64Data);
  } catch (error) {
    console.error('确认签名失败:', error);
    Taro.showToast({
      title: '请先录入签名',
      icon: 'error',
    });
    return;
  }
};
</script>

<style lang="scss">
.signature_page {
  height: 100vh;
  height: 100%;
  display: flex;
  flex-direction: column;
  overflow: hidden; // 防止页面滚动
  position: fixed;
  width: 100%;
  top: 0;
  left: 0;
  touch-action: none; // 阻止触摸默认行为

  .signature_content {
    flex: 1;
    margin: 24px 32px;
    display: flex;
    flex-direction: column;
    min-height: 0; // 防止flex子元素溢出
    overflow: hidden; // 防止内容区域滚动
    touch-action: none; // 阻止触摸默认行为
    .signature_canvas {
      flex-shrink: 0;
      height: 620px; // 减小高度
      position: relative;
      overflow: hidden; // 防止canvas区域滚动
      touch-action: none; // 阻止触摸默认行为
      .signImg {
        padding: 2px;
        width: 100%;
        height: 100%;
        background: #fff;
        border-radius: 0.6rem;
        box-shadow: 0rem 0rem 0.25rem 0rem rgba(207, 207, 207, 0.5);
      }
    }
  }
  .signature_btns {
    flex-shrink: 0;
    padding: 24px 32px 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 24px;
    .signature_btn {
      width: 210px;
      height: 88px;
      font-size: 28px;
    }
    .signature_save {
      view view {
        color: #fff;
      }
    }
  }
}
</style>

页面效果

相关推荐
毕设源码-邱学长2 小时前
【开题答辩全过程】以 基于微信小程序的课程表信息系统的开发实现为例,包含答辩的问题和答案
微信小程序·小程序
CHU7290352 小时前
线上美容预约小程序:开启便捷美肤新方式
小程序
编程、小哥哥2 小时前
物业小程序(业主端+物业端)功能逻辑图与原型图
小程序
Goona_2 小时前
PyQt+Excel学生信息管理系统,增删改查全开源
python·小程序·自动化·excel·交互·pyqt
郑州光合科技余经理3 小时前
O2O上门预约小程序:全栈解决方案
java·大数据·开发语言·人工智能·小程序·uni-app·php
weixin_177297220693 小时前
旧物回收新风尚,绿色生活新篇章——小程序引领环保新潮流
小程序·生活
CHU7290353 小时前
智慧回收新体验:同城废品回收小程序的便捷功能探索
java·前端·人工智能·小程序·php
2501_916008893 小时前
在不越狱前提下导出 iOS 应用文件的过程,访问应用沙盒目录,获取真实数据
android·macos·ios·小程序·uni-app·cocoa·iphone