2026年3月

概述

本文档系统性总结前端开发中颜色透明度处理的完整方案,涵盖 JavaScript 转换函数、CSS 原生特性、性能优化策略及工程化实践,为开发者提供从基础到进阶的技术参考。

一、JavaScript 颜色转换函数

1.1 hexToRGBA - 基础转换函数

/**
 * 将十六进制颜色转换为 RGBA 格式
 * @param hex 十六进制颜色值,如 '#1890ff'
 * @param alpha 透明度,范围 0-1
 * @returns rgba 格式的颜色字符串
 */
export const hexToRGBA = (hex: string, alpha: number) => {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};

// 使用示例
const color = hexToRGBA('#1890ff', 0.5); // 'rgba(24, 144, 255, 0.5)'

1.2 addColorOpacity - 带容错的转换函数

/**
 * 添加颜色透明度(带容错处理)
 * @param hexColor 十六进制颜色值,如 '#FF5733'
 * @param opacity 透明度,范围 0-1
 * @returns rgba 格式的颜色字符串
 */
export const addColorOpacity = (hexColor: string, opacity: number): string => {
  if (!hexColor) return 'rgba(0, 0, 0, 0.1)';

  // 移除 # 号
  const hex = hexColor.replace('#', '');

  // 解析RGB值
  const r = parseInt(hex.slice(0, 2), 16);
  const g = parseInt(hex.slice(2, 4), 16);
  const b = parseInt(hex.slice(4, 6), 16);

  // 返回rgba格式
  return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};

// 使用示例
const color1 = addColorOpacity('#FF5733', 0.8); // 'rgba(255, 87, 51, 0.8)'
const color2 = addColorOpacity('', 0.5); // 'rgba(0, 0, 0, 0.1)' (容错)

二、CSS 原生方法

2.1 color-mix() - 现代 CSS 方法

CSS color-mix() 函数可以混合两种颜色,常用于创建半透明效果。

基础语法

color-mix(in srgb, 颜色1 百分比, 颜色2)

使用场景

场景 1:菜单选中状态背景

.menu-item-selected {
  color: var(--primary-color);
  background-color: color-mix(in srgb, var(--primary-color) 5%, transparent);
}

场景 2:卡片背景

const StyledCard = styled.div`
  padding: 10px;
  background-color: color-mix(in srgb, var(--primary-color) 5%, transparent);
  border-radius: 4px;
`;

场景 3:搜索高亮

const highlightText = (text: string, keyword: string) => {
  return text.replace(
    new RegExp(keyword, 'g'),
    `<span style="color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 10%, transparent)">${keyword}</span>`,
  );
};

场景 4:按钮悬停效果

const Button = styled.button<{ $themeColor: string }>`
  background-color: ${(props) => `color-mix(in srgb, ${props.$themeColor} 10%, transparent)`};

  &:hover {
    background-color: ${(props) => `color-mix(in srgb, ${props.$themeColor} 15%, transparent)`};
  }
`;

场景 5:渐变背景

const GradientBox = styled.div<{ $color: string }>`
  background: linear-gradient(
    90deg,
    ${(props) => `color-mix(in srgb, ${props.$color} 10%, transparent)`} 0%,
    rgba(255, 255, 255, 0) 100%
  );
`;

2.2 十六进制 8 位格式 (#RRGGBBAA)

使用 8 位十六进制颜色值,最后两位表示透明度(00-FF)。

透明度对照表

透明度十六进制示例常见用途
100% (1.0)FF#1890ffFF完全不透明
90% (0.9)E6#1890ffE6主要内容
80% (0.8)CC#1890ffCC次要内容
70% (0.7)B3#1890ffB3辅助信息
60% (0.6)99#1890ff99遮罩层
50% (0.5)80#1890ff80半透明效果
40% (0.4)66#1890ff66禁用状态
30% (0.3)4D#1890ff4D水印、占位符
20% (0.2)33#1890ff33分割线
10% (0.1)1A#1890ff1A背景色、悬停效果
5% (0.05)0D#1890ff0D极浅背景
0% (0.0)00#1890ff00完全透明

使用示例

// 按钮背景
const Button = styled.button`
  background: #ffffff99; // 白色 60% 透明度
`;

// 文本颜色
<span style={{ color: '#626060ff' }}>提示文本</span>

// 遮罩层
const Overlay = styled.div`
  background: #00000080; // 黑色 50% 透明度
`;

转换函数

/**
 * 将透明度(0-1)转换为十六进制(00-FF)
 * @param alpha 透明度,范围 0-1
 * @returns 两位十六进制字符串
 */
const alphaToHex = (alpha: number): string => {
  const value = Math.round(Math.min(1, Math.max(0, alpha)) * 255);
  return value.toString(16).padStart(2, '0').toUpperCase();
};

// 使用示例
const color1 = `#1890ff${alphaToHex(0.5)}`; // '#1890ff80'
const color2 = `#1890ff${alphaToHex(0.6)}`; // '#1890ff99'

/**
 * 将十六进制颜色添加透明度
 * @param hex 十六进制颜色,如 '#1890ff'
 * @param alpha 透明度,范围 0-1
 * @returns 8位十六进制颜色
 */
const addAlphaToHex = (hex: string, alpha: number): string => {
  const cleanHex = hex.replace('#', '');
  return `#${cleanHex}${alphaToHex(alpha)}`;
};

// 使用示例
const transparentColor = addAlphaToHex('#1890ff', 0.5); // '#1890ff80'

2.3 rgba() - 传统方法

直接使用 rgba 颜色值,最直观的方式。

// 在 styled-components 中使用
const StyledDiv = styled.div`
  background-color: rgba(24, 144, 255, 0.1);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
`;

// 在内联样式中使用
<div style={{ backgroundColor: 'rgba(24, 144, 255, 0.1)' }}>内容</div>

三、实际应用场景

3.1 从图片提取主色并设置透明度

/**
 * 从图片提取主色
 * @param url 图片地址
 * @returns rgba 格式的颜色字符串
 */
const extractColor = (url: string): Promise<string> => {
  return new Promise((resolve) => {
    try {
      const img = new Image();
      img.crossOrigin = 'Anonymous';
      img.src = url;

      img.onload = () => {
        try {
          const canvas = document.createElement('canvas');
          const ctx = canvas.getContext('2d');
          if (!ctx) return resolve('rgba(0,0,0,0.2)');

          const w = 20;
          const h = 20;
          canvas.width = w;
          canvas.height = h;
          ctx.drawImage(img, 0, 0, w, h);

          const imageData = ctx.getImageData(0, 0, w, h);
          const data = imageData.data;
          let r = 0,
            g = 0,
            b = 0,
            count = 0;

          for (let i = 0; i < data.length; i += 4) {
            r += data[i];
            g += data[i + 1];
            b += data[i + 2];
            count++;
          }

          r = Math.round(r / count);
          g = Math.round(g / count);
          b = Math.round(b / count);

          // 返回带透明度的颜色
          resolve(`rgba(${r}, ${g}, ${b}, 0.6)`);
        } catch {
          resolve('rgba(0,0,0,0.2)');
        }
      };

      img.onerror = () => resolve('rgba(0,0,0,0.2)');
    } catch {
      resolve('rgba(0,0,0,0.2)');
    }
  });
};

// 动态调整透明度
const adjustOpacity = (color: string): string => {
  const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
  if (match) {
    const r = Number(match[1]);
    const g = Number(match[2]);
    const b = Number(match[3]);
    const a = match[4] !== undefined ? Math.min(1, Number(match[4]) + 0.4) : 0.4;
    return `rgba(${r}, ${g}, ${b}, ${a})`;
  }
  return 'rgba(0,0,0,0.2)';
};

3.2 主题色透明度处理

// 根据主题色生成不同透明度的颜色
const ThemeCard = styled.div<{ $color?: string }>`
  background-color: ${(props) => {
    if (props.$color) {
      const hex = props.$color;
      const r = parseInt(hex.slice(1, 3), 16);
      const g = parseInt(hex.slice(3, 5), 16);
      const b = parseInt(hex.slice(4, 6), 16);
      return `rgba(${r}, ${g}, ${b}, 0.05)`;
    }
    return 'rgba(24, 144, 255, 0.05)';
  }};
  border-radius: 8px;
  padding: 16px;
`;

3.3 条件透明度

// 根据状态设置不同透明度
const StatusBadge = styled.div<{ active: boolean }>`
  background-color: ${({ active }) => (active ? 'rgba(24, 144, 255, 0.1)' : 'rgba(0, 0, 0, 0.04)')};
  padding: 4px 8px;
  border-radius: 4px;
`;

3.4 悬停效果

const InteractiveButton = styled.button`
  background-color: rgba(0, 0, 0, 0.04);
  border: none;

  &:hover {
    background-color: rgba(0, 0, 0, 0.06);
  }
`;

四、方法对比

方法优点缺点适用场景
hexToRGBA()简单直接,兼容性好需要手动计算动态颜色转换
addColorOpacity()带容错处理代码稍多需要容错的场景
color-mix()CSS 原生,性能好浏览器兼容性要求高现代浏览器项目
#RRGGBBAA简洁,性能最好透明度不直观固定透明度值
rgba()最直观不够灵活固定颜色值

五、最佳实践

5.1 选择合适的方法

// ✅ 推荐:动态颜色使用 hexToRGBA
const dynamicColor = hexToRGBA(userColor, 0.5);

// ✅ 推荐:CSS 变量使用 color-mix
background-color: color-mix(in srgb, var(--primary-color) 10%, transparent);

// ✅ 推荐:固定颜色使用 8 位十六进制(性能最好)
background-color: #1890ff1A; // 10% 透明度

// ✅ 推荐:需要直观表达时使用 rgba
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);

5.2 透明度值规范

// 常用透明度值
const OPACITY = {
  HOVER: 0.06, // 悬停效果
  BACKGROUND: 0.05, // 背景色
  DISABLED: 0.4, // 禁用状态
  SHADOW: 0.1, // 阴影
  OVERLAY: 0.6, // 遮罩层
};

// 使用示例
const hoverColor = hexToRGBA('#1890ff', OPACITY.HOVER);

5.3 性能优化

// ❌ 避免:频繁计算
const Component = () => {
  return <div style={{ backgroundColor: hexToRGBA('#1890ff', 0.1) }}>内容</div>;
};

// ✅ 推荐:缓存计算结果
const COLORS = {
  primaryBg: hexToRGBA('#1890ff', 0.1),
  primaryHover: hexToRGBA('#1890ff', 0.15),
};

const Component = () => {
  return <div style={{ backgroundColor: COLORS.primaryBg }}>内容</div>;
};

5.4 类型安全

// 定义颜色类型
type HexColor = `#${string}`;
type RGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`;

// 类型安全的转换函数
export const hexToRGBA = (hex: HexColor, alpha: number): RGBAColor => {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return `rgba(${r}, ${g}, ${b}, ${alpha})` as RGBAColor;
};

六、浏览器兼容性

color-mix() 兼容性

  • Chrome: 111+
  • Firefox: 113+
  • Safari: 16.2+
  • Edge: 111+

如需支持旧版浏览器,建议使用 JavaScript 方法或 rgba()。

RRGGBBAA 兼容性

  • Chrome: 62+
  • Firefox: 49+
  • Safari: 10+
  • Edge: 79+

兼容性较好,可放心使用。

七、参考资源

👉 访问地址:免费 SSL 证书签发

前言

兄弟们,最近运维圈有个大事:2026 年 3 月 15 日起,行业新规正式生效。原本能管一年的付费证书,最长有效期也被强行砍到了 200 天

这就非常尴尬了:以前很多人花钱买证书是为了“一年才折腾一次”,现在既然连付费的都要半年折腾一回,那跟 90 天有效期的 Let's Encrypt 相比,手动更新的性价比已经彻底没了。

既然横竖都要频繁折腾,为什么不直接搞个“全自动续期”?上周我因为忘记续期导致子域名报不安全,被吐槽了一顿,痛定思痛,我自己撸了一个自动化工具分享给大家。

为什么要搞这个?

目前市面上的工具要么太笨重(要装各种 Certbot 依赖、跑命令行),要么太封闭(云厂商工具只管自家的,跨云就不灵了)。

我的需求很简单:

  1. 别让我注册(这种救急工具,注册流程真的很拦人)。
  2. 别让我手动改 DNS 验证(手动加 TXT 记录太反人类,还容易忘删)。
  3. 申请完能直接拿走,也可以一键托管。

功能亮点

  • 🚀 免注册领证:输入域名和解析商 API,后台自动跑脚本,直接出证书 ZIP 包(含有 .crt 和 .key),拿走即用。
  • ☁️ 多云适配:目前跑通了阿里云、腾讯云的 DNS API,自动添加验证记录并自动删除,保持解析列表干净。
  • 🛠️ 自动化托管:你可以选择“拿到证书就走”,也可以一键开启“自动托管”,以后每 90 天它会自动去跑挑战并续期,你只需要坐收通知。

操作流程

  1. 输入域名:比如 yourdomain.com
  2. 选择解析商:目前支持阿里云、腾讯云。
  3. 填入 API Key:为了安全,强烈建议去云商后台开一个只具备“DNS 修改权限”的子账号(RAM/CAM),别用主账号 Key。
  4. 一键生成:后台异步调用逻辑,自动修改 DNS 并向 CA 机构发起挑战。
  5. 下载 & 托管:拿到证书后,可以顺手勾选“自动续期”,从此彻底告别证书焦虑。

最后

这个小工具目前部署在 AWS 上,还是 MVP 阶段,界面走极简风格。大家如果正好有证书快到期的,欢迎来“救急”测试,反馈 Bug:

👉 访问地址:免费 SSL 证书签发

PS: 暂时只做了基础功能。如果大家有需要增加其他解析商(如华为云、Cloudflare)支持的,可以在评论区留言,我在线蹲一个反馈!


标签: #SSL证书 #HTTPS #自动化运维 #阿里云 #腾讯云 #acme.sh #后端开发

一、基础概念

HTML5 Drag & Drop API 是浏览器原生提供的拖拽功能,允许用户通过鼠标拖动元素并将其放置到目标位置。

核心角色

  1. 拖拽源(Drag Source):可以被拖动的元素
  2. 拖放目标(Drop Target):可以接收被拖动元素的区域

二、完整的拖拽流程

2.1 事件流程图

用户按下鼠标并开始拖动
    ↓
dragstart 事件 (在拖拽源触发,只触发一次)
    ↓
drag 事件 (在拖拽源持续触发,拖动过程中)
    ↓
dragenter 事件 (鼠标进入拖放目标时触发)
    ↓
dragover 事件 (鼠标在拖放目标上方时持续触发)
    ↓
dragleave 事件 (鼠标离开拖放目标时触发)
    ↓
drop 事件 (在拖放目标释放鼠标时触发)
    ↓
dragend 事件 (在拖拽源触发,拖拽结束)

2.2 事件详解

事件名称触发位置触发时机触发频率
dragstart拖拽源开始拖动时一次
drag拖拽源拖动过程中持续触发
dragenter拖放目标进入目标区域一次
dragover拖放目标在目标区域上方持续触发
dragleave拖放目标离开目标区域一次
drop拖放目标释放鼠标一次
dragend拖拽源拖拽结束一次

三、基础用法示例

3.1 最简单的拖拽实现

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>HTML5 拖拽示例</title>
  <style>
    .drag-source {
      width: 200px;
      padding: 20px;
      background: #4CAF50;
      color: white;
      cursor: move;
      margin: 20px;
    }

    .drop-target {
      width: 300px;
      height: 200px;
      border: 2px dashed #999;
      padding: 20px;
      margin: 20px;
    }

    .drop-target.drag-over {
      background: #e3f2fd;
      border-color: #2196F3;
    }
  </style>
</head>
<body>
  <!-- 拖拽源 -->
  <div class="drag-source" draggable="true" id="dragItem">
    拖动我
  </div>

  <!-- 拖放目标 -->
  <div class="drop-target" id="dropZone">
    拖放到这里
  </div>

  <script>
    const dragItem = document.getElementById('dragItem');
    const dropZone = document.getElementById('dropZone');

    // 1. 拖拽开始
    dragItem.addEventListener('dragstart', function(e) {
      console.log('dragstart: 开始拖拽');
      // 设置拖拽数据
      e.dataTransfer.setData('text/plain', '这是拖拽的数据');
      // 设置拖拽效果
      e.dataTransfer.effectAllowed = 'copy';
    });

    // 2. 拖拽结束
    dragItem.addEventListener('dragend', function(e) {
      console.log('dragend: 拖拽结束');
    });

    // 3. 进入目标区域
    dropZone.addEventListener('dragenter', function(e) {
      console.log('dragenter: 进入目标区域');
      e.preventDefault();
      this.classList.add('drag-over');
    });

    // 4. 在目标区域上方(必须阻止默认行为)
    dropZone.addEventListener('dragover', function(e) {
      e.preventDefault(); // 必须调用,否则 drop 事件不会触发
      e.dataTransfer.dropEffect = 'copy';
    });

    // 5. 离开目标区域
    dropZone.addEventListener('dragleave', function(e) {
      console.log('dragleave: 离开目标区域');
      this.classList.remove('drag-over');
    });

    // 6. 放置到目标区域
    dropZone.addEventListener('drop', function(e) {
      e.preventDefault(); // 阻止默认行为(如打开链接)
      this.classList.remove('drag-over');
      
      // 获取拖拽数据
      const data = e.dataTransfer.getData('text/plain');
      console.log('drop: 接收到数据:', data);
      
      // 显示数据
      this.innerHTML = `<p>接收到: ${data}</p>`;
    });
  </script>
</body>
</html>

3.2 关键点说明

  1. draggable="true":使元素可拖拽
  2. e.preventDefault():在 dragoverdrop 中必须调用,否则拖放无效
  3. dataTransfer:用于在拖拽源和拖放目标之间传递数据

四、DataTransfer 对象详解

4.1 核心属性

属性说明设置位置
effectAllowed允许的拖放效果拖拽源(dragstart)
dropEffect实际的拖放效果拖放目标(dragover)
files拖拽的文件列表只读
types数据类型列表只读
items数据项列表只读

4.2 核心方法

// 设置数据
e.dataTransfer.setData(format, data);

// 获取数据
const data = e.dataTransfer.getData(format);

// 清除数据
e.dataTransfer.clearData(format);

// 设置拖拽图像
e.dataTransfer.setDragImage(element, xOffset, yOffset);

五、鼠标效果控制

5.1 effectAllowed(拖拽源设置)

dragstart 事件中设置,定义允许的操作类型:

dragItem.addEventListener('dragstart', function(e) {
  // 可选值
  e.dataTransfer.effectAllowed = 'none';       // 不允许
  e.dataTransfer.effectAllowed = 'copy';       // 复制
  e.dataTransfer.effectAllowed = 'move';       // 移动
  e.dataTransfer.effectAllowed = 'link';       // 链接
  e.dataTransfer.effectAllowed = 'copyMove';   // 复制或移动
  e.dataTransfer.effectAllowed = 'copyLink';   // 复制或链接
  e.dataTransfer.effectAllowed = 'linkMove';   // 链接或移动
  e.dataTransfer.effectAllowed = 'all';        // 所有操作
});

5.2 dropEffect(拖放目标设置)

dragover 事件中设置,定义实际执行的操作:

dropZone.addEventListener('dragover', function(e) {
  e.preventDefault();
  
  // 可选值
  e.dataTransfer.dropEffect = 'none';  // 禁止放置 🚫
  e.dataTransfer.dropEffect = 'copy';  // 复制 ➕
  e.dataTransfer.dropEffect = 'move';  // 移动
  e.dataTransfer.dropEffect = 'link';  // 链接 🔗
});

六、数据传递

6.1 基本数据类型传递

// 拖拽源:设置数据
dragItem.addEventListener('dragstart', function(e) {
  // 文本数据
  e.dataTransfer.setData('text/plain', 'Hello World');
  
  // HTML 数据
  e.dataTransfer.setData('text/html', '<strong>Bold Text</strong>');
  
  // URL 数据
  e.dataTransfer.setData('text/uri-list', 'https://example.com');
});

// 拖放目标:获取数据
dropZone.addEventListener('drop', function(e) {
  e.preventDefault();
  
  // 获取文本数据
  const text = e.dataTransfer.getData('text/plain');
  console.log('接收到文本:', text);
  
  // 获取 HTML 数据
  const html = e.dataTransfer.getData('text/html');
  console.log('接收到 HTML:', html);
});

6.2 传递对象数据(JSON 序列化)

由于 dataTransfer 只能传递字符串,传递对象需要序列化:

<!DOCTYPE html>
<html>
<head>
  <style>
    .item { 
      padding: 15px; 
      margin: 10px; 
      background: #2196F3; 
      color: white;
      cursor: move;
      display: inline-block;
    }
    .drop-zone { 
      min-height: 150px; 
      border: 2px dashed #999; 
      padding: 20px;
      margin: 10px;
    }
  </style>
</head>
<body>
  <div class="item" draggable="true" data-id="1" data-name="产品A" data-price="99.99">
    产品A - ¥99.99
  </div>
  
  <div class="item" draggable="true" data-id="2" data-name="产品B" data-price="199.99">
    产品B - ¥199.99
  </div>

  <div class="drop-zone" id="cart">购物车(拖放产品到这里)</div>

  <script>
    // 拖拽源:序列化对象
    document.querySelectorAll('.item').forEach(item => {
      item.addEventListener('dragstart', function(e) {
        // 创建对象
        const product = {
          id: this.dataset.id,
          name: this.dataset.name,
          price: parseFloat(this.dataset.price)
        };
        
        // 序列化为 JSON 字符串
        const jsonData = JSON.stringify(product);
        e.dataTransfer.setData('application/json', jsonData);
        
        console.log('拖拽产品:', product);
      });
    });

    // 拖放目标:反序列化对象
    const cart = document.getElementById('cart');
    
    cart.addEventListener('dragover', function(e) {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'copy';
    });

    cart.addEventListener('drop', function(e) {
      e.preventDefault();
      
      // 获取 JSON 字符串
      const jsonData = e.dataTransfer.getData('application/json');
      
      // 反序列化为对象
      const product = JSON.parse(jsonData);
      
      console.log('接收到产品:', product);
      
      // 显示产品信息
      const productDiv = document.createElement('div');
      productDiv.style.cssText = 'padding: 10px; margin: 5px; background: #4CAF50; color: white;';
      productDiv.innerHTML = `
        <strong>${product.name}</strong><br>
        ID: ${product.id}<br>
        价格: ¥${product.price}
      `;
      this.appendChild(productDiv);
    });
  </script>
</body>
</html>

6.3 传递复杂对象的注意事项

// ❌ 错误:直接传递对象(会被转换为 "[object Object]")
e.dataTransfer.setData('text/plain', { name: 'John' });

// ✅ 正确:序列化后传递
const data = { name: 'John', age: 30, tags: ['developer', 'designer'] };
e.dataTransfer.setData('application/json', JSON.stringify(data));

// 接收时反序列化
const receivedData = JSON.parse(e.dataTransfer.getData('application/json'));
console.log(receivedData.name); // 'John'
console.log(receivedData.tags); // ['developer', 'designer']

6.4 传递多种格式的数据

dragItem.addEventListener('dragstart', function(e) {
  const data = {
    id: 123,
    title: '示例标题',
    content: '示例内容'
  };
  
  // 同时设置多种格式
  e.dataTransfer.setData('text/plain', data.title);
  e.dataTransfer.setData('text/html', `<h1>${data.title}</h1><p>${data.content}</p>`);
  e.dataTransfer.setData('application/json', JSON.stringify(data));
});

// 接收时根据需要选择格式
dropZone.addEventListener('drop', function(e) {
  e.preventDefault();
  
  // 检查可用的数据类型
  console.log('可用类型:', e.dataTransfer.types);
  
  // 优先使用 JSON 格式
  if (e.dataTransfer.types.includes('application/json')) {
    const data = JSON.parse(e.dataTransfer.getData('application/json'));
    console.log('使用 JSON 数据:', data);
  } else if (e.dataTransfer.types.includes('text/html')) {
    const html = e.dataTransfer.getData('text/html');
    console.log('使用 HTML 数据:', html);
  } else {
    const text = e.dataTransfer.getData('text/plain');
    console.log('使用纯文本数据:', text);
  }
});

七、跨窗口拖拽

7.1 同源窗口间拖拽

HTML5 Drag & Drop API 支持在同源的不同窗口/标签页之间拖拽:

<!-- 窗口 A: source.html -->
<!DOCTYPE html>
<html>
<head>
  <title>拖拽源窗口</title>
  <style>
    .drag-item {
      padding: 20px;
      background: #4CAF50;
      color: white;
      cursor: move;
      margin: 20px;
      display: inline-block;
    }
  </style>
</head>
<body>
  <h2>拖拽源窗口</h2>
  <p>将下面的元素拖到另一个窗口</p>
  
  <div class="drag-item" draggable="true" id="item">
    拖动我到另一个窗口
  </div>

  <script>
    document.getElementById('item').addEventListener('dragstart', function(e) {
      const data = {
        message: '来自窗口 A 的数据',
        timestamp: new Date().toISOString(),
        windowName: 'Window A'
      };
      
      e.dataTransfer.setData('application/json', JSON.stringify(data));
      e.dataTransfer.effectAllowed = 'copy';
      
      console.log('开始跨窗口拖拽:', data);
    });
  </script>
</body>
</html>
<!-- 窗口 B: target.html -->
<!DOCTYPE html>
<html>
<head>
  <title>拖放目标窗口</title>
  <style>
    .drop-zone {
      min-height: 200px;
      border: 3px dashed #999;
      padding: 20px;
      margin: 20px;
      text-align: center;
    }
    .drop-zone.drag-over {
      background: #e3f2fd;
      border-color: #2196F3;
    }
    .received-item {
      padding: 15px;
      margin: 10px;
      background: #4CAF50;
      color: white;
      border-radius: 4px;
    }
  </style>
</head>
<body>
  <h2>拖放目标窗口</h2>
  <p>从另一个窗口拖拽元素到这里</p>
  
  <div class="drop-zone" id="dropZone">
    <p>拖放区域</p>
    <p style="color: #999;">从另一个窗口拖拽元素到这里</p>
  </div>

  <script>
    const dropZone = document.getElementById('dropZone');

    dropZone.addEventListener('dragenter', function(e) {
      e.preventDefault();
      this.classList.add('drag-over');
    });

    dropZone.addEventListener('dragover', function(e) {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'copy';
    });

    dropZone.addEventListener('dragleave', function(e) {
      this.classList.remove('drag-over');
    });

    dropZone.addEventListener('drop', function(e) {
      e.preventDefault();
      this.classList.remove('drag-over');
      
      // 接收跨窗口数据
      const jsonData = e.dataTransfer.getData('application/json');
      
      if (jsonData) {
        const data = JSON.parse(jsonData);
        console.log('接收到跨窗口数据:', data);
        
        // 显示接收到的数据
        const itemDiv = document.createElement('div');
        itemDiv.className = 'received-item';
        itemDiv.innerHTML = `
          <strong>接收到数据:</strong><br>
          消息: ${data.message}<br>
          来源: ${data.windowName}<br>
          时间: ${data.timestamp}
        `;
        this.appendChild(itemDiv);
      }
    });
  </script>
</body>
</html>

7.2 跨窗口拖拽的限制

  1. 同源策略:只能在同源(相同协议、域名、端口)的窗口间拖拽
  2. 数据类型限制:只能传递字符串数据,复杂对象需要序列化
  3. 安全限制:某些浏览器可能限制跨窗口拖拽功能

7.3 跨窗口拖拽文件

<!DOCTYPE html>
<html>
<head>
  <title>文件拖放</title>
  <style>
    .file-drop-zone {
      min-height: 200px;
      border: 3px dashed #999;
      padding: 40px;
      text-align: center;
      margin: 20px;
    }
    .file-drop-zone.drag-over {
      background: #e8f5e9;
      border-color: #4CAF50;
    }
    .file-list {
      margin-top: 20px;
      text-align: left;
    }
    .file-item {
      padding: 10px;
      margin: 5px 0;
      background: #f5f5f5;
      border-left: 4px solid #4CAF50;
    }
  </style>
</head>
<body>
  <h2>文件拖放示例</h2>
  <p>从文件管理器拖拽文件到下方区域</p>

  <div class="file-drop-zone" id="fileDropZone">
    <p style="font-size: 48px;">📁</p>
    <p>拖拽文件到这里</p>
    <p style="color: #999; font-size: 14px;">支持从文件管理器或其他窗口拖拽</p>
  </div>

  <div class="file-list" id="fileList"></div>

  <script>
    const fileDropZone = document.getElementById('fileDropZone');
    const fileList = document.getElementById('fileList');

    fileDropZone.addEventListener('dragenter', function(e) {
      e.preventDefault();
      this.classList.add('drag-over');
    });

    fileDropZone.addEventListener('dragover', function(e) {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'copy';
    });

    fileDropZone.addEventListener('dragleave', function(e) {
      this.classList.remove('drag-over');
    });

    fileDropZone.addEventListener('drop', function(e) {
      e.preventDefault();
      this.classList.remove('drag-over');
      
      // 获取拖拽的文件
      const files = e.dataTransfer.files;
      
      console.log('接收到文件数量:', files.length);
      
      // 显示文件信息
      Array.from(files).forEach(file => {
        const fileItem = document.createElement('div');
        fileItem.className = 'file-item';
        fileItem.innerHTML = `
          <strong>📄 ${file.name}</strong><br>
          类型: ${file.type || '未知'}<br>
          大小: ${(file.size / 1024).toFixed(2)} KB<br>
          最后修改: ${new Date(file.lastModified).toLocaleString()}
        `;
        fileList.appendChild(fileItem);
      });
    });
  </script>
</body>
</html>

7.4 检测拖拽来源

dropZone.addEventListener('drop', function(e) {
  e.preventDefault();
  
  // 检查是否是文件
  if (e.dataTransfer.files.length > 0) {
    console.log('拖拽来源: 文件系统');
    console.log('文件数量:', e.dataTransfer.files.length);
  }
  
  // 检查是否有 URL
  else if (e.dataTransfer.types.includes('text/uri-list')) {
    const url = e.dataTransfer.getData('text/uri-list');
    console.log('拖拽来源: URL -', url);
  }
  
  // 检查是否有自定义数据
  else if (e.dataTransfer.types.includes('application/json')) {
    const data = JSON.parse(e.dataTransfer.getData('application/json'));
    console.log('拖拽来源: 自定义数据 -', data);
  }
  
  // 纯文本
  else if (e.dataTransfer.types.includes('text/plain')) {
    const text = e.dataTransfer.getData('text/plain');
    console.log('拖拽来源: 纯文本 -', text);
  }
});

7.5 常用数据类型说明

HTML5 Drag & Drop API 支持多种标准 MIME 类型:

数据类型说明使用场景浏览器支持
text/plain纯文本拖拽文本内容✅ 所有浏览器
text/htmlHTML 内容拖拽富文本✅ 所有浏览器
text/uri-listURI 列表拖拽链接、书签✅ 所有浏览器
application/jsonJSON 数据自定义数据传递✅ 所有浏览器
Files文件对象拖拽文件✅ 所有浏览器

text/uri-list 详解

text/uri-list 是一个标准的 MIME 类型,用于传递一个或多个 URI(统一资源标识符)。

格式规范:

  • 每个 URI 占一行
  • # 开头的行是注释
  • 空行会被忽略
  • 多个 URI 用换行符 \n 分隔

使用示例:

<!DOCTYPE html>
<html>
<head>
  <style>
    .link-item {
      padding: 15px;
      margin: 10px;
      background: #2196F3;
      color: white;
      cursor: move;
      display: inline-block;
      text-decoration: none;
      border-radius: 4px;
    }
    .drop-zone {
      min-height: 200px;
      border: 2px dashed #999;
      padding: 20px;
      margin: 20px;
    }
    .received-link {
      padding: 10px;
      margin: 5px 0;
      background: #f5f5f5;
      border-left: 4px solid #4CAF50;
    }
  </style>
</head>
<body>
  <h2>拖拽链接示例</h2>
  
  <!-- 可拖拽的链接 -->
  <a href="https://www.example.com" class="link-item" draggable="true" id="link1">
    拖动这个链接
  </a>
  
  <a href="https://www.github.com" class="link-item" draggable="true" id="link2">
    GitHub 链接
  </a>

  <!-- 拖放区域 -->
  <div class="drop-zone" id="dropZone">
    <p>拖放链接到这里</p>
  </div>

  <script>
    // 拖拽链接时,浏览器会自动设置 text/uri-list
    document.querySelectorAll('.link-item').forEach(link => {
      link.addEventListener('dragstart', function(e) {
        // 浏览器会自动设置 text/uri-list 为链接的 href
        // 也可以手动设置
        e.dataTransfer.setData('text/uri-list', this.href);
        e.dataTransfer.setData('text/plain', this.href);
        
        console.log('拖拽链接:', this.href);
      });
    });

    const dropZone = document.getElementById('dropZone');

    dropZone.addEventListener('dragover', function(e) {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'link';
    });

    dropZone.addEventListener('drop', function(e) {
      e.preventDefault();
      
      // 获取 URI 列表
      const uriList = e.dataTransfer.getData('text/uri-list');
      
      if (uriList) {
        // 处理 URI 列表(可能包含多个 URI)
        const uris = uriList.split('\n').filter(uri => {
          // 过滤掉注释和空行
          return uri.trim() && !uri.startsWith('#');
        });
        
        console.log('接收到的 URI:', uris);
        
        // 显示接收到的链接
        uris.forEach(uri => {
          const linkDiv = document.createElement('div');
          linkDiv.className = 'received-link';
          linkDiv.innerHTML = `
            <strong>接收到链接:</strong><br>
            <a href="${uri}" target="_blank">${uri}</a>
          `;
          this.appendChild(linkDiv);
        });
      }
    });
  </script>
</body>
</html>

从浏览器地址栏拖拽:

<!DOCTYPE html>
<html>
<head>
  <style>
    .drop-zone {
      min-height: 200px;
      border: 3px dashed #999;
      padding: 40px;
      text-align: center;
      margin: 20px;
      background: #f9f9f9;
    }
    .drop-zone.drag-over {
      background: #e3f2fd;
      border-color: #2196F3;
    }
  </style>
</head>
<body>
  <h2>从浏览器地址栏拖拽 URL</h2>
  <p>尝试从浏览器地址栏或书签栏拖拽 URL 到下方区域</p>

  <div class="drop-zone" id="urlDropZone">
    <p style="font-size: 48px;">🔗</p>
    <p>拖拽 URL 到这里</p>
  </div>

  <div id="result"></div>

  <script>
    const urlDropZone = document.getElementById('urlDropZone');
    const result = document.getElementById('result');

    urlDropZone.addEventListener('dragenter', function(e) {
      e.preventDefault();
      this.classList.add('drag-over');
    });

    urlDropZone.addEventListener('dragover', function(e) {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'link';
    });

    urlDropZone.addEventListener('dragleave', function(e) {
      this.classList.remove('drag-over');
    });

    urlDropZone.addEventListener('drop', function(e) {
      e.preventDefault();
      this.classList.remove('drag-over');
      
      // 获取 URI 列表
      const uriList = e.dataTransfer.getData('text/uri-list');
      const plainText = e.dataTransfer.getData('text/plain');
      
      console.log('URI List:', uriList);
      console.log('Plain Text:', plainText);
      
      if (uriList) {
        // 解析 URI 列表
        const uris = uriList.split('\n').filter(uri => {
          return uri.trim() && !uri.startsWith('#');
        });
        
        result.innerHTML = `
          <h3>接收到的 URL:</h3>
          <ul>
            ${uris.map(uri => `
              <li>
                <a href="${uri}" target="_blank">${uri}</a>
              </li>
            `).join('')}
          </ul>
        `;
      } else if (plainText) {
        result.innerHTML = `
          <h3>接收到的文本:</h3>
          <p>${plainText}</p>
        `;
      }
    });
  </script>
</body>
</html>

多个 URI 的格式:

// 设置多个 URI
const uriList = `https://www.example.com
https://www.github.com
# 这是注释
https://www.google.com`;

e.dataTransfer.setData('text/uri-list', uriList);

// 接收时解析
dropZone.addEventListener('drop', function(e) {
  e.preventDefault();
  
  const uriList = e.dataTransfer.getData('text/uri-list');
  
  // 解析 URI 列表
  const uris = uriList.split('\n').filter(line => {
    const trimmed = line.trim();
    // 过滤空行和注释
    return trimmed && !trimmed.startsWith('#');
  });
  
  console.log('解析出的 URI:', uris);
  // ['https://www.example.com', 'https://www.github.com', 'https://www.google.com']
});

浏览器支持情况:

浏览器支持版本说明
Chrome✅ 所有版本完全支持
Firefox✅ 所有版本完全支持
Safari✅ 所有版本完全支持
Edge✅ 所有版本完全支持
IE✅ IE 10+部分支持

常见使用场景:

  1. 拖拽浏览器书签
  2. 拖拽地址栏 URL
  3. 拖拽网页中的链接
  4. 拖拽邮件客户端中的链接
  5. 拖拽文件管理器中的网络位置

注意事项:

  1. text/uri-list 只能包含 URI,不能包含其他数据
  2. 每个 URI 必须是完整的绝对 URI(包含协议)
  3. 相对 URI 可能在某些浏览器中不被支持
  4. 建议同时设置 text/plain 作为备用

八、高级功能

8.1 自定义拖拽图像

<!DOCTYPE html>
<html>
<head>
  <style>
    .drag-item {
      padding: 20px;
      background: #2196F3;
      color: white;
      cursor: move;
      margin: 20px;
      display: inline-block;
    }
    .custom-drag-image {
      padding: 15px;
      background: #FF5722;
      color: white;
      border-radius: 8px;
      position: absolute;
      left: -9999px;
    }
  </style>
</head>
<body>
  <div class="drag-item" draggable="true" id="item">
    拖动我(自定义拖拽图像)
  </div>

  <!-- 自定义拖拽图像(隐藏) -->
  <div class="custom-drag-image" id="customImage">
    🎯 正在拖拽...
  </div>

  <div style="min-height: 200px; border: 2px dashed #999; margin: 20px; padding: 20px;" id="dropZone">
    拖放区域
  </div>

  <script>
    const item = document.getElementById('item');
    const customImage = document.getElementById('customImage');
    const dropZone = document.getElementById('dropZone');

    item.addEventListener('dragstart', function(e) {
      e.dataTransfer.setData('text/plain', 'Custom Image Demo');
      
      // 设置自定义拖拽图像
      // 参数: (元素, x偏移, y偏移)
      e.dataTransfer.setDragImage(customImage, 50, 25);
    });

    dropZone.addEventListener('dragover', function(e) {
      e.preventDefault();
    });

    dropZone.addEventListener('drop', function(e) {
      e.preventDefault();
      const data = e.dataTransfer.getData('text/plain');
      this.innerHTML = `<p>接收到: ${data}</p>`;
    });
  </script>
</body>
</html>

8.2 拖拽时的视觉反馈

<!DOCTYPE html>
<html>
<head>
  <style>
    .item {
      padding: 15px;
      margin: 10px;
      background: #4CAF50;
      color: white;
      cursor: move;
      transition: opacity 0.3s;
    }
    .item.dragging {
      opacity: 0.5;
      border: 2px dashed #fff;
    }
    .drop-zone {
      min-height: 150px;
      border: 2px dashed #999;
      margin: 10px;
      padding: 20px;
      transition: all 0.3s;
    }
    .drop-zone.drag-over {
      background: #e3f2fd;
      border-color: #2196F3;
      border-width: 3px;
      transform: scale(1.02);
    }
  </style>
</head>
<body>
  <div class="item" draggable="true">项目 1</div>
  <div class="item" draggable="true">项目 2</div>
  <div class="item" draggable="true">项目 3</div>

  <div class="drop-zone">拖放区域</div>

  <script>
    const items = document.querySelectorAll('.item');
    const dropZone = document.querySelector('.drop-zone');

    items.forEach(item => {
      // 拖拽开始 - 添加视觉效果
      item.addEventListener('dragstart', function(e) {
        this.classList.add('dragging');
        e.dataTransfer.setData('text/plain', this.textContent);
      });

      // 拖拽结束 - 移除视觉效果
      item.addEventListener('dragend', function(e) {
        this.classList.remove('dragging');
      });
    });

    dropZone.addEventListener('dragenter', function(e) {
      e.preventDefault();
      this.classList.add('drag-over');
    });

    dropZone.addEventListener('dragover', function(e) {
      e.preventDefault();
    });

    dropZone.addEventListener('dragleave', function(e) {
      this.classList.remove('drag-over');
    });

    dropZone.addEventListener('drop', function(e) {
      e.preventDefault();
      this.classList.remove('drag-over');
      
      const data = e.dataTransfer.getData('text/plain');
      const newItem = document.createElement('div');
      newItem.style.cssText = 'padding: 10px; margin: 5px; background: #4CAF50; color: white;';
      newItem.textContent = data;
      this.appendChild(newItem);
    });
  </script>
</body>
</html>

九、在 Vue 框架中使用注意事项

9.1 Vue 2 中的使用

基本用法

<template>
  <div>
    <!-- 拖拽源 -->
    <div
      v-for="item in items"
      :key="item.id"
      draggable="true"
      @dragstart="handleDragStart(item)"
    >
      {{ item.name }}
    </div>

    <!-- 拖放目标 -->
    <div
      class="drop-zone"
      @dragover.prevent
      @drop="handleDrop"
    >
      拖放区域
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, name: '项目 1' },
        { id: 2, name: '项目 2' }
      ],
      dragData: null // 使用组件状态存储拖拽数据
    };
  },
  methods: {
    handleDragStart(item) {
      // 直接保存到组件状态
      this.dragData = item;
    },
    
    handleDrop(event) {
      event.preventDefault();
      
      if (this.dragData) {
        console.log('接收到:', this.dragData);
        // 处理数据...
      }
      
      this.dragData = null;
    }
  }
};
</script>

Vue 2 注意事项

  1. 必须使用 .prevent 修饰符
<!-- ✅ 正确 -->
<div @dragover.prevent="handleDragOver"></div>

<!-- ❌ 错误:drop 事件不会触发 -->
<div @dragover="handleDragOver"></div>
  1. 响应式数据更新
// ❌ 错误:直接修改数组可能不触发更新
this.items.push(newItem);

// ✅ 正确:使用 Vue.set 或替换整个数组
this.items = [...this.items, newItem];
// 或
this.$set(this.items, this.items.length, newItem);
  1. 数据传递推荐方式

在 Vue 2 中,推荐使用组件状态而不是 dataTransfer:

// ✅ 推荐:使用组件状态
data() {
  return {
    dragData: null
  };
},
methods: {
  handleDragStart(item) {
    this.dragData = item; // 直接保存
  },
  handleDrop(event) {
    event.preventDefault();
    console.log(this.dragData); // 直接使用
  }
}

9.2 Vue 3 中的使用

组合式 API (Composition API)

<template>
  <div>
    <!-- 拖拽源 -->
    <div
      v-for="item in items"
      :key="item.id"
      draggable="true"
      @dragstart="handleDragStart(item)"
    >
      {{ item.name }}
    </div>

    <!-- 拖放目标 -->
    <div
      class="drop-zone"
      @dragover.prevent
      @drop="handleDrop"
    >
      拖放区域
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const items = ref([
  { id: 1, name: '项目 1' },
  { id: 2, name: '项目 2' }
]);

const dragData = ref(null);

const handleDragStart = (item) => {
  dragData.value = item;
};

const handleDrop = (event) => {
  event.preventDefault();
  
  if (dragData.value) {
    console.log('接收到:', dragData.value);
    // 处理数据
    items.value.push({ ...dragData.value });
  }
  
  dragData.value = null;
};
</script>

Vue 3 注意事项

  1. 响应式引用
// ✅ 正确:使用 .value 访问 ref
const dragData = ref(null);
dragData.value = item;

// ❌ 错误:忘记 .value
dragData = item;
  1. 响应式数组操作
// ✅ Vue 3 中直接修改数组是响应式的
items.value.push(newItem);
items.value.splice(index, 1);

// 也可以替换整个数组
items.value = [...items.value, newItem];
  1. 使用 toRefs 解构
import { reactive, toRefs } from 'vue';

const state = reactive({
  items: [],
  draggedItem: null
});

// ✅ 使用 toRefs 保持响应式
const { items, draggedItem } = toRefs(state);
  1. 数据传递推荐方式(重要!)

在 Vue 3 中,同样推荐使用组件状态而不是 dataTransfer:

<template>
  <div>
    <!-- 拖拽源 -->
    <div
      v-for="item in sourceItems"
      :key="item.id"
      draggable="true"
      @dragstart="handleDragStart(item)"
    >
      {{ item.name }}
    </div>

    <!-- 拖放目标 -->
    <div
      class="drop-zone"
      @dragover.prevent
      @drop="handleDrop"
    >
      拖放区域
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const sourceItems = ref([
  { id: 1, name: '项目 1', metadata: { /* 复杂对象 */ } },
  { id: 2, name: '项目 2', metadata: { /* 复杂对象 */ } }
]);

// 使用 ref 存储拖拽数据
const dragData = ref(null);

// ✅ 推荐:直接使用组件状态
const handleDragStart = (item) => {
  // 直接保存,无需序列化
  dragData.value = item;
  
  // 不需要使用 dataTransfer!
};

const handleDrop = (event) => {
  event.preventDefault();
  
  // 直接使用,无需反序列化
  if (dragData.value) {
    console.log('接收到数据:', dragData.value);
    console.log('可以直接访问复杂对象:', dragData.value.metadata);
    
    // 处理数据...
  }
  
  // 清空
  dragData.value = null;
};
</script>

9.3 Vue 通用最佳实践

  1. 什么时候必须使用 dataTransfer?

只有以下场景才需要使用 dataTransfer:

// ❌ 场景 1: 跨窗口拖拽(必须使用 dataTransfer)
methods: {
  handleDragStart(event, item) {
    // 跨窗口无法访问组件状态,必须序列化
    event.dataTransfer.setData('application/json', JSON.stringify(item));
  },
  handleDrop(event) {
    const data = JSON.parse(event.dataTransfer.getData('application/json'));
  }
}

// ❌ 场景 2: 拖拽文件(必须使用 dataTransfer.files)
methods: {
  handleDrop(event) {
    event.preventDefault();
    const files = event.dataTransfer.files;
    console.log('接收到文件:', files);
  }
}

// ❌ 场景 3: 从外部拖拽内容(如浏览器地址栏、其他应用)
methods: {
  handleDrop(event) {
    event.preventDefault();
    // 获取拖拽的 URL
    const url = event.dataTransfer.getData('text/uri-list');
    // 获取拖拽的文本
    const text = event.dataTransfer.getData('text/plain');
  }
}
  1. 对比总结
<!-- ✅ 推荐:同一父组件内使用组件状态 -->
<script>
export default {
  data() {
    return {
      dragData: null // 简单直接
    };
  },
  methods: {
    onDragStart(item) {
      this.dragData = item; // 无需序列化
    },
    onDrop() {
      console.log(this.dragData); // 直接使用
    }
  }
};
</script>

<!-- ❌ 不推荐:同一父组件内使用 dataTransfer -->
<script>
export default {
  methods: {
    onDragStart(event, item) {
      // 需要序列化,代码冗余
      event.dataTransfer.setData('application/json', JSON.stringify(item));
    },
    onDrop(event) {
      // 需要反序列化,容易出错
      const data = JSON.parse(event.dataTransfer.getData('application/json'));
    }
  }
};
</script>

十、在 React 框架中使用注意事项

10.1 React 基本用法

import React, { useState } from 'react';

function DragDropExample() {
  const [items] = useState([
    { id: 1, name: '项目 1' },
    { id: 2, name: '项目 2' }
  ]);
  
  const [draggedItem, setDraggedItem] = useState(null);
  const [droppedItems, setDroppedItems] = useState([]);

  const handleDragStart = (event, item) => {
    setDraggedItem(item);
    event.dataTransfer.effectAllowed = 'copy';
    
    // 也可以使用 dataTransfer
    event.dataTransfer.setData('application/json', JSON.stringify(item));
  };

  const handleDragEnd = () => {
    setDraggedItem(null);
  };

  const handleDragOver = (event) => {
    event.preventDefault();
    event.dataTransfer.dropEffect = 'copy';
  };

  const handleDrop = (event) => {
    event.preventDefault();
    
    if (draggedItem) {
      setDroppedItems([...droppedItems, draggedItem]);
      setDraggedItem(null);
    }
  };

  return (
    <div>
      <div>
        <h3>拖拽源</h3>
        {items.map(item => (
          <div
            key={item.id}
            draggable
            onDragStart={(e) => handleDragStart(e, item)}
            onDragEnd={handleDragEnd}
            style={{
              padding: '10px',
              margin: '5px',
              background: '#4CAF50',
              color: 'white',
              cursor: 'move'
            }}
          >
            {item.name}
          </div>
        ))}
      </div>

      <div
        onDragOver={handleDragOver}
        onDrop={handleDrop}
        style={{
          minHeight: '200px',
          border: '2px dashed #999',
          padding: '20px',
          marginTop: '20px'
        }}
      >
        <h3>拖放区域</h3>
        {droppedItems.map((item, index) => (
          <div key={index} style={{ padding: '10px', background: '#f0f0f0', margin: '5px' }}>
            {item.name}
          </div>
        ))}
      </div>
    </div>
  );
}

export default DragDropExample;

十一、常见问题汇总

问题 1: drop 事件不触发

原因: 没有在 dragover 中调用 preventDefault()

// ✅ 正确
element.addEventListener('dragover', (e) => {
  e.preventDefault();
});

问题 2: 无法传递对象数据

原因: dataTransfer 只能传递字符串

// ✅ 正确:序列化对象
e.dataTransfer.setData('application/json', JSON.stringify(obj));

// 接收时反序列化
const obj = JSON.parse(e.dataTransfer.getData('application/json'));

问题 3: 拖拽图像显示不正确

解决方案: 使用 setDragImage 自定义

e.dataTransfer.setDragImage(customElement, offsetX, offsetY);

问题 4: 移动端不支持

解决方案: 需要额外处理 touch 事件或使用第三方库

// 监听 touch 事件模拟拖拽
element.addEventListener('touchstart', handleTouchStart);
element.addEventListener('touchmove', handleTouchMove);
element.addEventListener('touchend', handleTouchEnd);

问题 5: 跨域拖拽限制

解决方案: 只能在同源窗口间拖拽,跨域需要其他方案(如 postMessage)

十二、参考资料

  1. MDN - HTML Drag and Drop API
  2. MDN - DataTransfer
  3. Vue 3 官方文档
  4. React 官方文档

🎵 你的听歌历史,不应只是流媒体服务器上的冷数据

大家好,作为一名 macOS 深度乐迷,我一直觉得:音乐不仅是流动的空气,更是我们生命中不曾停歇的数字资产。

然而,目前的流媒体平台( Apple Music, Tidal, Qobuz 等)往往将我们的听歌记录封锁在各自的围墙内。如果你使用 AudirvanaRoon 追求极致音质,这些宝贵的聆听痕迹更是难以被统一记录和深度挖掘。

于是,我用 Go 编写了 SonicLens (音眸)


✨ 什么是 SonicLens ?

SonicLens 是一架专为 macOS 用户打造的“声之透镜”。它静默地守候在播放器之后,通过高频采样与无感监控,将你的每一次聆听凝结为属于你个人的、跨平台的音眸轨迹

🔗 GitHub: https://github.com/vincentchyu/sonic-lens


🚀 核心亮点

  • 🛡️ 数据资产化: 所有播放数据通过 SQLite 、MySQL 存储在本地,彻底摆脱平台限制,你的数据你做主。
  • 🎧 多平台无感监控: 基于 Go 并发特性,完美支持 AudirvanaRoon 以及 Apple Music 的实时状态采集。
  • 👁️ 音眸智能洞察 (Sonic Insight): 接入 AI 大模型( Gemini/Ollama 等),对歌词进行深度情感与语义解析,帮你从文字维度重新认识每一首歌,尤其是小语种。
  • 实时交互仪表板: 采用 WebSocket 架构,秒级同步播放状态(比特率、封面、进度等),UI 追求极致的设计感。
  • 📮 一键分享印记: 生成带有封面的 AI 见解海报,让你的聆听瞬间充满仪式感。


🛠️ 技术侧写

作为一个程序员,我在实现过程中加入了一些好玩的细节:

  • 使用 Goroutines 为每个播放器开启独立监听。
  • 通过 AppleScript 实现对底层播放器的无感采样。
  • 遵循 Last.fm 协议实现 Scrobble 逻辑,并使用 Redis 进行状态预测加速。
  • 前端基于 SSE (Server-Sent Events) 实现 AI 解析结果的流式呈现。


📸 效果展示

(附上 README 中的几张截图)

项目展示
AI 解析展示 1
AI 解析展示 2
播放展示


📢 结语

这个项目是我对“科技与人文”结合的一次尝试。如果你也是对音质有追求、对数据有执念的 macOS 用户,欢迎试用并提出你的建议!

如果觉得还不错,也欢迎点个 Star 鼓励一下。

再次感谢大家的时间。


GitHub 地址:https://github.com/vincentchyu/sonic-lens


小红书可以搜索 #Soniclens #音眸轨迹 词条

今日速览

  1. TestSprite 2.1:AI 团队的自动化测试管家,速度提升 5 倍。
  2. Copperlane:用 AI 秒批贷款,告别漫长等待。
  3. Codex Security:代码安全守护者,自动揪出漏洞。
  4. FasterGH:GitHub 镜像加速器,浏览快如闪电。
  5. 21st Agents SDK:一键集成 AI 代理,让应用秒变智能。
  6. Variant:创意无限的设计灵感库,滑动即得。
  7. LTX Desktop:本地开源视频编辑器,GPU 加持 AI 生成。
  8. Thinking Line:AI 涂鸦转矢量,轻松制作解说视频。
  9. Tailwind Form Builder:拖拽生成响应式表单,无需登录。
  10. NotchPad:Mac 刘海里的安全记事本,点击即用。


1. TestSprite 2.1

这款神器能帮 AI 原生团队自动搞定测试,让开发流程无缝衔接。

  • 直接连到 IDE,自动生成完整测试套件,无需手动操作
  • 测试引擎提速 4-5 倍,几分钟内完成全量测试
  • 可视化编辑器支持点击查看实时快照,即时修复问题
  • 集成 GitHub,PR 时自动运行测试,失败则阻止合并

热度:🔺401

TestSprite 2.1

访问官网 Product Hunt 详情


2. Copperlane

它用 AI 重构贷款流程,把几小时的审批压缩到秒级。

  • AI 助手 Penny 优化利率定价,指导借款人
  • 自动验证文档,大幅缩短处理时间
  • 基于人工智能的贷款发放系统,提升效率

热度:🔺276

Copperlane

访问官网 Product Hunt 详情


3. Codex Security

你的代码安全管家,自动扫描漏洞并给出修复方案。

  • 发现代码库中的潜在漏洞
  • 验证漏洞真实性,提出修复建议
  • 帮助团队聚焦关键问题,加速发布

热度:🔺274

Codex Security

访问官网 Product Hunt 详情


4. FasterGH

给 GitHub 加个涡轮,用镜像界面实现低延迟浏览。

  • 基于 Convex 提供实时缓存和同步
  • 保持 GitHub 为数据源,提升读取速度
  • 优化开源代码浏览体验

热度:🔺224

FasterGH

访问官网 Product Hunt 详情


5. 21st Agents SDK

最快的方式把 AI 代理塞进你的应用,省去基础设施烦恼。

  • 用 TypeScript 定义代理,一键部署
  • 内置流媒体、会话管理和计费功能
  • 提供即用型聊天界面,专注差异化
  • 获 Y Combinator 支持

热度:🔺187

21st Agents SDK

访问官网 Product Hunt 详情


6. Variant

告别解释,直接滑动获取无尽设计灵感,像有个创意总监随时待命。

  • 直观展示设计选项,无需描述需求
  • 捕捉氛围感,应用到你的项目中
  • 提供丰富视觉选择,激发创意

热度:🔺134

Variant

访问官网 Product Hunt 详情


7. LTX Desktop

一款本地运行的开源视频编辑器,GPU 加速 AI 生成,免费又强大。

  • 非线性视频编辑,结合设备端 AI 技术
  • 完全免费开源,本地运行不依赖云
  • 基于 LTX-2.3 版本,GPU 优化提升性能

热度:🔺125

LTX Desktop

访问官网 Product Hunt 详情


8. Thinking Line

用 AI 把涂鸦变成可编辑的矢量图和视频,让创意动起来。

  • 将提示转换为 SVG 文件,支持编辑
  • 生成引人入胜的解说视频
  • 基于人工智能进行图像矢量化

热度:🔺112

Thinking Line

访问官网 Product Hunt 详情


9. Tailwind Form Builder

拖拽几下,就能生成干净漂亮的响应式表单,连登录都免了。

  • 免费拖拽构建器,快速创建 HTML 表单
  • 专为 Tailwind CSS 设计,导出纯净代码
  • 支持 HTML、React 或 Vue,无平台限制
  • 无需注册登录,即用即走

热度:🔺103

Tailwind Form Builder

访问官网 Product Hunt 详情


10. NotchPad

把 MacBook Pro 的刘海变成随时可用的安全记事本,点击即写。

  • 集成在刘海区域,无需切换窗口
  • 结合便签、剪贴板管理和代码片段功能
  • 支持 AES-256 加密、1Password 和 Touch ID
  • 数据本地保存,15 天免费试用后一次性付费

热度:🔺98

NotchPad

访问官网 Product Hunt 详情

点赞 + 关注 + 收藏 = 学会了

整理了一个NAS小专栏,有兴趣的工友可以关注一下 👉 《NAS邪修》

AutoPiano(自由钢琴)是一款网页钢琴模拟器,支持电脑键盘和鼠标演奏,内置多种乐器音色与教学曲谱,零基础也能轻松上手。

01.png

在飞牛 NAS,找到“文件管理”App,在“docker”文件夹里创建一个“autopiano”文件夹。

02.png

然后打开“Docker”App,在“Compose”页面创建一个项目。

相关配置如下图所示。

03.png

代码如下:

services:
  autopiano:
    image: wbsu2003/autopiano:latest
    container_name: autopiano
    ports:
      - 2338:80
    restart: unless-stopped

这里配置的端口是 2338,如果这个端口和你其他项目冲突了,可以设置成别的端口。

项目构建成功后,在浏览器访问 NAS的IP:2338 就可以弹琴了。

04.png

AutoPiano 内置了一些琴谱,你跟着琴谱打字就可以弹出一首曲子了。

你就弹吧~


以上就是本文的全部内容啦,有疑问可以在评论区讨论~

想了解更多NAS玩法可以关注《NAS邪修》👏

往期推荐:

点赞 + 关注 + 收藏 = 学会了

Python 的内置函数 frozenset 用于创建一个不可变的集合对象。与普通集合 set 不同,frozenset 一旦创建就不能修改(如添加或删除元素),这使得它可以作为字典的键或其他集合的元素。

基本用法

fs = frozenset([1, 2, 3, 4])
print(fs)  # 输出: frozenset({1, 2, 3, 4})

特性

  1. 不可变性frozenset 的内容在创建后无法更改,因此它是哈希的,可以作为字典的键。

    d = {frozenset([1, 2]): "value"}
    print(d)  # 输出: {frozenset({1, 2}): 'value'}
  2. 支持集合操作frozenset 支持常见的集合操作,如并集(union)、交集(intersection)、差集(difference)等。

    fs1 = frozenset([1, 2, 3])
    fs2 = frozenset([3, 4, 5])
    print(fs1.union(fs2))  # 输出: frozenset({1, 2, 3, 4, 5})
  3. 性能优化:由于不可变,frozenset 在某些操作上比可变集合更高效,尤其是在哈希和比较时。

应用场景

  • 字典键:当需要使用集合作为字典的键时,frozenset 是唯一选择。

    groups = {
        frozenset(["admin", "editor"]): "high_privilege",
        frozenset(["viewer"]): "low_privilege"
    }
  • 集合元素:当集合的元素需要是另一个集合时,必须使用 frozenset

    s = {frozenset([1, 2]), frozenset([3, 4])}

注意事项

  • 创建 frozenset 时,传入的可迭代对象中的重复元素会被自动去重。
  • 由于不可变性,frozenset 没有 addremove 等方法。

示例

# 去重示例
fs = frozenset([1, 2, 2, 3])
print(fs)  # 输出: frozenset({1, 2, 3})

# 错误示例(尝试修改会报错)
fs.add(4)  # 抛出 AttributeError: 'frozenset' object has no attribute 'add'

frozenset 提供了一种安全、高效的方式来处理不可变集合需求,特别适用于需要哈希或不可变性的场景。

突然想到一件事我这有个小县城,一辆电车着火了

旁边银行的人拿着干粉灭火器使劲喷,然后车就烧的一干二净

旁边有人问这车咋着火了,有人说因为他充的是好电,这是个烂车

以为旁边人不懂也就当个笑话,然后过了好久好久

突然我就又听到这句话了,一个年轻人说的,跟我差不多大
这个充电桩充的快,是因为充的好电

非常棒...

在中国银行 app 首页即可看到该活动。
如果有实际消费可以实际消费,如果没有的话,可以给余额宝或是微信零钱通充 10000,然后再提现。
大部分是实际消费的。
活动内容具体看介绍吧。

image

image

11 月份的又报名了。

有人中一等奖、二等奖,我就不上传图片了,等自己中了再上传实际的图片。

Chatbox AI / Cherry Studio / Monica 这类第三方工具需要通过 LLM 的 API 去调用模型的。本身开 llm plus 或者 pro 也没办法在第三方平台使用?如果是 api 的费用应该比 plus ,pro 要高吧?

或者 在第三方平台开其会员。但是这样就和中转站一样了吧?我之前试过几个平台,感觉明显的降智,根本无法判断是其声明的模型。通用的提示词和问题,gpt5.2 ,gemini pro 的结果和官方 ui 差别巨大。

所以不是太理解这类平台的用户群体是什么样的?或者应该什么场景使用?

开源了一个 terminal-first 的 Twitter/X CLI 。
不需要 API key ,直接用浏览器 Cookie 读取 for-you / following timeline 、bookmarks 、user posts 。
支持 JSON output 。
可配置的打分算法进行筛选想看的帖子。

目前是直接用 API 来做的,比较稳定,后面还会用 playwright/agent-browser 支持更多的功能。

https://github.com/jackwener/twitter-cli

即上次 openai 出现 bug 重置额度后,今天又重置了额度。官方人员说是因为有用户反馈额度消耗过快,所以重置了。

切换 gpt-5.4 后,额度消耗真的很快,我以为是因为 5.4 输出快导致的,没想到很可能是一个 bug 。

这世界果然都是草台班子。

我知道有的人图片显示正常,也肯定有人建议换节点。

但是:

即便已经能访问 V2EX 的用户,仍然相当一部份用户无法访问 imgur ,因为被主动屏蔽。

发贴发图是为了传递信息,如果你不在意大量的人看到图裂的话,可以无视本贴。

Google 和 V2 对于用户们来说基本是不可以替代的,但是 imgur 有很多代替品,这种玻璃心产品,放弃吧。

各位好。

最近看了这个帖子:
https://v2ex.com/t/1186956
(头戴式耳机选择?)

很多朋友推荐索尼,不过价格确实有点超预算。我目前预算大概 200 元左右,想买一款 国产头戴式耳机。

主要使用场景是:

1. 在咖啡厅自习时,屏蔽咖啡厅背景音乐

2. 听一些轻音乐

我自己简单搜了一下,大概看到这两个品牌 (参考了这个视频: https://www.bilibili.com/video/BV1LDAUzWENx/?spm_id_from=333.1391.0.0&vd_source=c006dc43b14db8cb4c565fcec508613e ):

1. iKF

2. 漫步者

想问问大家:
200 左右的预算,国产头戴式耳机符合我的需求吗?

谢谢!

由于在网上看到半价,就提前一周预约了,上面也显示 399 。去到门店后,一句没货就把我打发走了。想骂人,但是又觉得没用,只能自己默默走了。

今天在抖音也看到线下那种店有 399 ,不知道会不会骗人。如果是真的原装,就不用跑那么远了。我之前是要跑 30 几公里到市里,这种线下的好像家附近就有。