uniapp使用canvas实现逐字书写任意文字内容,后合成一张图片提交

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>
相关推荐
一袋米扛几楼981 天前
【网络安全】SIEM -Security Information and Event Management 工具是什么?
前端·安全·web安全
小陈工1 天前
2026年4月7日技术资讯洞察:下一代数据库融合、AI基础设施竞赛与异步编程实战
开发语言·前端·数据库·人工智能·python
Cobyte1 天前
3.响应式系统基础:从发布订阅模式的角度理解 Vue2 的数据响应式原理
前端·javascript·vue.js
竹林8181 天前
从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战
前端·javascript
Mintopia1 天前
别再迷信"优化":大多数性能问题根本不在代码里
前端
倾颜1 天前
接入 MCP,不一定要先平台化:一次 AI Runtime 的实战取舍
前端·后端·mcp
军军君011 天前
Three.js基础功能学习十八:智能黑板实现实例五
前端·javascript·vue.js·3d·typescript·前端框架·threejs
恋猫de小郭1 天前
Android 上为什么主题字体对 Flutter 不生效,对 Compose 生效?Flutter 中文字体问题修复
android·前端·flutter
Moment1 天前
AI全栈入门指南:一文搞清楚NestJs 中的 Controller 和路由
前端·javascript·后端
禅思院1 天前
前端架构演进:基于AST的常量模块自动化迁移实践
前端·vue.js·前端框架