普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月10日掘金 前端

从“死了么”到“我在”:用uniCloud开发一款温暖人心的App

2026年3月10日 11:49

大家好,我是前端大鱼。

前几个月,“死了么”App火了。几个人很短的时间做了一个极简功能——每天签到,两天不签就发邮件给紧急联系人。就这么简单,冲上了付费榜第一。

评论区最高赞的留言我一直记得:“名字太晦气了,为什么不叫‘活着么’?”

我想了很久。活着么?还是有点丧。后来有个读者留言说:“叫‘我在’吧,两个字,既回答了活着,也说出了陪伴。”

就它了。

「我在」——双层含义:我在(活着)、我在这里(守护你)。

今天这篇文章,是一个完整的项目规划书,从产品构想到技术实现,希望能给想做独立开发的朋友一些启发。


一、「我在」是什么?

1.1 产品定位

“死了么”的核心逻辑很简单:每日签到,两天不签就发邮件通知紧急联系人。它的成功在于直面死亡的黑色幽默。

但「我在」不想做第二个“死了么”。

我的产品定位是:从“怕死”到“惜活”,从“被动通知”到“主动记录”,从“孤独一人”到“有人陪伴”。

简单说:

  • “死了么”是在你可能死了的时候通知别人
  • 「我在」是在你确定活着的时候记录自己,同时告诉在乎你的人:我还在

1.2 核心功能

功能模块 具体内容 免费/付费
每日签到 一键打卡“我在”,记录心情和今日小事 免费
守护者机制 绑定一位守护者,渐进式提醒,48小时未签发送邮件 免费
心情日记 记录每天的情绪和琐事,形成时光相册 付费
时光胶囊 写给未来的自己,1/3/5/10年后打开 付费(免费限3个)
生命树 连续签到养成虚拟树,30天长叶,365天开花 付费皮肤
陪伴地图 匿名查看全国用户的“我在”状态 免费

1.3 渐进式提醒机制

这是「我在」最核心的守护功能:

  • 12小时未签到:App推送提醒用户自己:“今天记得说‘我在’哦”
  • 24小时未签到:通知守护者:“你守护的人今天还没说‘我在’”
  • 36小时未签到:守护者需确认是否联系上你
  • 48小时未签到:发送邮件给紧急联系人:“您的亲友已48小时未说‘我在’”

二、为什么叫「我在」?

这两个字,我想了很久。

第一层含义:活着。 当你在App里点击“我在”,就是在告诉世界:今天我也在好好地活着。

第二层含义:陪伴。 当你成为别人的守护者,你的存在本身就是一种承诺——“别怕,我在。”

第三层含义:回响。 在这个孤独的时代,有人问你“在吗”,你可以回一句“我在”。简单,却温暖。

比起“活着么”的质问,「我在」更像是一个回答,一个承诺,一个拥抱。


三、为什么选uniCloud + UniApp?

作为一个独立开发者,我的选型原则是:一次编写,多端运行,免运维,低成本

3.1 uniCloud的核心优势

  1. 一体化开发:前端直接调用云函数,不用配域名、HTTPS、跨域
  2. 定时任务内置:通过trigger配置,比node-cron更稳定
  3. 推送集成:uni-push 2.0直接可用,支持离线推送
  4. 免费额度够用:阿里云或腾讯云空间,每月有免费调用次数
  5. 自动扩缩容:不用关心服务器压力

四、技术架构

4.1 整体架构图

4.2 云函数结构

cloudfunctions/
├── user/                  # 用户相关
├── checkin/               # 签到相关
├── capsule/               # 时光胶囊
├── timer/                 # 定时任务
└── common/                # 公共模块

五、核心代码实现

5.1 数据库设计(简版)

users集合

{
  "_id": "用户ID",
  "nickname": "昵称",
  "guardian_id": "守护者ID",
  "emergency_email": "紧急联系人邮箱",
  "last_checkin": "最后签到时间",
  "continuous_days": "连续签到天数"
}

checkins集合

{
  "user_id": "用户ID",
  "mood": "心情",
  "note": "今日小事",
  "create_date": "签到时间"
}

5.2 签到云函数:说“我在”

// cloudfunctions/checkin/create.js
exports.main = async (event, context) => {
  const { mood, note } = event;
  const { uid } = context.auth;
  
  const db = uniCloud.database();
  const dbCmd = db.command;
  
  // 检查今天是否已签到
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  
  const exist = await db.collection('checkins').where({
    user_id: uid,
    create_date: dbCmd.gte(today)
  }).get();
  
  if (exist.data.length > 0) {
    return { code: 400, msg: '今天已经说过“我在”了' };
  }
  
  // 获取用户信息
  const user = await db.collection('users').doc(uid).get();
  const userData = user.data[0];
  
  // 计算连续天数
  let continuousDays = 1;
  if (userData.last_checkin) {
    const last = new Date(userData.last_checkin);
    const yesterday = new Date(today);
    yesterday.setDate(yesterday.getDate() - 1);
    
    if (last >= yesterday) {
      continuousDays = (userData.continuous_days || 0) + 1;
    }
  }
  
  // 开启事务
  const transaction = await db.startTransaction();
  
  try {
    // 插入签到记录
    await transaction.collection('checkins').add({
      user_id: uid,
      mood,
      note,
      create_date: new Date(),
      continuous_days: continuousDays
    });
    
    // 更新用户信息
    await transaction.collection('users').doc(uid).update({
      last_checkin: new Date(),
      continuous_days: continuousDays,
      total_checkins: dbCmd.inc(1)
    });
    
    await transaction.commit();
    
    // 通知守护者(如果有)
    if (userData.guardian_id) {
      uniCloud.callFunction({
        name: 'sendPush',
        data: {
          userId: userData.guardian_id,
          title: '❤️ 你守护的人说“我在”了',
          content: `${userData.nickname}今天打卡了,连续${continuousDays}天`
        }
      });
    }
    
    return { code: 0, msg: '打卡成功', data: { continuous_days: continuousDays } };
  } catch (e) {
    await transaction.rollback();
    throw e;
  }
};

5.3 定时任务:检查未签到用户

// cloudfunctions/timer/checkReminder.js
'use strict';

exports.main = async (event, context) => {
  const db = uniCloud.database();
  const dbCmd = db.command;
  const now = new Date();
  
  // 查找48小时未签到的用户
  const cutoff48 = new Date(now - 48 * 3600 * 1000);
  const users48 = await db.collection('users').where({
    last_checkin: dbCmd.lt(cutoff48),
    emergency_email: dbCmd.exists(true)
  }).get();
  
  for (const user of users48.data) {
    // 发送邮件给紧急联系人
    await sendEmail({
      to: user.emergency_email,
      subject: '【紧急提醒】您的亲友可能失联',
      html: `${user.nickname}已48小时未打卡,请确认其安全。`
    });
  }
  
  // 查找24小时未签到的用户
  const cutoff24 = new Date(now - 24 * 3600 * 1000);
  const users24 = await db.collection('users').where({
    last_checkin: dbCmd.lt(cutoff24),
    guardian_id: dbCmd.exists(true)
  }).get();
  
  for (const user of users24.data) {
    // 通知守护者
    await sendPushToUser(user.guardian_id, 
      '你守护的人还没打卡', 
      `${user.nickname}已24小时未说“我在”`
    );
  }
  
  return { code: 0 };
};

// 发送推送
async function sendPushToUser(userId, title, content) {
  const uniPush = uniCloud.getPushManager();
  await uniPush.sendMessage({ user_id: userId, title, content });
}

// 发送邮件(使用nodemailer)
async function sendEmail({ to, subject, html }) {
  const nodemailer = require('nodemailer');
  const transporter = nodemailer.createTransport({
    host: 'smtp.qq.com',
    port: 465,
    secure: true,
    auth: {
      user: process.env.EMAIL_USER,
      pass: process.env.EMAIL_PASS
    }
  });
  
  await transporter.sendMail({
    from: `"我在" <${process.env.EMAIL_USER}>`,
    to, subject, html
  });
}

5.4 前端:首页调用云函数

<template>
  <view class="container">
    <view class="streak-card">
      <text class="streak-num">{{ continuousDays }}</text>
      <text class="streak-label">连续说“我在” {{ continuousDays }} 天</text>
    </view>
    
    <view v-if="!todayChecked">
      <button @click="handleCheckin">说「我在」</button>
    </view>
    
    <view v-else>
      <text>✅ 今天已打卡</text>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      continuousDays: 0,
      todayChecked: false
    }
  },
  onLoad() {
    this.checkTodayStatus();
  },
  methods: {
    async checkTodayStatus() {
      const res = await uniCloud.callFunction({
        name: 'checkin-status'
      });
      this.continuousDays = res.result.data.continuous_days;
      this.todayChecked = res.result.data.today_checked;
    },
    
    async handleCheckin() {
      uni.showLoading({ title: '打卡中...' });
      
      const res = await uniCloud.callFunction({
        name: 'checkin-create',
        data: { mood: 'happy', note: '今天很好' }
      });
      
      if (res.result.code === 0) {
        this.todayChecked = true;
        this.continuousDays = res.result.data.continuous_days;
        uni.showToast({ title: '打卡成功', icon: 'success' });
      }
      
      uni.hideLoading();
    }
  }
}
</script>

六、成本估算

免费额度(阿里云uniCloud)

资源项 免费额度 说明
云函数调用 10万次/月 支撑1000日活
云数据库 2GB 存10万条记录
云存储 5GB 存放照片
CDN流量 5GB/月 图片加载

1000日活成本:0元


七、写在最后

「我在」这个名字,是我能想到的最温暖的回答。

如果你也想做独立开发,欢迎评论区聊聊:

  1. 你最想要「我在」有什么功能?
  2. 你会为哪些功能付费?
  3. 这个名字,你喜欢吗?

评论区抽三位送终身会员(如果App真做出来的话😂)


关注公众号" 大前端历险记",掌握更多前端开发干货姿势!

在 HTTP/3 普及的 2026 年,那些基于 Webpack 的性能优化经验,有一半该扔了

作者 ErpanOmer
2026年3月10日 11:46

screenshot-20260310-112029.png

最近面了几个号称精通前端工程化的候选人,看着他们简历里大段大段的 Webpack 性能优化实战,我心情挺复杂的。🤷‍♂️

现在已经是 2026 年了,HTTP/3 早就成了基建标配。可是很多人脑子里的优化八股文,还停留在 2018 年 HTTP/1.1 和早期 HTTP/2 的时代。

他们在面试时背的流水:怎么配 SplitChunks,怎么做域名分片,怎么把小图片转 Base64,怎么拼雪碧图。说实话,听得我直皱眉头😖。

脱离了网络协议谈打包优化,全是在耍流氓。 在 HTTP/3(QUIC协议)普及,以及被 Vite 等打包工具加速淘汰的今天,你引以为傲的那些 Webpack 神级配置,有一半不仅没用,反而正在拖慢你的首屏速度。

今天我就直白点,扒一扒在 HTTP/3 时代,哪些老掉牙的优化经验该直接扔进垃圾桶。


打包成大 Chunk,你还在合并 Vendor 吗?

以前我们用 Webpack,最核心的诉求是什么?减少 HTTP 请求数。

因为 HTTP/1.1 有队头阻塞(Head-of-Line Blocking),浏览器对同一个域名还有 6 个并发连接的限制。所以我们要把 reactlodash 这些第三方库死死地打成一个 vendor.js,把业务代码打成 app.js

但在 HTTP/3 面前,这种做法极其愚蠢😒。

HTTP/3 底层是基于 UDP 的 QUIC 协议。它不仅解决了 TCP 层面的队头阻塞,还把多路复用(Multiplexing)做到了极致。几百个并发请求在 QUIC 看来成本极低,通道之间互不干扰。

现在的反直觉真相是:细粒度的模块加载(Fine-grained Loading, 推荐好文章😁),远比打包成大块更高效。

image.png

如果你把 20 个依赖打成一个 2MB 的 vendor.js,只要其中一个依赖升级了小版本,整个 2MB 的缓存全部失效,用户得重新下载。

所以咱们得顺应 ESM 和当前主流的构建工具(比如 Vite/Rspack/Turbopack)的趋势,把依赖拆碎。按包名输出单文件,利用 HTTP/3 的高并发特性,让浏览器自己去精准命中强缓存。

域名分片?

image.png

我看到还有人的简历里写着:通过配置多个 CDN 域名(static1.domain.com, static2.domain.com, static3.domain.com)突破浏览器并发限制,提升加载速度。

这在 HTTP/1.1 时代是标答,但在 HTTP/3 时代,这是纯纯的愚蠢😖。

  • 握手成本: HTTP/3 虽然支持 0-RTT,但建立一个新的 QUIC 连接,依然需要 DNS 解析和初始的握手计算。
  • 拥塞窗口重置: QUIC 连接刚建立时,为了探测网络情况,发送窗口是比较小的(Slow Start)。如果你把资源散布在 4 个域名上,浏览器就要建立 4 个 QUIC 连接,每个连接都要经历一次缓慢的热身过程。

所以结合以上👆特点,把所有静态资源集中在一个域名下。这样不仅只发生一次 DNS 解析和握手,还能让这个唯一的 QUIC 连接迅速撑大拥塞窗口,后续的并发请求速度会快得飞起。连接复用率越高,HTTP/3 的优势才越大。

Base64 内联与雪碧图(CSS Sprites):拿 CPU 算力换网络,亏本买卖

Webpack 时代,url-loader 的标配是:小于 8KB 的图片直接转 Base64 塞进 JS 或 CSS 里。前端甚至为了几个 icon 专门搞一套 webpack-spritesmith 自动化拼图。

为什么?还是为了省那几个可怜的 HTTP 请求。

但在 2026 年,这样做弊大于利:

解析成本太高: Base64 字符串的体积比原图大 30% 左右。更致命的是,浏览器解析巨型 JS/CSS 文件中的 Base64 非常消耗主线程 CPU。在低端移动设备上,直接导致长时间的 Long Task,页面会卡死。

image.png

而且雪碧图里只要改了一个 10x10 的小图标,整张大图的缓存直接作废😒。

别再折腾了,直接用 HTTP/3 并发请求原生的 WebP 或 AVIF 格式图片和算法优势。既省下了转码带来的体积膨胀,又释放了主线程的解析压力,还能做到完美的单文件缓存。

Tree-shaking 依然很重要,但重心变了

image.png

有人可能会杠:既然 HTTP/3 并发这么牛,那我是不是不需要构建工具了,全裸奔上 ESM?

当然不是。网络协议再快,也救不了你几兆的无用代码。浏览器下载完 JS 是要 Parsing 和 Compiling 的,这段 CPU 执行时间 HTTP/3 帮不了你。

但在 HTTP/3 时代,工程化的重心已经从如何把文件拼得更好看(Bundling),彻底转移到了如何精准剔除废代码(Dead Code Elimination)和极致的按需加载

这也就是为什么基于 Rust 的无打包/轻打包工具(No-bundle / Bundleless)在近几年彻底取代 Webpack 成为了主流。因为它们顺应了底层网络协议的演进方向👍。


技术的演进是自下而上的。从 TCPUDP,从 HTTP/1.1HTTP/3,基础设施变了,上层建筑就得跟着翻修。

作为 9 年经验的老兵,我给还在死磕 Webpack 复杂配置的同行一句忠告😊:

停下来,打开 Chrome 的 Network 面板,看看 Protocol 那一栏是不是已经全是 h3 了。如果是,请把你脑子里那些为了减少请求数而做的扭曲 Hack 手段,干脆利落地删掉。

你的前端架构应该顺应浏览器的天性,而不是去填补十年前的网络缺陷。

祝大家面试好运🙌🙌🙌

好好运,好好好好好好好好.gif

Flutter Widget 基础手把手教你创建自定义组件(二)

作者 HelloReader
2026年3月10日 11:38

一、准备工作:添加游戏逻辑文件

在正式开始写 UI 之前,我们需要先给项目添加一个游戏逻辑文件。这个文件负责处理猜词游戏的规则判断(比如字母猜对了没有、位置对不对等),与界面无关,所以官方教程直接提供了现成的代码。

操作步骤:

  1. 在项目的 lib/ 目录下,创建一个新文件 game.dart
  2. 将官方提供的游戏逻辑代码复制进去(可以从官方教程页面下载)。
  3. lib/main.dart 文件顶部添加导入语句:
import 'package:flutter/material.dart';
import 'game.dart';  // 新增这一行

这个文件里定义了一些关键概念,其中最重要的是 HitType 枚举,它表示猜测结果的类型:

  • HitType.hit:字母和位置都猜对了(绿色)
  • HitType.partial:字母对了但位置不对(黄色)
  • HitType.miss:字母完全猜错了(灰色)
  • HitType.none:还没有猜测(白色)

如果你玩过 Wordle,对这几种状态一定不陌生。

二、什么是 StatelessWidget?

在 Flutter 中,Widget 分为两大类:

  • StatelessWidget(无状态组件) :一旦创建,内容就固定不变。适合展示静态内容。
  • StatefulWidget(有状态组件) :可以根据用户操作或数据变化来更新界面。后续教程会详细讲解。

今天我们要创建的 Tile 组件就是一个 StatelessWidget。为什么?因为每个方块的内容(显示什么字母、什么颜色)在创建时就已经确定了,不需要在运行过程中自己改变。

你可以把 StatelessWidget 想象成一张打印好的卡片——上面的内容在打印时就定了,不会再变。

三、创建你的第一个自定义 Widget

3.1 定义 Tile 类

main.dart 文件中,MainApp 类的下方,添加以下代码:

class Tile extends StatelessWidget {
  const Tile(this.letter, this.hitType, {super.key});

  final String letter;
  final HitType hitType;

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

别急,我们一行一行来拆解。

3.2 理解构造函数

const Tile(this.letter, this.hitType, {super.key});

这是 Tile 的构造函数。构造函数的作用是:当你要创建一个 Tile 时,告诉它需要哪些信息。

  • this.letter:这个方块要显示的字母,比如 "A"、"B"。
  • this.hitType:猜测结果的类型,决定方块的颜色。
  • {super.key}:Flutter 内部用来追踪组件的标识符,照写就行,不用深究。

打个比方:构造函数就像点菜单上的选项。你告诉服务员(Flutter)"我要一个显示字母 A 的绿色方块",服务员就按你的要求端上来。

这就是让 Widget 可复用的关键。 同一个 Tile 类,传入不同的参数,就能显示不同的字母和颜色。

3.3 理解 build 方法

@override
Widget build(BuildContext context) {
  return Container();
}

每个 Widget 都必须有一个 build 方法。它的职责很简单:告诉 Flutter 这个组件长什么样。它必须返回另一个 Widget。

现在返回的是一个空的 Container(),所以屏幕上什么都看不到。接下来我们会一步步给它"化妆"。

四、使用你的自定义 Widget

在看到效果之前,我们先把 Tile 放到界面上。修改 MainAppbuild 方法:

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Tile('A', HitType.hit),  // 使用自定义的 Tile 组件
        ),
      ),
    );
  }
}

这里我们创建了一个 Tile,传入字母 'A' 和猜测类型 HitType.hit(猜对了)。此时运行应用,屏幕还是空白的,因为 Tile 的 build 方法还只返回一个空容器。

五、认识 Container 组件

Container 是 Flutter 中最常用的组件之一。你可以把它理解为一个万能盒子,可以设置大小、颜色、边框、内边距等各种样式。

5.1 设置大小

先给方块一个固定的宽高:

@override
Widget build(BuildContext context) {
  return Container(
    width: 60,
    height: 60,
  );
}

这样就创建了一个 60×60 像素的方块。虽然现在还是看不见(因为没有颜色),但它在布局中已经占了位置。

5.2 添加边框:BoxDecoration

接下来用 BoxDecoration 给方块加上边框:

@override
Widget build(BuildContext context) {
  return Container(
    width: 60,
    height: 60,
    decoration: BoxDecoration(
      border: Border.all(color: Colors.grey.shade300),
    ),
  );
}

BoxDecoration 是 Container 的"化妆师",它可以给容器添加边框、背景色、圆角、阴影等装饰效果。这里我们用 Border.all() 给四个边都加上了浅灰色的边框。

热重载一下(按 r),你应该能看到屏幕中央出现了一个带边框的小方块。

5.3 根据猜测结果变换颜色

这是最有趣的部分。我们需要根据 hitType 的值来决定方块的背景颜色。Dart 语言提供了一种叫做 switch 表达式的语法,非常适合这种"根据不同条件返回不同值"的场景:

decoration: BoxDecoration(
  border: Border.all(color: Colors.grey.shade300),
  color: switch (hitType) {
    HitType.hit     => Colors.green,   // 猜对了 → 绿色
    HitType.partial => Colors.yellow,  // 位置不对 → 黄色
    HitType.miss    => Colors.grey,    // 猜错了 → 灰色
    _               => Colors.white,   // 默认 → 白色
  },
),

这段代码的逻辑很直观:如果猜对了就显示绿色,位置不对就显示黄色,猜错了就显示灰色,其他情况显示白色。

六、添加文字内容

最后一步,在方块中间显示字母。这里用到两个我们已经认识的 Widget:Center(居中)和 Text(文字)。

@override
Widget build(BuildContext context) {
  return Container(
    width: 60,
    height: 60,
    decoration: BoxDecoration(
      border: Border.all(color: Colors.grey.shade300),
      color: switch (hitType) {
        HitType.hit     => Colors.green,
        HitType.partial => Colors.yellow,
        HitType.miss    => Colors.grey,
        _               => Colors.white,
      },
    ),
    child: Center(
      child: Text(
        letter.toUpperCase(),
        style: Theme.of(context).textTheme.titleLarge,
      ),
    ),
  );
}

几个要点:

  • child 属性:大多数 Flutter Widget 都有 child(放一个子组件)或 children(放多个子组件)属性。这是 Widget 嵌套的核心机制。
  • letter.toUpperCase() :把字母转成大写显示。
  • Theme.of(context).textTheme.titleLarge:使用应用主题中预定义的大号标题字体样式,省去手动设置字号和字重。

热重载后,你应该能看到一个绿色的方块,中间显示着大写的字母 "A"。

6.1 试着切换颜色

你可以通过修改传入 Tile 的参数来观察不同颜色的效果:

// 绿色(猜对了)
child: Tile('A', HitType.hit)

// 黄色(字母对了,位置不对)
child: Tile('A', HitType.partial)

// 灰色(完全猜错了)
child: Tile('A', HitType.miss)

每次修改后按 r 热重载,颜色会立刻切换,非常直观。

七、完整的 Widget 树

让我们回顾一下现在的 Widget 树结构:

MainApp
  └── MaterialApp
        └── Scaffold
              └── Center
                    └── Tile (我们的自定义组件)
                          └── Container (60x60, 带边框和背景色)
                                └── Center
                                      └── Text ('A')

你会发现,创建自定义 Widget 的本质就是把多个现有的 Widget 组合在一起,打包成一个新组件。这就像用乐高积木搭建:基础积木(Container、Center、Text)是 Flutter 提供的,你通过组合它们来创造属于自己的"零件"(Tile),然后再用这些零件组装出更复杂的界面。

八、本节知识点小结

StatelessWidget: 无状态组件,适合展示固定内容。通过继承 StatelessWidget 类并实现 build 方法来创建自定义组件。

构造函数传参: 通过构造函数接收外部数据(如字母和颜色类型),是让 Widget 可复用的核心方式。同一个 Widget 类传入不同参数就能呈现不同的效果。

Container 和 BoxDecoration: Container 是万能盒子,用来设置大小、内边距等。BoxDecoration 是它的"化妆师",负责添加边框、背景色、阴影等视觉装饰。

child 和 children: Flutter 中组件的嵌套通过 child(单个子组件)或 children(多个子组件)属性实现,这是构建 Widget 树的基本方式。

九、下一步学习

现在你已经学会了创建自定义 Widget,下一课将进入布局(Layout)章节,学习如何用 ColumnRow 等布局组件把多个 Tile 排列成游戏需要的网格。猜词游戏的界面正在一步步成型!

我们下篇文章见!

参考资料:Flutter 官方教程 - Widget Fundamentals

别再写JS监听滚动了!一行CSS搞定导航固定+通讯录效果(附3个案例)

2026年3月10日 11:28

各位前端小伙伴,今天我们来聊聊——position: sticky。无论你是刚入行的新人,还是有一定经验的开发者,掌握 position: sticky都能让你在开发页面时游刃有余。 position: sticky 是一个非常实用的 CSS 属性,它可以让元素在滚动时“粘”在某个位置,像是 相对定位 和 固定定位 的混合体。简单来说,在元素跨越特定阈值之前,它表现为相对定位;一旦跨越阈值,就变成固定定位,直到其容器滚出视口。

通俗点来说,sticky 就是: “在父容器范围内,滚动到指定位置就固定,父容器滚走它就跟着滚走”

它不像 fixed 那样死死钉在屏幕上一个地方不动,也不像 relative 那样完全随波逐流。它是两者的“混血儿”,聪明又灵活。

所以做网页时,你想让某个元素(导航栏、标题、侧边栏)在滚动时“粘”在某个位置,但又不想让它超出自己的地盘,用 sticky 就对了!

下面详细介绍它的用法,并附上可直接运行的代码实例。


1. 基本语法

css

.sticky-element {
  position: sticky;
  top: 0;        /* 可选:left/bottom/right,至少指定一个阈值 */
}

必须指定 topbottomleft 或 right 中的一个,元素才会在到达该阈值时“粘”住。


2. 工作原理

  • 元素在父容器范围内时,遵循正常文档流(相对定位)。
  • 当滚动到指定阈值(例如 top: 10px)时,元素变为固定定位(相对于视口),直到其父容器完全滚出视口。
  • 关键限制:粘性效果只在父容器内生效,且父容器不能设置 overflow: hidden 或 overflow: auto 等(会破坏粘性)。

3. 📁案例一:粘性导航栏

文件名建议sticky-nav-demo.html
效果:导航栏在滚动到顶部时固定,始终可见。

html

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>案例1:粘性导航栏 · position: sticky</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: 'Segoe UI', Roboto, sans-serif;
      background: linear-gradient(145deg, #f1f5f9 0%, #e6edf5 100%);
      min-height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 20px;
    }
    .card {
      max-width: 700px;
      width: 100%;
      background: white;
      border-radius: 40px;
      box-shadow: 0 30px 50px -20px rgba(0,30,60,0.25);
      padding: 30px;
    }
    h2 {
      font-weight: 550;
      color: #0f172a;
      margin-bottom: 6px;
      font-size: 2rem;
      display: flex;
      align-items: center;
      gap: 8px;
    }
    h2 span {
      background: #2563eb;
      color: white;
      font-size: 0.9rem;
      padding: 4px 12px;
      border-radius: 30px;
    }
    .desc {
      color: #475569;
      margin-bottom: 30px;
      padding-left: 12px;
      border-left: 5px solid #2563eb;
      background: #f8fafc;
      padding: 12px 18px;
      border-radius: 20px;
    }
    /* 演示区域:固定高度+滚动 */
    .demo-scroll-box {
      height: 380px;
      overflow-y: auto;
      border-radius: 24px;
      background: #ffffff;
      border: 2px solid #d9e2ef;
      scroll-behavior: smooth;
    }
    .demo-header {
      background: #cbd5e1;
      padding: 20px;
      text-align: center;
      font-weight: 600;
      color: #1e293b;
    }
    .sticky-nav {
      position: sticky;
      top: 0;
      background: #0f172a;
      color: white;
      padding: 16px 20px;
      text-align: center;
      font-weight: 500;
      letter-spacing: 0.8px;
      box-shadow: 0 6px 12px rgba(0,0,0,0.1);
      z-index: 10;
    }
    .content p {
      background: #f9f9fc;
      padding: 18px 22px;
      margin: 20px;
      border-radius: 20px;
      border: 1px solid #e2e8f0;
      box-shadow: 0 2px 5px rgba(0,0,0,0.02);
    }
    .note {
      margin-top: 30px;
      background: #fef9c3;
      border-left: 6px solid #eab308;
      padding: 16px 22px;
      border-radius: 24px;
      color: #854d0e;
    }
  </style>
</head>
<body>
<div class="card">
  <h2>📌 案例1 <span>sticky</span></h2>
  <div class="desc">⚓ 粘性导航栏 — 滚动时自动吸附在顶部,无需JavaScript。</div>

  <!-- 可滚动的演示区域 -->
  <div class="demo-scroll-box">
    <div class="demo-header">我是普通头部(会滚走)</div>
    <div class="sticky-nav">🧲 导航栏 · 粘在顶部 (top:0)</div>
    <div class="content">
      <p>📄 第1条内容:继续向下滚动,观察导航栏。</p>
      <p>📄 第2条内容:它会一直固定在顶部。</p>
      <p>📄 第3条内容:直到父容器滚出视口才消失。</p>
      <p>📄 第4条内容:原理是 position: sticky; top:0。</p>
      <p>📄 第5条内容:兼容现代浏览器,简单高效。</p>
      <p>📄 第6条内容:可以替代传统的JS监听滚动。</p>
      <p>📄 第7条内容:再加一条,看看滚动条长度。</p>
    </div>
  </div>

  <div class="note">
    💡 核心点:<code>position: sticky;</code> 必须搭配 <code>top</code> / <code>bottom</code> 使用;父容器不能设置 <code>overflow:hidden</code></div>
</div>
</body>
</html>

效果:当滚动使导航栏触碰到视口顶部时,它会固定在上方,直到父容器 .content 完全离开视口。

3月10日 (1).gif


📁 案例二:粘性章节标题(通讯录效果)

文件名建议sticky-sections-demo.html
效果:每个分组的标题在滚动到顶部时固定,直到被下一个标题推上去。

html

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>案例2:粘性章节标题 · position: sticky</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: 'Segoe UI', Roboto, sans-serif;
      background: #eef2f6;
      min-height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 20px;
    }
    .card {
      max-width: 600px;
      width: 100%;
      background: white;
      border-radius: 40px;
      box-shadow: 0 30px 50px -20px rgba(0,20,40,0.25);
      padding: 30px;
    }
    h2 {
      font-weight: 550;
      color: #0f172a;
      margin-bottom: 6px;
      font-size: 2rem;
      display: flex;
      align-items: center;
      gap: 8px;
    }
    h2 span {
      background: #2563eb;
      color: white;
      font-size: 0.9rem;
      padding: 4px 12px;
      border-radius: 30px;
    }
    .desc {
      color: #475569;
      margin-bottom: 30px;
      padding-left: 12px;
      border-left: 5px solid #2563eb;
      background: #f8fafc;
      padding: 12px 18px;
      border-radius: 20px;
    }
    /* 可滚动容器 */
    .scroll-sections {
      max-height: 420px;
      overflow-y: auto;
      border-radius: 24px;
      background: #ffffff;
      border: 2px solid #d1dbe9;
    }
    .section {
      background: white;
    }
    .sticky-title {
      position: sticky;
      top: 0;
      background: #2563eb;
      color: white;
      padding: 12px 20px;
      margin: 0;
      font-size: 1.4rem;
      font-weight: 600;
      border-bottom: 3px solid #1e3f8f;
      box-shadow: 0 2px 8px rgba(37,99,235,0.3);
      z-index: 5;
    }
    /* 为了让不同标题视觉区分 */
    .section:nth-child(2) .sticky-title {
      background: #c2410c;
      border-bottom-color: #9a3412;
    }
    .section:nth-child(3) .sticky-title {
      background: #2b6f4b;
      border-bottom-color: #1e4b34;
    }
    .section p {
      padding: 14px 22px;
      margin: 0;
      border-bottom: 1px solid #e9edf3;
      font-size: 1.1rem;
    }
    .section p:last-child {
      border-bottom: none;
    }
    .note {
      margin-top: 30px;
      background: #fef9c3;
      border-left: 6px solid #eab308;
      padding: 16px 22px;
      border-radius: 24px;
      color: #854d0e;
    }
  </style>
</head>
<body>
<div class="card">
  <h2>📇 案例2 <span>sticky</span></h2>
  <div class="desc">🗂️ 粘性分区标题 — 类似通讯录,当前标题固定,直到被下一个顶替。</div>

  <div class="scroll-sections">
    <div class="section">
      <h3 class="sticky-title">🌟 A 组 · 设计部</h3>
      <p>Alice Chen</p>
      <p>Amanda Li</p>
      <p>Alex Wang</p>
      <p>Angela Zhao</p>
      <p>Adam Brown</p>
    </div>
    <div class="section">
      <h3 class="sticky-title">🔥 B 组 · 市场部</h3>
      <p>Bob Zhang</p>
      <p>Bella Xu</p>
      <p>Ben Liu</p>
      <p>Bianca Wu</p>
      <p>Bruce Huang</p>
    </div>
    <div class="section">
      <h3 class="sticky-title">🌿 C 组 · 技术部</h3>
      <p>Chris Sun</p>
      <p>Ciara Zhou</p>
      <p>Carlos Lin</p>
      <p>Catherine Ma</p>
      <p>Clark Lee</p>
    </div>
  </div>

  <div class="note">
    💡 关键点:每个标题都是 <code>sticky</code>,后一个会把前一个顶上去,形成通讯录效果。父容器滚动区域必须足够高。
  </div>
</div>
</body>
</html>

效果:滚动时,当前章节的标题会固定在顶部,直到被下一个章节的标题推上去。这正是 iOS 通讯录常用的交互。

3月10日 (1)(1).gif


📁 案例三:粘性侧边栏

文件名建议sticky-sidebar-demo.html
效果:在双栏 Flex 布局中,左侧边栏滚动到指定位置后固定。

html

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>案例3:粘性侧边栏 · position: sticky</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: 'Segoe UI', Roboto, sans-serif;
      background: #e7edf5;
      min-height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 20px;
    }
    .card {
      max-width: 800px;
      width: 100%;
      background: white;
      border-radius: 40px;
      box-shadow: 0 30px 50px -20px rgba(0,30,50,0.25);
      padding: 30px;
    }
    h2 {
      font-weight: 550;
      color: #0f172a;
      margin-bottom: 6px;
      font-size: 2rem;
      display: flex;
      align-items: center;
      gap: 8px;
    }
    h2 span {
      background: #2563eb;
      color: white;
      font-size: 0.9rem;
      padding: 4px 12px;
      border-radius: 30px;
    }
    .desc {
      color: #475569;
      margin-bottom: 30px;
      padding-left: 12px;
      border-left: 5px solid #2563eb;
      background: #f8fafc;
      padding: 12px 18px;
      border-radius: 20px;
    }
    /* flex 演示容器 */
    .demo-flex {
      display: flex;
      align-items: flex-start;  /* 防止拉伸,保证粘性有效 */
      gap: 20px;
      background: #ffffff;
      border-radius: 24px;
      border: 2px solid #ccdbe9;
      min-height: 380px;
    }
    .sidebar {
      position: sticky;
      top: 20px;                /* 距离父容器顶部20px时固定 */
      width: 180px;
      background: #dbeafe;
      padding: 30px 16px;
      border-radius: 18px;
      border: 2px solid #2563eb;
      color: #1e3a8a;
      font-weight: 600;
      text-align: center;
      margin: 18px 0 18px 18px;
      box-shadow: 0 8px 18px rgba(37,99,235,0.12);
    }
    .main-content {
      flex: 1;
      padding: 16px 20px 16px 0;
      height: 380px;
      overflow-y: auto;
    }
    .main-content p {
      background: #f1f5f9;
      padding: 16px 20px;
      border-radius: 16px;
      margin: 18px 0;
      border: 1px solid #dce3ef;
    }
    .note {
      margin-top: 30px;
      background: #fef9c3;
      border-left: 6px solid #eab308;
      padding: 16px 22px;
      border-radius: 24px;
      color: #854d0e;
    }
  </style>
</head>
<body>
<div class="card">
  <h2>🧩 案例3 <span>sticky</span></h2>
  <div class="desc">📌 粘性侧边栏 — 在双栏布局中,侧边栏滚动到距离父容器顶部20px时固定。</div>

  <div class="demo-flex">
    <aside class="sidebar">
      ⚡ 侧边栏<br>
      <span style="font-size:0.9rem;">top:20px 固定</span>
    </aside>
    <div class="main-content">
      <p>📘 第1条:侧边栏会粘在距离父容器顶部20px的位置。</p>
      <p>📘 第2条:注意父容器要设置 align-items: flex-start。</p>
      <p>📘 第3条:并且不能有 overflow:hidden 限制。</p>
      <p>📘 第4条:增加内容方便滚动查看粘性效果。</p>
      <p>📘 第5条:直到父容器完全离开视口,侧边栏才会消失。</p>
      <p>📘 第6条:纯CSS实现,比JS方案更流畅。</p>
    </div>
  </div>

  <div class="note">
    💡 小提示:侧边栏的父容器(flex容器)必须有足够高度,并且侧边栏自身不能是 flex 拉伸的高度(使用 <code>align-self: flex-start</code> 或父级 <code>align-items: flex-start</code>)。
  </div>
</div>
</body>
</html>

注意:要保证侧边栏的父容器(.container)有足够的高度,并且没有溢出隐藏。同时,侧边栏的兄弟元素(主内容)高度应大于侧边栏,否则侧边栏可能提前停止粘性。

3月10日 (1)(2).gif


这三个文件各自独立,展示了 position: sticky 最常见的应用场景。每个页面都包含了清晰的说明和可交互的滚动区域,非常适合作为技术博客的嵌入示例。读者可以分别打开,直观感受粘性定位的魅力。

4. 注意事项

  1. 父容器限制
    sticky 元素的活动范围仅限于其父容器内。当父容器滚出视口时,粘性元素也随之消失。
  2. 溢出属性
    如果父容器或任一祖先设置了 overflow: hidden / auto / scrollsticky 可能会失效(因为固定定位被限制在可滚动区域内)。通常建议不要在粘性元素的祖先上设置 overflow 非 visible
  3. 必须指定阈值
    至少指定 topbottomleft 或 right 中的一个,否则行为类似 relative
  4. 多个粘性元素
    多个同向的粘性元素会依次堆叠,后一个会顶替前一个的位置(如实例二所示)。
  5. z-index
    需要时可以通过 z-index 控制堆叠顺序,避免被其他元素覆盖。
  6. 表格表头
    也可以用在 <thead> 上实现表头固定(但需注意浏览器兼容性)。

5. 浏览器兼容性

现代浏览器(Chrome、Firefox、Safari、Edge 较新版本)均支持 sticky。对于旧版浏览器(如 IE),可以使用 JavaScript 回退(例如用 position: fixed 结合滚动监听模拟),但通常 sticky 是渐进增强的特性,不强制支持。


6. 总结

position: sticky 让我们用纯 CSS 实现过去需要 JavaScript 才能完成的“滚动粘性”效果,代码简洁且性能更好。只需记住:一个粘性元素,一个阈值,一个不设溢出隐藏的父容器,就能轻松实现导航栏固定、分区标题、侧边栏跟随等常见交互。

如果这篇文章让你对CSS刮目相看,点个赞,转个发,收藏下,让更多朋友看到——CSS真的在吃掉前端。

#前端#CSS#干货

从零创建你的第一个 Flutter 应用(一)

作者 HelloReader
2026年3月10日 11:27

一、我们要做什么?

在官方教程中,最终要构建一个叫做「Birdle」的小游戏应用(类似于大家熟悉的 Wordle 猜词游戏)。不过今天这一课的重点不是完成整个游戏,而是迈出第一步:创建一个能运行的 Flutter 项目,并理解它的基本结构

二、创建新项目

2.1 打开终端

在 VS Code 中,按 Ctrl + `(键盘左上角的反引号键)打开终端。终端就是一个可以输入命令的窗口,你可以把它想象成"跟电脑对话的聊天框"。

2.2 运行创建命令

在终端中输入以下命令:

flutter create birdle --empty

这行命令做了什么呢?

  • flutter create:告诉 Flutter "我要创建一个新项目"。
  • birdle:项目的名字,你可以换成任何你喜欢的名字(注意:只能用小写字母和下划线,比如 my_app)。
  • --empty:使用最简洁的模板,生成的代码最少,最适合学习。

命令执行完毕后,Flutter 会自动帮你创建好整个项目的文件夹结构。

2.3 进入项目目录

cd birdle

这行命令的意思是"进入 birdle 这个文件夹"。后续所有操作都需要在项目目录下执行。

三、看懂你的第一份 Flutter 代码

用 VS Code 打开项目中的 lib/main.dart 文件。这是整个应用的入口文件,所有 Flutter 应用都从这里开始运行。

别被代码吓到,我们一段一段来看。

3.1 导入和入口函数

import 'package:flutter/material.dart';

void main() {
  runApp(const MainApp());
}

这几行代码的含义:

  • import:导入 Flutter 的 Material 组件库。你可以理解为"从工具箱里取出工具"。Material 是 Google 设计的一套 UI 风格,提供了大量现成的按钮、文字、布局等组件。
  • main() 函数:这是每个 Dart 程序的起点,就像一本书的第一页。程序启动后,电脑会第一时间找到这个函数并执行它。
  • runApp() :Flutter 提供的方法,作用是"把一个组件渲染到屏幕上"。它接收一个 Widget(组件)作为参数,这个组件就是整个应用的"根"。

3.2 MainApp 组件

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Text('Hello World!'),
        ),
      ),
    );
  }
}

这段代码定义了一个叫 MainApp 的组件。让我们逐层拆解:

  • StatelessWidget:无状态组件。意思是这个组件一旦构建好,内容就不会变化。后面你还会学到 StatefulWidget(有状态组件),它可以响应用户操作而更新界面。
  • build() 方法:每个组件都必须有的方法,它告诉 Flutter "这个组件长什么样"。
  • MaterialApp:应用的最外层包装,提供了 Material Design 风格的基础配置。
  • Scaffold:脚手架,提供了页面的基本布局结构(比如顶部导航栏、底部栏、内容区域等)。
  • Center:让子组件居中显示。
  • Text('Hello World!') :显示一段文字。

3.3 理解 Widget 树

如果把这些组件的嵌套关系画出来,就像一棵倒着的树:

MainApp
  └── MaterialApp
        └── Scaffold
              └── Center
                    └── Text('Hello World!')

这就是 Flutter 中非常核心的概念——Widget 树(组件树)。在 Flutter 的世界里,一切皆 Widget。按钮是 Widget,文字是 Widget,布局也是 Widget。你的工作就是像搭积木一样,把这些 Widget 组合起来,构建出漂亮的界面。

四、运行你的应用

4.1 启动应用

在终端中输入:

flutter run -d chrome

这行命令的意思是"用 Chrome 浏览器运行这个 Flutter 应用"。第一次运行可能需要一点时间来编译,请耐心等待。

运行成功后,Chrome 浏览器会自动打开,你将看到屏幕中央显示着"Hello World!"。

💡 小贴士: 如果你想用其他方式运行,也可以在 VS Code 中直接按 F5,或者点击右上角的运行按钮。

4.2 可能遇到的问题

如果运行失败,可以先检查以下几点:

  • 确认你已经进入了项目目录(cd birdle)。
  • 确认 Flutter 已正确安装(运行 flutter doctor 检查)。
  • 确认 Chrome 浏览器已安装。

五、体验热重载(Hot Reload)

这是 Flutter 最受开发者欢迎的功能。传统开发中,改一行代码就要重新编译整个项目,等上几十秒甚至几分钟。Flutter 的热重载能在一秒内把修改反映到屏幕上,而且不会丢失当前的应用状态。

5.1 动手试试

  1. 保持应用运行状态,打开 lib/main.dart
  2. 找到 Text('Hello World!') 这一行。
  3. Hello World! 改成任意你想要的文字,比如 你好,Flutter!
  4. 在运行应用的终端中按下 r 键。

看!屏幕上的文字瞬间就变了,不需要重新启动应用。

5.2 为什么热重载这么重要?

想象一下,你正在调试一个需要先登录、再进入某个页面才能看到的界面。如果没有热重载,每次改代码都要从头走一遍登录流程。有了热重载,你只需按一下 r,修改后的界面就立刻呈现,之前的登录状态完全保留。这大大提升了开发效率。

六、本节知识点小结

经过这一课,你已经掌握了以下内容:

创建项目: 使用 flutter create 命令加上 --empty 参数,可以快速生成一个最简洁的 Flutter 项目结构。

理解代码结构: Flutter 应用从 main() 函数开始,通过 runApp() 将根组件渲染到屏幕上。每个组件都有一个 build() 方法来描述界面。

Widget 树: Flutter 的界面是由一层层嵌套的 Widget 组成的树状结构。你的任务就是组合这些 Widget 来构建 UI。

运行和热重载: 使用 flutter run 启动应用,按 r 触发热重载,可以在不到一秒内看到代码修改的效果。

七、下一步学习

恭喜你成功创建并运行了第一个 Flutter 应用!接下来,官方教程会带你学习 Widget 基础(Widget Fundamentals),深入了解更多类型的组件以及它们的用法。

我们下篇文章见!

参考资料:Flutter 官方教程 - Create an App

AI 数学辅导老师项目构想和初始化

作者 前端付豪
2026年3月10日 11:24

项目产生原因

拍照搜题是个非常有用的操作,尤其对于学生学习辅助帮助很大,市面上这类的软件也有很多,但能不能结合 AI 做出核心功能,并且可以商用? 而不是技术的自嗨模式,真的有用户使用上,先能产生公益价值。

项目初始化

结构

ai-math-tutor/
  frontend/
  backend/

后端初始化

1)进入 backend 目录:

mkdir ai-math-tutor
cd ai-math-tutor
mkdir backend
cd backend
python3 -m venv venv

激活虚拟环境:

macOS / Linux

source venv/bin/activate

2)安装依赖

pip install fastapi uvicorn python-dotenv openai

3)创建目录结构

backend/
  app/
    main.py
    schemas.py
    llm_service.py
  .env
  requirements.txt

llm_service.py

import os
import json
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()

client = OpenAI(
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_BASE_URL"),
)

MODEL = os.getenv("OPENAI_MODEL", "moonshot-v1-8k")


SYSTEM_PROMPT = """
你是一位专业的初中数学辅导老师,擅长:
1. 一元一次方程
2. 二元一次方程
3. 几何基础
4. 分数与整数运算
5. 一次函数

请严格返回 JSON:
{
  "answer": "最终答案",
  "steps": ["步骤1", "步骤2"],
  "knowledge_points": ["知识点1"],
  "similar_question": "类似题"
}

要求:
1. 只返回 JSON
2. 每个步骤必须适合学生理解
3. 不要省略关键推导
4. 如果题目超出初中范围,也要说明
"""

def solve_math_question(question: str):
    resp = client.chat.completions.create(
        model=MODEL,
        temperature=0.3,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"题目:{question}"},
        ],
    )

    content = resp.choices[0].message.content.strip()

    try:
        data = json.loads(content)
        return data
    except Exception:
        return {
            "answer": "解析失败",
            "steps": [content],
            "knowledge_points": ["待识别"],
            "similar_question": "请再输入一道类似题目",
        }

main.py

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.schemas import SolveQuestionRequest, SolveQuestionResponse
from app.llm_service import solve_math_question

app = FastAPI(title="AI Math Tutor API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/")
def health():
    return {"message": "AI Math Tutor API is running"}


@app.post("/api/solve", response_model=SolveQuestionResponse)
def solve_question(req: SolveQuestionRequest):
    result = solve_math_question(req.question)
    return SolveQuestionResponse(**result)

schemas.py

from pydantic import BaseModel
from typing import List


class SolveQuestionRequest(BaseModel):
    question: str


class SolveQuestionResponse(BaseModel):
    answer: str
    steps: List[str]
    knowledge_points: List[str]
    similar_question: str

4)写 .env

如果你用 Moonshot/OpenAI 兼容接口,可以这样:

OPENAI_API_KEY=你的key
OPENAI_BASE_URL=https://api.moonshot.cn/v1
OPENAI_MODEL=moonshot-v1-8k

如果是 OpenAI 官方兼容接口,就改成对应地址。

启动

backend 目录下执行:

uvicorn app.main:app --reload --port 8000

打开:

http://127.0.0.1:8000/docs

你可以直接在 Swagger 页面测试接口。

前端初始化

1)创建前端项目

回到项目根目录:

cd ..
npm create vite@latest frontend

选择:

  • Vue
  • JavaScript 或 TypeScript 都行

建议你用 TypeScript。

进入目录:

cd frontend
npm install
npm install axios naive-ui

2)前端目录建议

frontend/
  src/
    api/
      math.ts
    views/
      Home.vue
    App.vue
    main.ts

3)写 src/api/math.ts

import axios from 'axios'

const request = axios.create({
  baseURL: 'http://127.0.0.1:8000',
  timeout: 30000,
})

export interface SolveRequest {
  question: string
}

export interface SolveResponse {
  answer: string
  steps: string[]
  knowledge_points: string[]
  similar_question: string
}

export function solveMathQuestion(data: SolveRequest) {
  return request.post<SolveResponse>('/api/solve', data)
}

4)写 src/main.ts

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

5)写 src/App.vue

<template>
  <div class="page">
    <div class="container">
      <h1>AI 数学辅导老师</h1>

      <textarea
        v-model="question"
        class="question-input"
        placeholder="请输入一道数学题,例如:解方程 3x + 5 = 11"
      />

      <button class="submit-btn" @click="handleSubmit" :disabled="loading">
        {{ loading ? '解析中...' : '开始解析' }}
      </button>

      <div v-if="result" class="result-card">
        <h2>答案</h2>
        <p>{{ result.answer }}</p>

        <h2>步骤解析</h2>
        <ol>
          <li v-for="(item, index) in result.steps" :key="index">
            {{ item }}
          </li>
        </ol>

        <h2>知识点</h2>
        <ul>
          <li v-for="(item, index) in result.knowledge_points" :key="index">
            {{ item }}
          </li>
        </ul>

        <h2>相似题</h2>
        <p>{{ result.similar_question }}</p>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { solveMathQuestion, type SolveResponse } from './api/math'

const question = ref('')
const loading = ref(false)
const result = ref<SolveResponse | null>(null)

const handleSubmit = async () => {
  if (!question.value.trim()) return

  loading.value = true
  try {
    const { data } = await solveMathQuestion({
      question: question.value,
    })
    result.value = data
  } catch (error) {
    console.error(error)
    alert('解析失败,请检查后端是否启动')
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.page {
  min-height: 100vh;
  background: #f5f7fa;
  padding: 40px 16px;
}
.container {
  max-width: 800px;
  margin: 0 auto;
  background: #fff;
  padding: 24px;
  border-radius: 12px;
}
.question-input {
  width: 100%;
  min-height: 140px;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  resize: vertical;
  font-size: 16px;
  box-sizing: border-box;
}
.submit-btn {
  margin-top: 16px;
  padding: 10px 18px;
  border: none;
  background: #18a058;
  color: #fff;
  border-radius: 8px;
  cursor: pointer;
}
.result-card {
  margin-top: 24px;
  padding: 20px;
  background: #fafafa;
  border-radius: 8px;
}
</style>

6)启动前端

npm run dev

image.png

运行结果

image.png

nice !

基于 LLM Function Calling 的前端动态表单生成引擎:从 JSON Schema 映射到运行时组件树的端到端实现

2026年3月10日 11:22

基于 LLM Function Calling 的前端动态表单生成引擎:从 JSON Schema 映射到运行时组件树的端到端实现

一个真实的需求

上个月接了个活:给内部运营平台加一个"智能工单"功能。运营人员用自然语言描述需求,系统自动生成对应的表单让用户填写。

听起来很酷,对吧?

问题来了——LLM 返回的是 JSON Schema,而前端需要的是可交互的组件树。中间隔着一条鸿沟:类型映射、校验规则注入、条件渲染、组件动态加载……每一步都能让你怀疑人生。

这篇文章就聊这个:怎么把 LLM Function Calling 吐出来的 JSON Schema,变成一棵真正能跑的前端表单组件树


Function Calling 到底给了你什么

先搞清楚起点。当你给 LLM 定义一个 function,它返回的 parameters 本质上就是一份 JSON Schema:

{
  "name": "create_ticket",
  "parameters": {
    "type": "object",
    "properties": {
      "title": { "type": "string", "description": "工单标题" },
      "priority": { "type": "string", "enum": ["low", "medium", "high"] },
      "deadline": { "type": "string", "format": "date" },
      "attachments": {
        "type": "array",
        "items": { "type": "string", "format": "uri" }
      }
    },
    "required": ["title", "priority"]
  }
}

这份 Schema 告诉你数据长什么样,但不告诉你UI 长什么样

string 应该渲染成 Input 还是 Textarea?enum 是下拉框还是 Radio?format: "date" 用哪个日期组件?

这就是核心问题:Schema 描述的是数据契约,不是 UI 契约。你需要一套映射引擎把前者翻译成后者。


映射引擎的本质:类型系统到组件系统的编译器

把 JSON Schema 变成组件树,本质上和编译器做的事情一样——词法分析(解析 Schema)、语法分析(构建 UI AST)、代码生成(渲染组件)。

只不过你的"源语言"是 JSON Schema,"目标语言"是 React/Vue 组件树。

第一层:基础类型映射

先解决最简单的问题——把 JSON Schema 的类型映射到组件:

// 类型映射注册表:JSON Schema type → 组件
const typeRegistry: Record<string, ComponentResolver> = {
  string: (schema) => {
    // format 优先级最高
    if (schema.format === 'date') return DatePicker
    if (schema.format === 'uri') return UrlInput
    // enum 次之
    if (schema.enum) return schema.enum.length > 4 ? Select : RadioGroup
    // 长文本判断
    if (schema.maxLength && schema.maxLength > 200) return Textarea
    // 兜底
    return Input
  },

  number: (schema) => {
    if (schema.minimum !== undefined && schema.maximum !== undefined) return Slider
    return InputNumber
  },

  boolean: () => Switch,

  array: (schema) => {
    // 数组套枚举 → 多选
    if (schema.items?.enum) return CheckboxGroup
    // 普通数组 → 动态列表
    return DynamicList
  },

  object: (schema) => FieldGroup, // 递归处理
}

这层映射看似简单,但藏着一个决策:映射规则的优先级怎么定?

我们的优先级是:format > enum > 其他约束 > type 兜底。为什么?因为 format 是最具体的语义声明,而 type 是最泛化的。越具体的信息,越应该优先决定 UI 形态。

第二层:Schema → UI AST

拿到组件类型还不够,你需要一个中间表示层(IR),我们叫它 UI AST

interface UINode {
  id: string
  component: string          // 组件标识
  field: string              // 对应 Schema 的字段路径,如 "address.city"
  props: Record<string, any> // 传给组件的 props
  rules: ValidationRule[]    // 校验规则
  children?: UINode[]        // 嵌套节点(object / array 场景)
  visible?: ConditionExpr    // 条件渲染表达式
}

为什么不直接从 Schema 渲染组件,非要搞个中间层?

三个理由:

  1. 解耦。Schema 变了不用改渲染逻辑,渲染逻辑变了不用改解析逻辑
  2. 可干预。中间层可以被二次修改——比如运营想调整字段顺序、覆盖默认组件
  3. 可序列化。UI AST 是纯数据,可以缓存、持久化、跨端复用

这个思路和 Vue 的虚拟 DOM 一模一样——不是直接操作真实 DOM,而是先生成一份描述,再统一渲染。


Schema 解析器:递归下降 + 特征提取

Schema 解析是整个引擎最核心的部分。JSON Schema 支持嵌套、引用($ref)、组合(allOf/oneOf)等复杂特性,你得递归处理:

function parseSchema(
  schema: JSONSchema,
  path: string = '',
  required: string[] = []
): UINode[] {
  const nodes: UINode[] = []

  for (const [key, fieldSchema] of Object.entries(schema.properties || {})) {
    const fieldPath = path ? `${path}.${key}` : key
    const isRequired = required.includes(key)

    // 递归处理嵌套 object
    if (fieldSchema.type === 'object' && fieldSchema.properties) {
      nodes.push({
        id: generateId(),
        component: 'FieldGroup',
        field: fieldPath,
        props: { label: fieldSchema.description || key },
        rules: [],
        // 关键:递归下降,把子字段也解析成 UINode
        children: parseSchema(fieldSchema, fieldPath, fieldSchema.required || []),
      })
      continue
    }

    // 通过注册表解析组件类型
    const resolver = typeRegistry[fieldSchema.type as string]
    const component = resolver?.(fieldSchema) ?? Input

    nodes.push({
      id: generateId(),
      component: component.name,
      field: fieldPath,
      props: extractProps(fieldSchema),   // 从 Schema 约束中提取组件 props
      rules: extractRules(fieldSchema, isRequired), // 约束 → 校验规则
      children: undefined,
    })
  }

  return nodes
}

约束到校验规则的翻译

JSON Schema 的约束(minLengthpatternminimum 等)需要翻译成前端校验规则:

function extractRules(schema: JSONSchema, isRequired: boolean): ValidationRule[] {
  const rules: ValidationRule[] = []

  if (isRequired) {
    rules.push({ type: 'required', message: `${schema.description || '此字段'}不能为空` })
  }

  // minLength / maxLength → 长度校验
  if (schema.minLength) {
    rules.push({ type: 'minLength', value: schema.minLength, message: `至少输入 ${schema.minLength} 个字符` })
  }

  // pattern → 正则校验(LLM 有时会生成正则,需要做安全校验)
  if (schema.pattern) {
    try {
      new RegExp(schema.pattern) // 先验证正则合法性,LLM 给的不一定靠谱
      rules.push({ type: 'pattern', value: schema.pattern, message: '格式不正确' })
    } catch {
      console.warn(`非法正则,已跳过: ${schema.pattern}`) // 防御性编程,别让 LLM 搞崩你
    }
  }

  // enum → 枚举校验
  if (schema.enum) {
    rules.push({ type: 'enum', value: schema.enum, message: '请选择有效选项' })
  }

  return rules
}

注意那个 try/catch——永远不要相信 LLM 生成的正则表达式。它偶尔会给你一个语法错误的正则,甚至一个会导致 ReDoS 的正则。防御性编程不是多余的,是必须的。


运行时渲染:从 UI AST 到真实组件树

有了 UI AST,渲染就是一个递归 render 的过程。以 React 为例:

// 组件注册表:字符串标识 → 真实组件
const componentMap: Record<string, React.ComponentType<any>> = {
  Input, InputNumber, Select, RadioGroup,
  CheckboxGroup, DatePicker, Switch, Textarea,
  Slider, UrlInput, DynamicList, FieldGroup,
}

function DynamicForm({ nodes, value, onChange }: DynamicFormProps) {
  return (
    <Form>
      {nodes.map(node => (
        <DynamicField key={node.id} node={node} value={value} onChange={onChange} />
      ))}
    </Form>
  )
}

function DynamicField({ node, value, onChange }: DynamicFieldProps) {
  const Component = componentMap[node.component]

  if (!Component) {
    // 未注册的组件类型,降级为 Input,别直接崩
    console.warn(`未知组件: ${node.component},降级为 Input`)
    return <Input placeholder="(降级渲染)" />
  }

  // 嵌套节点递归渲染
  if (node.children?.length) {
    return (
      <FieldGroup label={node.props.label}>
        {node.children.map(child => (
          <DynamicField key={child.id} node={child} value={value} onChange={onChange} />
        ))}
      </FieldGroup>
    )
  }

  return (
    <FormItem
      label={node.props.label}
      rules={node.rules}
      field={node.field}
    >
      <Component {...node.props} />
    </FormItem>
  )
}

整个链路跑通了:

自然语言 → LLM → JSON Schema → UI AST → 组件树 → 用户交互 → 提交数据


设计权衡:几个绕不开的选择

1. 组件映射写死还是可配置?

写死最简单,但业务方总会说"这个字段我想用富文本编辑器"。

我们的做法是默认映射 + Schema 扩展字段覆盖

{
  "type": "string",
  "description": "商品详情",
  "x-component": "RichTextEditor",
  "x-component-props": { "height": 300 }
}

x- 前缀是 JSON Schema 的扩展约定,不会影响标准校验。这样 LLM 生成基础 Schema,业务方可以通过配置覆盖组件选择。

2. LLM 直接生成 UI AST 不行吗?

试过,放弃了。原因:

  • LLM 对 UI 组件库的 API 记忆不准确,经常生成错误的 props
  • UI AST 结构变了(比如换组件库),所有 prompt 都得重写
  • JSON Schema 是标准协议,LLM 训练数据里大量存在,生成质量稳定得多

让 LLM 做它擅长的事(生成结构化数据),让前端引擎做它擅长的事(UI 渲染)。 这是最重要的架构决策。

3. Schema 校验要不要在前端做?

必须做。两个层面:

  • 结构校验:LLM 返回的真的是合法 JSON Schema 吗?用 ajv 跑一遍
  • 安全校验:有没有危险的正则?字段数量是否合理(防止 LLM 幻觉生成 200 个字段)?
function validateSchema(schema: JSONSchema): { valid: boolean; errors: string[] } {
  const errors: string[] = []

  // 字段数量限制,LLM 偶尔会疯狂输出
  const fieldCount = Object.keys(schema.properties || {}).length
  if (fieldCount > 30) {
    errors.push(`字段数量 ${fieldCount} 超过上限 30,疑似幻觉输出`)
  }

  // 嵌套深度检查
  const maxDepth = getMaxDepth(schema)
  if (maxDepth > 4) {
    errors.push(`嵌套深度 ${maxDepth} 层,超过上限 4 层`)
  }

  return { valid: errors.length === 0, errors }
}

你不设防线,LLM 迟早给你一个惊喜。


条件渲染:表单的"活"逻辑

真实表单不是所有字段都永远可见的。比如选了"紧急"优先级,才出现"审批人"字段。

JSON Schema 原生支持 if/then/else,但这玩意的语法设计……写到这里我开始怀疑人生:

{
  "if": { "properties": { "priority": { "const": "high" } } },
  "then": { "properties": { "approver": { "type": "string" } } }
}

我们的处理方式是在解析阶段把这类条件提取出来,挂到 UI AST 的 visible 字段上:

// UI AST 中的条件表达式
{
  field: "approver",
  component: "Select",
  visible: {
    operator: "eq",
    dependsOn: "priority",  // 依赖哪个字段
    value: "high"           // 当值等于 "high" 时显示
  }
}

渲染时用一个简单的条件判断:

function shouldShow(node: UINode, formValues: Record<string, any>): boolean {
  if (!node.visible) return true // 没有条件,永远显示

  const { dependsOn, operator, value } = node.visible
  const current = get(formValues, dependsOn) // lodash.get 取嵌套值

  switch (operator) {
    case 'eq': return current === value
    case 'in': return (value as any[]).includes(current)
    case 'ne': return current !== value
    default: return true
  }
}

可扩展性:当需求开始膨胀

第一版做完,需求就开始野蛮生长了:

"能不能支持自定义组件?" —— 可以,往 componentMap 里注册就行。

"能不能支持布局控制?一行两列?" —— 在 UI AST 加 layout 字段,引入栅格系统。

"能不能多个 LLM 调用串联,一个表单的结果作为下一个表单的输入?" —— 这就不是表单引擎的事了,你需要一个工作流编排层。

架构上我们把引擎拆成了三层,每层可独立替换:

┌─────────────────────────────────────────┐
│  Schema Provider(LLM / 手写 / 远程)    │  ← 数据来源可替换
├─────────────────────────────────────────┤
│  Schema → UI AST 编译器                  │  ← 映射规则可扩展
├─────────────────────────────────────────┤
│  UI AST → Component Renderer            │  ← 组件库可替换
└─────────────────────────────────────────┘

换组件库?只改 Renderer 层。换 LLM 提供商?只改 Provider 层。这不是过度设计,这是被需求变更教育后的防御姿态。


踩坑实录

坑 1:LLM 返回的 Schema 不一定合法。 type 拼错、enum 给了空数组、required 里写了不存在的字段——都遇到过。解析前必须做 normalize。

坑 2:数组类型的表单状态管理很痛。 用户动态添加/删除数组项时,field path 会变(items.0.name → 删掉第一个后变成 items.0.name 但指向了原来的第二项)。用 id 而不是 index 做 key,这是老生常谈但每次都有人踩。

坑 3:流式响应下的 Schema 解析。 如果 LLM 用流式返回 JSON Schema,你拿到的是不完整的 JSON。要么等流结束再解析,要么用增量 JSON 解析器(如 partial-json)。我们选了前者——复杂度不值得。

坑 4:description 字段的双重身份。 JSON Schema 的 description 本意是给开发者看的字段说明,但在表单场景下它变成了给用户看的 label。LLM 有时会在 description 里写"This field represents the..."这种开发者语言。解决方案:在 prompt 里明确告诉 LLM,description 要写中文、面向用户。


通用模型:这个问题的本质是什么

退一步看,这整个引擎做的事情可以抽象为一个通用模型:

声明式描述 → 中间表示 → 运行时实例化

这个模式到处都是:

  • SQL Schema → ORM Model → 数据库表
  • OpenAPI Spec → API Client → HTTP 请求
  • Figma Design Token → Theme Config → UI 样式
  • JSON Schema → UI AST → 表单组件

核心思想就一个:用数据描述意图,用引擎翻译成行为。当你下次遇到"需要从某种描述自动生成某种 UI"的需求时,先别急着写代码,先想想:中间表示层应该长什么样?映射规则的扩展点在哪里?哪些决策应该留给引擎,哪些应该留给使用者?

把这三个问题想清楚,架构就不会跑偏。

至于 LLM——它只是这条链路上一个新的数据源。它很强,但它不可靠。 你的引擎需要对它的输出做校验、降级、容错。就像你不会信任用户输入一样,也别信任 AI 输出。

前端进阶:小程序 Canvas 2D 终极指北 — 给图片优雅添加水印

2026年3月10日 11:29

在之前的文章中,我们详细拆解了如何使用小程序旧版 Canvas API 给图片添加水印。随着小程序框架(如 Taro、uniapp)和微信底层基础库的演进,Canvas 2D 凭借更高清的渲染质量和更好的性能,已经逐渐成为业界首选方案。

今天,我们将之前的打水印代码,全面升级为 Canvas 2D 的版本!不仅能学到如何平滑迁移,最后还会彻底讲透“新旧 Canvas 到底有什么区别”。


💡 为什么我们要换用 Canvas 2D?

Canvas 2D 的 API 设计完全对齐了 Web 标准标准(W3C Standard)。这意味着:

  1. 渲染更清晰:支持硬件加速,不会轻易出现糊边。
  2. 不用重复造轮子:只要你有 HTML5 开发经验,可以直接零成本迁移过去,再也不用记 wx.createCanvasContext 这种蹩脚的“微信特色特供版”原生 API 啦!
  3. 同层渲染支持更好:旧版 Canvas 在小程序中是原生组件,层级最高,经常盖住网页中的其他弹窗(比如弹框、Toast);而 Canvas 2D 引入了同层渲染,和普通 view 标签能和谐共存。

🚀 核心实践:用 Canvas 2D 把图“画”出来

整体的思路和旧版类似(获取尺寸 -> 建黑框 -> 写白字 -> 导出),但在实现的手法上大变样了。快来看看新代码。

第 1 步:改变 HTML 标签的宣告方式

首先,我们需要在 <canvas> 标签上明确声明 type="2d"。注意,有了这个类型声明,canvas-id 就不再生效了,我们必须通过普通的 HTML id 来识别它!

<template>
  <view class="container">
    <button @click="takePhoto">拍照并加水印</button>

    <canvas
      type="2d"                 <!-- 核心改动 1声明为 Web 标准 2D 画布 -->
      id="wmCanvas"             <!-- 核心改动 2:使用 id 代替 canvas-id -->
      class="watermark_canvas"
      :style="'width:' + canvasWidth + 'px;height:' + canvasHeight + 'px;'"
    ></canvas>
  </view>
</template>

第 2 步:获取画布节点 (Node) 和 网页画笔 (Context)

旧版我们是用 wx.createCanvasContext("wmCanvas", this) 凭空抓取一把画笔。 在 Canvas 2D 时代,我们必须老老实实地:先在图纸上找到标签(Node) -> 初始化画板宽度 -> 然后从这块白板上拿画笔

// 【代码场景:我们拿到原始图片的路径后,首先需要获取它原本的尺寸】
wx.getImageInfo({
  src: imgPath,
  success: (imgInfo) => {
    // 1. 和旧版逻辑一模一样,我们算出不让真机崩溃的安全比例宽和高
    const ratio = Math.min(1, 1280 / Math.max(imgInfo.width, imgInfo.height));
    const drawWidth = Math.max(1, Math.round(imgInfo.width * ratio));
    const drawHeight = Math.max(1, Math.round(imgInfo.height * ratio));

    // 同步更新页面上 canvas 标签的尺寸大小
    this.canvasWidth = drawWidth;
    this.canvasHeight = drawHeight;

    // 2. 也是等画布在页面上调整完大小后,我们再通过 DOM 节点分析来寻找它
    this.$nextTick(() => {
      setTimeout(() => {
        // (1) 获取当前页面组件的作用域 (在 Taro / 原生小程序框架中十分必要,避免找错 canvas)
        const instance = Taro.getCurrentInstance
          ? Taro.getCurrentInstance()
          : null;
        const pages = Taro.getCurrentPages ? Taro.getCurrentPages() : [];
        const scope =
          this.$scope ||
          (instance && instance.page) ||
          (pages && pages[pages.length - 1]);

        // (2) 发起类似 Web 中 document.getElementById 的查询请求
        const query = Taro.createSelectorQuery().in(scope);
        query
          .select("#wmCanvas")
          .fields({ node: true, size: true }) // 告诉微信,我们需要真实 DOM 节点
          .exec((res) => {
            // 3. 拦截节点实例
            const canvas = res && res[0] && res[0].node;
            if (!canvas) return console.error("画布初始化没找到对应的节点!");

            // 4. 重塑画板的物理像素大小(极度关键:保证导出不再是黑屏或者残缺一半)
            canvas.width = drawWidth;
            canvas.height = drawHeight;

            // 5. 正式拿到属于这块画板的 2D 水彩笔!
            const ctx = canvas.getContext("2d");

            // 接下来我们就可以传址开启真正的绘图流程了...
            drawWatermarkCore(canvas, ctx, drawWidth, drawHeight, imgInfo.path);
          });
      }, 60);
    });
  },
});

第 3 步:把图片当成一个"真实对象"加载完毕再画

这一步是很多第一次接触 Canvas 2D 的老司机最容易翻车的地方! 旧版我们能直接 ctx.drawImage('图片的临时本地路径.jpg');但在 Web 规范里,你必须把图片当作一个对象,等浏览器完全解析完该对象的缓存后,才能画!

const drawWatermarkCore = (canvas, ctx, drawWidth, drawHeight, imgPath) => {
  // 前期的公式就算省略,和旧版一模一样!算出字体大小和居中位置
  const fontSize = 16;
  const boxX = 40;
  // ...

  // 【1. 用画板亲自创造一个空白的图像容器】
  const image = canvas.createImage();

  // 【2. 照片是个异步过程!等图像数据流成功涌入到这具容器内,触发加载完毕的回调】
  image.onload = () => {
    // (1) 把加载完实体的照片铺面屏幕
    ctx.drawImage(image, 0, 0, drawWidth, drawHeight);

    // (2) 画半透明黑底
    ctx.fillStyle = "rgba(0, 0, 0, 0.22)"; // Note:变成了属性赋值
    ctx.fillRect(boxX, boxY, boxWidth, boxHeight);

    // (3) 写纯白字体
    ctx.fillStyle = "#ffffff";
    ctx.font = `${fontSize}px sans-serif`; // Note:字号变成了 CSS 简写语法

    lines.forEach((line, index) => {
      ctx.fillText(line, boxX + 10, textY);
    });

    // ⚠️【高能预警】Canvas 2D 属于“所画即所得”:
    // 没有 ctx.draw() !
    // 没有 ctx.draw() !
    // 没有 ctx.draw() 啦!画完上面几行,画布上的字和图就已经成型了!准备导出吧。

    exportImage(canvas, drawWidth, drawHeight);
  };

  // 如果中途断网或文件损坏导致报错
  image.onerror = (err) => {
    console.error("图片转译抛锚了", err);
  };

  // 【3. 把之前手机本地文件里的照片路径,塞进这个图像容器(必须塞在 onload 事件之后)】
  image.src = imgPath;
};

第 4 步:从画板对象里把照片截图出炉

因为我们在第三步已经拿到过 canvas 对象了,所以生成临时图片方法里,也不再需要提供 canvasIdthis 实例,而是直接把这块画板交出去截图。

const exportImage = (canvas, drawWidth, drawHeight) => {
  wx.canvasToTempFilePath({
    canvas: canvas, // 直接给出整个 Node 节点即可!不要再传 Id!
    x: 0,
    y: 0,
    width: drawWidth,
    height: drawHeight,
    destWidth: drawWidth,
    destHeight: drawHeight,
    fileType: "jpg",
    quality: 0.9,
    success: (res) => {
      // 生成无与伦比的高清图成功!
      this.imgWithWatermark = res.tempFilePath;
    },
  });
};

完整可用代码 (可以直接 Copy 进项目哦)

为了大家能够拿来即用,这里是一份融合了所有计算细节、基于 Taro/Vue 语法的无依赖组件代码,你可以直接放在页面里运行:

<template>
  <view class="container">
    <button @click="takePhoto">拍照并加水印</button>

    <view class="preview" v-if="imgWithWatermark">
      <view class="title">由于新版 Canvas 清晰度太高,建议横屏观看效果:</view>
      <image class="result-img" mode="widthFix" :src="imgWithWatermark"></image>
    </view>

    <!-- 同样地,把 Canvas 藏出屏幕外,用作在后台悄悄合成图的底板 -->
    <canvas
      type="2d"
      id="wmCanvas"
      class="watermark_canvas"
      :style="'width:' + canvasWidth + 'px;height:' + canvasHeight + 'px;'"
    ></canvas>
  </view>
</template>

<script>
  // 这里引入你框架提供的基础对象,例如 Taro
  import Taro from "@tarojs/taro";

  export default {
    data() {
      return {
        imgWithWatermark: "",
        canvasWidth: 300,
        canvasHeight: 300,
      };
    },
    methods: {
      // 1. 获取当前时间的格式化字符串
      formatCurrentTime() {
        const d = new Date();
        const p = (num) => num.toString().padStart(2, "0");
        return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(
          d.getHours(),
        )}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
      },

      takePhoto() {
        wx.chooseMedia({
          count: 1,
          mediaType: ["image"],
          sourceType: ["camera", "album"],
          sizeType: ["compressed"],
          success: (res) => {
            this.doWatermark(res.tempFiles[0].tempFilePath);
          },
        });
      },

      doWatermark(imgPath) {
        // 准备要在相纸上写的水印文案
        const lines = [
          `巡检记录人:李工程师`,
          `当前任务区:A区服务器机房`,
          `拍摄录入时间:${this.formatCurrentTime()}`,
          `仅供公司系统上传使用`,
        ];

        wx.getImageInfo({
          src: imgPath,
          success: (imgInfo) => {
            // 真机上尺寸过大极易导致导出的图片截断,我们强制让边长不超过 1280
            const maxSide = 1280;
            const ratio = Math.min(
              1,
              maxSide / Math.max(imgInfo.width, imgInfo.height),
            );
            const drawWidth = Math.max(1, Math.round(imgInfo.width * ratio));
            const drawHeight = Math.max(1, Math.round(imgInfo.height * ratio));

            this.canvasWidth = drawWidth;
            this.canvasHeight = drawHeight;

            // 开启画布绘制主流程
            this.$nextTick(() => {
              setTimeout(() => {
                // (1) 兼容各种环境里的作用域查找
                const instance = Taro.getCurrentInstance
                  ? Taro.getCurrentInstance()
                  : null;
                const pages = Taro.getCurrentPages
                  ? Taro.getCurrentPages()
                  : [];
                const scope =
                  this.$scope ||
                  (instance && instance.page) ||
                  (pages && pages[pages.length - 1]);

                if (!scope)
                  return wx.showToast({
                    icon: "none",
                    title: "页面未完全就绪!",
                  });

                // (2) 寻找页面上真实挂载的 Canvas 节点
                const query = Taro.createSelectorQuery().in(scope);
                query
                  .select("#wmCanvas")
                  .fields({ node: true, size: true })
                  .exec((res) => {
                    const canvas = res && res[0] && res[0].node;
                    if (!canvas)
                      return wx.showToast({
                        icon: "none",
                        title: "找不到画布元素",
                      });

                    // 非常关键,这一步没做导出来的图可能会残缺并带有黑框
                    canvas.width = drawWidth;
                    canvas.height = drawHeight;

                    const ctx = canvas.getContext("2d");

                    // (3) 基于画布宽度的动态字体与排版宽高运算
                    const fontSize = Math.max(
                      16,
                      Math.round(drawWidth * 0.038),
                    );
                    const lineHeight = Math.round(fontSize * 1.5);
                    const textPadding = Math.round(fontSize * 0.8);
                    const boxPadding = Math.round(fontSize * 0.9);
                    const boxHeight =
                      boxPadding * 2 + lineHeight * lines.length;
                    const boxWidth = Math.round(drawWidth * 0.92);
                    const boxX = Math.round((drawWidth - boxWidth) / 2); // 居中
                    const boxY = drawHeight - boxHeight - boxPadding; // 贴底

                    // ================ 核心 2D 作图逻辑 ================
                    const image = canvas.createImage();

                    image.onload = () => {
                      // 铺设图片底图
                      ctx.drawImage(image, 0, 0, drawWidth, drawHeight);
                      // 画个垫底黑框
                      ctx.fillStyle = "rgba(0, 0, 0, 0.22)";
                      ctx.fillRect(boxX, boxY, boxWidth, boxHeight);
                      // 切字体渲染色
                      ctx.fillStyle = "#ffffff";
                      ctx.font = `${fontSize}px sans-serif`;

                      // 把文案行行写下
                      lines.forEach((line, index) => {
                        const textY =
                          boxY +
                          boxPadding +
                          lineHeight * (index + 1) -
                          (lineHeight - fontSize) / 2;
                        ctx.fillText(line, boxX + textPadding, textY);
                      });

                      // 立刻调用快照方法(此处不需要旧版的 ctx.draw 啦!)
                      wx.canvasToTempFilePath({
                        canvas: canvas, // 传入实体 Node!
                        x: 0,
                        y: 0,
                        width: drawWidth,
                        height: drawHeight,
                        destWidth: drawWidth,
                        destHeight: drawHeight,
                        fileType: "jpg",
                        quality: 0.9,
                        success: (res) => {
                          this.imgWithWatermark = res.tempFilePath;
                        },
                        fail: (err) => {
                          console.error("canvasToTempFilePath fail", err);
                        },
                      });
                    };

                    image.onerror = (err) => {
                      console.error("canvas image load fail", err);
                    };

                    // 触发图片的加载
                    image.src = imgPath;
                  });
              }, 60); // 留点时间让 Vue 的绑定属性被 Webview 真实渲染完
            });
          },
        });
      },
    },
  };
</script>

<style>
  .container {
    padding: 20px;
  }
  .watermark_canvas {
    position: fixed;
    top: -9999px;
    left: -9999px;
    opacity: 0;
  }
  .result-img {
    width: 100%;
    border-radius: 8px;
    margin-top: 10px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  }
  .title {
    font-size: 14px;
    color: #666;
    margin-top: 15px;
  }
</style>

🏆 终极灵魂拷问:旧版 Canvas vs Canvas 2D 到底差在哪?

回顾我们今天改造的代码,你会发现核心逻辑只是皮囊换了!我把重点区别提炼成以下表格,保证你从此在面试和实战中得心应手:

差异维度 以前的旧代码 (经典版 Canvas API) 现在的 Canvas 2D (推荐写法)
标签宣告 canvas-id="myId" 无需声明类型 必须加 type="2d" 及普通 id="myId"
获取画笔 (Context) 简单粗暴指令:wx.createCanvasContext(Id, this) 遵循 W3C 标准:先用 SelectorQuery 获取 Node 元素节点,再由节点 canvas.getContext("2d") 获取。
API 调用风格 特有的函数调用方式:ctx.setFillStyle()ctx.setFontSize() W3C 原生属性赋值:ctx.fillStyle = 'red'ctx.font = '16px auto'
绘制本地图片 万能参数,可以直接传进 String 路径:ctx.drawImage('img.jpg', ...) 非常规范,必须先根据节点创建原生对象:let img = canvas.createImage() ,等 img.onload 触发后再把对象当作参数传进 drawImage
真正渲染的时机 所有命令类似于“记录剧本”,最后必须使用打板: ctx.draw(false, callback) 统一执行。 所见即所得,写下一句 fillText,画板上立刻浮现,全面废除了 ctx.draw 方法。
导出为图片 wx.canvasToTempFilePath 认准 canvasId 弃用 Id 判断,直接传入实体 canvas 节点本身,而且更加流畅、不易报错!

全篇总结: 如果说旧版的 API 像是微信自己包了一层“快捷指令糖衣”,适合简单业务;那 Canvas 2D 就是一把真刀真枪、符合全球标准的 HTML5 瑞士军刀

它在初始化的 SelectorQuery 查询和图片 onload 等待阶段略显繁琐,但这换来的是彻底消灭奇奇怪怪的组件层级覆盖 Bug、更好的渲染性能、以及你可以毫无障碍地把网上的网页端 Canvas 老特技和流行库直接搬进小程序! 掌握 Canvas 2D 是前端开发者在小程序开发进阶过程中的一块必修内功!

活动落地页效率翻倍:RollCode 这次更新有点猛

2026年3月10日 11:09

一、活动落地页开发的真实痛点

如果你做过企业活动页面开发,大概率会对这种场景非常熟悉:运营提出活动需求,设计师给出视觉稿,开发团队在极短时间内完成页面搭建并上线。等活动结束后,这个页面往往就被废弃,下一次活动又重新开发一个新的页面。

活动页面看起来简单,本质却是一个 高频、重复、协作复杂的工作流。每一次活动都会产生新的页面需求,而这些页面往往只存在几天或几周。

开发团队通常会陷入这样的循环:

这种开发模式会带来几个明显的问题。

首先是 页面重复开发。很多活动页面结构高度相似,例如 Banner、商品卡片、活动介绍模块等,但每次活动依然需要重新写一套页面代码。

其次是 设计与开发流程割裂。设计师交付视觉稿,开发需要重新实现 HTML 与组件结构。

再者是 海报设计与页面制作是两套流程。设计团队制作海报用于宣传,而开发再根据海报重新搭建页面。

还有一个很现实的问题是 上线周期长。一个简单活动页,往往要经历设计、开发、联调、发布多个环节。

本质上,活动页面属于 内容驱动型页面。页面结构稳定,而变化最多的是内容。如果继续用传统开发方式处理这类需求,效率提升空间非常有限。

于是一个问题变得非常清晰:

有没有一种方式,可以通过组件 + AI 的方式快速生成活动页面? 【传送门】


二、认识 RollCode:一个活动页面生产工具

在这样的背景下,RollCode 的设计思路就显得非常清晰。

RollCode 是一个面向企业营销场景的 可视化页面搭建平台。它并不是简单的低代码工具,而是一套完整的 活动页面生产系统

RollCode 提供了一系列核心能力:

  • 可视化组件搭建
  • 页面模板复用
  • 自定义组件开发
  • 开放式代码嵌入
  • 页面代码导出部署

开发者可以通过组件方式构建页面结构,例如 Banner 组件、商品卡片组件、活动模块组件等,然后像搭积木一样组合页面。

这种模式的核心价值在于 结构复用。开发团队可以沉淀一套营销组件体系,在后续活动中直接复用已有组件。

一句话总结 RollCode 的目标:

让活动落地页像搭积木一样构建出来。


三、本次更新的核心能力

这次 RollCode 更新,重点围绕 AI内容生成能力页面搭建能力 两个方向进行了升级。

核心更新内容如下:

模块 更新能力
AI海报组件 AI生成营销海报并转化为页面组件
布局系统 新增容器能力与嵌套布局
数据修改器 支持组件数据修改
调试模式 支持组件开发调试
项目管理 支持项目导入导出
发布系统 页面构建性能优化
模板库 新增行业模板

整体来看,这次更新实际上打通了 内容生成 → 页面搭建 → 项目复用 → 页面发布 的完整链路。其中最有意思的一项能力就是 AI海报组件


四、最有意思的能力:AI海报组件

AI海报组件试图解决一个长期存在的问题:设计内容如何快速转化为页面结构。

在传统流程中,设计师制作营销海报,开发团队需要根据海报重新搭建页面结构。这个过程通常需要人工拆解海报中的内容,例如标题、图片、按钮等。

RollCode 的 AI海报组件将这个过程自动化。

开发者只需要输入海报需求,系统就可以生成营销海报,并进一步解析图片内容,将其转化为页面组件结构。

整个流程如下:

最终效果是:

一张海报可以直接变成页面内容。

这意味着过去需要 设计 + 前端协作 才能完成的流程,现在可以通过工具快速完成。

从工程角度来看,这是一种 视觉内容结构化 的能力。


五、布局系统升级:复杂页面也能轻松搭建

活动页面结构通常比较复杂。例如一个活动页面可能包含:

  • Banner模块
  • 商品卡片区
  • 活动介绍区
  • 表单模块

这些模块通常需要不同的布局方式。在这次更新中,RollCode 对布局系统进行了升级,新增了:

  • 平分最大宽度
  • 水平容器
  • 网格容器
  • 任意嵌套组合
  • 行列间距控制

这种能力本质上是 Flex + Grid 的可视化封装。开发者不需要写 CSS 布局代码,就可以快速搭建复杂页面结构。


六、开发者能力升级

这次更新还增强了开发者的扩展能力,其中比较重要的是 数据修改器组件开发调试模式。数据修改器允许开发者对组件数据进行改写。例如通过接口数据更新页面内容。

组件开发调试模式则为开发者提供了独立的调试环境。开发者可以在不影响真实页面的情况下调试组件。这对于构建 企业组件库 非常重要。


七、项目复用与发布体系:活动页面效率的关键

在实际业务中,大量活动页面的结构是高度相似的。例如常见的页面结构通常包括 Banner、商品展示区、活动介绍模块以及用户表单区域。不同活动之间变化最大的往往只是图片、文案和少量模块结构,而页面整体框架基本一致。

针对这一特点,RollCode 提供了 项目导入与导出能力。开发者可以将已经搭建好的页面项目直接导出,在新的活动中重新导入并进行修改,从而快速复用已有页面结构。

通过这种方式,团队可以逐渐沉淀出一套稳定的 活动页面模板体系。当新的活动需求出现时,只需要在模板基础上调整内容,而不需要重新搭建页面结构,大幅减少开发时间。

在页面发布环节,RollCode 也进行了多项优化。平台采用 SSG(Static Site Generation)静态构建方式,并结合按需加载、代码分割以及路由预加载等技术,对页面性能进行了系统优化。

这些优化带来的效果非常直接:

  • 页面体积更小
  • 加载速度更快
  • 用户体验更流畅

对于活动页面来说,页面加载速度往往会直接影响用户停留时间和转化率,因此发布性能优化同样是页面生产体系中的重要一环。

除了项目复用能力之外,RollCode 还提供了一套 行业模板库,帮助团队更快地启动新的页面项目。当前模板类型包括:

  • 活动页面模板
  • 产品推广页模板
  • App 下载页模板
  • 商业展示页模板

开发团队可以在模板基础上快速生成新的活动页面,并根据具体需求进行调整,从而进一步提升页面上线效率。


总结

整体来看,这次 RollCode 更新让它从一个 页面搭建工具 逐渐演变成 活动页面生产平台

核心能力可以概括为三点:

  • AI生成内容
  • 可视化组件搭建
  • 企业级页面发布

当组件化、模板化和 AI 内容生成结合在一起时,活动页面的生产效率会得到非常明显的提升。


结尾

以上就是 RollCode 本次更新的主要内容。如果你正在做:企业活动页面、活动落地页、产品推广页

可以体验一下 RollCode。【传送门】

我是 安东尼,持续分享前端工程、AI工具与开发效率实践。欢迎关注我,一起做 前端周刊博主联盟AI工具实践

Vue3 + Element Plus 全局 Message、Notification 封装与规范|Vue生态精选

作者 SuperEugene
2026年3月10日 11:03

前端实战:Vue3 + Element Plus 全局 Message、Notification 封装教程,从概念区分、场景选择到统一错误处理、代码落地,一站式学会前端提示框封装,告别混乱代码与重复开发。

📑 文章目录


同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、我们为什么要封装?

很多同学会直接这样写:

// 散落在业务里的各种提示
this.$message.success('保存成功')
ElMessage.error('网络错误')
alert('操作失败')  // 甚至还有人用 alert

看起来能用,但会带来这些问题:

  • 提示风格不统一:有的用 Message,有的用 Notification,有的用 alert
  • 错误处理分散:每个接口各自 try-catch 各自 message
  • 难以维护:改文案、改样式、加埋点,要改很多地方
  • 用户体验差:错误提示不统一,成功/失败没规范

所以需要:把通知和消息系统统一封装,集中管理风格和错误处理

⬆ 返回目录

二、概念扫盲:Message / Notification / Toast 有啥区别?

类型 特点 典型场景
Message 轻量、短暂、通常居中或顶部,自动消失 操作结果反馈:保存成功、删除成功
Notification 带标题、正文,可带操作按钮,位置可配置 系统通知、任务完成、重要提示
Toast 和 Message 概念接近,有些库叫 Toast 同上,多用于移动端

可以简单记:Message 偏轻量,Notification 偏正式、信息更多。封装时建议:

  • 简单反馈 → Message
  • 需要标题、描述、操作 → Notification

⬆ 返回目录

三、典型使用场景

  1. 接口成功/失败:统一用 Message,成功/警告/错误三种类型
  2. 表单校验失败:一般用 Message,文案来自校验规则
  3. 全局错误:如 401、403、500 → 统一错误处理 + Message/Notification
  4. 长时间任务完成:如导出、报表生成 → 用 Notification 更合适
  5. 业务重要事件:如订单状态变更 → Notification + 操作入口

⬆ 返回目录

四、封装思路:三层结构

┌─────────────────────────────────────┐
│  业务层:直接调用 msg.success() 等  
├─────────────────────────────────────┤
│  封装层:msg / notify 统一入口      
│  - 统一风格                       
│  - 统一文案模板                   
│  - 统一埋点/日志                 
├─────────────────────────────────────┤
│  底层:Element Plus / Ant Design 等
└─────────────────────────────────────┘

业务层只调用封装好的 API,不直接接触 UI 库。

⬆ 返回目录

五、统一风格:主题、样式、交互

5.1 风格统一要管什么?

  • 类型:success / warning / error / info
  • 位置:如 Message 顶部居中,Notification 右上角
  • 持续时间:成功 2s,错误 4s 等
  • 样式:颜色、圆角、阴影等
  • 防重复:相同文案不重复弹

⬆ 返回目录

5.2 示例:统一配置

// src/utils/message.config.js

/**
 * Message 统一配置
 * 所有地方用 Message 时都走这套配置,保证风格一致
 */
export const MESSAGE_CONFIG = {
  duration: 2000,           // 默认 2 秒消失
  showClose: false,         // 不显示关闭按钮,靠自动消失
  center: true,             // 水平居中
  offset: 80,               // 距离顶部的距离
  grouping: true,           // 相同内容合并显示,避免刷屏
}

/**
 * 不同类型建议的 duration
 * 成功可以短一点,错误要留足阅读时间
 */
export const DURATION_BY_TYPE = {
  success: 2000,
  warning: 3000,
  error: 4000,
  info: 2500,
}

⬆ 返回目录

六、统一错误处理:拦截、提示、降级

6.1 核心思路

  • HTTP 拦截器:统一捕获 401、403、500 等
  • 业务错误码映射:后端错误码 → 前端文案
  • 兜底:网络异常、超时等给出通用提示

⬆ 返回目录

6.2 错误码与文案映射示例

// src/utils/errorCodeMap.js

/**
 * 后端错误码 → 前端展示文案
 * 避免把后端原始错误直接抛给用户
 */
export const ERROR_CODE_MAP = {
  401: '登录已过期,请重新登录',
  403: '没有权限执行此操作',
  404: '请求的资源不存在',
  500: '服务器异常,请稍后重试',
  10001: '参数错误',
  10002: '数据已存在',
  // ... 按你们项目补充
}

/**
 * 根据错误码获取友好提示
 */
export function getErrorMessage(code, defaultMsg = '操作失败,请稍后重试') {
  return ERROR_CODE_MAP[code] || defaultMsg
}

⬆ 返回目录

6.3 在 axios 里用

// src/api/request.js 示意

import axios from 'axios'
import { ElMessage } from 'element-plus'
import { getErrorMessage } from '@/utils/errorCodeMap'

const request = axios.create({
  baseURL: '/api',
  timeout: 10000,
})

// 响应拦截器:统一错误处理
request.interceptors.response.use(
  (response) => {
    const { code, data, message } = response.data
    // 假设业务成功是 code === 0
    if (code !== 0) {
      ElMessage.error(getErrorMessage(code, message))
      return Promise.reject(new Error(message))
    }
    return data
  },
  (error) => {
    if (error.response) {
      const { status } = error.response
      const msg = getErrorMessage(status)
      ElMessage.error(msg)
      // 401 可以在这里跳转登录
      if (status === 401) {
        // router.push('/login')
      }
    } else {
      ElMessage.error('网络异常,请检查网络后重试')
    }
    return Promise.reject(error)
  }
)

export default request

⬆ 返回目录

七、完整封装示例(Vue 3 + Element Plus)

7.1 封装文件结构

src/
├── utils/
│   ├── message.config.js    # 配置
│   ├── errorCodeMap.js      # 错误码映射
│   └── message.js           # 封装入口

⬆ 返回目录

7.2 封装实现

// src/utils/message.js

import { ElMessage, ElNotification } from 'element-plus'
import { MESSAGE_CONFIG, DURATION_BY_TYPE } from './message.config'
import { getErrorMessage } from './errorCodeMap'

/**
 * 全局 Message 封装
 * 统一风格、统一入口,方便以后替换 UI 库或加埋点
 */

function createMessage(type) {
  return (content, duration) => {
    ElMessage({
      ...MESSAGE_CONFIG,
      type,
      message: typeof content === 'string' ? content : content?.message || '操作成功',
      duration: duration ?? DURATION_BY_TYPE[type] ?? MESSAGE_CONFIG.duration,
    })
  }
}

// 对外暴露的 API
export const msg = {
  success: createMessage('success'),
  warning: createMessage('warning'),
  error: createMessage('error'),
  info: createMessage('info'),
}

/**
 * 全局 Notification 封装
 * 适合需要标题、描述、操作按钮的场景
 */
export const notify = {
  success(title, message, options = {}) {
    ElNotification({
      type: 'success',
      title: title || '成功',
      message: message || '',
      duration: 4000,
      position: 'top-right',
      ...options,
    })
  },
  error(title, message, options = {}) {
    ElNotification({
      type: 'error',
      title: title || '错误',
      message: message || '',
      duration: 5000,
      position: 'top-right',
      ...options,
    })
  },
  // warning、info 同理...
}

/**
 * 统一错误提示入口
 * 支持:错误码、Error 对象、字符串
 */
export function showError(error) {
  let message = '操作失败,请稍后重试'
  if (typeof error === 'number') {
    message = getErrorMessage(error)
  } else if (error?.message) {
    message = error.message
  } else if (typeof error === 'string') {
    message = error
  }
  msg.error(message)
}

⬆ 返回目录

7.3 业务里怎么用

// 业务组件里
import { msg, notify, showError } from '@/utils/message'

// 简单成功反馈
msg.success('保存成功')

// 接口失败时(如果拦截器没处理,可以手动调)
try {
  await saveData()
  msg.success('保存成功')
} catch (e) {
  showError(e)
}

// 重要通知
notify.success('导出完成', '您的报表已生成,请到下载中心查看')

⬆ 返回目录

7.4 全局挂载(可选)

// main.js
import { msg, notify, showError } from '@/utils/message'

app.config.globalProperties.$msg = msg
app.config.globalProperties.$notify = notify
app.config.globalProperties.$showError = showError

// 组件内:this.$msg.success('保存成功')

⬆ 返回目录

八、常见坑点与排查思路

8.1 同一个提示狂弹

  • 原因:接口失败在循环/频繁请求里被多次触发。
  • 做法:开启 grouping,或在封装层做「相同文案节流」。

⬆ 返回目录

8.2 样式跟项目不一致

  • 原因:直接用了 UI 库默认主题,或部分地方用内联样式覆盖。
  • 做法:所有 Message/Notification 都走封装层,在封装里统一传入配置,必要时用 CSS 变量或主题覆盖。

⬆ 返回目录

8.3 错误提示内容太“技术”

  • 原因:直接把后端 messageError 文本展示给用户。
  • 做法:用错误码映射表,把技术信息转成用户可读文案。

⬆ 返回目录

8.4 封装后换 UI 库很痛苦

  • 原因:业务里到处直接调用 ElMessageElNotification
  • 做法:业务只依赖 msgnotify,底层实现集中在 message.js,换库只改这一层。

⬆ 返回目录

8.5 在 setup 里没有 this

  • 做法:用 import { msg } from '@/utils/message' 直接引入,不依赖 this.$msg

⬆ 返回目录

九、实战规范总结

规范 说明
统一入口 只用 msg / notify,不直接调用 UI 库
统一风格 通过 message.config.js 统一 duration、位置、样式
统一错误处理 用错误码映射 + axios 拦截器,业务少写 try-catch
类型区分 简单反馈用 Message,复杂通知用 Notification
文案友好 错误码转成用户能看懂的话,不暴露技术细节
可扩展 封装层预留埋点、日志、国际化等扩展点

⬆ 返回目录

十、小结

封装全局 Message / Notification 的核心是:

  1. 统一入口:所有提示都从 msg / notify 走。
  2. 统一风格:配置集中管理,避免到处写死。
  3. 统一错误处理:拦截器 + 错误码映射,减少重复代码。
  4. 把用户当小白:错误文案要易懂,不吓人。

⬆ 返回目录


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

Electrobun 开发必看:CEF 依赖下载失败?手动解压一招搞定!

作者 梁三石
2026年3月10日 10:57

在使用 Electrobun + Bun + WebView2 构建跨平台桌面应用时,很多开发者(尤其是国内网络环境)在运行 bun dev 启动项目时,可能会遇到这样一个令人头大的报错:

$ electrobun dev
Using config file: electrobun.config.ts
Ensured launcher has .exe extension on Windows
CEF dependencies not found for win-x64, downloading...
Downloading CEF (attempt 1/3from: https://github.com/blackboardsh/electrobun/releases/download/v1.15.1/electrobun-cef-win-x64.tar.gz
Download attempt 1 failed: Unable to connect. Is the computer able to access the url?
Retrying in 2 seconds...
Downloading CEF (attempt 2/3from: https://github.com/blackboardsh/electrobun/releases/download/v1.15.1/electrobun-cef-win-x64.tar.gz
Download attempt 2 failed: Unable to connect. Is the computer able to access the url?
Retrying in 4 seconds...
Downloading CEF (attempt 3/3from: https://github.com/blackboardsh/electrobun/releases/download/v1.15.1/electrobun-cef-win-x64.tar.gz
Download attempt 3 failed: Unable to connect. Is the computer able to access the url?
Failed to download CEF dependencies for win-x64: Failed to download after 3 attempts: Unable to connect. Is the computer able to access the url?

Please ensure you have an internet connection and the release exists.
If the problem persists, try clearing the cache: rm -rf "E:\workspace\DesktopApp\electrobun\my-app\node_modules\electrobun"
error: script "dev" exited with code 1

错误原因:Electrobun 需要在首次运行时自动下载约 156MB 的 CEF (Chromium Embedded Framework) 依赖包。由于该资源托管在 GitHub Releases 上,受网络波动或防火墙影响,命令行工具往往无法直接连接下载,导致项目无法启动。

解决方案:无需等待重试,我们可以手动下载该依赖包,解压到指定目录,让 Electrobun 直接识别本地缓存,瞬间启动!

🛠️ 三步解决下载失败问题

第一步:手动下载 CEF 依赖包

既然命令行下载不动,我们就用浏览器或专业下载工具(如 IDM、迅雷)来下载。

  • 下载地址github.com/blackboards… (注:如果版本更新,请前往 Electrobun GitHub Release 页面查找最新的 electrobun-cef-win-x64.tar.gz )
  • 文件大小:约 156MB
  • 操作:复制链接到浏览器下载,确保文件完整。

第二步:解压并放入指定目录

下载完成后,我们需要将文件“归位”。Electrobun 会优先检查 node_modules 下的特定目录。

  1. 找到项目目录: 进入你的项目根目录,找到以下路径: 你的项目路径\node_modules\electrobun

  2. 创建/确认目标文件夹: 在该目录下,确保存在名为 dist-win-x64 的文件夹。

    路径示例:E:\workspace\DesktopApp\electrobun\my-app\node_modules\electrobun\dist-win-x64

  3. 解压文件: 使用解压软件(如 7-Zip, WinRAR)打开下载的 electrobun-cef-win-x64.tar.gz

    • 先解压出 .tar 文件。
    • 再解压 .tar 文件,将其中的所有文件和文件夹(包括 cef, bun.exe, launcher.exe, dll 文件等)直接复制到 dist-win-x64 目录中。

    ⚠️ 注意:确保 dist-win-x64 目录下直接包含 cef 文件夹和 launcher.exe 等核心文件,不要多套一层文件夹。 正确的目录结构应类似如下:

    node_modules/electrobun/dist-win-x64/
    ├── cef/                 <-- 核心 CEF 文件
    ├── bun.exe
    ├── launcher.exe
    ├── d3dcompiler_47.dll
    └── ... (其他 dll 和文件)
    
  4. cef的目录如下图所示

完整的目录

完整的目录

第三步:重新启动项目

完成上述操作后,再次回到项目根目录运行开发命令:

bun dev

此时,你会看到 Electrobun 不再尝试下载,而是直接识别到了本地文件:

$ electrobun dev
Using config file: electrobun.config.ts
Ensured launcher has .exe extension on Windows
CEF dependencies found for win-x64, using cached version
skipping codesign
skipping notarization
Attached to parent console
Child process spawned with PID anyopaque@d8
[LAUNCHER] Loaded identifier: dev.my.app, name: MyApp-dev, channel: dev
[LAUNCHER] Loading app code from flat files
[CEF] Created job object for process tracking
[CEF] Using path: C:\Users\love2\AppData\Local\dev.my.app\dev\CEF
[CEF] Applying user chromium flag: user-agent=My App/0.0.1(custom user agent)
Server started at http://localhost:50000
[Tue Mar 10 10:30:45 2026] setJSUtils called but using map-based approach instead of callbacks

DevTools listening on ws://127.0.0.1:9222/devtools/browser/e16f1c80-96ff-4094-9221-8b514696a1d4
[Tue Mar 10 10:30:46 2026] Custom class failedfalling back to STATIC class
DEBUGBrowserView constructor - no HTML provided for webview 1
setting webviewId:  1
WebView2Download handler registered successfully
[WebView2NavigationStarting fired for webview 1
[WebView2NavigationCompleted fired for webview 1
[Bridge:eventBridgeUnknown method DISPID=1 for webview 1
[Bridge:eventBridge] Received message for webview 1

🎉 恭喜!项目成功启动!


💡 原理解析

Electrobun 在启动时会执行以下逻辑:

  1. 检查 node_modules/electrobun/dist-win-x64 是否存在且包含必要的 CEF 文件。
  2. 如果存在,直接跳过下载步骤,使用本地缓存(这就是我们手动复制的目的)。
  3. 如果不存在,则尝试从 GitHub 下载。

通过手动预置文件,我们完美绕过了不稳定的网络环境,实现了“秒级”启动。


📝 常见问题 Q&A

Q: 如果升级了 Electrobun 版本怎么办? A: 如果大版本更新(例如 v1.15.1 -> v1.16.0),建议删除 dist-win-x64 目录,重新按照上述步骤下载对应新版本的 .tar.gz 包,以确保内核兼容性。

Q: Mac 或 Linux 用户需要这样做吗? A: 本教程主要针对 Windows (win-x64) 用户。Mac 和 Linux 对应的文件名分别为 electrobun-cef-darwin-x64.tar.gzlinux 版本,操作方法一致,只需修改目标文件夹名称(如 dist-darwin-x64)。

Q: 解压后还是报错怎么办? A: 请检查文件层级。很多时候是因为解压时多套了一层目录(例如 dist-win-x64/electrobun-cef-win-x64/cef)。请确保 cef 文件夹直接在 dist-win-x64 下。


🌟 结语

工欲善其事,必先利其器。在网络环境复杂的情况下,掌握手动管理依赖的技巧,能大大提升我们的开发效率。

希望这篇教程能帮你顺利跑通 Electrobun 项目!如果觉得有用,欢迎点赞、在看、转发,让更多开发者少走弯路!

👇 你在开发中还遇到过哪些奇怪的依赖下载问题?欢迎在评论区留言交流!

小白也能看懂:小程序 Canvas 给图片添加水印的终极指南

2026年3月10日 10:56

在小程序开发中,给用户拍摄的图片或上传的图片添加“自带信息”的水印(如:打卡时间、地点、防伪标识等)是一个非常普遍的需求。

如果你是 Canvas 相关的“小白”,一听到“图像处理”、“画布”就觉得头大,别慌!今天我们就用最通俗的语言和结构化的步骤,带你彻底搞懂如何在小程序中用 Canvas 给图片优雅地打上水印

💡 核心思路:像做手工一样加水印

给图片加水印,就像我们做手工一样,分四步走:

  1. 找相纸:你需要准备一个画布(Canvas)。
  2. 洗照片并贴满相纸:拿到原图,等比例贴在画布上。
  3. 贴胶布并写字:在相纸的某个角落,贴一块半透明的胶布,用白颜料在上面写上我们需要的水印信息。
  4. 重新拍张照:用相机把加工好的相纸拍下来,导出一张新的图片。

🛠️ 第一步:在页面里准备一块“隐形画布”

我们需要在前端模板里加上 <canvas> 标签。为了不影响页面的正常布局,我们通常会让它“默默在后台工作”(你可以通过样式把它移出屏幕外,或者利用 v-if 控制,但在小程序中建议给它动态设定尺寸)。

<!-- 这是一个通用的 Vue/uniapp/Taro 模板示例 -->
<template>
  <view class="container">
    <button @click="takePhoto">拍照并加水印</button>

    <!-- 用于展示最后效果的图片 -->
    <image v-if="imgWithWatermark" :src="imgWithWatermark" mode="widthFix" />

    <!-- 制作水印的画板 -->
    <canvas
      canvas-id="wmCanvas"
      class="watermark_canvas"
      :style="'width:' + canvasWidth + 'px;height:' + canvasHeight + 'px;'"
      :width="canvasWidth"
      :height="canvasHeight"
    ></canvas>
  </view>
</template>

📸 第二步:获取原图片并决定相纸大小

图片有大有小,如果画布(Canvas)写死了宽高,图片就会被拉伸或者裁剪。在真机上,太大的图片如果没有控制尺寸,甚至会导致只渲染左上角。

所以我们先用 wx.getImageInfo 读取真实宽高,缩放控制在安全范围内。

// 选择照片
const takePhoto = () => {
  wx.chooseMedia({
    count: 1,
    mediaType: ["image"],
    sourceType: ["camera", "album"],
    sizeType: ["compressed"],
    success: (res) => {
      const tempFilePath = res.tempFiles[0].tempFilePath;
      doWatermark(tempFilePath);
    },
  });
};

// 开始水印处理
const doWatermark = (imgPath) => {
  // 准备要打的水印文案
  const watermarkText = [
    `打卡人:张三`,
    `📍 地点:科技园某某大厦`,
    `⏰ 时间:2024-10-01 12:00:00`,
    `仅供学习交流使用`,
  ];

  wx.getImageInfo({
    src: imgPath,
    success: (imgInfo) => {
      // 【控制尺寸与比例缩放详解】
      // 1. 设定最大边长限制
      // 为什么是 1280?在很多旧款手机或微信小程序的底层实现中,Canvas 绘制过大的图片(比如 4K 分辨率的照片)
      // 极易导致内存溢出闪退,或者只绘制出图片的左上角。1280 是一个兼顾清晰度和性能的经典安全值。
      const maxSide = 1280;

      // 2. 计算缩放比例 (ratio)
      // Math.max(imgInfo.width, imgInfo.height):找出原照片较长的那一边(宽或长)。
      // maxSide / Math.max(...):算出如果要让最长边变成 1280,需要缩小多少倍。
      // Math.min(1, ...):如果原图本身比 1280 还小,算出来的比例会大于 1。
      // 这个 min(1) 确保了:对于本来就小的图片,我们保持原大小(不拉伸放大导致模糊);只有超大图才会被缩小。
      const ratio = Math.min(
        1,
        maxSide / Math.max(imgInfo.width, imgInfo.height),
      );

      // 3. 算出最终要绘制在 Canvas 上的实际宽和高
      // 原宽 x 缩放比例 = 实际绘制宽度。
      // Math.round:四舍五入取整,因为 Canvas 画布的像素长宽最好是整数,不能是小数(比如 800.5px)。
      // Math.max(1, ...):极端防御性编程,防止图片极度长条化导致算出来的高度等于 0 像素。最少也要保证 1 像素。
      const drawWidth = Math.max(1, Math.round(imgInfo.width * ratio));
      const drawHeight = Math.max(1, Math.round(imgInfo.height * ratio));

      // 更新画布大小到 vue/data 中
      this.canvasWidth = drawWidth;
      this.canvasHeight = drawHeight;

      // 我们等画布尺寸在页面上生效后,再开始画画
      this.$nextTick(() => {
        drawCanvas(imgPath, drawWidth, drawHeight, watermarkText);
      });
    },
  });
};

🎨 第三步:拿起画笔,开始绘制

画布大小定好了,我们开始调用 Canvas API 制图。为了保证文字在任何背景下都能看清,我们会先画一个半透明的黑色背景框,再在上面写白色文字。

const drawCanvas = (imgPath, drawWidth, drawHeight, lines) => {
  // ⚠️ 避坑:真机上稍微延迟一下,确保 canvas 的宽高渲染完毕,否则可能出现大面积留白
  setTimeout(() => {
    // 【获取画布的画笔 (Context)】
    // wx.createCanvasContext 是小程序专门用来获取 Canvas 绘图上下文的 API。
    // 你可以把它理解为:我们找到了页面上 id="wmCanvas" 的那块相纸(Canvas 标签),
    // 并且向系统申请了一支全能的“智能画笔” ctx。
    // 接下来的 ctx.drawImage、ctx.setFillStyle 等操作,都是这支画笔在画布上工作。
    // 第二个参数 `this` 在 Vue/组件环境里必传,它告诉系统去当前组件的作用域里找这个 Canvas 标签,不然可能找不到。
    const ctx = wx.createCanvasContext("wmCanvas", this);

    // 【动态计算文字排版与尺寸详解】
    // 为什么不直接写死 fontSize = 16 呢?
    // 因为前面的代码对超大图片进行了等比例缩小,如果图片被缩小得很厉害,写死的 16 号字可能会显得太大;
    // 反之,如果用户传了一张很小的图(没被缩小),16 号字可能会显得像芝麻一样小。
    // 所以,这里我们要让字体大小“跟着画布宽度走”,保持一个稳定的视觉观感比例。

    // 1. 计算基准字号 (fontSize)
    // drawWidth * 0.038:规定字号大概占整个画板宽度的 3.8% 左右,这是一个看着比较舒服的比例。
    // Math.max(16, ...):防御性限制,就算图片再小,字号也不能小于 16px,否则人眼就看不清了。
    const fontSize = Math.max(16, Math.round(drawWidth * 0.038));

    // 2. 计算行高 (lineHeight) 和 各种边距 (Padding)
    // 行高设定为字号的 1.5 倍,这是业内长文本排版最常用的黄金阅读间距。
    const lineHeight = Math.round(fontSize * 1.5);
    // textPadding:文字距离黑框左侧边缘的留白宽度。
    const textPadding = Math.round(fontSize * 0.8);
    // boxPadding:黑框上下的留白宽度,以及黑框距离图片最底部的安全距离。
    const boxPadding = Math.round(fontSize * 0.9);

    // 3. 计算半透明黑框的整体高和宽
    // 高度 (boxHeight) = 上下留白的 Padding × 2 + 每一行字的高度 × 总行数。这样黑框就能完美包裹住所有文字内容了。
    const boxHeight = boxPadding * 2 + lineHeight * lines.length;
    // 宽度 (boxWidth) = 画板宽度的 92%。给黑框左右各留出 4% 的空隙,不至于让黑框死板地顶到图片最边缘。
    const boxWidth = Math.round(drawWidth * 0.92);

    // 【计算黑框在画板上的绝对坐标位置】
    // 在 Canvas 里,画任何东西都需要用坐标 (x, y) 来定位,原点 (0, 0) 在左上角。

    // 1. 水平居中 (boxX)
    // 整体宽度减去黑框宽度,剩下的是左右两边的总空白。除以 2,就是左边需要预留的 X 坐标偏移量。
    // 例如:(1000 - 920) / 2 = 40。那么只要从 x=40 开始画框,右边肯定也会正好剩下 40,完美居中!
    const boxX = Math.round((drawWidth - boxWidth) / 2);

    // 2. 贴近底部 (boxY)
    // drawHeight 顾名思义是最底部的 Y 坐标。
    // 减去整个黑框的高度,意味着把框“托”上来了;然后再减去 boxPadding(预留的安全边距),
    // 意味着黑框不会死死贴着图片的下边沿,而是往上方悬浮了一段距离,显得更有呼吸感。
    const boxY = drawHeight - boxHeight - boxPadding;

    // 【步骤 1:把原图片画满整个 Canvas 相纸】
    // ctx.drawImage(图片路径, X轴起始位, Y轴起始位, 指定绘制宽度, 指定绘制高度)
    // 这里的 0, 0 表示从相纸的绝对左上角开始贴图,占满我们计算好的 drawWidth 和 drawHeight。
    ctx.drawImage(imgPath, 0, 0, drawWidth, drawHeight);

    // 【步骤 2:画一个半透明的黑色背景框】
    // 为什么要有这个黑框?因为用户的图片可能是纯白的,如果上面的字体也是白色的,水印就会完全看不见!
    // 垫一层 30% 透明度 (0.3) 的黑底,任何背景下都能看清白字,这是一个极佳的用户体验细节。
    ctx.setFillStyle("rgba(0, 0, 0, 0.3)"); // 把画笔沾上这种半透明黑色颜料

    // ctx.fillRect(X位置, Y位置, 矩形宽度, 矩形高度);
    // 拿着黑笔,在前面算好的坐标 (boxX, boxY) 处,画一个实心的长方形。
    ctx.fillRect(boxX, boxY, boxWidth, boxHeight);

    // 【步骤 3:准备写字】
    // 换一把纯白色的笔,设置好拿捏得死死的字号大小。
    ctx.setFillStyle("#ffffff");
    ctx.setFontSize(fontSize);

    // 【步骤 4:循环把每一行文字写上去】
    lines.forEach((line, index) => {
      // ⚠️ 极其关键的一步:计算文字的真实 Y 坐标!
      // 很多人画图发现字挤在一起或者偏上/偏下,就是这里没算对。
      // 在 Canvas 里,文字默认是“基于底部基线(Baseline)”对齐的,非常难受。

      // 我们来一步步拆解这行巨长公式:
      // (1) boxY + boxPadding:这是黑框内部,最顶部的可写字区域。
      // (2) lineHeight * (index + 1):第一行 index=0 (行高x1),第二行 index=1 (行高x2)。意思是每换一行,就往下挪一行的距离。
      // (3) - (lineHeight - fontSize) / 2:微调!因为行间距往往大于字号本身(比如字高 16,行距占位 24)。
      //     多出来的 8px 需要平均分摊到文字的上下,这样文字在每一“行”里才能绝对垂直居中!
      const textY =
        boxY +
        boxPadding +
        lineHeight * (index + 1) -
        (lineHeight - fontSize) / 2;

      // ctx.fillText(文本内容, X坐标开始位置, Y坐标开始位置)
      // 在黑框左边缘 (boxX) 加上我们预留好的留白 (textPadding) 处下笔。
      ctx.fillText(line, boxX + textPadding, textY);
    });

    // 【步骤 5:发号施令,让画笔真正干活】
    // ctx.draw(boolean 是否保留上次绘制, 回调函数)
    // 前面写的 drawImage, fillRect 等全都是在“打草稿记录指令”,并不会真正显示出来。
    // 只有调用了 ctx.draw(),系统才会“刷”地一下把所有步骤画到 Canvas 上!
    // false 表示:每次都擦干净黑板重新画,不要保留之前旧的斑马线。
    // 回调函数 () => {}:画完了之后要干嘛?当然是通知下一步(导出图片)啦!
    ctx.draw(false, () => {
      exportImage(drawWidth, drawHeight);
    });
  }, 100);
};

📤 第四步:快照导出,大功告成

最后一步,在 ctx.draw 的回调里,用 wx.canvasToTempFilePath 给这个画布拍个照,生成一张全新的图片路径!

const exportImage = (drawWidth, drawHeight) => {
  wx.canvasToTempFilePath(
    {
      canvasId: "wmCanvas",
      x: 0,
      y: 0,
      width: drawWidth,
      height: drawHeight,
      destWidth: drawWidth,
      destHeight: drawHeight,
      fileType: "jpg", // jpg 比 png 体积小
      quality: 0.9, // 控制一下质量,兼顾清晰与体积
      success: (res) => {
        // 这里就拿到了最终带有水印的图片路径!
        this.imgWithWatermark = res.tempFilePath;
        wx.showToast({ title: "水印添加成功", icon: "success" });
      },
      fail: (err) => {
        console.error(err);
        wx.showToast({ title: "水印生成失败", icon: "none" });
      },
    },
    this,
  );
};

🎁 完整可用代码

为了方便你直接参考,这里提供一个合并后的通用的 Vue 小程序组件(基于 Taro / uniapp 等跨端框架兼容语法):

<template>
  <view class="watermark-page">
    <view class="btn-wrap">
      <button @tap="takePhoto" type="primary">拍摄并生成水印图</button>
    </view>

    <view class="preview" v-if="imgWithWatermark">
      <view class="title">最终效果图:</view>
      <image class="result-img" mode="widthFix" :src="imgWithWatermark"></image>
    </view>

    <!-- 隐藏在视区之外的画布 -->
    <canvas
      canvas-id="wmCanvas"
      class="watermark-canvas"
      :style="'width:' + canvasWidth + 'px;height:' + canvasHeight + 'px;'"
      :width="canvasWidth"
      :height="canvasHeight"
    ></canvas>
  </view>
</template>

<script>
  export default {
    data() {
      return {
        imgWithWatermark: "",
        canvasWidth: 300,
        canvasHeight: 300,
      };
    },
    methods: {
      takePhoto() {
        wx.chooseMedia({
          count: 1,
          mediaType: ["image"],
          sourceType: ["camera", "album"],
          sizeType: ["compressed"],
          success: (res) => {
            const tempFile = res.tempFiles[0];
            this.doWatermark(tempFile.tempFilePath);
          },
        });
      },

      // 获取当前时间的格式化字符串
      formatCurrentTime() {
        const d = new Date();
        const p = (num) => num.toString().padStart(2, "0");
        return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
      },

      doWatermark(imgPath) {
        // 通用的配置数据
        const lines = [
          `拍摄人:李开发者`,
          `当前项目:前端 Canvas 研究`,
          `拍摄时间:${this.formatCurrentTime()}`,
          `未经允许,严禁盗图验证`,
        ];

        wx.getImageInfo({
          src: imgPath,
          success: (imgInfo) => {
            // 控制极限大小,防止真机崩溃
            const maxSide = 1200;
            const ratio = Math.min(
              1,
              maxSide / Math.max(imgInfo.width, imgInfo.height),
            );
            const drawWidth = Math.max(1, Math.round(imgInfo.width * ratio));
            const drawHeight = Math.max(1, Math.round(imgInfo.height * ratio));

            this.canvasWidth = drawWidth;
            this.canvasHeight = drawHeight;

            this.$nextTick(() => {
              // 延迟等待 Canvas DOM 渲染宽高完毕
              setTimeout(() => {
                const ctx = wx.createCanvasContext("wmCanvas", this);

                // 动态计算间距与字号
                const fontSize = Math.max(14, Math.round(drawWidth * 0.038));
                const lineHeight = Math.round(fontSize * 1.5);
                const textPadding = Math.round(fontSize * 0.8);
                const boxPadding = Math.round(fontSize * 0.9);
                const boxHeight = boxPadding * 2 + lineHeight * lines.length;
                const boxWidth = Math.round(drawWidth * 0.92);
                const boxX = Math.round((drawWidth - boxWidth) / 2);
                const boxY = drawHeight - boxHeight - boxPadding;

                // 铺底图
                ctx.drawImage(imgInfo.path, 0, 0, drawWidth, drawHeight);

                // 画黑底半透明背景
                ctx.setFillStyle("rgba(0, 0, 0, 0.25)");
                ctx.fillRect(boxX, boxY, boxWidth, boxHeight);

                // 准备写字
                ctx.setFillStyle("#ffffff");
                ctx.setFontSize(fontSize);

                lines.forEach((line, index) => {
                  const textY =
                    boxY +
                    boxPadding +
                    lineHeight * (index + 1) -
                    (lineHeight - fontSize) / 2;
                  ctx.fillText(line, boxX + textPadding, textY);
                });

                ctx.draw(false, () => {
                  wx.canvasToTempFilePath(
                    {
                      canvasId: "wmCanvas",
                      x: 0,
                      y: 0,
                      width: drawWidth,
                      height: drawHeight,
                      destWidth: drawWidth,
                      destHeight: drawHeight,
                      fileType: "jpg",
                      quality: 0.9,
                      success: (res) => {
                        this.imgWithWatermark = res.tempFilePath;
                      },
                      fail: (err) => {
                        console.error("生成失败", err);
                      },
                    },
                    this,
                  );
                });
              }, 100);
            });
          },
        });
      },
    },
  };
</script>

<style>
  .watermark-page {
    padding: 20px;
  }
  .btn-wrap {
    margin-bottom: 20px;
  }
  .result-img {
    width: 100%;
    margin-top: 10px;
    border-radius: 8px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  }
  .title {
    font-size: 16px;
    font-weight: bold;
    color: #333;
  }
  /* 最关键的一步!把 canvas 定位到屏幕外,或者通过透明度隐藏,避免干扰页面布局 */
  .watermark-canvas {
    position: fixed;
    top: -9999px;
    left: -9999px;
    opacity: 0;
  }
</style>

vue中怎么监测一个div的宽度变化

2026年3月10日 10:55

在 Vue 中监测一个 div 的宽度变化,可以使用以下几种方法,主要结合 ResizeObserver 或其他方式来实现动态监听。以下是具体实现方案:

方法 1:使用 ResizeObserver

ResizeObserver 是现代浏览器提供的 API,专门用于监听元素尺寸变化。它性能高效,适合动态监测 div 的宽度变化。

<template>
  <div ref="targetDiv" class="target-div">
    这是一个可调整大小的 div
  </div>
</template>

<script>
export default {
  data() {
    return {
      divWidth: 0,
    };
  },
  mounted() {
    // 创建 ResizeObserver 实例
    const observer = new ResizeObserver((entries) => {
      for (let entry of entries) {
        // 获取 div 的宽度
        this.divWidth = entry.contentRect.width;
        console.log('Div 宽度变化:', this.divWidth);
      }
    });

    // 监听目标 div
    observer.observe(this.$refs.targetDiv);
    
    // 组件销毁时清理 observer
    this.$on('hook:beforeDestroy', () => {
      observer.disconnect();
    });
  },
};
</script>

<style>
.target-div {
  width: 200px;
  height: 100px;
  background: lightblue;
  resize: horizontal; /* 允许水平拖动调整大小 */
  overflow: auto;
}
</style>

说明

  • ResizeObserver 会在 div 尺寸变化时触发回调,获取最新的宽度。
  • 使用 this.$refs.targetDiv 获取 DOM 元素。
  • 在组件销毁时调用 observer.disconnect() 清理监听,避免内存泄漏。
  • resize: horizontal 是 CSS 属性,方便测试宽度调整(需要配合 overflow: auto)。

方法 2:结合 Vue 的 watch 监听动态宽度

如果 div 的宽度是由响应式数据(如 style 或计算属性)控制的,可以通过 watch 监听相关数据的变化。

<template>
  <div :style="{ width: divWidth + 'px' }" class="target-div">
    宽度: {{ divWidth }}px
  </div>
</template>

<script>
export default {
  data() {
    return {
      divWidth: 200,
    };
  },
  watch: {
    divWidth(newWidth) {
      console.log('Div 宽度变化:', newWidth);
    },
  },
};
</script>

<style>
.target-div {
  height: 100px;
  background: lightcoral;
}
</style>

说明

  • 适用于宽度由 Vue 响应式数据驱动的场景。
  • 如果宽度变化是由外部(如用户拖动或 CSS)引起的,这种方法不适用。

方法 3:使用 window resize 事件(间接监测)

如果 div 的宽度变化与窗口大小相关(例如百分比宽度),可以监听 windowresize 事件。

<template>
  <div ref="targetDiv" class="target-div">
    这是一个宽度随窗口变化的 div
  </div>
</template>

<script>
export default {
  data() {
    return {
      divWidth: 0,
    };
  },
  methods: {
    updateWidth() {
      this.divWidth = this.$refs.targetDiv.offsetWidth;
      console.log('Div 宽度:', this.divWidth);
    },
  },
  mounted() {
    this.updateWidth(); // 初始化宽度
    window.addEventListener('resize', this.updateWidth);
    
    // 清理事件监听
    this.$on('hook:beforeDestroy', () => {
      window.removeEventListener('resize', this.updateWidth);
    });
  },
};
</script>

<style>
.target-div {
  width: 50%; /* 宽度随窗口变化 */
  height: 100px;
  background: lightgreen;
}
</style>

说明

  • 适合 div 宽度依赖窗口大小的场景(如 width: 50%)。
  • 使用 offsetWidth 获取 div 的实际宽度。
  • 注意清理事件监听以防止内存泄漏。

方法 4:使用第三方库(如 element-resize-detector)

如果需要兼容旧浏览器或更复杂的场景,可以使用第三方库如 element-resize-detector

  1. 安装库:

    npm install element-resize-detector
    
  2. 在 Vue 组件中使用:

<template>
  <div ref="targetDiv" class="target-div">
    这是一个可调整大小的 div
  </div>
</template>

<script>
import elementResizeDetectorMaker from 'element-resize-detector';

export default {
  data() {
    return {
      divWidth: 0,
    };
  },
  mounted() {
    const erd = elementResizeDetectorMaker();
    erd.listenTo(this.$refs.targetDiv, (element) => {
      this.divWidth = element.offsetWidth;
      console.log('Div 宽度变化:', this.divWidth);
    });

    // 清理监听
    this.$on('hook:beforeDestroy', () => {
      erd.removeAllListeners(this.$refs.targetDiv);
    });
  },
};
</script>

<style>
.target-div {
  width: 200px;
  height: 100px;
  background: lightyellow;
  resize: horizontal;
  overflow: auto;
}
</style>

说明

  • element-resize-detector 提供了跨浏览器兼容的尺寸变化监听。
  • 适合不支持 ResizeObserver 的旧浏览器。

推荐方案

  • 首选 ResizeObserver:现代、性能高、代码简洁,适合大多数场景。
  • 如果 div 宽度由响应式数据控制,使用 watch
  • 如果宽度与窗口大小相关,使用 window resize 事件。
  • 如果需要兼容旧浏览器,考虑 element-resize-detector

注意事项

  1. 性能:避免在大量元素上绑定监听,可能导致性能问题。
  2. 清理:总是清理 ResizeObserver、事件监听或第三方库的绑定,防止内存泄漏。
  3. 浏览器兼容性ResizeObserver 在现代浏览器(Chrome 64+、Firefox 69+ 等)支持良好,旧浏览器需 polyfill 或使用第三方库。

OpenClaw Skills 进阶实战:前端开发者的 AI 技能库搭建指南

作者 FE_winter
2026年3月10日 10:54

OpenClaw Skills 进阶实战:前端开发者的 AI 技能库搭建指南

ChatGPT Image Mar 10, 2026, 10_48_28 AM.png 部署好 OpenClaw 后,很多人会发现它还只是个“聊天机器”。 其实,OpenClaw 真正强大的地方在于 Skills 生态 —— 通过不同技能插件,你的 AI 助手可以具备代码生成、UI 设计、性能优化、调试排错等前端开发能力。

本文不重复基础配置,而是聚焦: 如何针对前端开发场景,构建真正有用的技能矩阵。


一、按需构建:前端开发者的 Skills 选择策略

不要看到什么 Skill 都想安装。更好的方式是: 根据技术栈和业务场景,按需选择。

不同技术栈对应的 Skills 组合

技术栈 推荐 Skills 组合
React 全栈开发 React + Frontend Design + UI/UX Pro Max + Zustand Patterns
Vue 开发 Vue + Component Api Design + Frontend Design
移动端开发 React Native Skills + Radon AI
UI/UX 设计 UI/UX Pro Max + UI Audit + Frontend Design Extractor
性能优化 Frontend Performance + Browser Devtools Inspector

二、Skills 安装全攻略

万事开头难,很多人一听到要配置 Skills 就头大。其实 OpenClaw 提供了多种安装方式,总有一款适合你。

方法一:使用 OpenClaw 自带的 53 个 Skills

OpenClaw 内置了一批基础 Skills,包含飞书、Discord、ClawHub 等常用能力:

# 列出所有技能
openclaw skills list

# 查看当前可用的 skills
openclaw skills list --eligible

# 查看技能详细信息(技能介绍、技能细节、必备库)
openclaw skills info <技能名称>

# 启用技能
openclaw skills enable <技能名称>

# 禁用技能
openclaw skills disable <技能名称>

# 检查技能状态
openclaw skills check <技能名称>

方法二:ClawHub 安装(推荐)

ClawHub 是 OpenClaw 官方维护的 Skills 注册中心,目前已有 17000+ Skills,是最推荐的安装方式。

# 使用 npm 安装
npm i -g clawhub

# 或使用 pnpm 安装
pnpm add -g clawhub

安装完成后,管理 Skills 非常简单:

# 搜索技能
clawhub search "react"

# 安装技能
clawhub install <skill-slug>
clawhub install <skill-slug> --version <版本号> # 安装指定版本
clawhub install <skill-slug> --force # 强制覆盖已存在文件夹

# 更新技能
clawhub update <skill-slug> # 更新单个技能
clawhub update --all # 更新所有已安装技能

# 查看已安装技能
clawhub list

方法三:GitHub 手动安装

对于 GitHub 上直接托管的 Skills,可以手动克隆到本地:

# 进入到工作区的 Skills 文件夹下
cd ~/.openclaw/workspace/skills

# 克隆技能仓库到本地
git clone https://github.com/BankrBot/openclaw-skills.git ./skills

方法四:直接对话安装

最简单的方式——直接告诉 OpenClaw 你要安装什么:

请帮我安装这个 skills,github 链接是 xxxx

这种方式对新手最友好,无需记忆任何命令。

安装后的安全检查

在安装任何第三方 Skills 之前,安全必须是第一优先级:

Skill-Vetter —— 安装任何 Skills 之前,用它扫描检测恶意代码:

# 安装
clawhub install skill-vetter

# 使用
skill-vetter <skill-name>

三、2026 年最热门的 OpenClaw Skills 推荐

在深入前端专项技能之前,先看 OpenClaw 社区中最受欢迎、下载量最高的技能。这些技能经过大量用户验证,安全性和实用性都更有保障。

🛡️ 安全第一:必装安全工具

⚠️ 重要提醒:在安装任何第三方 Skills 之前,务必先安装这两个安全工具!

  1. Skill Vetter(3.5K 下载) — 技能安全审查工具
clawhub install skill-vetter
skill-vetter <skill-name>
  1. Link Checker(2.1K 下载) — URL 安全和钓鱼检测
clawhub install link-checker

🏆 前 5 个必装技能(零风险,超高下载量)

  1. Gog(33.8K 下载) — Google 全家桶集成 一次性接入 Gmail、Calendar、Drive、Docs、Sheets、Contacts 等服务。
clawhub install gog
  1. self-improving-agent(32K 下载,338 星) — 自我改进代理
clawhub install self-improving-agent
  1. Summarize(26.1K 下载) — 全能内容总结工具 支持 URL、PDF、图片、音频、YouTube 视频等。
clawhub install summarize
  1. Github(24.8K 下载) — GitHub CLI 集成 管理 issues、PR、CI 运行。
clawhub install github
  1. Weather(21.1K 下载) — 天气查询
clawhub install weather

🍎 macOS 用户专属(零配置,原生集成)

# Apple Notes(6.5K 下载)
clawhub install apple-notes

# Apple Reminders(5.8K 下载)
clawhub install apple-reminders

# Apple Calendar(4.4K 下载)
clawhub install apple-calendar

# Apple Shortcuts(5.9K 下载)
clawhub install apple-shortcuts

# iMessage(3.5K 下载)
clawhub install imessage

🔍 搜索和研究工具

# Tavily Web Search(28K 下载)
clawhub install tavily-web-search

# Brave Search(10.4K 下载)
clawhub install brave-search

# Multi Search Engine(4.5K 下载)
clawhub install multi-search-engine

📊 生产力和知识管理

# Ontology(27.6K 下载)
clawhub install ontology

# Notion(13.9K 下载)
clawhub install notion

# Obsidian(12.4K 下载)
clawhub install obsidian

💻 通信工具

# Himalaya(9.2K 下载)
clawhub install himalaya

# Slack(8.8K 下载)
clawhub install slack

# Discord(6.6K 下载)
clawhub install discord

# Signal(5.7K 下载)
clawhub install signal

✍️ 媒体和内容创作

# Nano Banana Pro(13.4K 下载)
clawhub install nano-banana-pro

# OpenAI Whisper(11.5K 下载)
clawhub install openai-whisper

# YouTube Watcher(9.1K 下载)
clawhub install youtube-watcher

💻 开发工具(通用)

# API Gateway(13K 下载)
clawhub install api-gateway

# Mcporter(11.1K 下载)
clawhub install mcporter

# Commit Message(3K 下载)
clawhub install commit-message

🤖 AI 和代理增强

# Free Ride(11.3K 下载)
clawhub install free-ride

# Model Usage(8.3K 下载)
clawhub install model-usage

# Oracle(3.3K 下载)
clawhub install oracle

🏠 智能家居

# Sonos CLI(20.2K 下载)
clawhub install sonos-cli

# Home Assistant(6.1K 下载)
clawhub install home-assistant

🚀 推荐安装顺序

  1. 先装安全工具:Skill Vetter + Link Checker
  2. 再装前 5 必装:Gog + self-improving-agent + Summarize + Github + Weather
  3. 根据平台选择:macOS 用户装 Apple 原生套件
  4. 按需添加:根据工作流扩展其他技能

四、前端开发专项 Skills 推荐

💡 强烈建议:先完成上一章节的安全工具和基础技能安装,再继续安装前端专项技能。

1)React 全栈开发

React(React 19、Server Components、Hooks、性能优化、测试部署)

clawhub install react

地址:clawhub.ai/ivangdavila…

React Production Engineering(生产级 React 方法论)

clawhub install react-production

地址:clawhub.ai/1kalin/afre…

React Component Generator(组件模板生成,TS/Hooks)

clawhub install react-component-generator

地址:clawhub.ai/Sunshine-de…

Zustand Patterns(状态管理实战模式)

clawhub install zustand-patterns

地址:clawhub.ai/bingfoon/zu…


2)UI/UX 设计相关(强烈推荐)

Canvas Design(Logo 与视觉方案)
npx skills add https://github.com/anthropics/skills --skill canvas-design --agent claude-code -y

特点:可从理念沟通到视觉产出,支持 PNG/SVG 与多尺寸布局。

UI/UX Pro Max(多技术栈 UI/UX 设计助手)

clawhub install ui-ux-pro-max

地址:clawhub.ai/xobi667/ui-…

UI/UX Design Guide(移动优先 + WCAG 2.2)

clawhub install ui-ux-design

地址:clawhub.ai/itsjustdri/…

Frontend Design(React/Next/Tailwind 生产级界面)

clawhub install frontend

地址:clawhub.ai/ivangdavila…

UI Audit(基于可用性原则的自动审计)

clawhub install ui-audit

地址:clawhub.ai/tommygeoco/…


3)性能优化

Frontend Performance(LCP/FCP/CLS/Bundle 分析)

clawhub install frontend-performance

地址:clawhub.ai/wangzhiming…

Browser Devtools Inspector(Console/Network/Performance 调试)

clawhub install qtada-browser-devtools-inspector

地址:clawhub.ai/QtadaGM/qta…


4)组件库相关

Ant Design Skill

clawhub install ant-design-skill

地址:clawhub.ai/FelipeOFF/a…

Component Api Design

clawhub install component-api-design

地址:clawhub.ai/wangzhiming…


5)移动端开发

React Native Skills

clawhub install vercel-react-native-skills

地址:clawhub.ai/xaiohuangni…

Radon AI

clawhub install radon-ai

地址:clawhub.ai/latekvo/rad…


五、重头戏:如何自定义开发一个 Skill

官方 Skills 再多,也不可能覆盖所有场景。此时你需要自定义 Skill。

5.1 Skill 基本结构

my-custom-skill/
├── SKILL.md # 元信息和使用说明
├── skill.json # 配置文件
├── main.py # 主逻辑(或其他语言)
└── requirements.txt # 依赖列表

5.2 示例:快速创建一个前端组件生成 Skill

第一步:创建 SKILL.md
---
name: my-component-generator
description: 自定义前端组件生成器
---

# My Component Generator

用于快速生成前端组件代码。

## 使用方法

`gen component [组件名] [类型]` - 生成指定类型的组件

示例:
- `gen component Button primary` - 生成主按钮组件
- `gen component Card dark` - 生成暗色卡片组件
第二步:编写 skill.json
{
"name": "my-component-generator",
"version": "1.0.0",
"description": "自定义前端组件生成器",
"entry": "main.py",
"dependencies": ["jinja2"]
}
第三步:编写 main.py
import json
from jinja2 import Template

BUTTON_TEMPLATE = '''
import React from 'react';
import './{{ name }}.css';

interface {{ name }}Props {
variant?: 'primary' | 'secondary' | 'ghost';
onClick?: () => void;
children: React.ReactNode;
}

export const {{ name }}: React.FC<{{ name }}Props> = ({
variant = 'primary',
onClick,
children
}) => {
return (
<button className={`btn btn-${variant}`} onClick={onClick}>
{children}
</button>
);
};
'''

CARD_TEMPLATE = '''
import React from 'react';
import './{{ name }}.css';

interface {{ name }}Props {
title: string;
content?: string;
variant?: 'light' | 'dark';
}

export const {{ name }}: React.FC<{{ name }}Props> = ({
title,
content,
variant = 'light'
}) => {
return (
<div className={`card card-${variant}`}>
<h3 className="card-title">{title}</h3>
{content && <p className="card-content">{content}</p>}
</div>
);
};
'''

def handle(request):
message = request.get("message", "").lower()
parts = message.split()

if len(parts) < 4 or parts[0] != "gen" or parts[1] != "component":
return {
"status": "error",
"message": "请使用格式:gen component [组件名] [类型]\\n例如:gen component Button primary"
}

component_name = parts[2]
component_type = parts[3]

templates = {
"button": BUTTON_TEMPLATE,
"card": CARD_TEMPLATE
}

template_key = component_type if component_type in templates else "button"
template = Template(templates[template_key])
code = template.render(name=component_name)

return {
"status": "success",
"message": f"生成的 {component_name} 组件代码:\\n```tsx\\n{code}\\n```"
}

if __name__ == "__main__":
test_request = {"message": "gen component MyButton primary"}
print(handle(test_request))

5.3 Skill 的触发机制(关键点)

  • 明确触发词:在 SKILL.md 中清晰标注命令格式
  • 参数解析健壮:兼容用户不同表达
  • 错误提示友好:给出可执行示例而不是仅报错

5.4 发布你的 Skill

  • 提交到 ClawHub
  • 发布 GitHub 仓库(符合目录结构)
  • 对话式分享安装:“请帮我安装这个 skills,github 链接是 xxx”

六、进阶技巧:前端 Skills 组合使用

单个 Skill 能力有限,但组合后会有乘法效应。

示例 1:自动化组件开发工作流

用户输入:帮我创建一个用户列表页面

流程:

  1. UI/UX Pro Max:确定页面布局和视觉风格
  2. React:生成列表组件代码
  3. Frontend Performance:性能检查
  4. UI Audit:交互和可用性审核

示例 2:技术调研自动化

用户输入:调研 React 19 的 Server Actions

流程:

  1. GitHub:获取官方文档/RFC
  2. multi-search-engine:汇总社区讨论
  3. playwright-scraper-skill:抓取关键页面细节
  4. Summarize:生成结构化调研报告

七、避坑指南

  1. 不要安装来源不明的 Skills(先用 skill-vetter 扫描)
  2. 定期更新(更新前做测试,不要生产环境裸更)
  3. 注意 API 配额(很多技能依赖第三方额度)
  4. 谨慎处理敏感信息(API Key 等)
  5. 新技能先在测试环境验证,再进核心流程

八、更多前端 Skills 资源

其他常用检索/效率类 Skills:

# 网页检索
clawhub install multi-search-engine
clawhub install agent-reach

# 代码调试
clawhub install playwright-scraper-skill

# 内容处理
clawhub install summarize
clawhub install humanizer

# 自我学习
clawhub install self-improving-agent

结语

OpenClaw Skills 生态给前端开发者的,不只是“自动补代码”。 它真正的价值在于:把需求分析、设计、编码、调优、交付串成一条可复用的流程链路。

不要试图一步到位。 从最需要的 1~2 个 Skills 开始,在真实项目中不断打磨,才是最高效的进阶路径。

如果让我给一个“前端优先组合”建议: UI/UX Pro Max + React + Frontend Design 这个组合已经能覆盖大多数日常开发场景。


“汛”速响应:流域洪水仿真分析,如何实现淹没过程的精准推演?

作者 Mapmost
2026年3月10日 10:48

中国是世界上受洪涝灾害影响最严重的国家之一!

近十年我国洪涝灾害年均造成约3000万人次受灾,直接经济损失超2000亿元,占自然灾害总损失的35%以上。每一次暴雨预警背后,都是千万家庭对安全的担忧;每一场洪水退去后,都需投入巨大资源重建家园。

对此,我们研发了针对流域洪水灾害的动力学仿真模型,希望可以基于动力波水流扩展模型实现大范围流域洪水的模拟,用于对流域或区域尺度的地表水与地下水动态过程进行仿真分析,同时还可以叠加道路、建筑、交通设施等承灾体数据进行脆弱性分析。在极端天气频发的当下,可以利用提前预测到的气象条件,模拟洪水的影响范围和严重程度,做好预案应对汛情,为灾害的闭环防治提供支持!

流域洪水为动态演示效果

我们进行了大量的调研、实验以及参数率定,最终完成流域洪水方仿真模型的基本功能研发。下面就为大家介绍下流域洪水动力学仿真分析模型背后的研发历程。

一、模型原理

首先需要清楚掌握模型的工作原理。本模型是以网格单元(DEM)为基本计算单元,输入地形、降雨、蒸发数据,以及边界条件(如边界类型、条件类型[可变流量or固定水位]、流量数据等),通过计算输出一系列灾害地图,并生成连贯的动画,实现在真实地形上对洪水淹没范围和水深的动态模拟

二、模型参数

掌握了模型原理,就可以知道地形、降雨、上游河道入流等是导致流域洪水形成和加速蔓延的核心诱因,其他的影响因素还包括水位、水深、河床形态等,将其抽象成输入和输出参数:

输入参数或文件

输出控制参数

#对以上参数感兴趣的朋友可以扫描文末二维码联系客服咨询哦

三、模型精度验证

要训练出一个合格的灾害仿真模型,需要对模型进行多次实验完成精度验证,以确保模型可以满足大多数情况的监测预警需求;本次实验将以**“2019年5月密苏里河流域洪水事件”**为样本进行实验。

在实验中,我们采用了3DEP的10米分辨率DEM,面积约为360平方公里,用6个流量监测站数据作为模拟输入数据,包括上游06601200站点、下游06610000站点,以及06602400、06607500、06608500、06609500等4个沿河站点,降雨包含从2019-03-10到2019-03-21共286个时刻空间分布的数据。曼宁系数全区域统一设为0.04,模拟时长1036800秒,每3600秒输出一次结果。

本模型模拟的2019年5月密苏里河流域洪水事件

#图中蓝色表示模拟和卫星影像共同的区域,绿色表示模拟特有的区域,红色表示卫星影像特有的区域。

基于卫星影像重分类的洪水区域

将FI(突发性指数:是衡量河流流量“忽高忽低、暴涨暴跌”特性的指标)作为流域洪水模型评估指数:

FI=A / (A+B+C)

其中,A是正确模拟的像元数,B是模拟的但不是实际的像元数,C是实际淹没的但模拟没有的像元数。计算图1的FI指数为74.532%

经过多轮调试后,精度验证完成,最终获得模型运行效果如下方视频所示:

#本案例仅用作功能性测试,因此没有处理美国地区平面坐标转球面坐标的偏移问题,敬请谅解。

四、模型在实际管理中的应用

完成了精度验证,算是基本跑通了技术路线。在验证时所采用的数据都是由研发人员根据参数表一点点配置出来的,如果在模型实际应用中,将参数全部交给用户自主配置,这无疑会增加用户使用产品的难度,并且体验感和效果都会很差。

01、上传文件驱动

因此在实际的功能设计中,我们为了兼顾仿真效果和用户数据制作度的平衡,将部分参数进行简化,最终用户只需要上传下图中的DEM栅格文件即可驱动模型:

用户自行制作数据上传,分析结果更精确

02、框选范围驱动

我们在文件驱动功能的基础上,做了进一步升级,用户只需要在地图上直接框选**“模拟区域”**就可以实现模拟,这样大大提高了操作的便利性,降低了对专业的依赖。

支持用户直接在地图上划定分析范围,操作更简便

现在,让我们将目光拉回到今年7月23-29日,北京特大暴雨洪涝灾害事件,历史罕见,破坏性极大!其中,密云区是受灾最严重的地区之一!据初步统计,全区17个镇162个村受灾,约11.3万群众受灾,因灾死亡37人,其中含太师屯镇养老照料中心31人。

#从图像上看黑色区域都是水,可以比较直观地看到陆地区域有不少已经被水覆盖了

下面,我们就选取“太师屯镇”作为案例,分析本次暴雨洪涝对太师屯镇的影响范围和程度。

在上述的文章中,已经介绍了分析范围的绘制,接下来就是配置参数,本次模拟将引用以下数据(从公开渠道获取)。

2025年7月23日14时至28日07时,密云区平均降雨312.3毫米,最大累计降水出现在朱家峪站,达522.2毫米,最大小时雨强83.9毫米,出现在黄土梁站。——数据来源:《北京日报网》

降雨参数配置

下面,一起来看看模型对本次流域洪水的仿真分析结果模拟:

密云区洪涝模拟(红圈处为太师屯镇)

#从视频中可以看出,模型根据降雨量,计算出了大致的洪水淹没范围、深度和水流速度。

在实际应用中,用户还可以在仿真模拟基础上,进行**“承灾体脆弱性分析”**:用户可以上传承灾体数据(包括建筑、道路、居民点等),用来分析当洪水的蔓延是否会影响到某个承灾体,并对其造成物理破坏,最终显示统计信息(如下图所示)。同时管理者可以看到承灾体在地图上的分布,从而对人员转移、河道整治等防汛预案的制定提供科学可靠的依据。

灾损统计

此外,以下功能也将在后续的版本中逐步上线:

01、防洪工程措施评估

用户可以在进行参数配置时,在模拟区域中添加防洪工程(如堤防、拦水坝等)。系统将快速模拟并对比**“有工程”**与“**无工程”**情景下的洪水淹没范围、水深和流速变化,为防洪预案提供定量依据。

02、疏散模拟

在预案制定时,也可以先给定洪水的到达时间,系统可结合路网模型,分析不同居民点的最佳疏散路径和所需时间,并标识出可能被洪水淹没的危险路线,从而生成一个最优的疏散方案。

Mapmost Risklnsight 预见风险,智慧决策

未来,模型还将支持:接入实时气象数据,对洪水进行预演,并将洪水演进的真实时间进行刻画,如当前的演进过程对应的是真实时间的多少分多少秒;此外,用户还可以框选一个区域,系统自动计算并展示该区域内的最大淹没面积、平均水深、受影响人口/房屋数量等统计信息,帮助管理者对该区域的预计受灾情况有一个直观的理解。

让灾害模拟更精准,让应急决策更智能——Mapmost RiskInsight,守护安全每一步。

👉 点击访问官网免费试用:

www.mapmost.com/#/layout/ri…

密集信息展示:表格与布局的取舍与实践指南

作者 LeonGao
2026年3月10日 10:38

一、引言

在后台管理系统、数据看板、监控平台、报表系统等场景中,我们经常需要在有限的屏幕空间里展示大量信息:几十列字段、上百条记录、复杂的指标对比、趋势与明细并存……如何在密集信息展示中做到「看得全、看得懂、点得准」,是前端、产品和交互设计绕不开的核心问题。

在这类场景下,**表格(Table)**几乎是默认选择,但随着需求变复杂,表格开始「吃不下」所有信息:列越来越多、单元格内容越来越复杂、需要和图表、筛选器、详情区联动,这时就不得不思考——**哪些信息应该放在表格中,哪些应该通过布局拆散?**如何在「密集展示」与「可用性」之间取舍?

本文围绕「密集信息展示——表格与布局的取舍」展开,从问题定义、设计原则和技术实践三个维度,结合代码示例,给出一套可落地的思路,帮助你在实际项目中做出更合理的设计与技术实现。


二、问题定义与背景

2.1 典型业务场景

密集信息展示主要出现在以下场景中:

  1. 运营/营销后台

    • 用户列表、订单列表、优惠活动列表
    • 每条记录拥有大量属性:用户画像、行为指标、标签、状态、来源渠道……
  2. 数据/BI 看板

    • 需要同时展示统计指标、趋势图、明细表
    • 指标之间频繁对比、钻取
  3. 监控与告警系统

    • 实时监控多维指标:服务节点、状态、耗时、错误比例、地域、版本……
    • 需要快速定位问题来源
  4. 配置/规则管理系统

    • 一条规则包含多层级条件、效果、优先级、发布状态
    • 既要批量浏览,又要支持快速编辑

这些场景的共同点是:

  • 信息维度多(字段多)
  • 信息密度高(很多内容必须被「放在眼前」)
  • 操作复杂(筛选、排序、批量操作、联动查看)

2.2 表格的天然优势与局限

表格适合:

  • 大量记录的横向批量对比
  • 结构化数据(同一列类型一致)
  • 明确的主键实体(订单、用户、设备、规则等)
  • 快速筛选、排序、分页浏览

表格的局限:

  • 当列数过多时,横向滚动变得难用
  • 单元格内容复杂时(多行文本、标签、操作按钮、状态图标),可读性急剧下降
  • 表格不适合展示层次很深的内容(嵌套结构 / 配置详情)
  • 对于视觉层次、聚焦与故事性展示较弱(不如图表和卡片)

于是我们面临核心问题:

在密集信息展示时,哪些内容适合留在「表格」中?哪些内容更适合交给「布局」去完成?如何既不牺牲信息密度,又维持可用性与可维护性?


三、解决思路:表格与布局的取舍原则

可以从「信息的角色」来思考,把一条记录中的信息分成几类:

  1. 主识别信息

    • 帮助用户「快速识别这条记录是谁」
    • 典型字段:名称、ID、时间、关键状态、主要指标
    • 通常应该放在表格前几列,列宽适当
  2. 高频决策信息

    • 用户浏览时,高频需要比较、排序或筛选的字段
    • 如:金额、状态、优先级、关键指标、负责人
    • 通常保留为表格列,可支持排序和筛选
  3. 低频细节信息

    • 只在需要深入了解时才看,如备注、历史、异常详情

    • 适合放在:

      • 行展开(Row Expansion)
      • 侧边详情抽屉(Drawer)
      • 悬浮卡片(Popover / Tooltip)
  4. 结构化 / 层级信息

    • 如 JSON 配置、条件组合、ACL 规则、多级依赖

    • 不适合直接平铺为列,适合折叠到:

      • 「详情」区域
      • Tab 内
      • 专门的编辑页或弹窗
  5. 交互型内容(操作、编辑入口)

    • 批量/单条操作入口:启用/停用、编辑、删除、复制链接

    • 一般集中在:

      • 表格「操作」列(Operation Column)
      • 行 hover 操作浮层
      • 详情区域中的操作按钮

基于以上分类,可以总结出一组实践性较强的指导原则:

3.1 表格的职责:列表、对比、筛选

  • 表格只承担「列表信息 + 快速对比 + 筛选/排序」的职责

  • 优先展示:

    • 唯一标识 / 名称
    • 关键指标(1–3 个)
    • 状态字段
    • 关键操作入口
  • 不要在表格中展示完整详情类内容(长备注、全文、配置 JSON)

3.2 布局的职责:结构组织与信息分层

  • 使用布局(Tabs / 抽屉 / 分栏 / 卡片)来:

    • 表达信息层次(基础信息 / 高级配置 / 历史 / 日志)
    • 承载复杂详情(如条件树、流程图、监控曲线)
    • 分隔不同视角(按业务、按时间、按用户)

具体做法包括:

  • 页面级布局

    • 顶部:筛选条件、关键指标总览(统计卡片)
    • 中部:主表格列表
    • 右侧 / 底部:详情区域(折叠/展开)
  • 局部布局

    • 行展开:展示子表格、标签详情、配置概要
    • 抽屉/侧边栏:展示一条记录的完整详情
    • 弹窗:用于编辑、创建等需要表单交互的内容

四、技术实现与代码示例

下面以前端(以 React + Ant Design 为例)为主线,展示如何在代码层面落地「表格 + 布局」的取舍策略。

4.1 基本表格结构:区分核心字段和详情字段

import React, { useState } from "react";
import { Table, Tag, Space, Drawer, Descriptions, Button } from "antd";

interface UserRecord {
  id: number;
  name: string;
  email: string;
  status: "active" | "inactive" | "banned";
  role: string;
  createdAt: string;
  tags: string[];
  remark: string;
  // 更多详情字段 ...
}

const mockData: UserRecord[] = [
  {
    id: 1,
    name: "Alice",
    email: "alice@example.com",
    status: "active",
    role: "Admin",
    createdAt: "2025-08-01 10:23:12",
    tags: ["vip", "beta-user"],
    remark: "重点客户,需要每季度回访一次。",
  },
  // ...
];

const UserTable: React.FC = () => {
  const [detailVisible, setDetailVisible] = useState(false);
  const [currentRecord, setCurrentRecord] = useState<UserRecord | null>(null);

  const columns = [
    {
      title: "用户",
      dataIndex: "name",
      key: "name",
      width: 180,
      fixed: "left" as const,
      render: (text: string, record: UserRecord) => (
        <Space direction="vertical" size={0}>
          <span style={{ fontWeight: 500 }}>{text}</span>
          <span style={{ fontSize: 12, color: "#999" }}>{record.email}</span>
        </Space>
      ),
    },
    {
      title: "状态",
      dataIndex: "status",
      key: "status",
      width: 100,
      filters: [
        { text: "启用", value: "active" },
        { text: "停用", value: "inactive" },
        { text: "封禁", value: "banned" },
      ],
      onFilter: (value: any, record: UserRecord) => record.status === value,
      render: (status: UserRecord["status"]) => {
        const colorMap = {
          active: "green",
          inactive: "default",
          banned: "red",
        } as const;
        const textMap = {
          active: "启用",
          inactive: "停用",
          banned: "封禁",
        } as const;
        return <Tag color={colorMap[status]}>{textMap[status]}</Tag>;
      },
    },
    {
      title: "角色",
      dataIndex: "role",
      key: "role",
      width: 120,
    },
    {
      title: "创建时间",
      dataIndex: "createdAt",
      key: "createdAt",
      width: 180,
      sorter: (a: UserRecord, b: UserRecord) =>
        new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
    },
    {
      title: "标签",
      dataIndex: "tags",
      key: "tags",
      width: 200,
      ellipsis: true,
      render: (tags: string[]) => (
        <Space size={4} wrap>
          {tags.slice(0, 3).map((tag) => (
            <Tag key={tag}>{tag}</Tag>
          ))}
          {tags.length > 3 && <span style={{ fontSize: 12 }}>+{tags.length - 3}</span>}
        </Space>
      ),
    },
    {
      title: "操作",
      key: "action",
      fixed: "right" as const,
      width: 160,
      render: (_: any, record: UserRecord) => (
        <Space>
          <Button
            type="link"
            onClick={() => {
              setCurrentRecord(record);
              setDetailVisible(true);
            }}
          >
            详情
          </Button>
          <Button type="link">编辑</Button>
          <Button danger type="link">
            删除
          </Button>
        </Space>
      ),
    },
  ];

  return (
    <>
      <Table<UserRecord>
        rowKey="id"
        columns={columns}
        dataSource={mockData}
        scroll={{ x: 900, y: 600 }}
        pagination={{ pageSize: 20 }}
      />

      <Drawer
        title={currentRecord ? `用户详情:${currentRecord.name}` : "用户详情"}
        placement="right"
        width={480}
        open={detailVisible}
        onClose={() => setDetailVisible(false)}
      >
        {currentRecord && (
          <Descriptions column={1} size="small" bordered>
            <Descriptions.Item label="ID">{currentRecord.id}</Descriptions.Item>
            <Descriptions.Item label="邮箱">
              {currentRecord.email}
            </Descriptions.Item>
            <Descriptions.Item label="角色">
              {currentRecord.role}
            </Descriptions.Item>
            <Descriptions.Item label="状态">{currentRecord.status}</Descriptions.Item>
            <Descriptions.Item label="创建时间">
              {currentRecord.createdAt}
            </Descriptions.Item>
            <Descriptions.Item label="标签">
              {currentRecord.tags.join(", ")}
            </Descriptions.Item>
            <Descriptions.Item label="备注">
              {currentRecord.remark}
            </Descriptions.Item>
          </Descriptions>
        )}
      </Drawer>
    </>
  );
};

export default UserTable;

要点说明:

  • 表格中只放核心字段,不展示 remark 全文,而是在 Drawer 中展示详情。
  • 首列 & 末列固定(fixed: 'left'/'right'),在横向滚动下仍能看到「主标识 + 操作」。
  • 使用 scroll={{ x: 900 }} 控制横向滚动,而不是无穷扩展列宽。

4.2 使用行展开承载「次级密集信息」

对于一些「中等重要」但又不至于要完整详情页的信息,可以利用行展开(Expandable Row) 。例如,在订单列表中展开显示商品明细子表格,而不是为每个商品单独建一行。

import React from "react";
import { Table } from "antd";

interface OrderItem {
  sku: string;
  name: string;
  price: number;
  quantity: number;
}

interface OrderRecord {
  id: number;
  userName: string;
  totalAmount: number;
  status: string;
  createdAt: string;
  items: OrderItem[];
}

const orderData: OrderRecord[] = [
  {
    id: 1001,
    userName: "Alice",
    totalAmount: 299,
    status: "已支付",
    createdAt: "2025-08-01 10:23:12",
    items: [
      { sku: "SKU001", name: "T恤", price: 99, quantity: 1 },
      { sku: "SKU002", name: "牛仔裤", price: 200, quantity: 1 },
    ],
  },
  // ...
];

const OrderTable: React.FC = () => {
  const columns = [
    {
      title: "订单号",
      dataIndex: "id",
      key: "id",
      width: 120,
    },
    {
      title: "用户",
      dataIndex: "userName",
      key: "userName",
      width: 160,
    },
    {
      title: "金额",
      dataIndex: "totalAmount",
      key: "totalAmount",
      width: 120,
    },
    {
      title: "状态",
      dataIndex: "status",
      key: "status",
      width: 120,
    },
    {
      title: "创建时间",
      dataIndex: "createdAt",
      key: "createdAt",
      width: 180,
    },
  ];

  const expandedRowRender = (record: OrderRecord) => {
    const itemColumns = [
      { title: "SKU", dataIndex: "sku", key: "sku" },
      { title: "商品名", dataIndex: "name", key: "name" },
      { title: "单价", dataIndex: "price", key: "price" },
      { title: "数量", dataIndex: "quantity", key: "quantity" },
    ];

    return (
      <Table<OrderItem>
        rowKey="sku"
        columns={itemColumns}
        dataSource={record.items}
        pagination={false}
        size="small"
      />
    );
  };

  return (
    <Table<OrderRecord>
      rowKey="id"
      columns={columns}
      dataSource={orderData}
      expandable={{ expandedRowRender }}
      pagination={{ pageSize: 10 }}
    />
  );
};

export default OrderTable;

要点说明:

  • 订单表格只展示「谁的订单、多少钱、什么状态」,把「买了哪些商品」放到展开区域。
  • 展开区域内部可以再使用表格或卡片,这就是布局承担「结构化展示」的责任。

4.3 使用 Tabs / 分栏布局组织复杂详情

当单条记录的详情本身就很「密集」时(如复杂规则、多类指标),适合在详情页/抽屉内部再用Tabs + 分栏布局组织内容,而不是一股脑长页面。

以规则引擎的配置详情为例:

import React from "react";
import { Drawer, Tabs, Descriptions, Card, Row, Col } from "antd";

const { TabPane } = Tabs;

interface RuleDetailProps {
  visible: boolean;
  onClose: () => void;
  rule: any; // 例子中省略类型
}

const RuleDetailDrawer: React.FC<RuleDetailProps> = ({ visible, onClose, rule }) => {
  return (
    <Drawer
      title={`规则详情:${rule?.name ?? ""}`}
      open={visible}
      onClose={onClose}
      width={720}
    >
      <Tabs defaultActiveKey="basic">
        <TabPane tab="基础信息" key="basic">
          <Descriptions column={2} size="small" bordered>
            <Descriptions.Item label="规则ID">{rule.id}</Descriptions.Item>
            <Descriptions.Item label="名称">{rule.name}</Descriptions.Item>
            <Descriptions.Item label="状态">{rule.status}</Descriptions.Item>
            <Descriptions.Item label="优先级">{rule.priority}</Descriptions.Item>
            <Descriptions.Item label="创建时间">{rule.createdAt}</Descriptions.Item>
            <Descriptions.Item label="更新人">{rule.updatedBy}</Descriptions.Item>
            <Descriptions.Item label="备注" span={2}>
              {rule.remark}
            </Descriptions.Item>
          </Descriptions>
        </TabPane>

        <TabPane tab="命中条件" key="conditions">
          <Row gutter={16}>
            <Col span={12}>
              <Card size="small" title="用户维度">
                {/* 这里可以展示条件树状结构或标签列表 */}
                {/* 示例: */}
                <ul>
                  <li>地区 = 北京 / 上海</li>
                  <li>年龄 ∈ [25, 35]</li>
                </ul>
              </Card>
            </Col>
            <Col span={12}>
              <Card size="small" title="行为维度">
                <ul>
                  <li>近7天下单次数 ≥ 2</li>
                  <li>近30天登录天数 ≥ 5</li>
                </ul>
              </Card>
            </Col>
          </Row>
        </TabPane>

        <TabPane tab="效果配置" key="effects">
          <Card size="small" title="触发动作">
            <ul>
              <li>发送优惠券:新客专享10元</li>
              <li>推送渠道:站内信 + App Push</li>
            </ul>
          </Card>
        </TabPane>

        <TabPane tab="历史与监控" key="metrics">
          <Row gutter={16}>
            <Col span={12}>
              <Card size="small" title="关键指标">
                <ul>
                  <li>近7天命中次数:1234</li>
                  <li>转化率:12.3%</li>
                </ul>
              </Card>
            </Col>
            <Col span={12}>
              <Card size="small" title="异常记录">
                {/* 这里可以嵌一个小表格或日志列表 */}
                暂无严重异常。
              </Card>
            </Col>
          </Row>
        </TabPane>
      </Tabs>
    </Drawer>
  );
};

export default RuleDetailDrawer;

要点说明:

  • 使用 Tabs 按功能分块:基础信息 / 条件 / 效果 / 监控,避免单屏信息爆炸。
  • 同一个 Tab 内再用 Row/Col 按列布局,形成更清晰的信息分区。
  • 这类复杂详情不应该全挤在表格列中,而是让表格只承担「规则列表」的工作。

4.4 响应式与密度调节

密集信息展示时,还要考虑不同屏幕尺寸信息密度偏好(比如「紧凑模式」)。

  1. 表格尺寸调节

    • 大多数 UI 组件库(Ant Design、Element、MUI)都支持 size 属性:small | middle | large
    • 可以提供一个切换开关,让用户在「紧凑模式」和「舒适模式」之间切换
// 示例:Ant Design 表格密度切换(简化版)
import { Table, Radio } from "antd";
import type { TableProps } from "antd";

type TableSize = TableProps<any>["size"];

const [size, setSize] = useState<TableSize>("middle");

<Radio.Group
  value={size}
  onChange={(e) => setSize(e.target.value)}
  style={{ marginBottom: 16 }}
>
  <Radio.Button value="small">紧凑</Radio.Button>
  <Radio.Button value="middle">中等</Radio.Button>
  <Radio.Button value="large">宽松</Radio.Button>
</Radio.Group>;

<Table size={size} /* 其它属性省略 */ />;
  1. 列的隐藏与显示(自定义列设置)

在列非常多的场景下,可以提供「列设置(Column Settings) 」功能,让用户自行选择要展示哪些列,把通用高频列默认勾选,把低频列作为可选项。

典型实现方式:

  • 使用 columns 配置 + visibleColumns 状态保存用户选择
  • 将用户选择持久化到 localStorage 或后端

伪代码:

interface ColumnConfig {
  key: string;
  title: string;
  dataIndex?: string;
  // ...
}

const allColumns: ColumnConfig[] = [
  { key: "name", title: "名称", dataIndex: "name" },
  { key: "email", title: "邮箱", dataIndex: "email" },
  { key: "phone", title: "电话", dataIndex: "phone" },
  // ...
];

const [visibleKeys, setVisibleKeys] = useState<string[]>([
  "name",
  "email",
  "status",
  // 默认展示的一部分
]);

const tableColumns = allColumns
  .filter((c) => visibleKeys.includes(c.key))
  .map((c) => ({
    ...c,
    // 其他渲染逻辑
  }));

<Table columns={tableColumns} /* ... */ />;

五、优缺点分析与实践建议

5.1 使用表格承载更多信息的优缺点

优点:

  • 集中管理:所有信息都在一个视图中,易于扫描与对比
  • 易于实现:表格组件通常很成熟,上手快
  • 便于导出:列结构清晰,易于导出 Excel/CSV

缺点:

  • 可读性下降:列过多导致横向滚动、字体缩小、内容挤压
  • 交互拥挤:在单元格中塞入标签、按钮、图标,会让操作变得难点
  • 复杂度提升:渲染逻辑非常复杂时,组件变大难维护

适用建议:

  • 控制可见列数,一般建议尽量控制在 8–12 列以内(视分辨率而定)
  • 只把需要对比与筛选的字段放到表格中
  • 避免在单元格中堆叠太多视觉元素(标签、Tooltip、按钮等)
    可以在 hover 时再展示更多信息

5.2 使用布局拆解信息的优缺点

优点:

  • 提升可读性:通过分区、分栏、Tabs 优化视觉结构
  • 更灵活:可以为不同类型的信息选择最合适的组件(图表、折线图、树、代码高亮等)
  • 易于扩展:新增字段更容易找到合适的位置,不必「硬塞」进表格

缺点:

  • 操作路径变长:用户需点击「详情」或「展开」才能看到完整信息
  • 需要更细致的交互设计:什么时候用抽屉,什么时候用弹窗,什么时候用新页面
  • 状态同步复杂:主列表筛选、排序与详情视图间的联动逻辑更多

适用建议:

  • 对于层次深、结构复杂的内容,一定要用布局拆解,避免强行平铺在表格
  • 将用户 80% 频次访问的内容留在主表格中,其余内容移到详情
  • 在「详情」中再做二次信息分层(Tabs / 折叠面板 / 分栏)

5.3 实际项目中的综合建议

  1. 从用户任务出发,而不是从字段列表出发

    • 先问:用户来到这个页面,最想完成什么任务?(浏览?筛选?批量操作?排查问题?)
    • 再决定:为这个任务,哪些字段必须一眼看到,哪些可以点一下再看
  2. 设定列数与行高的上限

    • 列数超过某个阈值(比如 12)时,强制进行字段分层(详情/展开)
    • 行高保持统一,使用 ellipsis(省略号)和 Tooltip 处理超长内容
  3. 优先使用「行展开 + 抽屉」模式,而不是全屏跳转详情页

    • 行展开适合「轻量级详情」或子表格
    • 抽屉适合「中量级详情」与表单编辑
    • 当详情非常复杂且独立任务多时,再考虑跳转新页面
  4. 引入「表格 + 概览卡片」混合布局

    • 页面顶部用简单的统计卡片展示关键指标(总数、转化率、错误率)
    • 中部以表格展示明细
    • 用户可以通过概览卡片的点击,驱动下方表格的筛选条件
  5. 给高级用户更多「自定义能力」

    • 列显隐、列宽拖拽、排序记忆、筛选条件收藏
    • 对高频使用的运营/分析用户非常有价值

六、结论:表格不是万能的,布局才是答案的一半

在密集信息展示的场景中,「表格」是重要的基础设施,但它不是全部答案。
真正高可用、高效率的界面,往往是**「表格 + 多层布局」的组合产物**:

  • 让表格回归本职:列表化的对比、筛选与批量操作
  • 让布局承担分层:将复杂且多样的内容拆解到合适的区域(Tabs、抽屉、行展开、分栏)
  • 结合响应式与用户自定义能力,在「信息密度」与「可读性」之间做出平衡

未来,随着大屏看板、自适应布局、个性化配置等能力的普及,「密集信息展示」会越来越从「一刀切模板」走向「可配置、多视图」,表格与布局的边界也会更加灵活。但无论如何,上述信息分层原则与职责划分会长期有效:

把「需要一眼看到的」放在表格,把「需要认真理解的」交给布局。


七、参考与延伸阅读

以下是一些有助于深入理解密集信息展示与表格设计的资料(多为英文,可结合实际访问情况):

  1. 设计原则与模式

  2. 组件库文档(实践参考)

  3. 信息密度与布局

    • Material Design – Layout
      m3.material.io/foundations…
    • Information Dashboard Design – Stephen Few(书籍,关于如何在有限空间展示复杂数据)

从一行字到改变世界:HTTP这三十年都经历了什么?

作者 牛奶
2026年3月10日 10:37

这是一个关于「一行字」如何改变世界的故事。从GET /index.html到QUIC,HTTP用了三十年。


原文地址

墨渊书肆/从一行字到改变世界:HTTP这三十年都经历了什么?


1991年,互联网还是个大农村。

那时候上网的人很少,网页也简陋得可怜。你能想象吗——第一个网页上只有一行字,连张图片都没有。

但就是从这一行字开始,一个帝国崛起了。


HTTP/0.9:一切的开始

1991年,一个叫蒂姆·伯纳斯-李(Tim Berners-Lee)的科学家,发明了HTTP

但你绝对想象不到,最初的HTTP能有多简单。

它只有一个方法GET

对,就一个。

你想获取一个网页?好,给服务器发一行字:

GET /index.html

服务器收到,嗷嗷一顿找,然后直接把内容返回给你。就这么粗暴。

没有响应头,没有状态码,没有POST,没有PUT。服务器返回什么,你就看什么。

像什么?

像一个只会说「好」的人。你问一句,它答一句,多余的一个字都没有。

但这就是HTTP的起点——一个简单到不能再简单的协议,奠定了互联网的基石。


HTTP/1.0:第一次进化

1996年,HTTP迎来了第一次大升级。

这时候的互联网已经开始热闹起来了。网页不再是纯文字,图片、音频、视频都冒出来了。原先那套「一行字」的打法,明显不够用了。

时代呼唤改变

人们开始提需求了:

  • 「我想知道请求成没成功」
  • 「我想传输其他类型的文件,不只是HTML」
  • 「我想知道这个页面有没有更新」
  • 「我想把页面做得更好看」

怎么办?HTTP/1.0来了。

新增了什么?

状态码:服务器现在会告诉你结果了。200是成功,404是找不到,500是服务器挂了——就像你问路别人会指路了一样。

请求头和响应头:你可以告诉服务器你能接受什么格式(Accept),服务器也能告诉你返回的是什么类型(Content-Type)、多大(Content-Length)。

支持多种请求方法GET有了,POST也有了。POST可以用来提交表单,比如你填完用户名密码点「登录」。

缓存机制ExpiresLast-Modified这些概念开始出现。浏览器知道什么该存、什么该用了。

这一升级,HTTP从一个只会说「好」的人,变成了一个会「点头摇头」「递纸条」「看备忘录」的完整的人。

但人们还是不满足。


HTTP/1.1:真正的霸主

1997年,HTTP/1.1发布了。

这是一个极其长寿的版本——它统治了互联网整整20多年,直到今天还有很多网站在用它。

你说它有多厉害?

HTTP/1.1的杀手锏

持久连接(Keep-Alive):这是最关键的改动。

以前的HTTP,每次请求都要建立一次TCP连接。请求完就断,断完再连。就像每次说话都要重新握手一样,累不累?

HTTP/1.1说:「别断了,咱们保持连接。」一个TCP连接可以跑完整个页面的所有请求。

管道化(Pipelining):这个就更狠了。

以前是这样的:发请求A,等响应;发请求B,等响应;发请求C,等响应——一个接一个,串着来。

HTTP/1.1可以这样:发请求A、请求B、请求C,一起发出去!不用等A的响应回来再发B。

想象一下你去麦当劳点餐。以前是:「我要一个汉堡」「好」「我要薯条」「好」「我要可乐」「好」。现在是:「我要一个汉堡、一份薯条、一杯可乐」「好嘞」。

爽不爽?

但是!

管道化有个问题:虽然请求一起发了,但响应必须按顺序回来。

这就叫「队头阻塞」(Head-of-Line Blocking)。

就像你点了三份菜,厨房先做了最简单的薯条,但得等你最想吃的汉堡做好了一起上——你只能看着薯条流口水,不能先吃。

这个问题,困扰了HTTP/1.1很多年。

其他小改进

  • 新增了一堆方法PUT(上传)、DELETE(删除)、HEAD(只获取头部)、OPTIONS(查看支持什么方法)
  • 分块传输编码Transfer-Encoding: chunked,服务器可以一块一块地返回数据,不用等全部算完
  • 缓存机制升级Cache-Control登场,比Expires更智能
  • Host头:一台服务器可以托管多个网站了(虚拟主机)

为什么HTTP/1.1能活这么久?

说白了,就是够用

虽然有队头阻塞的问题,但配合CDN、域名分片、静态资源合并这些「野路子」,1.1还是能打的。

再加上升级协议需要服务器、浏览器、CDN厂商全部配合——这是一个生态问题,不是技术问题。

所以HTTP/1.1硬是撑到了2015年,才等来下一代标准。


HTTP/2:真正的革命

2015年,HTTP/2正式发布。

这是HTTP诞生以来最大的一次升级。如果把HTTP/1.x比作绿皮火车,那HTTP/2就是高铁。

发生了什么变化?

二进制分帧:这是最底层的变化。

以前的HTTP/1.x是「文本协议」——你发的请求、服务器回的响应,都是明文写的,像写信一样。

HTTP/2改成了「二进制」——所有数据都转换成0和1来传输,就像发电报。

这意味着什么?效率更高了,因为计算机处理二进制比处理文本快多了。

而且数据被拆成了一个个「帧」(Frame),可以乱序发送、并行接收,彻底解决了队头阻塞!

多路复用(Multiplexing):这是HTTP/2的核心杀手锏。

一个TCP连接里,可以同时跑多个「流」(Stream)。每个流里可以双向传输数据,帧可以乱序、可以交叉。

还是点餐的例子:以前是点三份菜等服务员一份一份上,现在是服务员端着一个大盘子,三份菜一起给你端上来,而且你还可以边吃边点。

爽到飞起。

服务器推送(Server Push):这个功能也很革命。

以前是这样的:浏览器请求页面HTML → 服务器返回 → 浏览器解析HTML发现要CSS → 再请求CSS → 服务器返回 → 浏览器解析CSS发现要图片……

一套下来,浏览器累得够呛。

HTTP/2说:「别麻烦了,我知道你要什么。」服务器在返回HTML的时候,直接把CSS、图片一起推给你。

就像你去饭店,服务员看你落座就把碗筷、茶水、菜单一起摆好了——不用你开口。

头部压缩(HPACK)HTTP/1.x每次请求都要带一堆header,很多还是重复的。HTTP/2用HPACK算法压缩header,能省70%以上的流量。

HTTP/2的遗憾

HTTP/2解决了应用层的队头阻塞,但TCP层面还有个问题——丢包。

HTTP/2所有请求都跑在一个TCP连接上。如果其中一个包丢了,整个连接都要等它重传成功。

怎么办?

换协议呗。


HTTP/3:UDP登场

2018年,HTTP/3正式发布。

这是HTTP第一次抛弃TCP,拥抱UDP

为什么要用UDP?

TCP太可靠了——它保证数据一定到达、按顺序到达、中途不能出错。

但有时候,我们不需要这么可靠。

比如看直播——丢了一帧画面有什么关系?下一帧就来了。我要的是,不是「绝对不出错」。

UDP就是这样的「愣头青」——我负责发,你负责收,收没收到、有没有乱序,我不管。

这反而成了优势。

QUIC:HTTP/3的核心

HTTP/3用的是QUIC协议(Quick UDP Internet Connections),它是Google发明的,后来被IETF收编。

QUICUDP的方式,实现了TCP的效果:

  • 无队头阻塞:每个「流」是独立的,一个流丢包不影响其他流
  • 0-RTT连接:第一次连接后,后续连接可以瞬间建立(省去握手时间)
  • 连接迁移:你从WiFi切到5G,IP地址变了,连接不会断——因为QUIC用的是连接ID,不是IP地址
  • 内置TLS:TLS握手和QUIC握手一起完成,又省了一轮时间

简单说:TCP的优点我都有,TCP的缺点我避开,我还比TCP快。

HTTP/3带来了什么?

  • 更低的延迟
  • 更好的移动端体验(WiFi/5G切换不断连)
  • 抗丢包能力更强

HTTP/3也有问题:它需要服务器和客户端都支持,而且UDP在某些网络环境下可能被限速或被墙。

所以HTTP/2HTTP/3现在在并存使用,未来可能会慢慢过渡到3。


结尾:从一行字开始,到改变世界

回顾HTTP的进化史,你会发现一条清晰的线:

版本 年份 核心特点
HTTP/0.9 1991 只有一个GET,一行字
HTTP/1.0 1996 状态码、请求头、POST、缓存
HTTP/1.1 1997 持久连接、管道化、虚拟主机
HTTP/2 2015 二进制分帧、多路复用、服务器推送
HTTP/3 2018 QUIC + UDP、0-RTT

一行字到改变世界,从文本到二进制,从TCPUDP——HTTP用了三十多年。

今天,你打开任何一个网站、刷任何一个App、点任何一个链接——背后都是这一行字的子孙后代在为你工作。

这就是技术的魅力:从一个简单的想法出发,最终改变了整个世界。

前端监控与错误追踪实战指南:构建稳定应用的终极方案

作者 bluceli
2026年3月10日 10:27

前端监控与错误追踪实战指南

在现代化的前端应用中,完善的监控和错误追踪系统是保障用户体验的关键。本文将深入探讨如何构建一套完整的前端监控体系,从错误捕获到性能分析,全方位守护你的应用。

一、错误捕获机制

1. 全局错误监听

// 捕获全局错误
window.addEventListener('error', (event) => {
  const errorInfo = {
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
    error: event.error?.stack,
    type: 'error',
    timestamp: Date.now()
  };
  
  // 发送到监控服务器
  sendToMonitoring(errorInfo);
});

// 捕获未处理的Promise rejection
window.addEventListener('unhandledrejection', (event) => {
  const errorInfo = {
    message: event.reason?.message || 'Unhandled Promise Rejection',
    stack: event.reason?.stack,
    type: 'unhandledrejection',
    timestamp: Date.now()
  };
  
  sendToMonitoring(errorInfo);
});

2. Vue应用中的错误处理

// Vue 3 全局错误处理器
app.config.errorHandler = (err, vm, info) => {
  const errorInfo = {
    message: err.message,
    stack: err.stack,
    component: vm?.$options?.name,
    info: info,
    type: 'vue-error',
    timestamp: Date.now()
  };
  
  sendToMonitoring(errorInfo);
};

3. React应用中的错误边界

class ErrorBoundary extends React.Component {
  componentDidCatch(error, errorInfo) {
    const errorData = {
      message: error.message,
      stack: error.stack,
      componentStack: errorInfo.componentStack,
      type: 'react-error',
      timestamp: Date.now()
    };
    
    sendToMonitoring(errorData);
  }
  
  render() {
    return this.props.children;
  }
}

二、性能监控

1. 核心性能指标

// 页面加载性能
const performanceData = {
  // 页面加载时间
  pageLoadTime: performance.timing.loadEventEnd - performance.timing.navigationStart,
  
  // DOM解析时间
  domParseTime: performance.timing.domComplete - performance.timing.domLoading,
  
  // 资源加载时间
  resourceLoadTime: performance.timing.domContentLoadedEventEnd - performance.timing.domContentLoadedEventStart,
  
  // 首次内容绘制
  firstContentfulPaint: performance.getEntriesByType('paint')
    .find(entry => entry.name === 'first-contentful-paint')?.startTime,
  
  // 最大内容绘制
  largestContentfulPaint: performance.getEntriesByType('largest-contentful-paint')
    .pop()?.startTime
};

// 发送性能数据
sendToMonitoring({ type: 'performance', data: performanceData });

2. API请求监控

// 拦截fetch请求
const originalFetch = window.fetch;
window.fetch = async (...args) => {
  const startTime = performance.now();
  const url = args[0];
  
  try {
    const response = await originalFetch(...args);
    const endTime = performance.now();
    
    // 记录成功的API请求
    sendToMonitoring({
      type: 'api-request',
      url: url,
      status: response.status,
      duration: endTime - startTime,
      success: true
    });
    
    return response;
  } catch (error) {
    const endTime = performance.now();
    
    // 记录失败的API请求
    sendToMonitoring({
      type: 'api-request',
      url: url,
      error: error.message,
      duration: endTime - startTime,
      success: false
    });
    
    throw error;
  }
};

三、用户行为追踪

1. 页面访问追踪

// 页面访问记录
function trackPageView() {
  const pageInfo = {
    url: window.location.href,
    referrer: document.referrer,
    userAgent: navigator.userAgent,
    screenResolution: `${window.screen.width}x${window.screen.height}`,
    viewport: `${window.innerWidth}x${window.innerHeight}`,
    timestamp: Date.now()
  };
  
  sendToMonitoring({ type: 'page-view', data: pageInfo });
}

// 页面离开时记录停留时间
let pageStartTime = Date.now();
window.addEventListener('beforeunload', () => {
  const stayTime = Date.now() - pageStartTime;
  sendToMonitoring({
    type: 'page-stay-time',
    url: window.location.href,
    duration: stayTime
  });
});

2. 用户交互追踪

// 追踪用户点击
document.addEventListener('click', (event) => {
  const target = event.target;
  const clickInfo = {
    element: target.tagName,
    id: target.id,
    className: target.className,
    text: target.textContent?.substring(0, 50),
    x: event.clientX,
    y: event.clientY,
    timestamp: Date.now()
  };
  
  sendToMonitoring({ type: 'user-click', data: clickInfo });
}, true);

// 追踪表单提交
document.addEventListener('submit', (event) => {
  const formInfo = {
    formId: event.target.id,
    formAction: event.target.action,
    timestamp: Date.now()
  };
  
  sendToMonitoring({ type: 'form-submit', data: formInfo });
});

四、监控数据上报

1. 数据上报策略

class MonitoringReporter {
  constructor(config) {
    this.config = {
      endpoint: '/api/monitoring',
      batchSize: 10,
      flushInterval: 5000,
      ...config
    };
    this.queue = [];
    this.init();
  }
  
  init() {
    // 定时上报
    setInterval(() => this.flush(), this.config.flushInterval);
    
    // 页面关闭时上报
    window.addEventListener('beforeunload', () => this.flush());
  }
  
  report(data) {
    this.queue.push(data);
    
    if (this.queue.length >= this.config.batchSize) {
      this.flush();
    }
  }
  
  async flush() {
    if (this.queue.length === 0) return;
    
    const dataToSend = [...this.queue];
    this.queue = [];
    
    try {
      await navigator.sendBeacon(
        this.config.endpoint,
        JSON.stringify(dataToSend)
      );
    } catch (error) {
      // 失败时重新加入队列
      this.queue.unshift(...dataToSend);
    }
  }
}

// 使用示例
const reporter = new MonitoringReporter({
  endpoint: 'https://your-monitoring-api.com/collect',
  batchSize: 5,
  flushInterval: 3000
});

function sendToMonitoring(data) {
  reporter.report(data);
}

五、错误分析与告警

1. 错误聚合分析

class ErrorAnalyzer {
  constructor() {
    this.errorPatterns = new Map();
  }
  
  analyze(error) {
    // 提取错误特征
    const pattern = this.extractPattern(error);
    
    // 统计错误频率
    if (!this.errorPatterns.has(pattern)) {
      this.errorPatterns.set(pattern, {
        count: 0,
        firstSeen: Date.now(),
        lastSeen: Date.now(),
        samples: []
      });
    }
    
    const errorData = this.errorPatterns.get(pattern);
    errorData.count++;
    errorData.lastSeen = Date.now();
    errorData.samples.push(error);
    
    // 只保留最近的5个样本
    if (errorData.samples.length > 5) {
      errorData.samples.shift();
    }
    
    // 检查是否需要告警
    this.checkAlert(errorData);
  }
  
  extractPattern(error) {
    // 提取错误的关键特征
    return error.message.split(':')[0] + '|' + error.type;
  }
  
  checkAlert(errorData) {
    // 错误频率过高时触发告警
    if (errorData.count > 10) {
      this.triggerAlert({
        type: 'high-frequency-error',
        pattern: errorData.pattern,
        count: errorData.count,
        samples: errorData.samples
      });
    }
  }
  
  triggerAlert(alert) {
    // 发送告警通知
    console.warn('Alert triggered:', alert);
    // 可以集成邮件、短信、钉钉等通知方式
  }
}

六、最佳实践建议

  1. 采样策略:对于高流量应用,采用采样策略减少监控数据量
  2. 隐私保护:避免收集敏感用户信息,对数据进行脱敏处理
  3. 性能影响:监控代码本身要轻量,避免影响应用性能
  4. 数据安全:使用HTTPS传输监控数据,确保数据安全
  5. 分级告警:根据错误严重程度设置不同的告警级别

总结

构建完善的前端监控体系需要从错误捕获、性能监控、用户行为追踪等多个维度入手。通过实时监控和及时告警,我们可以快速发现和解决问题,持续提升用户体验。记住,好的监控系统是应用稳定运行的重要保障。

在实际项目中,可以考虑使用成熟的监控方案如Sentry、LogRocket等,它们提供了更完善的功能和更好的用户体验。但了解底层原理对于定制化需求仍然非常重要。

多 IDE/Agent 环境下的 Skill 管理方案

作者 streaker303
2026年3月10日 10:22

背景

在同一个工程项目中,团队成员可能使用不同的 IDE/Agent(例如 Codex、Cursor、GitHub Copilot 等)。尤其是在 token 消耗较快的情况下,单个开发者也可能需要在多个 IDE 之间切换。如果每个工具都维护一份独立的 skills,会带来以下问题:

  • 不同 IDE 的 skill 存储路径不同,导致 skill 内容重复且难以同步
  • 如果将每个 IDE 的 skill 存储文件都同步到项目仓库,会污染版本管理,增加心智负担

设计目标

  1. 单一事实源:技能定义只维护一份
  2. 一键分发:可同步到多个 Agent/IDE
  3. 本地隔离:IDE 运行目录不污染仓库版本管理
  4. 可重复执行:多次执行结果稳定

实现方案

虽然可以设计一个独立的 skill Git 仓库单独维护,但 skill 本身是项目的有机组成部分,新建仓库会增加维护成本,必要性不大。

因此采用以下方案:

  • 在项目中新增 agent-skills/ 目录作为共享 skill 的统一源
  • 通过 npx skills add 完成安装分发
  • 通过 .git/info/exclude 忽略本地产物,避免污染 .gitignore

配置脚本

1. 同步到所有已安装的 Agent(自动识别)

"skills:sync": "npx skills add ./agent-skills --skill '*' -y"
  • ./agent-skills:指定项目目录为 skills 源
  • --skill '*':一次性安装全部技能,避免逐个声明
  • -y:非交互执行,便于脚本化和自动化

2. 同步到指定 Agent

"skills:sync:target": "node scripts/skills-sync-target.mjs"
#!/usr/bin/env node

import { spawnSync } from "child_process";

const TARGET_AGENTS = ["codex", "github-copilot", "antigravity", "cursor"];
const SKILLS_SOURCE = "./agent-skills";
const SKILL_SELECTOR = "*";

if (!TARGET_AGENTS.length) {
  console.error("[skills:sync:target] No target agent configured.");
  process.exit(1);
}

const successAgents = [];

for (const agent of TARGET_AGENTS) {
  const args = [
    "skills",
    "add",
    SKILLS_SOURCE,
    "--skill",
    SKILL_SELECTOR,
    "--agent",
    agent,
    "-y",
  ];
  const result = spawnSync("npx", args, {
    stdio: "ignore",
    shell: false,
  });

  if (!result.error && result.status === 0) {
    successAgents.push(agent);
  }
}

if (successAgents.length > 0) {
  console.log(`Installed agents: ${successAgents.join(", ")}`);
  process.exit(0);
}

console.error("Installed agents: none");
process.exit(1);

用于只向特定的 Agent 分发 skills。

3. 本地忽略 IDE 目录

"skills:exclude": "node scripts/sync-exclude.mjs"
#!/usr/bin/env node

import fs from "fs";
import path from "path";

const EXCLUDE_FILE = path.join(".git", "info", "exclude");

// 需要忽略的目录或文件
const PATTERNS = [
  ".agent/",
  ".agents/",
  "openspec/",
  ".trae/",
  ".windsurf/",
  ".claude/",
  ".cursor/",
  ".codex/",
  ".github/",
];

function addExclude(pattern) {
  const content = fs.existsSync(EXCLUDE_FILE)
    ? fs.readFileSync(EXCLUDE_FILE, "utf-8")
    : "";
  const lines = content.split("\n");
  if (lines.some((line) => line === pattern)) {
    console.log(`  skip (already exists): ${pattern}`);
  } else {
    fs.appendFileSync(EXCLUDE_FILE, `${pattern}\n`, "utf-8");
    console.log(`  added: ${pattern}`);
  }
}

console.log(`Syncing local exclude rules to ${EXCLUDE_FILE} ...`);
PATTERNS.forEach(addExclude);
console.log("Done.");

该脚本将 .agent/.agents/.cursor/.codex/ 等目录写入 .git/info/exclude

为什么不直接写入 .gitignore? 实测发现如果在 .gitignore 中忽略 skill 和 command 所在目录,会导致部分 Agent 无法快捷唤醒相关功能。

4. 忽略 skills-lock.json

skills-lock.json 添加到 .gitignore 中,避免版本管理冲突。

使用流程

  1. agent-skills/ 中定义和维护 skills
  2. 执行 pnpm skills:sync 分发到所有 Agent
  3. 执行 pnpm skills:exclude 配置本地文件排除规则
  4. 私有的 skill 以及 command 等在对应的忽略目录中进行维护

该方案实现了单一事实源、一键分发和本地隔离的目标,有效解决了多 IDE/Agent 环境下的 skill 管理问题。

❌
❌