阅读视图

发现新文章,点击刷新页面。

实现最大异步并发执行队列

题目说明

日常开发中遇到异步并发执行时我们通常会使用 Promise.All([]) ,但是如果这个并发数量很大(如超过100)那么我们考虑到服务器的并发压力就需要设置一个最大并发数。

这也是一个初/中级的热门面试题,本文就详细介绍如何用各种姿势来实现 最大异步并发执行队列

/**
 * 最大异步并发执行队列
 * tasks 任务列表
 * maxConcurrency 最大并发数
 * @returns {Promise<void>}
 */
async function maxAsyncConcurrency(
  tasks: Array<() => Promise<void>>,
  maxConcurrency: number,
) {
  // 实现这个函数
  
}

测试代码


const wait = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const waitLog = async (ms, text) => {
  await wait(ms);
  console.log(text);
};
const main = async () => {
  await maxAsyncConcurrencyRecursion(
    [
      () => waitLog(1000, 1),
      () => waitLog(1000, 2),
      () => waitLog(1000, 3),
      () => waitLog(1000, 4),
      () => waitLog(1000, 5),
      () => waitLog(1000, 6),
      () => waitLog(1000, 7),
      () => waitLog(1000, 8),
      () => waitLog(1000, 9),
    ],
    3,
  );
}
main();

思路1:递归实现(最好理解)

通过递归方式实现,把每个并发当成一个运行管道,每个管道实现为一个运行任务的异步函数,函数中完成一个任务就从队列里取下一个任务继续执行,直到清空队列即可。

async function maxAsyncConcurrencyRecursion(tasks, maxConcurrency) {
  const queue = [...tasks];
  // 运行管道
  const pipeRunFn = async (fn) => {
    await fn();
    if (queue.length > 0) {
      const nextFn = queue.shift();
      await pipeRunFn(nextFn);
    }
  };
  // 最大运行管道
  const pipes = queue.splice(0, maxConcurrency);
  await Promise.all(pipes.map(pipeRunFn));
}

思路2:非递归实现

将思路1中的管道异步函数递归切换成 while 循环条件来实现。

async function maxAsyncConcurrency(fns, max) {
  const queue = [...fns];
  let active = 0;
  while(queue.length) {
    if (active >= max) {
      await wait(100); // 如果并发已经达到最大,就等会再进入while循环继续轮询
      continue;
    }
    const fn = queue.shift();
    active++;
    fn().finally(() => {
      active--;
    });
  }
}

更加贴合实践的用法,面向对象象实现流式新增任务

题目

class RequestQueue {
  private maxConcurrent: number; // 最大并发数量
  private queue: Array<() => Promise<void>> = []; // 存储任务队列
  
  constructor(maxConcurrent: number) {
    this.maxConcurrent = maxConcurrent;
  }

  /** 添加任务 */
  public addTask(task: () => Promise<void>) {}
}

测试代码


const main = async () => {
  const reqQueue = new RequestQueue(3);
  reqQueue.addTask(() => waitLog(1000, 1))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 2))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 3))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 4))
  await wait(2000);
  reqQueue.addTask(() => waitLog(1000, 5))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 6))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 7))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 8))
  await wait(100);
  reqQueue.addTask(() => waitLog(1000, 9))
}
main();

递归实现

流式增加任务,而不是一开始就拿到全量的任务列表。新增任务时自动触发并发执行

class RequestQueueRecursion {
  private maxConcurrent: number; // 最大并发数量
  private queue: Array<() => Promise<void>> = []; // 存储任务队列
  private active: number = 0; // 当前正在运行的任务计数

  constructor(maxConcurrent: number) {
    this.maxConcurrent = maxConcurrent;
  }

  /** 添加一个任务到队列中 */
  public addTask(task: () => Promise<void>) {
    this.queue.push(task);
    this.execute();
  }

  private async execute() {
    while(this.active < this.maxConcurrent && this.query.length > 0) {
      this.active ++;
      const fn = this.query.shift();
      fn().finally(() => {
        this.active--;
        this.execute();
      });
    }
  }
}

非递归实现

class RequestQueue {
  private maxConcurrent: number; // 最大并发数量
  private queue: Array<() => Promise<any>> = []; // 存储任务队列
  private active: number = 0; // 当前正在运行的任务计数

  constructor(maxConcurrent: number) {
    this.maxConcurrent = maxConcurrent;
  }

  /** 添加一个任务到队列中 */
  public addTask(task: () => Promise<any>) {
    this.queue.push(task);
    this.execute();
  }

  /** 运行管道 */
  private async execute() {
    const queue = this.queue;
    while(queue.length > 0) {
      if (this.active >= this.maxConcurrent) {
        await wait(100);
        continue;
      }
      this.active ++;
      const fn = queue.shift();
      fn().finally(() => {
        this.active--;
      });
    }
  }
}

上帝视角看 GPU 学习笔记

上帝视角看GPU(1):图形流水线基础_哔哩哔哩_bilibili

《上帝视角看 GPU》龚大教程学习笔记。

一、图形流水线基础

首先思考一张图片是如何显示在屏幕上呢?

首先需要了解一个概念帧缓存(Frame Buffer),这是内存中的一块区域,这块区域上的内容和显示器上显示的像素是一一对应的。

将帧缓存上的内容输出到屏幕上,需要通过显卡,显卡上有个输出端口连接到显示器上。

那这是最简单的需求,如果此时有一个需求:需要将图像的亮度提升 2 倍呢?也就是将每个像素的 RGB 分量都乘 2。

当然可以通过 CPU 来进行计算,但这样必然要占用大量 CPU 资源,更好的做法是加入一个 处理器(PU),这个处理器可以在每个像素上执行同样的操作;

为了适应更加灵活多变的需求,比如将图像上半部分亮度提升 2 倍,下半部分亮度提升 4 倍。我们可以在 PU 上挂上一个 **程序 **,这个程序是单入单出的,输入是像素坐标,输出的是像素 RGB 颜色,也就是 片元着色器 Pixel Shader。

好了,上边就是一个最基本的针对图像的流水线。

顺便一说,在任天堂的红白机上,就有这么一个处理器,叫做 PPU(Picture Processing Unit)

那么对于显示一个三维模型呢?我们来看看一个基础的完整的图形渲染管线:下图是一个最基础的图形渲染管线,其中绿色的部分是可编程的阶段,包括顶点着色器、片元着色器,红色的部分是固定流水线单元(Fixed-pipeline Unit),为了效率由硬件直接实现。

阶段 1:Input Assembler

Input Assembler 输入装配器,是图形流水线的第一个**固定阶段**(不可编程,但可配置)。它直接与应用程序(CPU端)提交的数据打交道。

💡PS:Input Assembler 是 Direct3D 中的明确标识的管线阶段,用于组装从 Vertex Buffers 和 Index Buffers 中的顶点数据,形成图元。

但在 OpenGL 中没有对应的阶段,这一“组装”的工作需要手动处理,通过glVertexAttribPointer 系列函数来指定顶点属性的格式和布局。

阶段 2: Vertex Shader

顶点着色器(Vertex Shader)是 图形渲染管线中第一个可编程的 部分,

图形渲染管线的第一个可编程部分是顶点着色器(Vertex Shader),它把一个单独的顶点 Vertex 作为输入,经过 shader 处理后输出,顶点着色器主要的目的是对 3D 坐标进行 MVP 变换,变换为屏幕坐标,同时顶点着色器允许我们对顶点属性进行一些基本处理。

阶段 3:Primitive Assembler

图元装配(Primitive Assembly)阶段也是一个固定阶段,用于将顶点着色器(或几何着色器)输出的所有顶点作为输入(如果是GL_TRIANGLE,那么就是一个三角形),并将所有的点装配成指定图元的形状。
![](https://cdn.nlark.com/yuque/0/2025/png/23057337/1757510502620-f817c78f-cdcd-44ec-958c-d9dfbbb9718b.png)

阶段 4:Rasterizer

图元装配阶段的输出会被传入光栅化阶段(Rasterization Stage),光栅化其实就是找出三角形所覆盖区域对应的屏幕上的像素,从而将这些像素提供给片元着色器。

光栅化阶段也是一个固定流水线单元,是一个算法固定的操作,由硬件直接处理,不可编程。

在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出屏幕范围以外的所有像素,用来提升执行效率。

💡 注意光栅化本质上是将顶点信息插值到图源覆盖的每个像素上。

这也可以解释从顶点着色器传值给片元着色器的值,会经过插值。这其实就是光栅化阶段后,片元着色器接收到的已经不是某个顶点的原始输出数据了,而是经过光栅化器插值后的、针对当前这个特定片元的值,这些变量在片元着色器中声明为 in 变量。

阶段 5: Pixel Shader

片段着色器 Pixel Shader 的主要目的是计算一个像素的最终颜色,这也是所有高级效果产生的地方。跟顶点着色器 Vertex Shader 一样,Pixel Shader 也是一个单入单出的结构,
#version 330 core
out vec4 FragColor;

in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)

void main()
{
    FragColor = vertexColor;
}

通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。

阶段 6:Output Merger

在所有对应颜色值确定以后,最终的对象将会被传到最后一个阶段,我们叫做**Output Merger**(或者Alpha测试和混合)阶段,这个阶段检测片段的对应的深度(和模板(Stencil))值,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值并对物体进行混合(Blend)。

所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。

Output Merger 也是固定流水线单元;

总结

+ 整个图形流水线要经过几个阶段,其中 Vertex Shader 和 Pixel Shader 是我们可以进行编程的阶段。 + 我们也必须定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)。 + Vertex Shader 和 Pixel Shader 编写就像是在编写一个回调函数,他们会对每一个顶点、每一个像素上执行。

二、逻辑上的模块划分

上一部分我们介绍了最基本的图形流水线,随着时代的发展,新的需求逐渐出现,现代的 GPU 不仅仅用于图形渲染,还可以进行通用计算、用于神经网络训练的 TensorCore 、用于视频编解码等多个领域,这些不同的领域在 GPU 上都是独立的模块,具有独立的流水线。我们来看看现在的 GPU 流水线是什么样的,以及他是如何发展来的。

1、 图形

Geometry Shader

Vertex Shader 和 Pixel Shader 都是单入单出的结构,只能处理单个顶点和像素。但如果我们要处理的是一个图元(三角形),就没法处理了。

这个需求催生了几何着色器 Geometry Shader 的出现, Geometry Shader 是渲染管线中一个可选的可编程阶段,它位于顶点着色器之后、图元装配和光栅化之前。几何着色器的输入是一个完整的图元及其所有顶点数据(例如,一个三角形需要输入3个顶点),而它的输出是零个、一个或多个全新的图元

换句话说,它是单入多出的结构。

注:Geometry Shader 看起来非常灵活,但实际使用时往往会发现性能很差,这是因为如由于灵活,硬件无法做各种假设来提升性能,只能实现的非常保守。

tessellation

image.png tessellation 的出现是因为对三角形细分需求的逐步增加,由于使用 Geometry shader 性能较差,GPU 流水线在 Vertex shader 后增加了专门的tessellation 功能,他是由三个部分组成 Hull Shader、tessellation、Domain Shader。

2、 计算

GPGPU 通用 GPU

由于 GPU 在图形渲染领域强大的计算能力,逐渐出现了使用 GPU 进行其他领域更加通用的并行计算的想法。

最早的想法是渲染一个覆盖屏幕的大三角形,在 Pixel Shader 里做通用的并行计算,相当于每一个像素 Pixel 是一个线程, 这种方式虽然很“hack”,但也产出了很多的成果,这个方向就是** GPU 通用计算(GPGPU)**。

这种方式虽然解决了一些问题,但也存在学习成本高(开发人员需要学习整个图形流水线)、存在性能浪费(还是需要通过整个图形流水线包括顶点着色器)。

这个需求进一步催生了有硬件支持的 GPGPU,不需要再通过图形流水线中那些固定的阶段,同时支持“多入多出”,独立与图形流水线单独存在,应用 GPU 上的计算单元进行通用计算的 shader 叫做 compute shader

3、 光线追踪

随着游戏对画面真实感的要求越来越高,基础图形流水线采用光栅化渲染方式,对于实现高质量画面往往要采用很多的 Hack,光线跟踪这一古老的技术逐渐引起了人们注意。

由于光线跟踪与光栅化渲染方式有着完全不一样的流程,长期以来研究人员一直在研究如何利用先有的 GPU 硬件实现光线跟踪,这样的需求随着 GPU 在硬件层面提供光线跟踪支持得到了实质的发展。

三、部署到硬件

前边介绍了 GPU 在逻辑模块上的划分,本节我们来看下具体对应到硬件上,GPU 是如何设计的。

unified shader

最开始的 GPU 设计上,Vertex Shader 和 Pixel Shader 有对应的处理单元进行处理,比如 2003 年的 GeForce FX 5600,有 2 个 Vertex Shader 单元和 4 个 Pixel Shader 单元,这意味着当顶点和像素的工作量是 1:2 的时候,他们才能发挥出最高效率。对于如果有一堆很小的三角形挤到一起(顶点多像素少)或很大的三角形覆盖(顶点少像素多)的情形都不能发挥出很高的效率。

最初这样设计是因为人们通常只用 Vertex Shader 处理坐标数据,需要有较高的精度处理能力;而 Pixel Shader 只处理纹理,只需低精度运算器,但需要采样器。

而随着需求的发展,Vertex Shader 和 Pixel Shader 的能力界限逐渐变的模糊。大规模地形渲染的需求,使得 Vertex Shader 得能读取纹理,而 Pixel Shader 进行通用计算的需求,也使得 Pixel Shader 得能处理高精度数据,最终就是两种处理单元统一了起来,叫做 <font style="color:rgb(15, 17, 21);">unified shader</font>,这样 GPU 也因为一致性而变得简单。

在 GPU 工作时,由调度器根据工作任务进行动态分配,决定哪些<font style="color:rgb(15, 17, 21);"> unified shader</font>用于处理 Pixel Shader,哪些处理 Vertex Shader。

最终的结果就是虽然图形流水线中有那么多的 shader,但在硬件层面他们的执行单元都是一样的。

四、完整的软件栈

理想的软件分层体系

理想的关于 GPU 软件分层体系:
  • API 应用程序接口层:为应用程序提供统一的编程接口(如 OpenGL、Vulkan、DirectX、CUDA 等),开发者使用 API 编写图形或计算任务,无需直接处理底层硬件细节;
  • OS 操作系统层;
  • DDI 设备驱动程序接口:是操作系统与 GPU 驱动程序之间的标准接口。由操作系统定义和实现;
  • Driver 驱动程序:将 API 调用翻译成 GPU 能理解的指令,管理 GPU 资源(如显存、命令队列),由 GPU 硬件厂商(如NVIDIA、AMD)实现,并且必须严格遵循DDI的规范;
  • GPU:GPU 接收由驱动程序提交的命令和数据,进行并行处理

但现实情况下会不一样,操作系统就包括了用户态和内核态。

Direct 3D

第一个例子是微软的 Direct 3D(D3D),这个 API 不跨平台(windows),但跨厂商(如NVIDIA、AMD)。

它将驱动拆分为了(用户态UMD + 内核态KMD),并引入引入核心调度器(DXGK),将厂商实现驱动程序由作文题变为填空题,减少了驱动程序开发工作量。

Direct 3D 采用自顶向下的模式,由微软定义 API,厂商来进行实现,不方便进行扩展。在 GPU 拥有新功能的硬件支持后,只有等待 Direct 3D 发布了新版本才能支持。

另外 D3D 的 每个版本的 API 是不兼容的,这意味着每出一版 D3D,程序都得大改才能用上。

OpenGL

OpenGL 是跨平台且跨厂商的,由 Khronos (开源组织)发布。

在不同的操作系统上,OpenGL

在 Windows 上,Windows 只提供了一个框架可安装用户驱动 ICD,让硬件厂商来实现 OpenGL runtime 的 UMD。

在 Linux 上,有两种方式,一种是完全由厂商来实现 UMD 和 KMD;另一种是基于 Mesa 框架。

OpenGL 的 API 设计是向下兼容的,之前的代码往往新版本也能用。

原教程视频中还有关于图形流水线中不可编程单元(重点光栅化)、光线跟踪流水线等部分内容,讲的也非常好。因为我本职工作涉及 WebGL 内容,所以对于暂时没有对这块内容不太有耐心写下来。大家由想了解的推荐看原视频。

React Native DApp 开发全栈实战·从 0 到 1 系列(兑换-合约部分)

前言

本文借助 Solidity 0.8、OpenZeppelin 与 Chainlink 喂价,构建一套 链上即时汇率结算、链下可信价格驱动 的微型兑换系统。本文将带你完成:

  1. 部署可铸造 ERC-20(BoykayuriToken,BTK)
  2. 部署 Chainlink 风格喂价合约(MockV3Aggregator),本地即可模拟 ETH/USD = 2000 的实时价格
  3. 部署 SwapToken —— 接收 ETH、按市价折算 USD、并立即向用户发放等值 BTK
  4. 使用 Hardhat 本地网络 + hardhat-deploy 插件一键启动,5 条指令完成编译、测试、部署全流程 无需前端,无需真实 LINK,即可体验 "价格输入 → 汇率计算 → 代币闪兑" 的完整闭环,为后续接入主网喂价、多币种池子、流动性挖矿奠定可复用的脚手架。

智能合约

代币合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SwapToken is Ownable {
    AggregatorV3Interface internal priceFeed;
    IERC20 public token;
    uint public constant TOKENS_PER_USD = 1000; // 1 USD = 1000 MTK

    constructor(address _priceFeed, address _token) Ownable(msg.sender) {
        priceFeed = AggregatorV3Interface(_priceFeed);
        token = IERC20(_token);
    }

    function swap() public payable {
        uint usd = getEthInUsd(msg.value);
        uint amount = usd * TOKENS_PER_USD;
        require(token.balanceOf(address(this)) >= amount, "Not enough liquidity");
        token.transfer(msg.sender, amount);
    }

    function getEthInUsd(uint ethAmount) public view returns (uint) {
        (, int price, , , ) = priceFeed.latestRoundData(); // price in 8 decimals
        uint ethUsd = (ethAmount * uint(price)) / 1e18; // ETH amount in USD (8 decimals)
        return ethUsd / 1e8; // return USD amount
    }

    receive() external payable {}
}
喂价合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";

contract MockV3Aggregator is AggregatorV3Interface {
    uint256 public constant versionvar = 4;

    uint8 public decimalsvar;
    int256 public latestAnswer;
    uint256 public latestTimestamp;
    uint256 public latestRound;
    mapping(uint256 => int256) public getAnswer;
    mapping(uint256 => uint256) public getTimestamp;
    mapping(uint256 => uint256) private getStartedAt;
    string private descriptionvar;

    constructor(
        uint8 _decimals,
        string memory _description,
        int256 _initialAnswer
    ) {
        decimalsvar = _decimals;
        descriptionvar = _description;
        updateAnswer(_initialAnswer);
    }

    function updateAnswer(int256 _answer) public {
        latestAnswer = _answer;
        latestTimestamp = block.timestamp;
        latestRound++;
        getAnswer[latestRound] = _answer;
        getTimestamp[latestRound] = block.timestamp;
        getStartedAt[latestRound] = block.timestamp;
    }

    function getRoundData(uint80 _roundId)
        external
        view
        override
        returns (
            uint80 roundId,
            int256 answer,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        )
    {
        return (
            _roundId,
            getAnswer[_roundId],
            getStartedAt[_roundId],
            getTimestamp[_roundId],
            _roundId
        );
    }

    function latestRoundData()
        external
        view
        override
        returns (
            uint80 roundId,
            int256 answer,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        )
    {
        return (
            uint80(latestRound),
            latestAnswer,
            getStartedAt[latestRound],
            latestTimestamp,
            uint80(latestRound)
        );
    }

    function decimals() external view override returns (uint8) {
        return decimalsvar;
    }

    function description() external view override returns (string memory) {
        return descriptionvar;
    }

    function version() external  pure override returns (uint256) {
        return versionvar;
    }
}
兑换合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SwapToken is Ownable {
    AggregatorV3Interface internal priceFeed;
    IERC20 public token;
    uint public constant TOKENS_PER_USD = 1000; // 1 USD = 1000 MTK

    constructor(address _priceFeed, address _token) Ownable(msg.sender) {
        priceFeed = AggregatorV3Interface(_priceFeed);
        token = IERC20(_token);
    }

    function swap() public payable {
        uint usd = getEthInUsd(msg.value);
        uint amount = usd * TOKENS_PER_USD;
        require(token.balanceOf(address(this)) >= amount, "Not enough liquidity");
        token.transfer(msg.sender, amount);
    }

    function getEthInUsd(uint ethAmount) public view returns (uint) {
        (, int price, , , ) = priceFeed.latestRoundData(); // price in 8 decimals
        uint ethUsd = (ethAmount * uint(price)) / 1e18; // ETH amount in USD (8 decimals)
        return ethUsd / 1e8; // return USD amount
    }

    receive() external payable {}
}
编译指令:npx hardhat compile

测试合约

const { ethers } = require("hardhat");
const { expect } = require("chai");

describe("SwapToken", function () {
  let SwapToken, MockToken, MockV3Aggregator;
  let owner, user;

  beforeEach(async () => {
    [owner, user] = await ethers.getSigners();
    await deployments.fixture(["MockV3Aggregator","token","SwapToken"]);
    const MockTokenAddress = await deployments.get("MyToken");          // 存入资产        // 奖励代币(USDC)
        const MockV3AggregatorAddress = await deployments.get("MockV3Aggregator");
        const SwapTokenAddress = await deployments.get("SwapToken");
        
        MockToken = await ethers.getContractAt("MyToken", MockTokenAddress.address);
        MockV3Aggregator = await ethers.getContractAt("MockV3Aggregator", MockV3AggregatorAddress.address);
        SwapToken = await ethers.getContractAt("SwapToken", SwapTokenAddress.address);
        // 给SwapToken合约铸造资产
        await MockToken.mint(await SwapToken.getAddress(), ethers.parseEther("1000000"));
        console.log('name',await MockToken.name())
        console.log("symbol",await MockToken.symbol())
        console.log(await MockV3Aggregator.latestAnswer())
   
  });

  it("Should swap ETH for MTK", async function () {
    const ethAmount = ethers.parseEther("1"); // 1 ETH = 2000 USD = 2,000,000 MTK
    await SwapToken.connect(user).swap({ value: ethAmount });

    const balance = await MockToken.balanceOf(user.address);
    console.log(balance)
    expect(balance).to.equal(2000 * 1000); // 2,000,000 MTK
  });
});
测试指令:npx hardhat test ./test/xxxx.js

部署合约

module.exports = async  ({getNamedAccounts,deployments})=>{
    const getNamedAccount = (await getNamedAccounts()).firstAccount;
    const secondAccount= (await getNamedAccounts()).secondAccount;
    console.log('secondAccount',secondAccount)
    const {deploy,log} = deployments;
    const MyAsset  = await deployments.get("MyToken");
    //执行MockV3Aggregator部署合约
  const MockV3Aggregator=await deploy("MockV3Aggregator",{
        from:getNamedAccount,
        args: [8,"ETH/USDC", 200000000000],//参数
        log: true,
    })
  console.log("MockV3Aggregator合约地址:", MockV3Aggregator.address);
    const SwapToken=await deploy("SwapToken",{
        from:getNamedAccount,
        args: [MockV3Aggregator.address,MyAsset.address],//参数 喂价,资产地址
        log: true,
    })
    // await hre.run("verify:verify", {
    //     address: TokenC.address,
    //     constructorArguments: [TokenName, TokenSymbol],
    //     });
    console.log('SwapToken 兑换合约地址',SwapToken.address)
}
module.exports.tags = ["all", "SwapToken"];
部署指令:npx hardhat deploy --tags token,MockV3Aggregator,SwapToken

总结

通过本文,我们完成了:

  • 价格层:MockV3Aggregator 遵循 Chainlink 接口,可无缝替换为主网喂价
  • 资产层:ERC-20 代币自带 mintOwnable,方便快速补充流动性
  • 兑换层:SwapToken 利用 latestRoundData() 实时计算 ETH→USD→BTK 数量,全程链上可查
  • 脚本层:Hardhat 脚本化部署 + 测试固件,保证"一键重置、秒级回滚",让迭代安全又高效

后续优化:

  • 把 Mock 喂价替换为 ETH/USD 主网聚合器
  • 引入 Uniswap V2 风格的流动性池,实现双向兑换

🚀🚀🚀 RichLab - 花呗前端团队招贤纳士 - 【转岗/内推/社招】

欢迎加入我们!与AI共舞,定义下一代智能金融体验

直接联系 👉🏻👉🏻👉🏻 邮箱:alei.xl@antgroup.com

💫 我们是谁?

RichLab - 花呗前端团队,一个充满活力与创新的技术团队,我们正在做一件超酷的事情 —— 让AI与金融完美融合,重新定义用户体验!

我们不是传统的前端团队,我们是金融科技前沿的探索者,是AI时代的先行者。在这里,每一行代码都可能改变千万用户的金融生活,每一次创新都可能引领行业潮流。

🎯 我们在做什么?

🌟 核心使命

负责亿级用户规模的AI产品核心功能研发,直接参与定义下一代智能金融交互。

简单来说,我们就是那个让花呗变得更智能、更懂你的团队!

🔥 AI创新业务探索方向

花呗正从“消费金融工具”进化为“懂你的成长型生活伙伴”。

  • 智能助理是服务大脑,统一调度各类助手,以亲密度为指挥棒,实现场景化、个性化的服务分发;
  • 多模态内容生成是情感触点,借力AIGC技术将用户的日常消费账单转化为具有情感温度的视觉化内容,让每一笔消费都有温度和记忆;
  • 端智能服务是感知神经,通过端侧实时感知用户行为、环境与意图,驱动智能助理提前介入,实现“比你更懂你”的主动式服务体验。

三者深度融合,共同构建一个有温度、有记忆、会思考的AI金融智能服务体。

image.png

🎪 为什么选择我们?

1️⃣ 技术作品丰富 💪

  • 拥有金融级稳定性的前端工程体系
  • 孵化了Rococo、Bakery、Lever、Tina、Galacean等备受赞誉的技术产品

2️⃣ AI前沿探索 🤖

  • 在AI领域重点投入,积极探索储备
  • 拥抱AI研发范式的全面转型
  • 打造有创意、有特色、行业领先的金融产品体验

3️⃣ 技术氛围浓厚 🌟

  • 多次在D2、SEEConf、Qcon、CCF中国计算机学会、W3C大会、CGS中国图学学会、中关村论坛等顶级会议担任嘉宾
  • 开放、协同、分享的浓厚技术氛围
  • 鼓励参与开源社区建设

4️⃣ 团队文化超赞 ❤️

  • 倡导「专业、匠心、自由、有爱」的技术文化
  • 重视团队成员成长,把个人发展作为第一优先级
  • 成员之间开放、平等、简单,充满极强的信任感

5️⃣ 生活丰富多彩 🎸

  • RichBand和吉他班:部门有自己的乐队,且为 0 基础同学提供系统性器乐培训,全面支持个人爱好
  • 健身文化:找到你的健身搭子、骑行搭子、跑步搭子
  • 日常惊喜:说走就走的团建、奶茶、黑珍珠,老板们常常制造惊喜

🎯 岗位要求

✅ 基础要求

  • 有扎实的计算机基础知识,熟悉常用的数据结构、算法和设计模式,在日常研发中灵活使用。
  • 掌握 HTML/CSS/JavaScript,熟悉 React/Vue/Angular 等前端框架。
  • 有丰富的前端性能优化及移动端适配经验。
  • 了解金融/互联网信贷业务,有相关行业经验者优先。
  • 具备良好的服务意识、团队协作精神,学习能力强。

💡 加分项

  • 有相关AI产品业务经验
  • 有开源项目经验
  • 长期维护有影响力的技术博客

🚀 在这里你能获得什么?

✨ 技术成长

  • 接触最前沿的AI技术
  • 参与亿级用户规模的产品开发
  • 在金融科技领域深耕,成为行业专家

✨ 个人发展

  • 包容、开放的心态拥抱人才多样性
  • 不论你是业务型、技术型、领域型、架构型、综合型人才,都能找到最适合的位置
  • 团队成员成长和发展是管理第一优先级

✨ 生活体验

  • 专业的技术氛围 + 轻松的生活氛围
  • 丰富的团队活动和文化建设
  • 与志同道合的伙伴一起成长

🎪 团队风采

🎉 团建、奶茶、黑珍珠,老板们的惊喜,让工作充满乐趣~ image.png

🎸 艺术细胞。我们不仅会写代码,还会玩音乐!部门有自己的乐队,定期排练演出,让技术人的艺术细胞得到充分释放。 image.png

💪 健身文化。技术要强,身体也要棒!在这里,你可以轻松找到健身搭子、骑行搭子、跑步搭子,大家一起在健身房共同进步。 image.png

🔥 更多资讯见「视频号:RichLab后花园」

📮 如何加入我们?

联系方式

邮箱:alei.xl@antgroup.com

🌟 写在最后

如果你:

  • 对AI技术充满热情
  • 想要在金融科技前沿探索
  • 希望在一个专业、有爱、自由的团队中成长
  • 想要参与定义下一代智能金融体验

那么,RichLab - 花呗前端团队就是你的不二之选! 我们相信,技术改变世界,AI改变金融。加入我们,让我们一起用代码和AI,创造更美好的金融未来!


🎯 还在等什么?快来加入我们,一起定义未来! RichLab - 花呗前端团队,期待与你相遇!

Oxc 和 Rolldown Q4 更新计划速览!🚀🚀🚀

前言

今天 Oxc 和 Rolldown 先后发布了 Q4 季度的更新计划,一起来看看吧!

往期精彩推荐

正文

Oxc

OXC Q4 计划

Q4 的重点在于推出 Alpha 和 Beta 版本的功能,扩展 linter、格式化和压缩能力:

  1. Oxlint Custom JavaScript Plugin Alpha

Oxlint将推出自定义JavaScript插件的Alpha版,支持开发者创建专属规则,集成到570+现有规则中。Oxlint已比ESLint快50-100倍,此功能将增强灵活性,适配特定项目需求,如自定义代码规范或复杂逻辑检查。Alpha版提供API和示例,开发者可通过GitHub测试。

  1. Formatter Alpha

格式化工具Alpha版将支持JavaScript和TypeScript代码的自动格式化,类似Prettier但速度更快。功能包括统一的缩进、引号处理和分号规则,优化与VS Code等编辑器的集成。Alpha阶段将验证核心算法,适合大规模代码库。

  1. Minifier Beta

压缩工具进入Beta阶段,利用Rust并行处理能力,优于Terser,生成更小的输出文件。支持死代码消除、变量重命名等高级选项,适用于生产环境。Beta版将进一步稳定性能。

  1. Type-Aware Linting Alpha

类型感知linter的Alpha版结合TypeScript类型信息,提供更精准的代码检查,如类型不匹配或空指针检测。相比传统linter,它能减少运行时错误,适合混合JS/TS项目。

Rolldown

Rolldown Q4 计划

Rolldown作为Rollup API兼容的捆绑器,目标是取代Vite中的esbuild,提供10-30倍性能提升。Q4计划聚焦于全功能捆绑、优化和Vite生态集成:

  1. 实现完整的捆绑流程,从入口到依赖,简化Vite生产构建,取代多工具组合,提供高效优化。

  2. 懒编译功能按需加载模块,缩短初始加载时间,结合Vite热重载,显著提升开发体验。

  3. 将importmaps集成到Vite,支持浏览器原生模块映射,简化第三方库导入,增强兼容性。

  4. 提高捆绑包大小:

  • Lazy Barrel Optimization:优化barrel文件懒加载,减少不必要导入。
  • More Cross-Chunk Optimizations:跨代码块优化,如共享模块提取和树摇,缩小bundle体积10-20%。
  • TS Const Enum Optimization:内联TypeScript常量枚举,减少运行时开销。
  1. 稳定Vite插件支持,确保无缝兼容,修复边缘问题并提供迁移指南。

  2. 自动解析tsconfig.json,简化TypeScript项目配置。

  3. 升级文档站点,提供详细指南、API参考和示例,方便开发者集成。

  4. 支持模块联邦,实现微前端动态加载,性能优于Webpack的类似功能。

最后

如果你期待重要的功能不再上述更新列表中,可以去社区积极反馈哈~

今天的分享就这些了,感谢大家的阅读,如果文章中存在错误的地方欢迎指正!

往期精彩推荐

Web安全必备:关键 HTTP 标头解析

保护您的网站免受注入漏洞的侵害

内容安全政策 (CSP)

跨站脚本攻击 (XSS) 是一种攻击,攻击者会利用网站上的漏洞注入和执行恶意脚本。

Content-Security-Policy 通过限制网页可以执行哪些脚本,提供了额外的层级来缓解 XSS 攻击。

Content-Security-Policy:
  script-src 'nonce-{RANDOM1}' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';

可信类型

基于 DOM 的 XSS 是一种攻击,攻击者会将恶意数据传递到支持动态代码执行的接收器(例如 eval() 或 .innerHTML)。

Content-Security-Policy: require-trusted-types-for 'script'
// Feature detection
if (window.trustedTypes && trustedTypes.createPolicy) {
  // Name and create a policy
  const policy = trustedTypes.createPolicy('escapePolicy', {
    createHTML: str => {
      return str.replace(/</g, '&lt;').replace(/>/g, '&gt;');
    }
  });
}
// Assignment of raw strings is blocked by Trusted Types.
el.innerHTML = &#39;some string&#39;; // This throws an exception.

// Assignment of Trusted Types is accepted safely.
const escaped = policy.createHTML(&#39;&lt;img src=x onerror=alert(1)&gt;&#39;);
el.innerHTML = escaped;  // &#39;&amp;lt;img src=x onerror=alert(1)&amp;gt;&#39;

X-Content-Type-Options

如果恶意 HTML 文档是从您的网域中提交的(例如,上传到照片服务中的图片包含有效的 HTML 标记),某些浏览器会将其视为有效文档,并允许其在应用上下文中执行脚本,从而导致跨网站脚本漏洞

X-Content-Type-Options: nosniff 通过指示浏览器为给定响应在 Content-Type 标头中设置的 MIME 类型正确无误来防止此类问题。

X-Content-Type-Options: nosniff

将您的网站与其他网站隔离

X-Frame-Options

如果恶意网站可以将您的网站嵌入为 iframe,攻击者就可能会通过点击欺骗来诱导用户执行意外操作。此外,在某些情况下,Spectre 类型的攻击会让恶意网站有机会了解嵌入式文档的内容。

X-Frame-Options 用于指示是否应允许浏览器在 <frame><iframe><embed> 或 <object> 中渲染网页。

X-Frame-Options: DENY

跨源资源政策 (CORP)

攻击者可以嵌入来自其他来源(例如您的网站)的资源,以利用基于网络的跨网站数据泄露来了解这些资源。

Cross-Origin-Resource-Policy 通过指明可由哪些网站加载来缓解此风险。标头采用以下三个值之一:same-originsame-site 和 cross-origin。建议所有资源发送此标头,以指明它们是否允许由其他网站加载。

Cross-Origin-Resource-Policy: same-origin

跨源打开者政策 (COOP)

攻击者的网站可以利用基于网页的跨网站数据泄露,在弹出式窗口中打开另一个网站,以了解该网站的相关信息。在某些情况下,这可能还会允许利用基于 Spectre 的旁道攻击。

Cross-Origin-Opener-Policy 标头提供了一种方法,可让文档与通过 window.open() 打开的跨源窗口或不带 rel="noopener" 的 target="_blank" 链接隔离。因此,文档的任何跨源打开器都不会引用该文档,也无法与其互动。

Cross-Origin-Opener-Policy: same-origin-allow-popups

跨源资源共享 (CORS)

与本文中的其他内容不同,跨源资源共享 (CORS) 不是标头,而是一种用于请求和允许访问跨源资源的浏览器机制。

默认情况下,浏览器会强制执行同源政策,以防止网页访问跨源资源。例如,在加载跨源图片时,即使该图片在网页上以可视方式显示,网页上的 JavaScript 也无法访问该图片的数据。资源提供方可以通过选择启用 CORS 来放宽限制,并允许其他网站读取资源。

Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Credentials: true

安全地构建强大的网站

跨源嵌入器政策 (COEP)

为了降低基于 Spectre 的攻击窃取跨源资源的能力,SharedArrayBuffer 或 performance.measureUserAgentSpecificMemory() 等功能默认处于停用状态。

Cross-Origin-Embedder-Policy: require-corp 会阻止文档和工作器加载跨源资源(例如图片、脚本、样式表、iframe 等),除非这些资源明确选择通过 CORS 或 CORP 标头加载。COEP 可与 Cross-Origin-Opener-Policy 结合使用,以选择将文档纳入跨源隔离

如需为文档启用跨源隔离,请使用 Cross-Origin-Embedder-Policy: require-corp

Cross-Origin-Embedder-Policy: require-corp

加密指向您网站的流量

HTTP 严格传输安全协议 (HSTS)

通过普通 HTTP 连接进行的通信不会加密,因此网络级窃听者可以访问传输的数据。

Strict-Transport-Security 标头会告知浏览器绝不应使用 HTTP 加载网站,而应改用 HTTPS。设置完毕后,在标头中定义的时间段内,浏览器将使用 HTTPS(而非 HTTP)访问网域,且不会重定向。

Strict-Transport-Security: max-age=31536000

优雅表格设计:CSS 美化技巧详解

一个普通 Table 分步骤进行美化 代码

<table>
  <thead>
    <tr>
      <th>Country</th>
      <th>Mean temperature change (°C)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>United Kingdom</th>
      <td>1.912</td>
    </tr>
    <tr>
      <th>Afghanistan</th>
      <td>2.154</td>
    </tr>
    <tr>
      <th>Australia</th>
      <td>0.681</td>
    </tr>
    <tr>
      <th>Kenya</th>
      <td>1.162</td>
    </tr>
    <tr>
      <th>Honduras</th>
      <td>0.945</td>
    </tr>
    <tr>
      <th>Canada</th>
      <td>1.284</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <th>Global average</th>
      <td>1.4</td>
    </tr>
  </tfoot>
</table>
  • 添加标题
<table>
<caption>Annual surface temperature change in 2022</caption>
</table>
  • 增加行距,对齐方式

第一列左对齐,其他列右对齐

body {
  font-family: "Open Sans", sans-serif;
  line-height: 1.5;
}
table {
  text-align: left;
}
th,
caption {
  text-align: start;
}
thead th:not(:first-child),
td {
  text-align: end;
}
th,
td {
  padding: 0.25rem 0.75rem;
}
  • 添加边框
table {
  border-collapse: collapse;
}
th,
td {
  border: 1px solid;
}
  • 修改表格头部和尾部
thead {
  border-block-end: 2px solid;
  background: whitesmoke;
}
tfoot {
  border-block: 2px solid;
  background: whitesmoke;
}
th,
td {
  border: 1px solid lightgrey;
}
  • 添加颜色
table {
  --color: #d0d0f5;
}
thead,
tfoot {
  background: var(--color);
}
tbody tr:nth-child(even) {
  background: color-mix(in srgb, var(--color), transparent 60%);
}
  • 固定第一列
th:first-child {
  position: sticky;
  inset-inline-start: 0;
}
td:first-of-type,
:where(thead, tfoot) th:nth-child(2) {
  border-inline-start: none;
}

th:first-child::after {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inset-inline-end: 0;
  width: 1px;
  height: 100%;
  background: lightgrey;
}
  • 垂直对齐
th,
td {
  vertical-align: baseline;
}

thead th {
  vertical-align: bottom;
}
  • 列宽度自适应
thead th:not(:first-child) {
  width: 9rem;
}
table {
  width: max(65rem, 100%);
  /* 浏览器忽略单元格内容,而是使用在第一个表格行的列或单元格上定义的宽度来解析列宽 */
  table-layout: fixed;
}
th:first-of-type {
  width: 10rem;
}
  • 无障碍
<div
  class="wrapper"
  tabindex="0"
  role="region"
  aria-labelledby="tableCaption_01"
>
  <table>
    <caption id="tableCaption_01"></caption>
    <thead>
      <tr>
        <th scope="column">Country</th>
        <th scope="column">Mean temperature change (°C)</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">United Kingdom</th>
        <td>1.912</td>
      </tr>
      <tr>
        <th scope="row">Afghanistan</th>
        <td>2.154</td>
      </tr>
      <tr>
        <th scope="row">Australia</th>
        <td>0.681</td>
      </tr>
      <tr>
        <th scope="row">Kenya</th>
        <td>1.162</td>
      </tr>
      <tr>
        <th scope="row">Honduras</th>
        <td>0.945</td>
      </tr>
      <tr>
        <th scope="row">Canada</th>
        <td>1.284</td>
      </tr>
    </tbody>
    <tfoot>
      <tr>
        <th scope="row">Global average</th>
        <td>1.4</td>
      </tr>
    </tfoot>
  </table>
</div>
[role="region"][aria-labelledby][tabindex]:focus {
  outline: 0.1em solid rgba(0, 0, 0, 0.1);
}

div[tabindex="0"][aria-labelledby][role="region"] {
  background: linear-gradient(to right, transparent 30%, rgba(255, 255, 255, 0)),
    linear-gradient(to right, rgba(255, 255, 255, 0), white 70%) 0 100%,
    radial-gradient(
      farthest-side at 0% 50%,
      rgba(0, 0, 0, 0.2),
      rgba(0, 0, 0, 0)
    ),
    radial-gradient(
        farthest-side at 100% 50%,
        rgba(0, 0, 0, 0.2),
        rgba(0, 0, 0, 0)
      )
      0 100%;
  background-repeat: no-repeat;
  background-color: #fff;
  background-size: 4em 100%, 4em 100%, 1.4em 100%, 1.4em 100%;
  background-position: 0 0, 100%, 0 0, 100%;
  background-attachment: local, local, scroll, scroll;
}

网页深色模式完整实现:从响应式设计到系统主题联动

需求

  • 页面跳转时主题不要发生变化
  • 如果是第一个页面,自动使用主题
  • 默认 light 还是 dark
  • 在一个浏览器选项卡中更改主题时,网站的所有其他选项卡也应随之更改
  • 用户修改操作系统主题模式时,网站应该对此做出反应
  • 根据时间变化自动切换主题

实现

在 <head> 中添加 <meta name="color-scheme" content="light dark">

浏览器只会改变那些没有主动设置颜色的元素

CSS 中通过 light-dark() 设置不同的颜色

:root {
    color-scheme: light dark;
}

@media (prefers-color-scheme: light) {
    .element {
        color: black;
        background-color: white;
    }
}

@media (prefers-color-scheme: dark) {
    .element {
        color: white;
        background-color: black;
    }
}
:root {
    color-scheme: light dark;
}

.element {
    /* fallback 的颜色,当用户浏览器不支持 color: light-dark(black, white); 时,回退到这个颜色 */
    color: black;
    /* light mode 下 color 用 black, dark mode 下 color 用 white */
    color: light-dark(black, white);
    background-color: white;
    background-color: light-dark(white, black);
}
<html class="theme-light">
  <form class="theme-selector">
  <button
    aria-label="Enable light theme"
    aria-pressed="false"
    role="switch"
    type="button"
    id="theme-light-button"
    class="theme-button enabled"
    onclick="enableTheme('light', true)"
  >Light theme</button>
  <button
    aria-label="Enable dark theme"
    aria-pressed="false"
    role="switch"
    type="button"
    id="theme-dark-button"
    class="theme-button"
    onclick="enableTheme('dark', true)"
  >Dark theme</button>
  </form>
  <!--- Rest of the website --->
</html>
$theme-light-text-color: #111;
$theme-dark-text-color: #EEE;

@mixin color($property, $var, $fallback){
  #{$property}: $fallback; // This is a fallback for browsers that don't support the next line.
  #{$property}: var($var, $fallback);
}

p{
  @include color(color, --text-color, $theme-light-text-color);
}
.theme-dark{
  --text-color: #{$theme-dark-text-color};
}
// Find if user has set a preference and react to changes
(function initializeTheme(){
  syncBetweenTabs()
  listenToOSChanges()
  enableTheme(
    returnThemeBasedOnLocalStorage() ||
    returnThemeBasedOnOS() ||
    returnThemeBasedOnTime(),
    false)
}())

// Listen to preference changes. The event only fires in inactive tabs, so theme changes aren't applied twice.
function syncBetweenTabs(){
  window.addEventListener('storage', (e) => {
    const root = document.documentElement
    if (e.key === 'preference-theme'){
      if (e.newValue === 'light') enableTheme('light', true, false)
      else if (e.newValue === 'dark') enableTheme('dark', true, false) // The third argument makes sure the state isn't saved again.
    }
  })
}

// Add a listener in case OS-level preference changes.
function listenToOSChanges(){
  let mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)')

  mediaQueryList.addListener( (m)=> {
    const root = document.documentElement
    if (m.matches !== true){
      if (!root.classList.contains('theme-light')){
        enableTheme('light', true)
      }
    }
    else{
      if(!root.classList.contains('theme-dark')) enableTheme('dark', true)
    }
  })
}

// If no preference was set, check what the OS pref is.
function returnThemeBasedOnOS() {
  let mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)')
  if (mediaQueryList.matches) return 'dark'
else {
    mediaQueryList = window.matchMedia('(prefers-color-scheme: light)')
    if (mediaQueryList.matches) return 'light'
    else return undefined
}
}

// For subsequent page loads
function returnThemeBasedOnLocalStorage() {
  const pref = localStorage.getItem('preference-theme')
  const lastChanged = localStorage.getItem('preference-theme-last-change')
  let now = new Date()
  now = now.getTime()
  const minutesPassed = (now - lastChanged)/(1000*60)

  if (
    minutesPassed < 120 &&
    pref === "light"
  ) return 'light'
  else if (
    minutesPassed < 120 &&
    pref === "dark"
  ) return 'dark'
  else return undefined
}

// Fallback for when OS preference isn't available
function returnThemeBasedOnTime(){
  let date = new Date
  const hour = date.getHours()
  if (hour > 20 || hour < 5) return 'dark'
  else return 'light'
}

// Switch to another theme
function enableTheme(newTheme = 'light', withTransition = false, save = true){
  const root = document.documentElement
  let otherTheme
  newTheme === 'light' ? otherTheme = 'dark' : otherTheme = 'light'
  let currentTheme
  (root.classList.contains('theme-dark')) ? currentTheme = 'dark' : 'light'

  if (withTransition === true && newTheme !== currentTheme) animateThemeTransition()

  root.classList.add('theme-' + newTheme)
  root.classList.remove('theme-' + otherTheme)

  let button = document.getElementById('theme-' + otherTheme + '-button')
  button.classList.add('enabled')
  button.setAttribute('aria-pressed', false)

  button = document.getElementById('theme-' + newTheme + '-button')
  button.classList.remove('enabled')
  button.setAttribute('aria-pressed', true)

  if (save) saveToLocalStorage('preference-theme', newTheme)
}

// Save the state for subsequent page loads
function saveToLocalStorage(key, value){
  let now = new Date()
  now = now.getTime()
  localStorage.setItem(key, value)
  localStorage.setItem(key+"-last-change", now)
}

// Add class to smoothly transition between themes
function animateThemeTransition(){
  const root = document.documentElement
  root.classList.remove('theme-change-active')
  void root.offsetWidth // Trigger reflow to cancel the animation
  root.classList.add('theme-change-active')
}
(function removeAnimationClass(){
  const root = document.documentElement
  root.addEventListener(supportedAnimationEvent(), ()=>root.classList.remove('theme-change-active'), false)
}())

function supportedAnimationEvent(){
  const el = document.createElement("f")
  const animations = {
    "animation"      : "animationend",
    "OAnimation"     : "oAnimationEnd",
    "MozAnimation"   : "animationend",
    "WebkitAnimation": "webkitAnimationEnd"
  }

  for (t in animations){
    if (el.style[t] !== undefined) return animations[t]   // Return the name of the event fired by the browser to indicate a CSS animation has ended
  }
}

参考

A guide to implementing dark modes on websites | Koos Looijesteijn

内容安全策略(CSP)深度指南:从基础配置到高级防护

概念

HTTP 响应标头 Content-Security-Policy 允许站点管理者控制用户代理能够为指定的页面加载哪些资源。除了少数例外情况,设置的政策主要涉及指定源服务器和脚本端点。这将帮助防止跨站脚本攻击

解释:哪些文件可以在网站上运行

注意语法规则标点符号使用

Content-Security-Policy: script-src 'self' https://safe-external-site.com; style-src 'self'

Fetch 指令

Fetch 指令控制指定资源类型可以从哪里加载。

  • default-src: 默认策略,没有具体指定策略 default-src 'self' trusted-domain.com
  • img-src: 图片 img-src 'self' img.mydomain.com
  • font-src: 字体
  • object-src: <object><embed>
  • media-src: 视频、音频
  • script-src 脚本
  • style-src css

Fetch 指令语法

  • 'none' :不匹配任何内容
  • 'self':匹配当前主机域(同源,即主机和端口)。但是,不要匹配子域
  • 'unsafe-inline':允许内联 JavaScript 和 CSS,尽量不要使用,nonce 代替 unsafe-inline
  • 'unsafe-eval':允许动态文本用于 JavaScript eval
  • domain.example.com:允许从指定域加载资源。要匹配任何子域,请使用 * 通配符,例如 *.example.com
  • https: 或 ws:: 仅允许通过 HTTPS 或 WebSocket 加载资源
  • nonce-{token}:允许包含相同 nonce 属性值的内联脚本或 CSS
  • 'strict-dynamic' 关键字使得通过 nonce 或 hash 信任的脚本扩展到此脚本动态加载的脚本,例如通过使用 Document.createElement() 创建新的 <script> 标签,然后通过 Node.appendChild() 将其插入文档中

使用

  • 服务器 nodenginx
    • Nginx 中可以使用内置变量的 $request_id 作为唯一 id,而当 nginx 版本不支持时,则可以借助 lua 去生产一个 uuid
    • 接着通过 Nginx 的 sub_filter NONCE_TOKEN 'id' 将页面中的 NONCE_TOKEN 占位符替换为 id,或者使用 lua 进行替换
    • 最后使用 add_header Content-Security-Policy "script-src 'nonce-{id}'" 添加对应的 CSP 返回头
Content-Security-Policy: script-src 'nonce-5fAifFSghuhdf' 'strict-dynamic'
  • html meta 标签
<meta http-equiv="Content-Security-Policy" content="script-src 'self' https://safe-external-site.com">

HTTP Content-Security-Policy-Report-Only响应头允许 web 开发人员通过监测 (但不强制执行) 政策的影响来尝试政策。这些违反报告由 JSON 文档组成通过一个 HTTP POST 请求发送到指定的 URI。

Reporting-Endpoints: name-of-endpoint="后端请求地址"
Content-Security-Policy: default-src 'self'; report-to name-of-endpoint

在 create-react-app (CRA) 创建的应用中使用 react-router-dom v7以及懒加载的使用方法

在 create-react-app (CRA) 创建的应用中使用 react-router-dom v7 的方法

众所周知,react-router v7 的使用方式发生了一些变化,开发者在使用时可能会遇到一些问题。
官方文档提供了三种模式:框架模式、数据模式、声明模式。

  • 本文将重点介绍数据模式的基本使用方法。

  • 懒加载的使用方式也与之前的 router v6 有所不同,将在后续代码中进行说明。

以下是具体的使用步骤:

1. 准备 CRA 项目

确保你已有一个使用 create-react-app 创建的项目。

2. 安装 react-router-dom v7

npm install react-router-dom@7

3. 配置路由器及路由表 (/src/router/index.tsx)

以下配置中的 Demo 组件使用了懒加载方式。

// /src/router/index.tsx

import type { RouteObject } from "react-router-dom";
import React from "react";
import { createHashRouter, Navigate } from "react-router-dom";

import App from "@/App";

const routes: RouteObject[] = [
  {
    path: "/",
    // App 组件未使用异步加载
    Component: App,
    children: [
      {
        index: true, // 当路径为 / 时,作为默认子路由
        element: <Navigate to="/demo" replace />
      },
      {
        path: "/demo",
        // 异步导入组件(懒加载)
        lazy: async () => {
          const Component = await import("@/views/Demo");
          return {
            Component: Component.default,
          };
        },
        // 如果不使用懒加载,则直接使用 element 属性
        // element: <Demo />
      },
    ],
  }
];

export const router = createHashRouter(routes);

4. App.tsx 组件的写法

  • 使用 <Outlet /> 作为子路由的占位符。
  • 利用 useNavigation 处理加载状态。
import React from "react";
import { Link, Outlet, useNavigation } from "react-router-dom";
import Loading1 from "@/views/loading1"; // 修正了组件名拼写 (loding1 -> loading1)

function App() {
  // 判断异步加载状态
  const navigation = useNavigation();
  console.log(navigation.state);
  const isLoading = navigation.state === "loading";

  return (
    <div className="App">
      <div>
        <Link to="/demo">Demo</Link>
      </div>

      <div>
        {/* 如果是异步加载组件,可以这样显示加载过程中的提示 */}
        {isLoading ? <Loading1 /> : <Outlet />}
      </div>
    </div>
  );
}

export default App;

5. 入口文件写法 (/src/index.tsx)

由于我们使用的是布局路由和数据模式,写法会有所不同:

  • 使用 <RouterProvider /> 渲染路由。
// /src/index.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";

// 导入之前配置的路由器
import { router } from "./router";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);

// 使用 RouterProvider 渲染,App 组件已包含在路由配置中
root.render(<RouterProvider router={router} />);

完成以上步骤即可正常使用路由。

Electron38-Winchat聊天系统|vite7+electron38+vue3电脑端聊天Exe

2025最新款原创新作electron38+vite7+vue3 setup+pinia3仿微信客户端聊天程序。

未标题-3.png

009360截图20250911225743164.png

技术知识点

  • 跨平台框架:electron38.0.0
  • 前端框架:vite7.1.2+vue3.5.18+vue-router4.5.1
  • 组件库:element-plus^2.11.2
  • 状态插件:pinia^3.0.3
  • 存储插件:pinia-plugin-persistedstate^4.5.0
  • 打包构建:electron-builder^24.13.3
  • electron结合vite插件:vite-plugin-electron^0.29.0

p1.gif

p3.gif

基于最新版跨平台技术electron38+vite7搭建项目模板,整个项目使用vue3 setup语法糖编码开发。

360截图20250911234347782.png

001360截图20250911221236448.png

360截图20250911235310309.png

360截图20250911235524326.png

360截图20250911235637658.png

360截图20250911235730072.png

360截图20250911235839919.png

项目通用模板

86044af8129cbb94dab7410db3416ed3_1289798-20250913073744421-1992270846.png

7a38d1a01768cfa947aa85f425926a64_1289798-20250913073839199-108741287.png

<template>
  <template v-if="!route?.meta?.isNewWin">
    <div
      class="vu__container flexbox flex-alignc flex-justifyc"
      :style="{'--themeSkin': appstate.config.skin}"
    >
      <div class="vu__layout flexbox flex-col">
        <div class="vu__layout-body flex1 flexbox" @contextmenu.prevent>
          <!-- 菜单栏 -->
          <slot v-if="!route?.meta?.hideMenuBar" name="menubar">
            <MenuBar />
          </slot>

          <!-- 侧边栏 -->
          <div v-if="route?.meta?.showSideBar" class="vu__layout-sidebar flexbox">
            <aside class="vu__layout-sidebar__body flexbox flex-col">
              <slot name="sidebar">
                <SideBar />
              </slot>
            </aside>
          </div>

          <!-- 主内容区 -->
          <div class="vu__layout-main flex1 flexbox flex-col">
            <ToolBar v-if="!route?.meta?.hideToolBar" />
            <router-view v-slot="{ Component, route }">
              <keep-alive>
                <component :is="Component" :key="route.path" />
              </keep-alive>
            </router-view>
          </div>
        </div>
      </div>
    </div>
  </template>
  <template v-else>
    <WinLayout />
  </template>
</template>

006360截图20250911223546910.png

007360截图20250911224016250.png

008360截图20250911224537972.png

009360截图20250911225000821.png

009360截图20250911230909907.png

010360截图20250911230952495.png

019360截图20250911232843999.png

基于uniapp+vue3+uvue短视频+聊天+直播app系统

基于uniapp+vue3+deepseek+markdown搭建app版流式输出AI模板

vue3.5+deepseek+arco+markdown搭建web版流式输出AI模板

unios-admin手机版后台|uniapp+vue3全端admin管理系统

基于flutter3.32+window_manager仿macOS/Wins风格桌面os系统

flutter3.27+bitsdojo_window电脑端仿微信Exe应用

自研tauri2.0+vite6.x+vue3+rust+arco-design桌面版os管理系统Tauri2-ViteOS

基于uni-app+vue3+uvui跨三端仿微信app聊天模板

Flutter3.x深度融合短视频+直播+聊天app实例

原创electron31+vite5.x+elementPlus桌面端后台管理系统

自研tauri2.0+vite5+vue3+element-plus电脑版exe聊天系统Vue3-Tauri2Chat

JavaScript中的dispatchEvent方法详解

dispatchEvent是JavaScript中用于手动触发事件的核心方法,它允许开发者以编程方式派发自定义事件或内置事件,为前端开发提供了强大的事件控制能力。

一、基本概念

dispatchEvent()方法用于在当前节点上触发指定事件,从而调用该事件的所有监听函数。它的核心特点包括:

  1. 可以触发任何类型的事件:包括标准DOM事件和自定义事件12
  2. 同步执行:事件监听函数会立即执行,而不是异步执行2
  3. 返回值:返回一个布尔值,如果有监听函数调用preventDefault()则返回false,否则返回true2

二、基本语法

target.dispatchEvent(event)
  • target:要触发事件的目标元素
  • event:要触发的事件对象,可以是EventCustomEvent实例

三、使用步骤详解

1. 创建事件对象

有两种主要方式来创建事件对象:

(1) 使用Event构造函数(不需要传递额外数据)

// 创建普通事件
const event = new Event('build')

// 带选项的事件(指定冒泡、可取消等特性)
const event = new Event('custom', {
  bubbles: true,    // 事件是否冒泡
  cancelable: true  // 事件能否被取消
})

(2) 使用CustomEvent构造函数(可以传递额外数据)

// 创建自定义事件并携带数据
const customEvent = new CustomEvent('userAction', {
  detail: { 
    action: 'click',
    time: new Date()
  },
  bubbles: true,
  cancelable: true
})

2. 监听事件

在目标元素上添加事件监听:

// 监听标准事件
document.addEventListener('click', (e) => {
  console.log('点击事件被触发', e)
})

// 监听自定义事件
element.addEventListener('userAction', (e) => {
  console.log('自定义事件数据:', e.detail)
})

3. 触发事件

通过dispatchEvent方法触发事件:

// 触发点击事件
const clickEvent = new Event('click')
button.dispatchEvent(clickEvent)

// 触发自定义事件
const customEvent = new CustomEvent('userAction', {
  detail: { userId: 123 }
})
document.dispatchEvent(customEvent)

四、实际应用示例

1. 自定义全局通知系统

// 创建自定义事件
const notificationEvent = new CustomEvent('globalNotification', {
  detail: {
    message: '系统更新即将开始',
    type: 'warning'
  }
})

// 监听通知事件
window.addEventListener('globalNotification', (e) => {
  showToast(e.detail.message, e.detail.type)
})

// 触发通知
window.dispatchEvent(notificationEvent)

2. 模拟用户点击

// 获取按钮元素
const button = document.getElementById('myButton')

// 创建鼠标点击事件
const clickEvent = new MouseEvent('click', {
  bubbles: true,
  cancelable: true,
  clientX: 100,  // 点击位置X坐标
  clientY: 50    // 点击位置Y坐标
})

// 触发点击事件
button.dispatchEvent(clickEvent)

3. 跨组件通信

// 模块A - 发布事件
function publishUpdate(data) {
  const event = new CustomEvent('dataUpdate', {
    detail: data,
    bubbles: true
  })
  document.dispatchEvent(event)
}

// 模块B - 订阅事件
document.addEventListener('dataUpdate', (e) => {
  updateUI(e.detail)
})

五、注意事项

  1. 事件传播:默认情况下自定义事件不会冒泡,需要显式设置bubbles: true12
  2. IE兼容性:IE9以下不支持EventCustomEvent构造函数,需要使用document.createEvent()方法2
  3. 性能考虑:频繁触发事件可能影响性能,应有节流机制3
  4. 事件类型:避免使用浏览器保留的事件名(如click、load等),以防止冲突1

六、兼容性写法

对于需要支持旧版浏览器的场景:

// 创建事件的兼容性写法
let event
if (typeof Event === 'function') {
  event = new Event('customEvent')
} else {
  event = document.createEvent('Event')
  event.initEvent('customEvent', true, true)
}

// 触发事件
element.dispatchEvent(event)

七、总结

dispatchEvent是JavaScript事件系统中的强大工具,它:

  1. 支持自定义事件系统的实现1
  2. 允许模拟用户操作进行测试3
  3. 实现松耦合的组件通信12
  4. 可以控制事件传播行为(冒泡/捕获)2

掌握dispatchEvent方法能够让你的前端代码更加灵活和可维护,是实现复杂交互和组件通信的重要基础。

Footnotes

  1. JS中window.dispatchEvent的原理和使用初识 2 3 4 5

  2. js基础-Event 事件★★★_js dispatchevent preventdefault-CSDN博客 2 3 4 5 6 7

  3. JavaScript 中 event 方法如何使用 – PingCode 2

F2C-PTD工具将需求快速转换为代码实践

F2C的PTDTC(prompt to design to code)体系可以让用户免费通过自然语言的形式将需求快速转换为Figma设计,再通过工具将生成的Figma设计稿转换成高还原度代码。

接下来逐步介绍实现的过程,以下内容和文档均参考:f2c.yy.com/

前期准备

支持MCP的Agent

需要一个支持mcp调用的agent,包括但不限于IDE、插件等,例如:VSCodeTraeCursorComate。本文以AugmentCode为例。

Figma空设计稿

准备一个空的Figma设计稿,需要有编辑权限,方便工具写入设计稿元素。推荐使用Web端的Figma。

F2C的Chrome插件

提前下载好F2C的配套chrome浏览器插件,截止写稿前的版本为v2.2.0。安装后刷新之前准备好的Figma空文件,看到下图所示即为安装成功。

image-20250915155743277

需求转设计稿部分

步骤一:安装和配置F2C的PTD MCP工具

这个MCP能让用户通过自然语言的方式对话Figma,让AI分析你的需求并转换为Figma生成步骤生成设计稿。

安装

推荐全局安装 F2C PTD 的 MCP:

pnpm i -g @f2c/ptd

配置

请参考以下配置:

// 如果不使用设计组件库,可删除 `your_figma_personal_token` 配置。若需使用组件库,则需要配置。token的获取参考:https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens
{
  "F2C-PTD": {
    "command": "npx",
    "args": [
      "-y",
      "@f2c/ptd",
      "--server=f2c-ptd.yy.com",
      "--figma-api-key=your_figma_personal_token"
    ],
    "env": {
      "personalToken": "your_figma_personal_token"
    }
  }
}

当你安装和配置完毕之后,可以看到MCP是可使用状态并列举出可以使用的tool。

image-20250915160714559

步骤二:加入频道开启自然语言对话

链接频道

在Web Figma中打开前期准备中准备好的Chrome插件,切换到 Design Tab,并点击连接。

image-20250915161022552

点击后的状态如下图,我们可以看到插件出现了一个频道ID,点击即可复制ID。

image-20250915161106934

切换至Agent中,开启对话,输入如下内容(频道自己替换为自己的):

加入频道channel_1757923859807_288

加入频道成功可以看到下图所示:

image-20250915161553211

开始对话生成

这里将prompt放在这里仅供参考,值得注意的是可以Rule或者其它形式更好的约束AI生成,这部分内容可以是你具体的需求,参考内容放置在文章最后。

shadcn/ui 风格中后台 Dashboard 视觉设计规范 请设计一个遵循 shadcn/ui 设计语言的中后台仪表盘,其核心是极简、现代、专业且高度一致的视觉体验。

  1. 整体氛围与美学 基调:营造一种干净、宁静、专业的氛围。避免过度设计和视觉噪音。 质感:追求“数字玻璃”般的通透感。大量运用留白,元素之间有充足的呼吸空间。 细节:注重微小的视觉细节,如统一的圆角半径(柔和但不过分)、精细的 1px 边框(使用中性或浅灰色)、以及恰到好处的细微阴影(仅用于轻微的层叠感,避免厚重投影)。
  2. 色彩体系 主色调: 以纯净的白色或极浅的灰白色作为背景基底。 文字和主要线条采用深灰色或接近黑色的深色(非纯黑),确保阅读舒适。 辅助与强调: 使用一个低饱和度的单一主色(如蓝紫色或青蓝色)作为唯一的强调色,仅用于关键按钮、选中状态和核心数据图表,起到画龙点睛的作用。 状态颜色(成功-绿色、警告-黄色、危险-红色)应清晰可辨,但饱和度不宜过高,保持整体调性的和谐。 中性色:广泛使用不同深浅的灰色来区分内容层级,例如用浅灰色表示边框、分隔线和次要信息,形成丰富的层次感而不显杂乱。
  3. 布局与结构 经典双栏:采用左侧垂直导航栏 + 右侧主内容区的布局。 导航栏:窄而简洁,图标与文字结合,选中项通过左侧一条细长的彩色高亮条和微妙的背景色变化来标识,避免使用大面积填充。 主内容区:所有信息模块都封装在卡片(Card) 内。卡片具有统一的内边距、圆角和轻柔的边框,悬浮时有极其轻微的抬升感(由细微阴影体现)。 响应式:在移动设备上,导航栏应能优雅地收起为汉堡菜单,主内容区的卡片自动调整为单列排列。
  4. 核心模块视觉描述 概览指标卡:每张卡片展示一个核心数据。数值字体较大且加粗,位于卡片中央偏上。下方配以小型的趋势折线图或一个带箭头的小标签(绿色向上/红色向下)显示增长率,颜色使用主色或状态色。 数据图表区:占据一张大卡片。图表本身设计简洁,坐标轴线条纤细,网格线若有则非常淡。图例清晰,颜色搭配协调,重点数据突出。 活动列表:以极简表格形式呈现。行与行之间通过细微的 border-bottom 分隔,悬停某一行时背景色发生轻微变化。时间戳使用较小字号和浅灰色。 快捷操作:使用无填充的“幽灵按钮”(Ghost Button)或带图标的文本链接,排列整齐,不喧宾夺主。
  5. 字体与排版 字体选择:使用无衬线字体,确保在各种尺寸下都清晰易读。 层级分明:标题、副标题、正文、说明文字之间有明确的字号和字重(bold, medium, normal)区分,引导用户的阅读视线。 对齐与间距:严格对齐,无论是左对齐还是居中对齐,都保持精确。段落和模块间的垂直间距宽松,水平间距也恰到好处。 最终目标:用户进入这个 Dashboard 时,首先感受到的是秩序、清晰和宁静。所有设计元素协同工作,让用户能快速、无干扰地获取信息,同时体会到一种低调而精致的现代美感。

移动端 Dashboard 页面元素细节 (shadcn/ui 风格)

设计一个专为移动设备优化的中后台 Dashboard,所有元素需适应小屏幕,确保信息清晰、操作便捷。

1. 整体布局与导航

  • 主导航

    :采用底部标签栏(Bottom Tab Bar)。

    • 位置:固定在屏幕最底部。
    • 样式:半透明或纯白背景,带有细微上边框 (border-t)。包含 3-5 个核心功能图标(如仪表盘、订单、用户、设置),配以简洁文字标签。
    • 交互:选中的标签项使用主色调高亮图标和文字,提供明确反馈。
  • 次级导航/筛选

    :位于页面顶部,紧接状态栏下方。

    • 样式:一个轻量级的横向滚动条或分段控件(Segmented Control)。
    • 内容:用于切换视图(如“今日”、“本周”、“本月”)或数据类别(如“全部订单”、“待处理”、“已完成”)。选中项通过背景色填充或下划线标识。
  • 主内容区:从顶部导航下方开始,一直延伸到标签栏上方,充分利用垂直空间。内容以单列流式布局排列。

2. 核心模块元素细节

  • 概览指标卡 (KPI Cards)

    • 布局:通常为全屏宽度的横幅或并排的两个小卡片(在稍大屏幕)。
    • 视觉:卡片边界清晰,有统一的内边距。内部采用两行布局:
      • 上行:指标名称(较小字号,浅灰色 text-muted-foreground)。
      • 下行:数值(大号、加粗字体,深色前景),其后紧跟一个微小的趋势图标(↑↓)和变化百分比(绿色/红色)。
    • 交互:卡片整体可点击,跳转到更详细的数据页。
  • 数据图表 (Charts)

    • 类型:优先使用高度压缩的图表,如微型折线图 (Sparkline)水平柱状图
    • 尺寸:宽度占满容器,高度适中(约 100-150px)。
    • 简化:省略复杂的坐标轴标签和图例。仅显示核心趋势。必要时,可通过点击图表区域展开为全屏模态图进行详细分析。
    • 配色:使用主色或状态色,保持简洁。
  • 列表与表格 (Lists & Tables)

    • 形式:将传统的表格转换为卡片式列表项 (Card List Items)

    • 每行结构:一个可点击的

      <ListItem>
      

      包含:

      • 左区:一个小型图标或头像(代表用户、订单类型等)。
      • 中区:主要信息(如订单ID、用户名)居左对齐,副信息(如时间、状态摘要)以小号浅色字体显示在主信息下方。
      • 右区:辅助信息(如金额)或一个“>”箭头图标,表示可进入详情页。
      • 底部分隔线:每个列表项底部有一条非常细的浅灰色线 (border-b),最后一项除外。
    • 状态标识:关键状态(如“待支付”、“已发货”)使用小型 Badge 组件,颜色对应状态色,放置在列表项的右上角或信息区内。

  • 快捷操作 (Quick Actions)

    • 位置:常置于页面右下角的浮动操作按钮 (FAB - Floating Action Button)
    • 样式:一个圆形按钮,填充主色,中心是“+”号或其他相关图标。悬浮于内容之上,易于触及。
    • 备选方案:也可作为一组小型的“幽灵按钮”(Ghost Buttons)水平排列在某个模块的上方。

3. 通用 UI 元素风格

  • 字体:字号适中,确保在小屏幕上易读。避免过小的说明文字。

  • 圆角:所有卡片、按钮、输入框保持统一的圆角大小(如 rounded-lg)。

  • 阴影:移动端谨慎使用阴影。卡片可有极轻微的底部阴影以示层叠,但不应过于厚重。

  • 图标:使用线条简洁、辨识度高的图标集。大小统一。

  • 加载与空状态:设计优雅的加载骨架屏(Skeleton Screen)和空状态插画,提示用户当前无数据或正在加载。

对话后Agent会自动寻找F2C提供的Tool来绘制Figma设计稿,对话内容和绘制效果如下图。

image-20250915164015677

image-20250915164344192

设计稿转代码部分

当我们有了设计稿之后就可以生成代码了,生成代码参考F2C官网有两种方式。

使用F2C的MCP生成(推荐)

使用MCP生成配置上会繁琐一点,但是生成的代码还原度高、可维护性强、还可以智能适配你的技术栈。教程和实战文章贴在这里了,这里不在赘述:

使用Chrome插件生成

前期准备的Chrome插件不仅能够链接Agent和Figma绘制设计稿,还可以拥有Figma的Dev Mode能力,当然包括生成代码了。

参考文章:F2C-Chrome插件-Figma免费的DevMode来了!插件官网

直接选中刚刚生成的设计稿图层,点击代码生成、下载和预览。参考下图:

image-20250915165128799

生成效果预览,注意这不是简单的一张图片,是代码。

image-20250915165259396

至此我们完成了PTDTC的实战。

总结&未来

F2C的生态非常完善,你可以需求生成设计稿,也可以将现有设计稿生成高质量代码,相比于Figma官方提供的需要席位费的MCP和系列工具,F2C一直是免费试用。有兴趣的大佬们可以尝试一下。

当然目前PTD尚处于Beta阶段,未来会着眼于设计稿的生成质量总结Rule和Prompt,提供一套完整的、稳定的方案。未来也会联合组件(例如:antd、shadcn/ui等)进行1比1的PTDTC的流程,目前内部已经在运行中。

参考文章

用户实战视频

HarmonyOS 5.0应用开发——V2装饰器@local的使用

V2装饰器@local的使用

【高心星出品】

概念

组件内部状态管理 @Local是专为@ComponentV2组件设计的装饰器,用于声明组件私有状态。被装饰的变量必须在组件内部初始化,禁止从父组件外部传入初始值(如Child({count: 10})的写法会报错),确保状态封装性。

观测能力

  • 支持类型:基本类型(number、string、boolean)、Object、class、Array、Set、Map、Date等内嵌类型,以及联合类型1。

  • 变化触发机制:

    • 简单类型(如number):赋值操作触发UI刷新(如this.count++)。
    • 对象类型:仅整体赋值时触发(如替换整个对象this.obj = new MyClass())。
    • 数组/集合类型:整体赋值或调用特定API(如push()set())时触发。

与@State的对比

特性 @Local(V2) @State(V1)
初始化 强制内部初始化 允许外部传入覆盖初始值
作用域 组件内部私有 可跨组件传递
性能优化 更精细的观测,减少不必要刷新 可能因外部修改导致过度渲染
适用版本 API 12+,ComponentV2组件 旧版本组件体系

使用场景

基本状态管理:

Button绑定local装饰的变量count,count值改变引起button刷新。

@Entry
@ComponentV2
struct Index {
  @Local count: number = 1;

  build() {
   Column(){
     Button('点击次数:'+this.count)
       .width('60%')
       .onClick(()=>{
         this.count+=1
       })
   }
    .height('100%')
    .width('100%')
    .justifyContent(FlexAlign.Center)
  }
}

装饰数组的情况:

button绑定数据源datas的长度,list绑定数据源datas,当数据源调用push api或者元素更新的时候会引起UI刷新。

@Entry
@ComponentV2
struct Localpage {
  @Local datas:number[]=[1,2,3,4,5,6,7,8,9,10]
  build() {
   Column({space:20}){
     Button('列表长度:'+this.datas.length)
       .width('60%')
       .onClick(()=>{
         // 调用api会被观察
        // this.datas.push(this.datas.length+1)
        //  更新数组项也会被观察
         this.datas[this.datas.length-1]=0
       })
     List(){
       Repeat<number>(this.datas).each((item:RepeatItem<number>)=>{
         ListItem(){
           Text('列表项:'+item.item)
             .fontSize(30)
             .fontWeight(FontWeight.Bolder)
             .padding(10)
             .textAlign(TextAlign.Center)
         }
       })
     }
     .width('100%')
     .divider({strokeWidth:2})
   }
    .height('100%')
    .width('100%')
  }
}

不可以从父组件向子组件传值

@ComponentV2
struct child {
  @Local count: number = 10

  build() {
    Column() {
      Button('child count: ' + this.count)
        .width('60%')
        .onClick(() => {
          this.count += 1
        })
    }
    .width('100%')
    .padding(20)
  }
}

@Entry
@ComponentV2
struct Localpage1 {
  @Local count: number = 11

  build() {
    Column() {
      // 有问题不能外部传值
      // child({count:11})
      // child({count:this.count})
      //   没问题
      child()
    }
    .justifyContent(FlexAlign.Center)
    .height('100%')
    .width('100%')
  }
}

后端转全栈之Next.js文件约定

本文概括

  • Page Router vs App Router:老版用 pages 目录,新版用 app 目录并通过约定文件定义路由和特殊功能。
  • page.tsx:定义具体页面,对应一个路由。
  • layout.tsx:定义可复用的页面布局,子页面会被包裹其中,根布局必须包含 html 和 body
  • template.tsx:类似布局但不会保留状态,每次路由切换都会重新渲染。
  • loading.tsx:在页面数据加载时显示的过渡界面。
  • error.tsx:客户端错误边界组件,捕获运行时错误并支持 reset 重试。
  • global-error.tsx:根目录的全局错误捕获页面。
  • not-found.tsx:定义 404 页面,用于未匹配路由或显式调用 notFound()

老版本的Next.js使用的是Page Router,在 pages 目录下,每个js文件就是一个路由,这就导致一些组件不能写在 pages 目录下,新版本换成了App Router ,文件放在 app目录下,目录下的 page.tsx 就是代表一个路由,Next.js约定了一些特殊的文件:

布局(layout.tsx)、模板(template.tsx)、加载状态(loading.tsx)、错误处理(error.tsx)、404(not-found.tsx)页面(page.tsx)

页面page.tsx

每个目录下的 page.tsx 会映射到一个路由,需要导出一个默认函数,例如:

export default function Page(){
return <>Next.js</>
}

布局layout.tsx

layout.tsx 文件导出一个React组件,接受 chidren 作为参数,表示的是子页面内容,子页面会拥有layout里的布局,也就是layout会包裹着page页面

export default function Layout({ children }: { children: React.ReactNode }) {
    return (
        <div>
            <h1>Test Layout</h1>
            {children}
        </div>
    )
}

根布局要求:

  • 必须有根布局 app/layout.tsx
  • 必须包含 html 和 body 标签

模版template.tsx

模版和layout类似,会包裹每个页面,但是和layout的区别是,模版不会维持状态,每次进入一个新的路由都会重新初始化,模版会被layout包裹起来。

例如在layout文件里写一个 表单,那么通过Link跳转到子路由,表单里的内容不会变,如果是使用template,那么就会重新渲染,表单里的数据消失

例如 layout.tsx

'use client'
import React, { useState } from 'react'

export default function Layout({ children }: { children: React.ReactNode }) {
    const [count, setCount] = useState(0)

    return (
        <div>
            <h1>Test Layout</h1>
            <>Layout Count: {count}</>
            <button onClick={() => setCount(count + 1)}>Layout数字增加</button>
            <br />
            {children}
        </div>
    )
}

template.tsx

'use client'
import React, { useState } from 'react'
export default function Template({ children }: { children: React.ReactNode }) {
    const [count, setCount] = useState(0)

    return (
        <div>
            <>Template Count: {count}</>
            <h1>Test Template</h1>
            <button onClick={() => setCount(count + 1)}>Template数字增加</button>
            {children}
        </div>
    )
}

当我们增加了数字之后,在test目录路由下跳转,会发现layout里的数字不变,template里的数字会清空

加载loading.tsx

loading.tsx 是加载页面,例如:

export default function Loading() {
    return (
        <div>
            <h1>Loading</h1>
        </div>
    )
}

这样在 page.tsx 加载数据的时候,在没拿到数据之前,就会显示loading的内容:

import Link from 'next/link'

async function getUser() {
    await new Promise((resolve) => setTimeout(resolve, 2000))
    return {
        name: 'cxk',
    }
}

export default async function Page() {
    const { name } = await getUser()
    return (
        <div>
            <Link href="/test/test2">跳转Page2</Link>
            <Link href="/test">跳转Page</Link>
            {name}
        </div>
    )
}

如果一个目录下有很多约定的文件,那么他们的层级是:

nextjs.org/docs/app/ge…

Next.js文件约定

image.png

错误error.tsx

错误页面必须是客户端组件, error.tsx 接受一个error和reset

  • error: Error

    捕获到的错误对象,可能带 digest(Next.js 内部生成的唯一标识符)。

  • reset: () => void

    调用它可以 重置错误边界,让 Next.js 重新尝试渲染页面(比如在用户点 “Retry” 按钮时)。

'use client'

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
    const handleReset = () => {
        console.log('reset')
        reset()
    }
    return (
        <div>
            <h1>Error</h1>
            <p>{error.message}</p>
            <button onClick={handleReset}>Reset</button>
        </div>
    )
}

可以在页面获取数据的时候抛出异常试试:

async function getUser() {
    await new Promise((resolve) => setTimeout(resolve, 2000))
    throw new Error('test error')
    return {
        name: 'cxk',
    }
}

export default async function Page() {
    const { name } = await getUser()
    return (
        <div>
            <Link href="/test/test2">跳转Page2</Link>
            <Link href="/test">跳转Page</Link>
            {name}
        </div>
    )
}

注意:错误边界不可以捕获同级的layout和tempalte,必须在父级的error.tsx去捕获,因为 ErrorBoundary 被Layout和Template包裹了。

根目录的捕获可以使用 global-error.tsx 进行捕获

404not-found.tsx

未找到页面会显示404页面的内容,触发情况主要有两种:

  • 组件调用 notFound 函数
  • 路由地址不匹配

JS-SDK开发企微侧边栏

一、背景

领导说,客户最近提出一个需求,他要在企微里面,嵌入咱们的h5页面,方便查看当前用户购买的产品信息。之前咱们没接触过这块,你回头调研一下。

企微侧边栏是一种常见的应用开发,他可以根据客户的诉求,开发任意类型的页面,包括小程序等等,灵活展示,前提是运行在企微环境中。开发前建议详细阅读企微的开发文档developer.work.weixin.qq.com/document/pa… 因为我这里的需求是h5所以采用的是js-sdk。

image.png

二、企微后台配置

登录企微后台work.weixin.qq.com/ 扫码登录,注意登录时会选择企业,选了哪个企业就是在哪个企业下配置应用。

image.png

1、创建应用

(1)、依次单击【应用管理】--【应用管理】--【应用】--在“自建”区【创建应用】

image.png

按要求上传 LOGO、填写表单后,单击【创建应用】。

image.png

(2)、打开应用,复制应用名称、AgentId 和 Secret,发给后端同学,他们会拿这个进行授权登录。

image.png

(3)、复制企业 ID

依次单击【我的企业】--【企业信息】,滑到页面底部,复制企业 ID,发给后端同学,也是授权登录用。

image.png

2、应用配置

(1)、配置“应用主页”,点击【设置】

image.png

根据需要选择网页或者是小程序。

我这里选择“网页”开发,网址地址就是你开发的h5应用的地址。

这里有个细节就是链接里面可以把agentid和corpid拼在链接上,因为后续应用当中需要用到这个参数,如果不放在链接里的话,就需要调用js-sdk去动态获取。

eg:xxxx.com/xxx/xxx/ind…

调用js-sdk方法内部涉及多个异步调用,本身就特别慢,所以能不调就不调吧。参数啥的完全可以放到链接里面。

配置完后单击【确定】

image.png

(2)、配置“配置到聊天工具栏”,单击【配置】

image.png

单击【配置页面】

image.png

输入“页面名称”,单击【确定】。

image.png

(3)、配置“网页授权及 JS-SDK”,单击【设置可信域名】

image.png

在“可信域名”输入你应用当中需要使用的所有域名,包括接口请求的域名。

image.png

单击【申请校验域名】,按提示下载文本文件。

这是个.txt类型的文件。

如果你的开发的项目就是你部署到服务器的项目,你可以把它放在你项目的根目录下。

因为我这里比较特殊,开发的项目跟部署的项目不是一个,所以得放在部署项目的根目录下。

等待上传完成,单击【确定】

(4)、配置“企业微信授权登录”,单击【设置】

image.png

在 Web 网页“授权回调域”输入回调地址,(就是你调后端登录接口的域名),单点【保存】

image.png

(5)、配置“企业可信 IP”,单击【配置】

image.png

在文本框输入ip地址,开发人员的ip,需要使用这个应用人员的ip(多个ip中间英文分号分隔),单击【确定】

image.png

3、设置外部联系人应用

(1)、设置可访问外部联系人的应用,依次单击【客户与上下游】--【客户关系】--【客户】--【API】--【修改】

image.png

在“设置可调用接口的应用”列表勾选刚才创建的应用,单击【确定】

image.png

到这里,创建跟配置应用部分就大功告成了!

三、实战操作

1、项目配置

(1)根组件

首先创建个h5工程(这里不赘述了),或者在已有工程上开发都可以。因为页面可能有多个。我这里采用的是动态组件的方式进行开发的,你也可以使用路由,比较随意了。

<template>
  <div id="app">
    <keep-alive v-if="currentView">
      <component :is="currentView" :userInfo="userInfo" />
    </keep-alive>
  </div>
</template>
export default {
  name: "App",
  components: {
    // 首页
    LhIndex: () => import("./pages/index"),
    // 跟踪
    LhTrack: () => import("./pages/track"),
    // 详情
    LhDetail: () => import("./pages/detail"),
    // 介绍
    LhIntro: () => import("./pages/stockIntro"),
  },
  created() {
    // 初始化函数
    this.init();
  },
  methods: {
    // 初始化
    init() {
      switch (this.$getWorkType) {
        case "index":
          this.currentView = "LhIndex";
          break;
        case "track":
          this.currentView = "LhTrack";
          break;
        case "detail":
          this.currentView = "LhDetail";
          break;
        case "intro":
          this.currentView = "LhIntro";
          break;
      }
      console.log("设置currentView为:", this.currentView);
    },
};

$getWorkType是通过window.loation.href拿到页面的路径,并获取页面的后缀名,根据后缀名去匹配页面,我把它挂在了全局。

其实这么做有一个问题,就是需要运维同学去帮你修改nginx配置。

当访问任何页面时,都把资源指向index.html。

如果你有两个页面分别是a.html、b.html。当访问这俩页面时,都指向index.html的资源(不然的话,访问页面会报资源找不到的错),这样就可以使用动态组件这种模式开发多页应用了。

(2)入口文件

其次在你的index.html文件中引入企微sdk。

<!-- 企业微信sdk -->
    <script src="https://wwcdn.weixin.qq.com/node/open/js/wecom-jssdk-2.0.2.js"></script>

2、登录

(1)获取参数

上面提到过,corpid和agentid等信息字段是可以放到链接里的,我这里通过一个挂在全局的方法直接拿到这两个参数,作为接口请求入参。

  const corpid = this.$getUrlParam("corpid") || "";
  const agentid = this.$getUrlParam("agentid") || "";
  this.corpid = corpid;
  this.agentid = agentid;

(2)授权方式

根据需求,只是在企微内嵌了h5页面,所以不需要登录界面,采用的是静默登录方式,既打开页面默认登录。这里需要你的后端配合,根据企微提供OAuth2登录流程developer.work.weixin.qq.com/document/pa…

在未登录时需要你在请求你后端同学包装好的链接地址,地址后面附上登录成功之后需要重定向的地址gourl,也就是你h5的首页地址。登录成功之后,直接回到首页。

// 如果用户信息不存在,则跳转到授权登录页面
console.warn("用户信息不存在,跳转授权页面");
window.location.href = `https://${this.$isTest ? "test" : ""}xxx.com.cn/api/v1/oauth/we-com/login?corpid=${corpid}&agentid=${agentid}&gourl=${encodeURIComponent(`https://${this.$isTest ? "test" : ""}xxx.com.cn/xx/xx/index.html?corpid=${corpid}&agentid=${agentid}`)}`;

用户登录过了则有登录信息,需要去调用企微的获取签名接口。

// 设置用户信息
  this.userInfo = res?.data?.info;
  console.log(this.userInfo, "this.res");
  try {
    // 获取签名
    this.getCompanySign();
  } catch (error) {
    console.warn("获取企业签名失败:", error);
  }

(3)获取签名

签名分为企业签名和应用签名,根据需要获取即可。我这里只需要获取应用签名。

this.getCompanySign()里面首先做一些兼容判断,以防报错直接挂掉。

  let ww = window?.ww;
  
  // 检查是否在企微环境中
  if (!ww) {
    console.warn("企业微信 SDK 未加载");
    return;
  }

  // 检查必要参数
  if (!this.corpid || !this.agentid) {
    console.warn("缺少必要参数: corpid 或 agentid");
    return;
  }

获取到签名,timestamp、nonceStr、signature都是固定返回字段,直接取就好了。

async function getConfigSignature(type) {
    try {
      const response = await that.$getData("getSignApi", {
        param: {
          ...param,
          type: type,
        },
      });

      console.log("签名接口返回:", response);

      if (response && response?.data) {
        console.log("签名成功:", response.data);
        return {
          timestamp: response?.data?.timestamp,
          nonceStr: response?.data?.nonceStr,
          signature: response?.data?.signature,
        };
      } else {
        throw new Error("签名接口返回数据格式错误");
      }
    } catch (error) {
      console.warn("签名失败:", error);
      throw error;
    }
  }

拿到之后作为返回值,直接调企微的注册wx.register。 developer.work.weixin.qq.com/document/pa…

签名的返回值作为getAgentConfigSignature的value。

ww?.register({
  corpId: that?.corpid,
  agentId: that?.agentid,
  jsApiList: ["getCurExternalContact"],
  getAgentConfigSignature: () => getConfigSignature("agent"),
});
try {
    console.log("=== 开始注册企业微信 ===");

    // 检查 ww.register 是否存在
    if (typeof ww?.register !== "function") {
      console.warn("ww.register 方法不存在");
      return;
    }

    ww?.register({
      corpId: that?.corpid,
      agentId: that?.agentid,
      jsApiList: ["getCurExternalContact"],
      getAgentConfigSignature: () => getConfigSignature("agent"),
    });

    console.log("企业微信应用注册成功,JSAPI 已准备就绪。");

    // 使用更安全的方式等待注册完成
    setTimeout(() => {
      console.log("延迟调用 getCurExternalContact...");
      // 调用获取外部联系人方法
      that?.getCurExternalContactFunc();
    }, 1000); // 增加延迟时间确保注册完成
  } catch (error) {
    console.warn("企业微信注册失败:", error);
  }

(4)获取外部联系人ID

需求是要在页面中获取到外部联系人购买的产品信息,所以必须首先获取到外部联系人的ID给到你亲爱的后端。

注册好了之后获取外部联系人ID。 ww?.getCurExternalContactdeveloper.work.weixin.qq.com/document/pa…

   getCurExternalContactFunc() {
      console.log("=== 开始调用 getCurExternalContact ===");

      // 检查 ww 对象
      if (!ww) {
        console.warn("企微 SDK 未加载");
        return;
      }

      // 检查方法是否存在
      if (typeof ww?.getCurExternalContact !== "function") {
        console.warn("getCurExternalContact 方法不存在");
        console.log("可用的 ww 方法:", Object.keys(ww));
        return;
      }
      console.log("准备调用 ww.getCurExternalContact...");

      try {
        // 调用企业微信SDK的getCurExternalContact接口
        ww?.getCurExternalContact({
          success: (res) => {
            console.log("获取外部联系人成功:", res);
            if (
              res &&
              res.errCode === 0 &&
              res.errMsg === "getCurExternalContact:ok"
            ) {
              // 保存到本地存储
              try {
                localStorage.setItem("externalUserId", res.userId);
                console.log("externalUserId已保存到本地存储:", res.userId);
              } catch (error) {
                console.warn("保存externalUserId到本地存储失败:", error);
              }
            } else {
              console.warn("返回数据格式不正确:", res);
            }
          },
          fail: (err) => {
            console.warn("获取外部联系人失败:", err);
          },
        });
      } catch (error) {
        console.warn("调用 getCurExternalContact 时发生错误:", error);
      }
    },

获取完之后,我给它存在了本地,方便在多个页面中获取。

(5)页面中调用

这就涉及到了很多异步方法,wx.register、wx.getCurExternalContact、外加你调后端的接口等等。所以干脆直接来了个轮询调用。

在首页mounted中调用startPollingExternalUserId

startPollingExternalUserId() {
  console.log("开始轮询检查externalUserId");
  // 记录开始轮询的时间,用于确保获取到的是新的externalUserId
  this.pollingStartTime = Date.now();

  this.externalUserIdPollingTimer = setInterval(() => {
    const externalUserId = localStorage.getItem("externalUserId");
    console.log("轮询检查externalUserId:", externalUserId);
    if (externalUserId) {
      console.log("轮询发现externalUserId,停止轮询并开始加载数据");
      clearInterval(this.externalUserIdPollingTimer);
      this.externalUserIdPollingTimer = null;
      // 获取接口数据
      this.pullData();
    }
  }, 500); // 每500ms检查一次

  // 设置最大轮询时间,避免无限等待
  setTimeout(() => {
    if (this.externalUserIdPollingTimer) {
      console.warn("轮询超时,停止轮询");
      clearInterval(this.externalUserIdPollingTimer);
      this.externalUserIdPollingTimer = null;
      this.loading = false;
    }
  }, 20000); // 20秒超时
},

请求页面数据之前,优先获取外部联系人IDexternalUserId。拿到之后调用 this.pullData()

这里加了个最大轮询时间20秒,以防陷入死循环。

3、开发调试

本地开发调试参考友情链接:juejin.cn/post/753277…

如果你想抓包,可以参考企微文档: developer.work.weixin.qq.com/document/pa…

image.png

开发过程中可能会遇到各种问题,包括调企微sdk时的各种状态码错误信息。不过别担心,企微很贴心的提供了踩坑记录: developer.work.weixin.qq.com/document/pa…

image.png

四、最后

实际开发的一个小案例,希望能够帮到你!

可可图片编辑 HarmonyOS(6)水印效果

可可图片编辑 HarmonyOS(6)水印效果

前言

可可图片编辑 也实现了水印效果,这个功能的实现比较取巧。

在预览阶段,直接通过层叠布局来实现水印效果。

在保存图片时,使用组件截图 componentSnapshot 的知识实现保存图片。

developer.huawei.com/consumer/cn…

image-20250912220119958

componentSnapshot 介绍

componentSnapshot 是 HarmonyOS提供的一个强大功能,允许开发者获取应用中组件的截图,生成 PixelMap 格式的图片数据。这对于实现分享功能内容保存动态生成图片等场景非常有用。

基本使用步骤

1. 准备工作:添加组件标识

首先需要为想要截图的组件添加唯一标识:

// 在build方法中为组件添加id
build() {
  Column() {
    // 需要截图的组件
    Column() {
      Text('这是要截图的内容')
        .fontSize(20)
      Image($r('app.media.icon'))
        .width(100)
        .height(100)
    }
    .id('targetComponent') // 添加唯一标识
    
    // 其他UI组件...
  }
}

2. 获取截图的基本方法

方法一:异步获取(推荐)
import { image } from '@kit.ImageKit';

// 在按钮点击或其他事件中
async takeScreenshot() {
  try {
    // 获取UIContext
    const uiContext = this.getUIContext();
    
    // 使用componentSnapshot获取截图
    const pixelMap = await uiContext.getComponentSnapshot()
      .get('targetComponent', { 
        scale: 1.0, // 缩放比例
        waitUntilRenderFinished: true // 等待渲染完成
      });
    
    // 处理截图结果
    this.handleScreenshotResult(pixelMap);
  } catch (error) {
    console.error('截图失败:', error);
  }
}

// 处理截图结果
handleScreenshotResult(pixelMap: image.PixelMap) {
  // 可以显示在Image组件中
  this.screenshotImage = pixelMap;
  
  // 或者保存到文件
  // this.saveToFile(pixelMap);
}
方法二:同步获取
takeScreenshotSync() {
  try {
    const uiContext = this.getUIContext();
    const pixelMap = uiContext.getComponentSnapshot()
      .getSync('targetComponent', {
        scale: 0.8,
        waitUntilRenderFinished: true
      });
    
    this.handleScreenshotResult(pixelMap);
  } catch (error) {
    console.error('同步截图失败:', error);
  }
}

3. 显示截图结果

// 在build方法中显示截图
build() {
  Column() {
    // 原始内容
    Column() {
      // ...原有内容
    }
    .id('targetComponent')
    
    // 截图结果显示
    Image(this.screenshotImage)
      .width(200)
      .height(200)
      .margin(10)
      .visibility(this.screenshotImage ? Visibility.Visible : Visibility.None)
    
    // 截图按钮
    Button('截图')
      .onClick(() => this.takeScreenshot())
      .margin(10)
  }
}

image-20250912223003666

4. 完整示例代码

import { image } from '@kit.ImageKit';

@Entry
@Component
struct ScreenshotExample {
  @State screenshotImage: image.PixelMap | undefined = undefined;

  // 异步截图方法
  async takeScreenshot() {
    try {
      const uiContext = this.getUIContext();
      const pixelMap = await uiContext.getComponentSnapshot()
        .get('contentToCapture', {
          scale: 1.0,
          waitUntilRenderFinished: true
        });
      
      this.screenshotImage = pixelMap;
    } catch (error) {
      console.error('截图失败:', error);
    }
  }

  build() {
    Column({ space: 20 }) {
      // 目标截图区域
      Column() {
        Text('欢迎使用组件截图功能')
          .fontSize(24)
          .fontColor(Color.Blue)
        
        Text('这是可以截图的内容区域')
          .fontSize(16)
          .margin({ top: 10 })
        
        Image($r('app.media.logo'))
          .width(120)
          .height(120)
          .margin({ top: 20 })
      }
      .id('contentToCapture')
      .padding(20)
      .border({ width: 2, color: Color.Gray })

      // 截图结果显示
      if (this.screenshotImage) {
        Image(this.screenshotImage)
          .width(300)
          .height(200)
          .border({ width: 1, color: Color.Black })
      }

      // 操作按钮
      Button('截图')
        .width(120)
        .onClick(() => this.takeScreenshot())
    }
    .width('100%')
    .padding(20)
  }
}

5. SnapshotOptions 重要参数说明

配置选项:

  • scale: number - 缩放比例(0.1-1.0),默认1.0
  • waitUntilRenderFinished: boolean - 是否等待渲染完成(推荐设为true)
  • region: Object - 指定截图区域
    • start: number - 起始x坐标
    • top: number - 起始y坐标
    • end: number - 结束x坐标
    • bottom: number - 结束y坐标

以往文章

PS2020,将所有图片不剪切,调整为800×800像素的文档尺寸。

Photoshop 2020 批处理脚本开发思路

项目需求

用户需要一个Photoshop 2020脚本来批量处理图片,具体要求:

  • 将所有图片调整为800×800像素的文档尺寸
  • 关键要求:图片内容保持原始大小,不进行缩放或拉伸
  • 图片在新画布中居中显示
  • 转换为PNG格式输出

开发过程与思路演进

第一次尝试:使用 resizeImage() 方法

思路:直接缩放图片到800×800像素

doc.resizeImage(UnitValue(TARGET_WIDTH, "px"), UnitValue(TARGET_HEIGHT, "px"), 
               72, ResampleMethod.BICUBIC);

问题:这种方法会缩放图片内容,导致图片变形,不符合用户需求。

第二次尝试:使用 resizeCanvas() 方法

思路:改变画布大小而不缩放图片内容

doc.resizeCanvas(UnitValue(TARGET_WIDTH, "px"), UnitValue(TARGET_HEIGHT, "px"), 
                AnchorPosition.MIDDLECENTER);

问题:在某些情况下效果不稳定,可能与图片的原始尺寸和位置有关。

第三次尝试:创建新文档 + 复制粘贴

思路

  1. 创建800×800的新文档
  2. 复制原图内容
  3. 粘贴到新文档
  4. 计算居中偏移并移动图层
// 创建新文档
var newDoc = app.documents.add(TARGET_WIDTH, TARGET_HEIGHT, 72, "temp_doc", NewDocumentMode.RGB);

// 复制粘贴
doc.selection.selectAll();
doc.selection.copy();
newDoc.paste();

// 计算居中位置
var offsetX = (TARGET_WIDTH - currentWidth) / 2;
var offsetY = (TARGET_HEIGHT - currentHeight) / 2;
pastedLayer.translate(offsetX, offsetY);

问题:translate()方法的定位计算有误,导致显示的是放大的局部内容而不是完整图片。

最终解决方案:使用 Place 动作

核心思路:模拟用户手动拖拽图片到新文档的操作

关键技术点
  1. 使用Photoshop的Place动作

    var idPlc = charIDToTypeID("Plc ");
    var desc = new ActionDescriptor();
    desc.putPath(charIDToTypeID("null"), file);
    desc.putBoolean(charIDToTypeID("Lnkd"), false);
    executeAction(idPlc, desc);
    
  2. 精确的居中计算

    // 获取图层实际边界
    var layerBounds = currentLayer.bounds;
    var layerWidth = layerBounds[2] - layerBounds[0];
    var layerHeight = layerBounds[3] - layerBounds[1];
    
    // 计算画布和图层的中心点
    var centerX = TARGET_WIDTH / 2;
    var centerY = TARGET_HEIGHT / 2;
    var layerCenterX = layerBounds[0] + layerWidth / 2;
    var layerCenterY = layerBounds[1] + layerHeight / 2;
    
    // 计算需要移动的距离
    var deltaX = centerX - layerCenterX;
    var deltaY = centerY - layerCenterY;
    

为什么最终方案有效

1. Place动作的优势

  • 保持原始尺寸:Place动作导入图片时保持原始像素尺寸
  • 模拟手动操作:这正是用户手动拖拽图片时Photoshop内部执行的操作
  • 兼容性好:适用于各种图片格式和尺寸

2. 精确的边界计算

  • 实际边界:通过currentLayer.bounds获取图层的实际边界坐标
  • 中心点计算:分别计算画布中心和图层中心
  • 偏移量:计算两个中心点的差值作为移动距离

3. 处理流程

原始图片 → 创建800×800新文档 → Place导入图片 → 计算居中位置 → 移动图层 → 合并图层 → 保存PNG

技术细节

支持的图片格式

var supportedFormats = /\.(jpg|jpeg|png|gif|tiff|tif|bmp|psd)$/i;

PNG保存选项

var pngSaveOptions = new PNGSaveOptions();
pngSaveOptions.interlaced = false;          // 不使用隔行扫描
pngSaveOptions.compression = 6;             // 压缩级别(0-9,6为平衡)

错误处理

  • 完善的try-catch机制
  • 确保文档正确关闭
  • 处理结果统计和反馈

最终效果

文档尺寸:精确的800×800像素
图片内容:保持原始大小,无缩放变形
位置:完美居中显示
格式:高质量PNG输出
批量处理:自动处理文件夹中的所有图片

学到的经验

  1. 理解用户真实需求:"改变像素尺寸但不缩放内容"这个需求需要仔细分析
  2. 模拟手动操作:最有效的自动化往往是对手动操作的精确模拟
  3. 精确的数学计算:居中定位需要准确的坐标计算
  4. 迭代改进:通过多次尝试和用户反馈不断优化方案
  5. Photoshop脚本的复杂性:不同方法在不同情况下可能有不同的表现

脚本文件

最终脚本文件:photoshop2020_batch_resize.jsx

使用方法:

  1. 将需要处理的图片放在 G:/input 文件夹中
  2. 在Photoshop 2020中运行脚本:文件 → 脚本 → 浏览
  3. 处理后的PNG文件将保存在 G:/input/output 文件夹中

下面是源码:

// Photoshop 2020 批处理脚本
// 功能:将所有照片调整为800x800像素文档(图片内容保持原始大小,居中显示)并转换为PNG格式
// 适用版本:Adobe Photoshop 2020

// 设置输入和输出文件夹
var inputFolder = Folder("G:/input/photos");
var outputFolder = Folder("G:/input/output");

// 创建输出文件夹(如果不存在)
if (!outputFolder.exists) {
    outputFolder.create();
}

// 支持的图片格式
var supportedFormats = /\.(jpg|jpeg|png|gif|tiff|tif|bmp|psd)$/i;
var files = inputFolder.getFiles(supportedFormats);

// 设置目标尺寸
var TARGET_WIDTH = 800;
var TARGET_HEIGHT = 800;

// 处理计数器
var processedCount = 0;
var skippedCount = 0;
var errorCount = 0;

// 主处理循环
for (var i = 0; i < files.length; i++) {
    var file = files[i];
    
    if (file instanceof File) {
        var doc = null;
        try {
            // 打开文档
            doc = app.open(file);
            
            // 获取原始文件名(不含扩展名)
            var originalName = doc.name.replace(/\.[^.]+$/, "");
            
            // 获取文档当前尺寸(转换为像素)
            var currentWidth = doc.width.as('px');
            var currentHeight = doc.height.as('px');
            
            // 创建一个新的800x800像素文档
            var newDoc = app.documents.add(TARGET_WIDTH, TARGET_HEIGHT, 72, "temp_doc", NewDocumentMode.RGB);
            
            // 将原图作为智能对象放置到新文档中
            app.activeDocument = newDoc;
            var placedLayer = newDoc.artLayers.add();
            
            // 使用place方法导入图片,这样可以保持原始尺寸
            var idPlc = charIDToTypeID("Plc ");
            var desc = new ActionDescriptor();
            desc.putPath(charIDToTypeID("null"), file);
            desc.putBoolean(charIDToTypeID("Lnkd"), false);
            executeAction(idPlc, desc);
            
            // 获取当前图层(放置的图片)
            var currentLayer = newDoc.activeLayer;
            
            // 计算居中位置
            var layerBounds = currentLayer.bounds;
            var layerWidth = layerBounds[2] - layerBounds[0];
            var layerHeight = layerBounds[3] - layerBounds[1];
            
            var centerX = TARGET_WIDTH / 2;
            var centerY = TARGET_HEIGHT / 2;
            var layerCenterX = layerBounds[0] + layerWidth / 2;
            var layerCenterY = layerBounds[1] + layerHeight / 2;
            
            var deltaX = centerX - layerCenterX;
            var deltaY = centerY - layerCenterY;
            
            // 移动图层到居中位置
            currentLayer.translate(deltaX, deltaY);
            
            // 合并所有图层
            newDoc.flatten();
            
            // 关闭原文档
            doc.close(SaveOptions.DONOTSAVECHANGES);
            
            // 将新文档设为当前处理文档
            doc = newDoc;
            
            // 设置输出文件路径
            var outputFile = new File(outputFolder.fsName + "/" + originalName + ".png");
            
            // 配置PNG保存选项
            var pngSaveOptions = new PNGSaveOptions();
            pngSaveOptions.interlaced = false;          // 不使用隔行扫描
            pngSaveOptions.compression = 6;             // 压缩级别(0-9,6为平衡)
            
            // 保存为PNG格式
            doc.saveAs(outputFile, pngSaveOptions, true, Extension.LOWERCASE);
            
            // 关闭文档,不保存更改
            doc.close(SaveOptions.DONOTSAVECHANGES);
            
            processedCount++;
            
        } catch (error) {
            // 错误处理
            errorCount++;
            
            // 确保文档被正确关闭
            if (doc !== null) {
                try {
                    doc.close(SaveOptions.DONOTSAVECHANGES);
                } catch (closeError) {
                    // 忽略关闭时的错误
                }
            }
            
            // 可选:记录错误信息(在实际使用中可以启用)
            // alert("处理文件时出错: " + file.name + "\n错误信息: " + error.message);
        }
    }
}

// 显示处理结果摘要
var summaryMessage = "批处理完成!\n\n" +
                    "成功处理: " + processedCount + " 个文件\n" +
                    "跳过处理: " + skippedCount + " 个文件(尺寸不足)\n" +
                    "处理错误: " + errorCount + " 个文件\n\n" +
                    "输出文件夹: " + outputFolder.fsName;

alert(summaryMessage);

// 脚本执行完毕
  • 保存为jsx文件。
  • 运行命令: Start-Process "F:\PS-2020\PS所在路径\Photoshop.exe" -ArgumentList "-r", "G:\脚本所在路径\photoshop2020_batch_resize.jsx"
❌