普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月23日技术

彻底搞懂 SSH 与 Git 的“幕后交易”

作者 ssshooter
2025年12月23日 22:01

很多开发者在配置 GitHub SSH 的时候,通常是按照网上的教程,噼里啪啦一顿复制:先 ssh-keygen,再把公钥贴到 GitHub,最后 git push。如果一切顺利,你会觉得这是一种“魔法”,但一旦报错,往往就一脸懵逼。

其实,这背后是一套非常逻辑严密的“身份代换”过程。

1. 钥匙与锁:非对称加密的“挑战”

SSH 的核心是非对称加密。当你运行 ssh-keygen 时,你其实是产生了一对关系:**私钥(Private Key)**留在你电脑里,相当于你的指纹;**公钥(Public Key)**上传到 GitHub,相当于在 GitHub 门口装了一个只认你指纹的扫描仪。

当你尝试 git push 时,并不是直接发送密码,而是一个“挑战与应答”的过程:

  1. GitHub 拿你的公钥加密一段随机信息发给你。
  2. 只有你电脑里的私钥能解开这段信息并签上名发回去。
  3. GitHub 验证签名成功,门开了。

这就是为什么 SSH 比账号密码安全得多:即便网络被监听,黑客也拿不到你的私钥。

2. 管理员 ssh-agent:你的“钥匙包”

如果你电脑里有很多把钥匙(比如公司的、个人的、不同服务器的),你不可能每次推送代码都手动指定用哪把。

这时候 ssh-add 就出场了。它把你的私钥加载进一个叫 ssh-agent 的后台管家那里。当你发起连接时,这个管家会像拿着一大串钥匙的保安,挨个去试。

但这里有个陷阱:试错是有上限的。如果管家试了 5-6 把钥匙都没对上,GitHub 就会觉得你在暴力破解,直接切断连接。这就是为什么有时候明明钥匙是对的,却依然报错 Too many authentication failures

3. SSH Config:这才是真正的“高级配置”

为了不让管家“盲目试错”,我们需要一个名为 config 的配置文件(在 ~/.ssh/ 目录下)。

这个文件就像一张精确的地图,它告诉 SSH:

  • “如果是去 GitHub,请直接用这把名为 id_ed25519_me 的钥匙。”
  • “不要去试其他的钥匙(IdentitiesOnly yes)。”
  • “甚至如果 22 端口被封了,你可以偷偷走 443 端口。”

有了它,SSH 就不再是猜测,而是精准导航。

4. Git 与 SSH 的“外包关系”

很多人分不清 Git 地址和 SSH 地址。其实,当你看到 git@github.com:user/repo.git 这种地址时,它本质上就是一个 SSH 路径

  • Git 是货物:它只负责打包你的代码变更(Commit)。
  • SSH 是隧道:它负责把这些货物安全地送到远程仓库(Push)。

Git 其实很懒,它根本不负责身份校验。它只是把地址里的 github.com 丢给系统里的 SSH 客户端,说:“喂,帮我连上这个地方,我要发货。”

这时候,SSH 客户端就会去翻你的 config 文件,找对应的钥匙,建立隧道。隧道一旦修通,Git 就在里面欢快地传输数据。

5. 总结:一个完整的链路

当你完成一次成功的提交并推送时,底层逻辑是这样的:

  1. Git Commit:在本地把修改存进仓库。
  2. Git Push:识别到地址是 SSH 格式,呼叫 SSH 客户端。
  3. SSH 寻址:查看 ~/.ssh/config,确定去哪个 IP、用哪个端口、带哪把钥匙。
  4. 身份验证:通过 ssh-agent 提供的私钥与 GitHub 上的公钥完成“暗号对接”。
  5. 数据传输:身份确认,隧道开启,Git 开始搬运代码。

一句话总结:

ssh-keygen 造了钥匙,GitHub 拿了模具,ssh-add 把它挂在腰间,config 给了你一张地图,而 Git 则是那个顺着地图走、拿着钥匙开门送货的快递员。

别再只会调 API 了!LangChain.js 才是前端 AI 工程化的真正起点

2025年12月23日 20:07

2025 年,如果你还在代码里写:
const res = await fetch('https://api.openai.com/v1/chat/completions')

那你大概率,正在亲手制造一个未来必然崩塌的 AI 屎山工程

随着 DeepSeek 等国产大模型的爆发,前端开发者的主战场已经彻底转移:

❌ 不再是:我会不会调用模型 API
✅ 而是:我能不能编排一个稳定、可扩展、可演进的 AI 工作流

LangChain.js,正是这场迁移里的标准答案


一、LangChain 到底是什么?为什么它成了“AI 工程事实标准”?

在写任何代码之前,我们必须搞清楚一件事:

LangChain ≠ AI 模型
LangChain = AI 应用的“工程骨架”

一个前端能秒懂的比喻

  • 🧠 大模型(LLM) :一个聪明但失忆、不会用工具的大脑

  • 🦴 LangChain:让这个大脑

    • 能记事
    • 会用工具
    • 能拆任务
    • 会按流程干活

它解决的不是“模型聪不聪明”,而是工程层面的三大致命痛点

❌ 痛点 1:逻辑碎片化

复杂业务 ≠ 一次 Prompt
LangChain 的 Chain,让多步推理变成流水线。

❌ 痛点 2:数据孤岛

模型不知道你的:

  • PDF
  • 私有文档
  • 数据库

LangChain 的 RAG(检索增强生成) ,让模型“读你自己的数据”。

❌ 痛点 3:模型锁死

今天 OpenAI
明天 DeepSeek
后天再换别的?

LangChain 用 统一接口,让你实现真正的 模型自由

一句话总结
👉 LangChain 之于 AI,就像 React 之于浏览器Express 之于 Node.js


二、LangChain 的“四大支柱”,你代码里其实已经用到了

你写的实战代码,其实已经踩在 LangChain 的核心设计上,只是你可能没意识到。

🧱 1. Model I/O(模型输入输出)

  • PromptTemplate
  • ChatModel
  • OutputParser

👉 负责 “怎么喂模型、怎么拿结果”


🧱 2. Retrieval(检索 / RAG)

  • 文档切片
  • 向量化
  • 相似度搜索

👉 负责 “让模型说你自己的话”


🧱 3. Chains(执行链)

LangChain 的灵魂

把 Prompt / Model / Parser
像管道一样串起来。


🧱 4. Agents(智能体)

不给流程
只给工具

让 AI 自己决定:

  • 搜索?
  • 算数?
  • 写代码?

👉 这是 AI 从“脚本”走向“自治” 的起点。


三、Prompt 工程化:像写 React 组件一样写 Prompt

你代码里最亮眼的一点,是这一段 👇

import { PromptTemplate } from '@langchain/core/prompts';

const prompt = PromptTemplate.fromTemplate(`
你是一个 {role}
请用不超过 {limit} 字回答以下问题:
{question}
`);

💡 为什么这是“工程级 Prompt”?

因为它解决了 提示词三大老问题

✅ 1. 解耦

Prompt 不再是字符串拼接
而是 配置 + 参数

✅ 2. 可维护

  • 可版本化
  • 可复用
  • 可测试

✅ 3. 防手滑

占位符校验,避免:

“怎么模型突然胡说八道了?”

类比一句话
👉 PromptTemplate 就是 AI 世界里的 Props


四、DeepSeek 丝滑接入:这才是适配器模式的正确用法

import { ChatDeepSeek } from '@langchain/deepseek';

const model = new ChatDeepSeek({
  model: 'deepseek-reasoner',
  temperature: 0.7,
});

为什么 LangChain 对前端特别友好?

因为各家模型虽然长得像 OpenAI,但细节全是坑:

  • BaseURL 不同
  • Auth 不同
  • Token 计算不同

LangChain 的 Provider 层,把这些脏活全吃掉了

你只需要:

关心业务,不关心模型厂商


五、真正的灵魂:.pipe() 与 LCEL 声明式编排

const chain = prompt.pipe(model);

这行代码,是整篇文章的技术核心

什么是 LCEL(LangChain Expression Language)?

它借鉴的是 Unix 管道思想

cat file | grep keyword | sort

在 LangChain 中:

Prompt → Model → Output

对比一下就懂了

❌ 命令式(越写越乱):

const text = await prompt.format(...)
const res = await model.invoke(text)

✅ 声明式(可组合、可扩展):

const chain = prompt.pipe(model)
await chain.invoke(...)

LCEL 的真正价值在于

  • 天然支持 Streaming
  • 天然支持并发
  • 天然可观测

六、进阶实战:多链协作,才是 AI 工程的常态

真实场景几乎从来不是“一问一答”。

你这个例子,非常典型,也非常高级 👇

const fullChain = RunnableSequence.from([
  (input) => explainChain.invoke({ topic: input.topic }).then(r => r.text),
  (explanation) =>
    summaryChain.invoke({ explanation }).then(r =>
      `【详情】${explanation}\n【总结】${r.text}`
    )
]);

这段代码本质上在干什么?

👉 AI 流水线加工

  • 原子性:每个 Chain 都能单测
  • 组合性:像搭积木一样拼
  • 闭环性:前端只管一个输入

这,才配叫 AI 工程化


七、前端 AI 实战避坑指南

⚠️ 1. 永远别把 API Key 写在浏览器

LangChain = Node / Serverless 专属

⚠️ 2. Temperature 不是随便填的

  • 0.0:代码 / 数学
  • 0.7:博客 / 对话
  • 1.0+:创作 / 发散

⚠️ 3. 一定要用 Streaming

chain.stream()

体验差距 = ChatGPT vs 普通输入框


八、结语:你不是不会 AI,你只是缺一套“工程语法”

很多人觉得 AI 开发 = 调包
其实真正的分水岭在于:

你有没有一套可组合、可演进的 AI 结构

LangChain 的意义,不是让模型更聪明
而是让 人类工程师重新掌控复杂度


🎯 最后一句话送你

Prompt → Model → Chain

这是前端迈入 AI 工程时代的第一性原理。

React/Vue 代理配置全攻略:Vite 与 Webpack 实战指南

作者 前端无涯
2025年12月23日 18:03

在前端开发中,跨域问题是我们绕不开的坎。浏览器的同源策略限制了不同域名、端口或协议之间的资源请求,而开发环境中前端项目(通常是localhost:3000/localhost:5173)与后端接口(如http://api.example.com)往往不在同一域下,直接请求会触发跨域错误。

为了解决开发环境的跨域问题,主流的前端构建工具(Vite、Webpack)都提供了 ** 代理(Proxy)** 功能。代理的核心原理是:以构建工具启动的开发服务器作为中间层,前端请求先发送到开发服务器,再由开发服务器转发到后端接口服务器(服务器之间的请求不受同源策略限制),最后将后端响应返回给前端,从而绕过浏览器的跨域限制。

本文将详细讲解 React 和 Vue 两大主流框架,分别基于Vite(新一代前端构建工具)和Webpack(传统主流构建工具)的代理配置方案,涵盖基础配置、进阶场景和常见问题排查。

一、Vue 框架的代理配置

Vue 项目的构建工具主要分为两种:Vite(Vue 3 推荐)和Vue CLI(基于 Webpack,Vue 2/3 都支持),两者的代理配置方式略有不同。

1.1 Vue + Vite 代理配置

Vite 的代理配置在项目根目录的vite.config.js(或vite.config.ts)文件中,通过server.proxy选项配置。

基础配置示例

假设前端项目运行在http://localhost:5173,后端接口地址为http://localhost:8080/api,我们希望将前端以/api开头的请求代理到后端接口。

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  // 开发服务器配置
  server: {
    port: 5173, // 前端项目端口(默认5173,可自定义)
    open: true, // 启动时自动打开浏览器
    // 代理配置
    proxy: {
      // 匹配以/api开头的请求
      '/api': {
        target: 'http://localhost:8080', // 后端接口的基础地址
        changeOrigin: true, // 开启跨域模拟(关键:让后端认为请求来自target域名)
        rewrite: (path) => path.replace(/^/api/, '') // 路径重写(可选:如果后端接口没有/api前缀,需要去掉)
      }
    }
  }
})

配置项说明

  • target:后端接口的服务器地址(必填)。
  • changeOrigin:是否开启跨域模拟,设置为true时,开发服务器会在转发请求时修改Host请求头为target的域名,避免后端因域名校验拒绝请求(建议始终开启)。
  • rewrite:路径重写函数,用于修改转发到后端的请求路径。例如前端请求/api/user,经过重写后会转发到http://localhost:8080/user(如果后端接口本身带有/api前缀,则不需要此配置)。

测试效果

前端发送请求:

// 原本需要直接请求http://localhost:8080/api/user(跨域)
// 现在直接请求/api/user,会被代理转发
fetch('/api/user')
  .then(res => res.json())
  .then(data => console.log(data))

1.2 Vue + Vue CLI(Webpack)代理配置

Vue CLI 基于 Webpack,其代理配置在项目根目录的vue.config.js文件中,通过devServer.proxy选项配置(底层依赖webpack-dev-server的 proxy 功能)。

基础配置示例

需求与上述一致:将/api开头的请求代理到http://localhost:8080

// vue.config.js
module.exports = {
  devServer: {
    port: 8081, // 前端项目端口
    open: true,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        pathRewrite: { '^/api': '' } // 路径重写(与Vite的rewrite作用一致,语法不同)
      }
    }
  }
}

注意:Vue CLI 的路径重写使用pathRewrite对象,而 Vite 使用rewrite函数,语法略有差异,但功能一致。

二、React 框架的代理配置

React 项目的构建工具同样分为Vite(新一代)和Create React App(基于 Webpack,简称 CRA,React 官方脚手架)。

2.1 React + Vite 代理配置

React + Vite 的代理配置与 Vue + Vite 完全一致,因为 Vite 的代理功能是框架无关的。

基础配置示例

// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/api/, '')
      }
    }
  }
})

2.2 React + Create React App(Webpack)代理配置

CRA 隐藏了 Webpack 的核心配置,因此其代理配置分为两种方式:简单配置(package.json)进阶配置(setupProxy.js)

方式 1:简单配置(package.json)

适用于单一路径的代理场景,直接在package.json中添加proxy字段。

{
  "name": "react-app",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  // 简单代理配置:将所有请求代理到http://localhost:8080
  "proxy": "http://localhost:8080"
}

注意:这种方式的局限性很大:

  • 只能配置一个代理目标,无法针对不同路径配置不同代理。
  • 不支持路径重写、HTTPS 等进阶配置。

方式 2:进阶配置(setupProxy.js)

适用于多路径代理、路径重写等复杂场景,需要创建src/setupProxy.js文件,并安装http-proxy-middleware依赖(CRA 已内置该依赖,若未安装可手动执行npm install http-proxy-middleware --save-dev)。

基础配置示例(匹配 /api 路径)
// src/setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware')

module.exports = function(app) {
  app.use(
    // 匹配以/api开头的请求
    '/api',
    createProxyMiddleware({
      target: 'http://localhost:8080',
      changeOrigin: true,
      pathRewrite: { '^/api': '' }
    })
  )
}

多代理规则示例

如果需要将/api代理到http://localhost:8080,将/admin代理到http://localhost:9090,可以配置多个代理规则:

// src/setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware')

module.exports = function(app) {
  // 代理/api到8080端口
  app.use(
    '/api',
    createProxyMiddleware({
      target: 'http://localhost:8080',
      changeOrigin: true,
      pathRewrite: { '^/api': '' }
    })
  )
  // 代理/admin到9090端口
  app.use(
    '/admin',
    createProxyMiddleware({
      target: 'http://localhost:9090',
      changeOrigin: true,
      pathRewrite: { '^/admin': '' }
    })
  )
}

注意:修改setupProxy.js后,需要重启 CRA 的开发服务器才能生效。

三、代理的进阶配置场景

除了基础的代理配置,我们还会遇到一些复杂场景,比如代理 HTTPS 接口、携带 Cookie、匹配正则路径等。

3.1 代理 HTTPS 接口

如果后端接口是 HTTPS 协议(如https://api.example.com),需要添加secure: false配置,忽略 SSL 证书验证(开发环境下常用,生产环境不建议)。

Vite 配置示例

proxy: {
  '/api': {
    target: 'https://api.example.com',
    changeOrigin: true,
    secure: false, // 忽略HTTPS证书验证
    rewrite: (path) => path.replace(/^/api/, '')
  }
}

Webpack(Vue CLI/CRA)配置示例

// Vue CLI:vue.config.js
proxy: {
  '/api': {
    target: 'https://api.example.com',
    changeOrigin: true,
    secure: false,
    pathRewrite: { '^/api': '' }
  }
}

// CRA:setupProxy.js
createProxyMiddleware({
  target: 'https://api.example.com',
  changeOrigin: true,
  secure: false,
  pathRewrite: { '^/api': '' }
})

3.2 跨域携带 Cookie

如果需要在跨域请求中携带 Cookie(如用户登录状态),需要同时配置前端请求的withCredentials和代理的cookieDomainRewrite

前端请求配置

// fetch请求
fetch('/api/user', {
  credentials: 'include' // 携带Cookie
})

// axios请求
axios.get('/api/user', {
  withCredentials: true // 携带Cookie
})

代理配置

// Vite
proxy: {
  '/api': {
    target: 'http://localhost:8080',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^/api/, ''),
    cookieDomainRewrite: 'localhost' // 将后端返回的Cookie域名重写为前端域名
  }
}

// Webpack
createProxyMiddleware({
  target: 'http://localhost:8080',
  changeOrigin: true,
  pathRewrite: { '^/api': '' },
  cookieDomainRewrite: 'localhost'
})

3.3 正则匹配路径

如果需要匹配更复杂的路径(如以/api/v1/api/v2开头的请求),可以使用正则表达式作为代理的匹配规则。

Vite 配置示例

proxy: {
  // 匹配以/api/v开头的请求
  '^/api/v\d+': {
    target: 'http://localhost:8080',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^/api/, '')
  }
}

Webpack(CRA)配置示例

app.use(
  // 正则匹配/api/v1或/api/v2
  /^/api/v\d+/,
  createProxyMiddleware({
    target: 'http://localhost:8080',
    changeOrigin: true,
    pathRewrite: { '^/api': '' }
  })
)

四、代理配置常见问题排查

配置代理后,如果请求仍然失败,可从以下几个方面排查:

4.1 代理规则未匹配

  • 检查前端请求的路径是否与代理的匹配规则一致(如前端请求/api/user,代理规则是/api,则匹配;若请求/user,则不匹配)。
  • 正则匹配时,注意正则表达式的语法是否正确(如转义字符、量词等)。

4.2 路径重写错误

  • 如果后端接口没有/api前缀,但代理配置了rewrite: (path) => path.replace(/^/api/, ''),则前端请求/api/user会被转发到/user;若后端接口有/api前缀,去掉重写配置即可。
  • 检查路径重写的语法(Vite 用rewrite函数,Webpack 用pathRewrite对象)。

4.3 changeOrigin 未开启

  • 若后端接口有域名校验(如只允许特定域名访问),未开启changeOrigin: true会导致后端拒绝请求,此时需要开启该配置。

4.4 后端接口地址错误

  • 检查target配置的后端地址是否正确(包括域名、端口、协议),可直接在浏览器中访问后端接口地址,确认接口是否正常。

4.5 开发服务器未重启

  • 修改 Vite/Vue CLI 的配置文件后,开发服务器会自动重启;但修改 CRA 的setupProxy.js后,需要手动重启开发服务器才能生效。

五、总结

开发环境的代理配置是解决跨域问题的最优方案,不同构建工具的配置方式虽有差异,但核心原理一致。

  • Vite:配置简洁,框架无关,通过server.proxy实现,支持函数式路径重写和正则匹配。
  • Webpack:Vue CLI 通过devServer.proxy配置,CRA 则分为简单的package.json配置和进阶的setupProxy.js配置,底层依赖http-proxy-middleware

在实际开发中,我们可以根据项目的框架(React/Vue)和构建工具(Vite/Webpack)选择对应的配置方式,并根据业务需求添加路径重写、HTTPS 支持、Cookie 携带等进阶配置。同时,遇到问题时可按照 “规则匹配→路径重写→跨域配置→接口地址” 的顺序排查,快速定位问题。

最后需要注意:代理配置仅适用于开发环境,生产环境的跨域问题需要通过后端配置 CORS(跨域资源共享)或 Nginx 反向代理来解决。

回顾计算属性的缓存与监听的触发返回结果

2025年12月23日 17:13

前言

网上的给出的区别有很多,今天只是简单来回归下其中的一些流程和区别的关键点。

以下面的代码为例进行思考:

<template>
  <div class="calc">
    <input v-model.number="n1" type="number" placeholder="数字1">
      <input v-model.number="n2" type="number" placeholder="数字2">
        <p>和:{{ sum }} 平均值:{{ avg }}</p>
        <input v-model="name" placeholder="用户名">
          <p :style="{color: tipColor}">{{ tip }}</p>
        </div>
</template>

<script>
  export default {
    data() {
      return { n1: 0, n2: 0, name: '', tip: '', tipColor: '#333' }
    },
    computed: {
      sum() { return this.n1 + this.n2 },
      avg() { return this.sum / 2 }
    },
    watch: {
      name(v) {
        if (!v.trim()) { this.tip = '不能为空'; this.tipColor = 'red' }
        else if (v.length < 3) { this.tip = '不少于3位'; this.tipColor = 'orange' }
        else { this.tip = '可用'; this.tipColor = 'green' }
      }
    }
  }
</script>

<style scoped>
  .calc { padding: 20px; border: 1px solid #eee; width: 300px; margin: 20px auto; }
  input { display: block; width: 100%; margin: 8px 0; padding: 6px; border: 1px solid #ccc; border-radius: 4px; }
</style>

关于 watch 和 computed 的使用我们很多,这里我们不一一介绍,但是请记住:监听是不需要 return 的,计算属性是百分百必须要有的。

1、监听和计算属性的区别

最关键的区别:

1、监听是没有缓存的,计算属性是有缓存的

2、监听是不需要有返回值的,但是机损属性是必须要有返回值的。(极限情况下不return基本没意义)

其他的区别:

对比维度 计算属性(computed) 监听器(watch)
核心用途 基于已有数据推导 / 计算新数据(数据转换 / 组合) 监听已有数据的变化,执行异步操作或复杂逻辑(无新数据产出,侧重 “副作用”)
依赖关系 只能依赖 Vue 实例中的响应式数据(data/props/ 其他 computed),自动感知依赖变化 可监听单个响应式数据、对象属性、数组,甚至通过 deep: true监听对象深层变化,支持手动指定监听目标
使用场景 1. 简单数据拼接(如全名:firstName + lastName)2. 数据格式化(如时间戳转日期字符串)3. 依赖多数据的计算(如总价:price * count)4. 需缓存的重复计算场景 1. 异步操作(如监听输入框变化,延迟请求接口获取联想数据)2. 复杂逻辑处理(如监听用户状态变化,同步更新权限菜单)3. 监听对象深层变化(如监听表单对象,统一处理提交前校验)4. 数据变化后的联动操作(非数据推导类)
是否支持异步 不支持异步操作:若在 computed中使用异步(如定时器、接口请求),无法正确返回推导结果,会得到 undefined 支持异步操作:这是 watch的核心优势之一,可在监听函数中执行任意异步逻辑

2: 监听和计算属性的基本触发流程:

核心逻辑:set → Dep → Watcher → watch/computed 联动流程

无论是 watch 还是 computed,底层联动流程的核心一致,仅在 Watcher 执行逻辑上有差异,完整流程如下:

第一步:初始化阶段 —— 依赖收集(get 拦截器 + Dep + Watcher 绑定)

  1. computed ****的依赖收集
  1. 组件初始化时,会为每个计算属性创建一个「计算属性 Watcher」; 1. 执行计算属性的 get 方法,访问依赖的响应式数据(如 this.num1); 1. 触发该数据的 get 拦截器,get 拦截器会将当前「计算属性 Watcher」添加到该数据的 Dep 依赖列表中; 1. 所有依赖数据都完成 Watcher 绑定,最终缓存计算属性的初始结果。
  1. watch ****的依赖收集
    1. 组件初始化时,会为每个 watch 监听目标创建一个「普通 Watcher」;
    2. 主动读取一次监听目标数据(如 this.username),触发该数据的 get 拦截器;
    3. get 拦截器将当前「普通 Watcher」添加到该数据的 Dep 依赖列表中;
    4. 若开启 deep: true,会递归遍历对象 / 数组的内部属性,完成深层依赖收集。

第二步:更新阶段 ——set 拦截器触发 Watcher 执行

当修改响应式数据时(如 this.num1 = 10),触发底层联动:

  1. 数据被修改,触发该数据的 set 拦截器;
  2. set 拦截器调用对应 Dep 的 notify() 方法(派发更新通知);
  3. Dep 遍历自身的依赖列表,通知所有绑定的 Watcher「数据已更新」;
  4. 不同类型的 Watcher 接收通知后,执行差异化操作(这是 watch 和 computed 表现不同的核心原因):
    • 普通 Watcher (对应 watch :收到通知后,立即执行 watch 的回调函数,传入 newVal 和 oldVal,执行异步 / 复杂逻辑;
    • 计算属性 Watcher (对应 computed :收到通知后,仅将自身标记为「脏状态」(缓存失效) ,不立即执行计算逻辑,等待下次访问计算属性时,才重新执行 get 方法计算新结果并更新缓存。

举个例子:

  watch: {
    num(newVal, oldVal) {
      console.log(`【watch】:num从${oldVal}变为${newVal},我立即执行回调`);
    }
  },

当 set 触发 watcher 后,watcher 就会立即触发:

num(newVal, oldVal) {
      console.log(`【watch】:num从${oldVal}变为${newVal},我立即执行回调`);
    }

什么叫 收到通知后, 仅将自身标记为「脏状态」(缓存失效) ,不立即执行计算逻辑,等待下次访问计算属性时,才重新执行 get 方法计算新结果并更新缓存。

举个例子:

<template>
  <div>
    <!-- 这里就是「访问计算属性」:模板渲染时会读取 numDouble 的值 -->
    <p v-if="num < 2">两倍数:{{ numDouble }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return { num: 1 };
  },
  computed: {
    numDouble() {
      console.log("计算属性执行计算");
      return this.num * 2;
    },
  },
  mounted() {
    setTimeout(() => {
    this.num = 5; // 2秒后,v-if不成立
  }, 2000);
  setTimeout(() => {
    this.num = 1; // 4秒后,v-if再次成立
  }, 4000);
  },
};
</script>

// 控制台只会打印2次“计算属性执行

为什么只打印 2 次:

原因就是我们的计算属性在 2s 后没有执行

1、 当初始化时,页面中 v-if 条件是符合的,会执行一次 get 计算得到返回值

2、当经过两秒后、 v-if 不符合条件,这个时候表明numDouble 是脏数据,会对其进行标记( v-if 不符合条件,所以无法对numDouble 进行访问, 这里也就是我们说的缓存,缓存计算属性的结果值,当脏状态取消时才会进行新的计算 )

3、当 经过 4 秒后条件再次被满足时,才会有新的计算。

3: 计算属性为什么不能异步

举个例子,我们使用延时进行模仿:

<template>
  <div>
    <!-- 这里就是「访问计算属性」:模板渲染时会读取 numDouble 的值 -->
    <p v-if="num < 2">两倍数:{{ numDouble }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return { num: 1 };
  },
  computed: {
    numDouble() {
      setTimeout(() => {
        return this.num * 2;
      }, 1000);
    },
  },
  mounted() {
    console.log(this.numDouble);
  },
};
</script>

打印结果如下:

为什么会打印 underfined 呢,这里有两个原因?

原因 1:JavaScript 中,任何函数如果没有显式写 return 语句,或 return 后没有跟随具体值,都会默认返回 undefined,这是计算属性返回 undefined 的基础原因。

举个例子:

function test() {
        setTimeout(() => {
          return 11;
        }, 1000);
      }
      setTimeout(() => {
        console.log(test());
      }, 2000);

这样写是属于语法的错误。

原因 2:setTimeout 的异步特性(关键)

即使你在 setTimeout 回调中写了 return this.num * 2,这个返回值也毫无意义,因为 setTimeout异步宏任务

  1. 当 Vue 访问 this.numDouble 时,会立即执行计算属性的函数体;
  2. 函数体执行到 setTimeout 时,只会「注册一个异步任务」,然后直接跳过 setTimeout 继续执行;
  3. 此时计算属性函数体已经执行完毕(没有显式 return),默认返回 undefined,并被 console.log 打印;
  4. 1 秒后,setTimeout 的回调函数才会执行,此时回调中的 return this.num * 2 只是回调函数自身的返回值,无法传递给计算属性,也无法改变之前已经返回的 undefined

简单说:异步回调的返回值,无法成为计算属性的返回值,计算属性会在异步任务注册后,直接默认返回 undefined

也可以分两步走

一、先明确:计算属性的函数体,只在 “被访问” 时同步执行一次(除非满足重新计算条件)

当 mounted 中访问 this.numDouble,或者模板渲染访问 numDouble 时,Vue 会同步、完整地执行一遍 ****numDouble ****函数体的代码,但这个执行过程和 setTimeout 内部的回调是完全分离的:

第一步:计算属性函数体「同步执行」(瞬间完成,不等待异步)

我们把 numDouble 的执行过程拆解成 “逐行执行”,你就能看清流程:

numDouble() {
  // 第1步:执行 setTimeout 这行代码
  // 作用:向浏览器“注册一个1秒后执行的异步任务”,仅此而已
  // 注意:这行代码执行时,不会等待1秒,也不会执行回调函数内部的代码
  setTimeout(() => {
    // 这是回调函数,此时完全没有执行!
    console.log("回调函数开始执行");
    return this.num * 2;
  }, 1000);

  // 第2步:计算属性函数体执行到末尾
  // 没有显式 return,默认返回 undefined
  // 此时,numDouble 已经完成了“返回值”的传递,整个函数体执行结束
}

简单来说就是numDouble 函数体的执行,只做了一件事 ——“安排了一个 1 秒后的任务”,然后就直接返回了 undefined,它不会停下来等 1 秒后回调执行完再返回值。

第二步:1 秒后,异步回调才执行,但为时已晚

  1. 计算属性已经在第一步就返回了 undefined,这个返回值已经被 console.log 打印,也被 Vue 缓存起来了;
  2. 1 秒后,浏览器才会执行 setTimeout 的回调函数,此时回调里的 return this.num * 2 只是 “回调函数自己的返回值”—— 这个值没有任何接收者,既不能传给 numDouble ,也不能改变之前已经返回的 undefined
  3. 更关键的是:回调执行时,numDouble 函数体早就执行完毕了,两者是完全独立的执行流程,回调的返回值无法 “回溯” 给已经执行完的计算属性。

简单来讲就是:

计算属性是要内置返回一个结果的,如果加入异步就会因为执行顺讯返回一个undefined,监听是在事件触发后对写入的回调函数的调用。

《闭包、RAG与AI面试官:一个前端程序员的奇幻LangChain之旅》

作者 Yira
2025年12月23日 17:08

《闭包、RAG与AI面试官:一个前端程序员的奇幻LangChain之旅》

在某个风和日丽的下午,小李——一位自诩“能用一行代码解决的问题绝不用两行”的前端工程师——正坐在工位上,盯着屏幕上一行报错发呆。他不是在调试React组件,也不是在修复CSS样式错乱,而是在尝试用AI模型回答一个看似简单却深不见底的问题:“什么是闭包?

但别误会,这不是一篇枯燥的技术教程。这是一场融合了LangChain魔法、DeepSeek大模型、提示词工程、工作流编排以及一点点程序员幽默感的奇妙冒险。我们将从一行import开始,穿越提示词模板、链式调用、序列化流程,最终抵达AI驱动的智能应用新大陆。准备好了吗?系好安全带,我们的LangChain飞船即将升空!


第一幕:环境变量——AI世界的“启动密钥”

一切的起点,都始于那句神秘的咒语:

import 'dotenv/config';

对,就是它!这行代码仿佛打开了通往异世界的传送门,把藏在 .env 文件里的 DEEPSEEK_API_KEY 唤醒。没有它,AI就像没插电的机器人——再聪明也动不了。小李深知:在AI时代,API密钥就是你的魔法杖

小知识:.env 文件通常包含敏感信息(如密钥、数据库密码),绝不能提交到Git!记得把它加入 .gitignore

接着,他召唤出了今天的主角之一:

import { ChatDeepSeek } from '@langchain/deepseek';

ChatDeepSeek 是 LangChain 为 DeepSeek 大模型提供的官方适配器。LangChain 的伟大之处在于——它用“适配器模式”帮我们屏蔽了不同大模型(LLM)之间的差异。换句话说,你不用再为每个模型重写一套调用逻辑。今天用 DeepSeek,明天换通义千问?只要接口一致,几乎无缝切换。

这省下的时间,足够你多喝三杯咖啡,或者多摸五分钟鱼——而后者,往往是程序员真正的刚需。


第二幕:提示词——给AI戴上“人设面具”

小李知道,直接问AI“什么是闭包?”太粗暴了。AI可能会给你一段教科书式的定义,也可能突然开始讲哲学,甚至反问你:“你确定要听真话吗?”

于是,他祭出了 PromptTemplate ——LangChain 中的提示词模板神器。

const prompt = PromptTemplate.fromTemplate(`
    你是一个{role},
    请用不超过{limit}字回答以下问题:
    {question}
`);

看,这不只是提问,这是在给AI分配角色!你可以让它扮演“毒舌面试官”、“耐心导师”,甚至是“脱口秀演员”。在这个例子中,小李设定了:

  • role: '前端面试官'
  • limit: '50'
  • question: '什么是闭包'

然后格式化:

const promptStr = await prompt.format({
    role:'前端面试官',
    limit:'50',
    question:'什么是闭包'
});

结果?AI秒回一句精准又犀利的回答:

“闭包是函数记住并访问其词法作用域的能力,即使在函数外部执行。”

50字,不多不少,面试官看了直呼内行

提示词工程(Prompt Engineering)的本质,就是“如何优雅地指挥AI”。你不是在写代码,你是在写剧本。而好的提示词,就是让AI入戏的导演手记。


第三幕:单步调用 vs 链式思维——从“问答机”到“思考者”

但小李不满足于一次性的问答。他想让AI先详细解释概念,再提炼要点,最后生成学习建议。这怎么办?总不能手动复制粘贴三次吧?

这时候,LangChain 的 Chain(链) 概念闪亮登场。

简单链:Pipe 一下,世界清净了

最基础的链,用 .pipe() 连接提示词和模型:

const chain = prompt.pipe(model);
const response = await chain.invoke({ topic: '什么是闭包' });

这里,prompt 是输入模板,model 是语言模型,pipe 把它们串成流水线。输入 {topic},输出 AI 回答。简洁、优雅、可复用

这就像你点奶茶:选口味(prompt)→ 加糖(参数)→ 出杯(invoke)。全程自动化,无需人工干预。

复杂链:RunnableSequence——AI的“多工序车间”

但真实业务往往更复杂。比如小李的需求:

  1. 先让AI详细解释“闭包”;
  2. 再让另一个提示词把解释总结成三个要点;
  3. 最后拼接成完整报告。

LangChain 提供了 RunnableSequence 来实现这种多步骤流程:

const fullChain = RunnableSequence.from([
    (input) => explainChain.invoke({topic: input.topic}).then(res => res.text),
    (explanation) => summaryChain.invoke({explain: explanation}).then(res => `知识点总结: ...`)
]);

注意:每一步都是异步的,且前一步的输出自动成为后一步的输入。整个过程像一条装配线,原料进去,成品出来

运行结果令人惊喜:

  • 第一阶段输出300字内的专业解释;
  • 第二阶段自动生成如“1. 函数携带作用域 2. 延长变量生命周期 3. 可能导致内存泄漏”这样的干货总结;
  • 最终返回结构化文本,可直接用于教学或笔记。

这哪是AI?这分明是你的私人学习助理+内容编辑+知识萃取师


第四幕:RAG?一句话说清!——测试AI的“基本功”

当然,小李也没忘了测试AI的基础能力。他直接问:

const res = await model.invoke('用一句话解释什么是RAG?');
console.log(res.content);

AI答:“RAG(检索增强生成)是一种结合外部知识库与大语言模型的技术,通过检索相关信息来增强生成内容的准确性。”

一句话,准确、简洁、无废话。温度(temperature)设为0,确保AI不“放飞自我”。

温度是什么?简单说,温度越高,AI越“有创意”(也越可能胡说八道);温度越低,越“严谨”(但也可能死板)。面试场景?当然选0.7左右,既专业又不死板。写诗?那就拉到1.2,让它尽情发挥!


第五幕:为什么LangChain是AI应用的“乐高积木”?

看到这里,你可能想问:为什么不用原生API直接调用?

答案是:复杂业务需要工程化

想象一下,你要做一个“智能面试系统”:

  • 用户输入问题;
  • AI先判断问题领域(前端/后端/算法);
  • 然后切换不同角色回答;
  • 再根据用户水平调整解释深度;
  • 最后生成学习路径+相关练习题。

如果全用手写Promise链,代码会变成意大利面条,维护成本爆炸。而LangChain的 Runnable、Chain、Agent、Memory 等抽象,让你像搭乐高一样组合功能模块。

更重要的是——每一步都可测试、可替换、可复用。今天用DeepSeek,明天换Claude?只要接口一致,几乎不用改代码!

LangChain 的哲学是:不要重复造轮子,而是把轮子组装成车


第六幕:闭包的隐喻——程序员与AI的共生关系

回到最初的问题:“什么是闭包?”

对小李来说,闭包不仅是JavaScript的一个特性,更是一种隐喻:函数携带着它诞生时的环境,在陌生的地方依然能访问“家”中的变量

而LangChain,就像是那个“环境”——它包裹着大模型的能力,让我们在复杂的AI应用世界中,依然能保持代码的清晰与可控。

我们写的每一行提示词,都是在为AI“注入上下文”;每一个Chain,都是在构建“认知流水线”。我们不是在取代AI,而是在与AI协同进化


终章:从玩具到生产——LangChain的实战价值

别以为这只是玩具代码。在真实项目中,LangChain 已被广泛应用于:

  • 智能客服(自动问答+意图识别)
  • 文档摘要(上传PDF → 自动生成摘要)
  • 代码助手(自然语言生成SQL/React组件)
  • 教育工具(自动生成习题+解析)

而这一切的核心,正是我们今天演示的:提示词 + 模型 + 链式编排

所以,下次当你被问到“什么是闭包”时,不妨笑着回答:

“闭包,就是一个函数带着它的‘童年回忆’闯荡江湖。而我,用LangChain给AI装上了‘回忆提取器’,让它不仅能答题,还能当老师、做总结、甚至讲段子。”

技术很酷,但更酷的是——我们正在用代码,重新定义人与智能的关系


彩蛋:避坑指南 & 最佳实践

  1. 不要在一个文件里重复导入
    你提供的代码片段中有多个 import 'dotenv/config' 和重复的模型初始化。实际项目中应拆分为模块,避免冲突。

  2. 合理使用 .gitignore
    确保 .env 不被提交!否则你的API密钥将公之于众。

  3. 温度控制很重要

    • temperature: 0 → 确定性输出(适合事实问答)
    • temperature: 0.7 → 平衡创意与准确(适合创作、解释)
    • temperature: 1.0+ → 放飞自我(适合诗歌、故事)
  4. Chain 可嵌套
    你可以把一个Chain作为另一个Chain的节点,构建树状工作流。

  5. 监控与日志
    在生产环境中,记得记录每一步的输入输出,便于调试和优化提示词。


Happy Coding,愿你的AI永远不胡说,你的闭包永不泄漏,你的Chain永远畅通无阻!

百度一站式全业务智能结算中台

作者 百度Geek说
2025年12月23日 16:41

导读

本文深入介绍了百度一站式全业务智能结算中台,其作为公司财务体系核心,支撑多业务线精准分润与资金流转。中台采用通用化、标准化设计,支持广告、补贴、订单等多种结算模式,实现周结与月结灵活管理。通过业务流程标准化、分润模型通用化及账单测算自动化,大幅提升结算效率与准确性,确保数据合规与业务稳健发展。未来,中台将推进全业务线结算立项线上化、数据智能分析,进一步提升数据分析智能化水平,为公司业务发展提供坚实保障。

01 概述

结算中台作为公司财务体系的核心组成部分,承担着多业务线分润计算、结算及资金流转的关键职能。采用通用化、标准化的设计理念,结算中台能够高效支撑公司内数十个业务线的分润需求,确保广告收入、订单收入、内容分发的精准结算,为公司的财务健康与业务稳健发展提供坚实保障。结算中台建设的核心目标是: 构建高效、标准化、智能化的结算中台体系,支撑多业务线分润计算与资金流转,确保结算数据准确性、高时效披露及业务快速迭代能力,同时降低运维复杂度,推动全业务线结算线上化管理。

结算中台已对接了百家号业务、搜索业务、智能体业务、小说等多个业务线的结算需求, 支持广告分润、补贴分润、订单分润三种结算模式。不同业务线根据各自的业务场景使用不同的结算模式,确保每个业务的收益分配准确无误。结算中台功能分层如图:

图片

02 基本功能

1. 结算模式

结算中台支持三种结算模式,以适应不同业务场景的结算需求:

  • 订单结算:基于直接订单数据,按照订单实际金额与分成策略进行分润计算。

  • 补贴结算:针对特定业务或用户群体,提供额外的收益补贴,以增强业务的市场竞争力。

  • 广告结算:根据分发内容的广告变现与渠道分成比例,精确计算媒体与内容的实际收益。

2. 结算能力

结算中台支持周结与月结两种结算能力:

  • 周结:适用于需要快速资金回笼的业务场景,比如短剧快速回款以后能够再次用于投流, 确保资金流转的高效性。

  • 月结:作为默认的结算周期,便于公司进行统一的财务管理与账务处理。

3. 账单测算自动化

结算中台支持重点业务账单自动测算,通过预设的分润模型,自动计算每个渠道、每位作者的应得收益,生成测算报告。这一自动化过程显著提升工作效率,减少人为错误,确保结算数据的绝对准确。

03 需求分析

在推进公司结算业务时,我们致力于实现统一化、标准化,规范业务流程,并确保数据合规化治理,我们面临着诸多问题与挑战,具体表现如下:

1. 流程与规范缺失

  • 结算流程管理混乱:存在结算需求未备案即已上线的情况,或者备案内容与实际实现不一致,甚至缺乏备案流程。

  • 日志规范陈旧:广告分润场景中,内容日志打点冗余,同时缺少扩展性,导致对新的业务场景无法很好兼容。

2. 烟囱式开发成本高

  • 标准化与统一化需求迫切:之前,各个结算业务维护各自的结算系统,涉及不同的技术栈和结算模型,线下、线上结算方式并存,导致人工处理环节多,易出错,case多,管理难度大。为提高效率,需实现结算业务的标准化与统一化,并拓展支持多种业务结算模式。

  • 分润模型通用化设计:多数业务结算方式相同,同时账单计算逻辑也相似或者相同,没有必要每个业务设计一套逻辑,需要做通用化设计。

3. 业务迭代中的新诉求

  • 测算系统需求凸显:在业务快速迭代的过程中,许多业务希望尽快看到结算效果,以推进项目落地。因此,构建高效的测算系统成为迫切需求,以加速业务迭代和决策过程。

  • 提升作者体验:为提升作者等合作伙伴的满意度和忠诚度,结算数据需实现高时效披露,确保他们能及时、准确地获取收益信息。结算账单数据的产出依赖百余条数据源,要保证数据在每天12点前产出,困难重重

  • 数据校验与监控机制:结算数据的准确性和质量直接关系到公司的财务健康和业务发展。因此,需建立完善的数据校验和监控机制,确保结算数据的准确无误和高质量。

04 技术实现

根据结算中台建设的核心目标,结合业务痛点,在结算系统建设中,基于通用化、标准化的理念,从以下五个方面来搭建统一的、规范化的结算中台。

  • 业务流程标准化:建设一套标准来定义三类结算模式下每个数据处理环节的实现方式,包括业务处理流程、数据处理过程。

  • 分润模型通用化:实现不同的账单计算算法,支持各个业务的各类作者收入分配诉求,并且实现参数配置线上化。

  • 技术架构统一:统一整个结算业务的技术栈、部署环境、功能入口和数据出口。

  • 建设账单测算能力:模拟线上结算流程的账单测算能力,支持业务快速验证分润模型参数调整带来的作者收入影响效果。

  • 建设质量保证体系:建设全流程预警机制,通过日志质检、自动对账、数据异常检测来保障账单产出数据时效性、准确性。

1. 业务流程标准化

不同业务场景,采用了通用化、标准化的设计来满足业务的特异性需求,下面是三大结算模式业务流程简图:

图片

在广告模式、补贴模式、订单模式结算流程设计中, 从日志打点、线上化、计算逻辑等方向考虑了通用化、标准化设计, 具体如下:

(1) 日志打点统一化

统一日志标准, 针对业务日志规范陈旧问题,要求所有接入的业务方严格按照统一格式打点日志,删除冗余字段, 确保数据的规范性与一致性,同时保证设计能够覆盖所有业务场景,为后续处理奠定坚实基础。

针对某些业务定制化的需求, 在广告模式、补贴模式、订单模式三种结算方式中,在设计日志打点规范时, 会预留一些扩展字段, 使用时以 JSON 形式表示, 不使用时打默认值。

(2) 账单计算线上化

在补贴结算模式中,之前不同业务都有各自的账单格式设计,同时存在离线人工计算账单的非规范化场景,账单无法统一在线计算、存储、监管。新的结算中台的补贴结算模式,将所有离线结算模式,使用统一的账单格式,全部实现线上化结算,实现了业务结算流程规范化。

(3) 账单计算逻辑优化

比如在广告模式中,百家号业务的公域视频、图文、动态场景中,由于收入口径调整,迭代效率要求,不再需要进行广告拼接,所以专门对账单计算流程做了优化调整。不仅满足业务诉求,同时做了通用化设计考虑,保证后续其他业务也可以使用这套流程的同时, 也能兼容旧的业务流程。

广告模式结算流程优化前:

图片

广告模式结算流程优化后:

图片

2. 分润模型通用化

不同业务场景,不同结算对象,有不同的结算诉求,不仅要满足业务形态多样化要求,还要具有灵活性。因此抽取业务共性做通用性设计,同时通过可插拔式设计灵活满足个性化需求。

图片

(1) 基于流量变化模型

以合作站点的优质用户投流方为代表的用户,他们在为百度提供海量数据获得收益的同时,也有自己的诉求,那就是自己内容的收益不能受到其他用户内容的影响。自己优质内容不能被其他用户冲淡,当然自己的低质内容也不会去拉低别人的收益水平。

对于此部分用户我们提供“基于流量变现的分润”策略,简单来说就是,某一篇内容的收益仅仅由它自己内容页面挂载的广告消费折算而来,这样就保证了优质用户投流方收益的相对独立,也促使优质用户产出更加多的内容。

(2) 基于内容分发模型

  • 部分作者只关注收益回报: 对百家号的某些作者来说,他们的目的很单纯,他们只关注产出的内容是否获得具有竞争力的收益回报,至于收益怎么来他们并不关心。

  • “基于流量变现”策略不妥此时,我们再使用“基于流量变现”的策略的话,就有些不妥,举个极端里的例子,有一个作者比较倒霉,每次分发都没有广告的渲染,那他是不是颗粒无收?这对作者是很不友好的。

  • “基于内容分发的分润”模型: 基于收益平衡性考虑,我们推出了更加适合百家号用户的“基于内容分发的分润”模型。在这种模型下,只要内容有分发,就一定有收益,而不管本身是否有广告消费。

  • 策略平衡考虑: 当然,为了防止海量产出低质内容来刷取利润,在分润模型上,我们同时将内容质量分和运营因子作为分润计算的权重,也就是说作者最终的收益由内容的质量和内容的分发量共同决定,以达到通过调整分润来指导内容产出的目的。

(3) 基于作者标签模型

为了实现对百家号头部优质作者进行激励,促进内容生态良性发展, 会对不同的作者进行打标, 并且使用不同的分润模型, 比如对公域的百家号作者进行打标, 优质作者, 通过动态单价及内容质量权重策略来给到他们更加的分成, 其他的普通作者, 通过内容分发模型来分润。这样不仅保证了优质作者取得高收益,也保证了其他作者也有一定的收益

另外,出于对预算的精确控制,发挥每一笔预算的钱效,优质的作者会占用较大的预算资金池,而普通作者使用占用较少的预算资金池。同时也会对每类资金池进行上下限控制,保证预算不会花超。

(4) 基于运营场景模型

为了实现对百家号作者的精细化运营,比如对一些参与各类短期活动的作者给予一定的阶段性的奖励,可以通过补贴模型来实现。在一些运营活动中,需要控制部分作者的分成上限,分润模型会进行多轮分成计算,如果作者的收益未触顶并且资金池还有余额的情况下,会对余额进行二次分配,给作者再分配一些收益。此类模型主要是为了实现灵活多变的作者分润策略。

3. 技术架构统一

根据业务流程标准化、分润模型通用化的设计原则,建设统一的结算中台。以下是结算中台统一结算业务前后的对比:

图片

图片

4. 建设账单测算能力

为各个需要测算能力的业务,设计了一套通用的测算流程,如下图:

图片

针对每个测算业务,设计了独立的测算参数管理后台,用于管理业务相关的分润模型参数,如下图:

图片

测算流程设计

(1) 功能简述: 每个测算业务, 产品需要登录模型参数管理后台,此后台支持对分润模型参数进行创建、查看、编辑、测算、复制、上线、删除,以及查看测算结果等操作, 出于业务流程合规化的要求, 每次模型参数上线前, 需要对变更的参数完成线上备案流程才可以上线,实现分润流程合规线上化。

(2) 流程简述

  • 流程简述概览: 每次测算时, 产品需要先创建一个版本的账单模型测算参数,并发起参数测算,参数状态变成待测算 。

  • 离线任务与收益计算: 此后,离线任务会轮询所有的待测算参数,提交Spark任务,调用账单计算模型来计算作者收益,最后生成TDA报告。

  • 查看与评估测算报告: 产品在管理平台看到任务状态变成测算完成时, 可以点击 TDA 链接来查看测算报告, 评估是否符合预期。

  • 根据预期结果的操作:如果不符合预期,可以编辑参数再次发起测算;如果符合预期,则可以发起备案流程,流程走完后可以提交上线。

(3) 收益明显: 通过账单测算建设, 不仅解决结算需求未备案即已上线或者备案内容与实际实现不一致,甚至缺乏备案流程的业务痛点问题,  而且把业务线下账单计算的流程做到了线上, 做到留痕可追踪。同时也满足了业务高效迭代的诉求, 一次账单测算耗时从半天下降到分钟级, 大大降低了账单测算的人力成本与时间成本。

5. 建设质量保障体系

为了保证业务质量,从以下几方面来建设:

(1) 建设数据预警机制:为保证作者账单数据及时披露, 分润业务涉及的 百余条数据源都签订了 SLA, 每份数据都关联到具体的接口人, 通过如流机器人来监控每个环节的数据到位时间, 并及时发出报警信息, 并推送给具体的接口负责人。对于产出延迟频次高的数据流,会定期拉相关负责人相关复盘,不断优化数据产出时效,保证账单数据在每天如期产出

(2) 数据异常检测机制:对账单数据进行异常波动性检测, 确保数据准确性 ,及时发现并处理潜在异常问题

(3) 自动对账机制:每天自动进行上下游系统间账单明细核对,保证出账数据流转的准确无误。

(4) 日志质检机制:每日例行对日志进行全面质检分析, 及时发现日志打点日志。

05 中台收益

结算中台作为公司财务体系的核心,承担多业务线分润计算与资金流转重任。

(1) 通过通用化、标准化设计,高效支撑数十个业务线的精准结算,确保广告、订单、内容分发的业务结算稳定、健康。近一年,结算业务零事故、零损失。

(2) 中台支持多种结算模式与灵活周期管理,实现账单测算自动化,账单测算时间从天级降到小时级。提升效率并减少错误,提升业务需求迭代效率。

(3) 通过业务流程标准化、分润模型通用化、账单测算能力建设及质量保证体系,解决了结算业务规范缺失、业务形态多样等问题。累计解决历史结算case数十个,涉及结算金额达千万级。

未来,结算中台将推进全业务线结算立项线上化、周结与测算能力落地、项目全生命周期管理,并依托大模型能力实现数据智能分析,进一步提升数据分析智能化水平,为公司业务稳健发展提供坚实保障。

06 未来规划

1、推进全业务线结算实现立项线上化;

2、推进周结 、测算能力在各业务线上落地;

3、推进项目全生命周期管理,实现项目从上线到下线整体生命周期变化线上化存档,可随时回顾复盘。

4、数据智能分析,依托公司大模型能力,实现通过多轮对话问答来进行数据分析,针对业务问题进行答疑解惑,提升数据分析的智能化水平。

JavaScript中的迭代器和生成器

2025年12月23日 16:35

先讲迭代器(Iterator):就是“能一个个往外拿东西的容器”

你可以把迭代器想象成自动售货机:

  • 你先有一堆商品(比如数组 [1,2,3]),把它们放进售货机里,这个售货机就是迭代器;
  • 你每次按“出货”按钮(调用 next() 方法),它就给你出一个商品,出完一个就少一个;
  • 等商品出完了,再按按钮就会返回 { value: undefined, done: true }(提示“没货了”);
  • 而且它只能单向前进,出了2就回不到1了,不能倒着拿。

JavaScript 迭代器的简单例子

// 1. 先准备一个数组(一堆商品)
const nums = [1, 2, 3];

// 2. 把数组变成迭代器(装进售货机)
const it = nums[Symbol.iterator]();

// 3. 按按钮拿东西(调用 next())
console.log(it.next()); // 输出 { value: 1, done: false }(拿第一个)
console.log(it.next()); // 输出 { value: 2, done: false }(拿第二个)
console.log(it.next()); // 输出 { value: 3, done: false }(拿第三个)
console.log(it.next()); // 输出 { value: undefined, done: true }(没货了)

迭代器的核心特点

  1. 必须有一个 next() 方法,调用后返回固定格式:{ value: 具体值, done: 是否迭代完 }
  2. 惰性获取:只有调用 next() 才会返回下一个值,不会一次性把所有值加载到内存;
  3. 用完就没:只能往前,不能回头,也不能重复用。

手动实现一个简单迭代器(理解原理)

就像自己造一个简易售货机:

// 手动实现迭代器:模拟售货机逻辑
function createIterator(arr) {
  let index = 0; // 记录当前拿到第几个
  return {
    // 核心的 next() 方法
    next: function() {
      // 如果没拿完,返回当前值 + 未完成;否则返回 undefined + 已完成
      if (index < arr.length) {
        return { value: arr[index++], done: false };
      } else {
        return { value: undefined, done: true };
      }
    }
  };
}

// 使用这个自定义迭代器
const myIt = createIterator([10, 20, 30]);
console.log(myIt.next()); // { value: 10, done: false }
console.log(myIt.next()); // { value: 20, done: false }
console.log(myIt.next()); // { value: 30, done: false }
console.log(myIt.next()); // { value: undefined, done: true }

再讲生成器:“自动造东西的迭代器”

生成器是迭代器的升级版,你可以把它想象成现做现卖的小吃摊:

  • 迭代器是“先把所有东西准备好再往外拿”(比如先把1、2、3都放进售货机);
  • 生成器是“你要一个,我才做一个”(比如你要第一个包子,我才包第一个,要第二个才包第二个);
  • 它不用提前把所有数据存在内存里,而是“按需生成”,特别省内存。

JavaScript 生成器的核心:function* + yield

function* 是生成器函数的标识(注意多了个 *),yield 就是“暂停并返回”的意思——比如你去小吃摊买包子,老板包一个给你,然后暂停,等你要下一个再继续包。

生成器的完整例子(最常用)

// 定义生成器函数(注意 function* 和 yield)
function* makeBuns() {
  console.log("开始包第一个包子");
  yield 1; // 包好第一个,返回,暂停
  console.log("开始包第二个包子");
  yield 2; // 继续,包第二个,返回,暂停
  console.log("开始包第三个包子");
  yield 3; // 继续,包第三个,返回,暂停
}

// 调用函数,得到生成器(此时函数不会执行,只是创建生成器)
const bunGen = makeBuns();

// 第一次拿:执行到第一个yield,返回 { value: 1, done: false }
console.log(bunGen.next()); // 输出:开始包第一个包子 → { value: 1, done: false }
// 第二次拿:从暂停的地方继续,执行到第二个yield
console.log(bunGen.next()); // 输出:开始包第二个包子 → { value: 2, done: false }
// 第三次拿:继续,执行到第三个yield
console.log(bunGen.next()); // 输出:开始包第三个包子 → { value: 3, done: false }
// 第四次拿:没包子了,返回 done: true
console.log(bunGen.next()); // 输出:{ value: undefined, done: true }

生成器的实用场景:按需生成大量数据

比如要生成 100 万个数字,用数组会占满内存,但生成器只在需要时计算:

// 生成器:按需生成数字,不占内存
function* generateBigNumbers(max) {
  let num = 1;
  while (num <= max) {
    yield num++; // 要一个,生成一个
  }
}

// 生成 100 万个数字的生成器(此时还没生成任何数字)
const bigNumGen = generateBigNumbers(1000000);

// 只拿前3个,后面的不会生成
console.log(bigNumGen.next().value); // 1
console.log(bigNumGen.next().value); // 2
console.log(bigNumGen.next().value); // 3

迭代器 vs 生成器 一句话区分(JS 版本)

  • 迭代器:已有数据的“搬运工”(把现成的东西一个个拿出来,比如数组迭代器);
  • 生成器:按需生成数据的“生产者”(没有现成数据,要的时候才造,用 function* + yield);
  • 生成器本质上是一种“自动实现了迭代器接口”的迭代器,写起来比手动迭代器简单 10 倍。

总结

  1. 迭代器:像自动售货机,提前装好物,按一次出一个,出完为止,核心是“遍历已有数据”;
  2. 生成器:像现做现卖的小吃摊,不用提前备货,要一个做一个,核心是“按需生成数据”,更省内存;
  3. JS 里迭代器靠 next() 方法和 { value, done } 格式,生成器靠 function* + yield,两者都是“惰性计算”,适合处理大数据。

2.2 Node的模块实现

作者 晚星star
2025年12月23日 16:31

好,我们深入第二章的第二小节:2.2 Node的模块实现

这一小节是第二章的核心部分,朴灵作者详细剖析了Node.js如何从源码层面实现CommonJS的require机制。整个过程分为三个关键步骤:优先从缓存加载路径分析和文件定位模块编译。理解这里,你就知道为什么require这么高效、为什么有缓存、为什么循环依赖不会死锁等“黑魔法”。

下面是这一小节的子结构(原书划分):

  • 2.2.1 优先从缓存加载
  • 2.2.2 路径分析和文件定位
  • 2.2.3 模块编译

为了让你更直观,我附上了一些来自读者笔记和网上分享的原书页面截图、模块加载流程图、包裹函数示意图等(这些是常见可视化辅助,很多开发者读书时都会截图或画图)。

详细讲解

2.2.1 优先从缓存加载

这是require最聪明的设计:模块只执行一次,后续直接返回缓存的exports对象。

  • 原理:Node用Module._cache(一个对象,以模块绝对路径为key)缓存已加载模块。
  • 第一次require('foo'):加载、执行、缓存module.exports。
  • 第二次require('foo'):直接从缓存取,返回同一个对象引用。

示例代码(书中有类似演示):

// a.js
console.log('a.js 执行了');
exports.done = false;

// b.js
var a = require('./a');
console.log('b.js 中,a.done =', a.done);
a.done = true;
console.log('b.js 中,修改后 a.done =', a.done);

// main.js
var a = require('./a');
var b = require('./b');
console.log('main.js 中,a.done =', a.done);

输出:

a.js 执行了  // 只打印一次!
b.js 中,a.done = false
b.js 中,修改后 a.done = true
main.js 中,a.done = true

关键点:

  • 模块代码只执行一次,避免副作用重复。
  • 导出的是对象引用,修改会共享(这是循环依赖能工作的基础:先导出空对象{})。

2.2.2 路径分析和文件定位

require的参数(模块标识符)可能是核心模块、相对路径、绝对路径或包名。Node按优先级分析:

  1. 核心模块(如'fs'、'http'):直接从内置加载,最快。
  2. 文件模块
    • 绝对路径:直接定位。
    • 相对路径(./ 或 ../):从调用者目录解析。
    • 无扩展名:依次尝试 .js → .json → .node(C++扩展)。
  3. 包模块(如require('express')):
    • 在当前目录的node_modules找。
    • 找不到?向上级目录逐级查找,直到根目录。
    • 找到目录后,读package.json的"main"字段(默认index.js)。

这个过程在源码的Module._resolveFilename函数实现。书里强调:这是NPM能工作的基础!

2.2.3 模块编译

找到文件后,Node不直接执行,而是包裹成一个函数运行,创造独立作用域。

  • 对于.js文件:读取内容,前面加(function (exports, require, module, __filename, __dirname) {,后面加});,然后用vm.runInThisContext执行。
  • 这五个参数就是模块的“私有空间”:
    • exports:导出对象(module.exports的引用)。
    • require:当前模块的require函数。
    • module:模块对象本身。
    • __filename / __dirname:当前文件路径和目录。

示例(书中原码简化版): 你的math.js内容是:

exports.add = (a, b) => a + b;

实际执行的是:

(function (exports, require, module, __filename, __dirname) {
  exports.add = (a, b) => a + b;
  // ... 你的代码
});
  • .json文件:JSON.parse后赋值给module.exports。
  • .node文件:用dlopen加载C++扩展。

编译后,module.exports缓存起来,返回给调用者。

图片懒加载

作者 Crystal328
2025年12月23日 16:28

图片懒加载是一种优化网页性能的技术,主要作用是延迟加载视口外的图片,直到用户滚动到它们附近时才加载

image.png 以下是常见的几种实现方式

1. 传统滚动事件监听方式

  • 监听到scroll事件后,调用目标元素的getBoundingClientRect()方法来实现。
function lazyLoad() {
    const images = document.querySelectorAll('img[data-src]');

    images.forEach(img => {
        if (img.getBoundingClientRect().top < window.innerHeight + 100) {
            img.src = img.dataset.src;
        }
    });
}

window.addEventListener('scroll', lazyLoad);
window.addEventListener('resize', lazyLoad);
lazyLoad(); // 初始加载

缺点:性能较差,需要手动处理节流

2、使用Intersection Observer API(现代推荐方式)

通过IntersectionObserverAPI 来实现,他可以自动"观察"元素是否可见。由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做"交叉口观测器"。

const images = document.querySelectorAll('img[data-src]');
const observer = new IntersectionObserver((entries) => {
    // entries 是一个数组,包含了我们所需要监听的所有图片对象的信息
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target; 
      img.src = img.dataset.src;
      observer.unobserve(img); // 停止监听
    }
  });
});
// 添加需要监听的图片
images.forEach(img => observer.observe(img));

优点:性能好,不阻塞主线程,代码简洁

IntersectionObserver是浏览器原生提供的构造函数,可以自动‘观察’元素是否可见。

//观察器实例
const io = new IntersectionObserver(callback,options)

参数

  • callback: 是可见性变化时的回调函数。callback一般会触发两次
    • 一次是目标元素刚刚进入视口(开始可见)
    • 另一次是完全离开视口(开始不可见)
  • option: 是配置对象(该参数可选) 优点:性能好,不阻塞主线程,代码简洁

返回值:一个观察期实例

观察器实例方法

  • observe方法可以指定观察哪个DOM节点,就需要多次调用observe方法
  • unobserve方法用于停止对某个元素的观察
  • disconnect方法用于关闭观察器
//开始观察box元素和container
io.server(document.getElementById('box'))
io.server(document.getElementById('container'))

//停止观察box元素
io.unobserve(document.getElementById('box'))

//关闭观察器
io.disconnect();

3. 使用loading="lazy"属性(HTML原生支持)

<img src="image.jpg" loading="lazy" alt="...">

优点:最简单的方式,现代浏览器都支持

缺点:兼容性问题(IE不支持),控制粒度较粗

4、第三方库实现

常用库:

  • lazysizes
  • lozad.js
  • vanilla-lazyload

以下是插件vanilla-lazyload的具体使用

进入 https://www.npmjs.com/ 官网,然后在搜索框中输入 vanilla-lazyload 回车 ,即可找到该插件

5. 框架内置懒加载

  • React: 使用 react-lazyload 等库
  • Vue: 有 vue-lazyload 插件
  • Angular: 有内置的懒加载支持

实现要点

  1. 使用data-src自定义属性代替src属性存储真实图片URL
  2. 设置占位图(可以是小尺寸预览图或纯色背景)
  3. 考虑添加加载动画或过渡效果
  4. 对于重要图片(如首屏)不要使用懒加载

选择哪种方式取决于项目需求、目标浏览器支持和开发环境。现代项目推荐优先使用Intersection Observer或原生loading属性。

我用 NestJS + Vue3 + Prisma + PostgreSQL 打造了一个企业级 sass 多租户平台

2025年12月23日 16:23

🎯 我用 NestJS + Vue3 + Prisma + PostgreSQL 打造了一个企业级 sass 多租户平台

一个生产级的全栈管理系统开源项目,从零到一的实战分享

前言

这是一个基于 NestJS + Vue3 + Prisma + PostgreSQL ** 构建的全栈管理系统,不是简单的 CRUD,而是一个生产级别**的解决方案。它包含了多租户架构、RBAC权限管理、请求加密、完善的日志监控等企业级特性。


🔗 项目链接

🌟 GitHub 开源地址github.com/linlingqin7…
🎮 在线体验地址www.linlingqin.top/
📖 项目文档地址项目文档

体验账号:demo / demo123 (租户 000000)
完整权限账号:admin / admin123 (租户 000000)


📱 先看效果

登录页面

login.png

支持账号密码登录、验证码验证、记住密码

首页仪表板

dashboard.png

系统概览、快捷入口、数据统计、图表展示

用户管理

user.png

用户列表、角色分配、部门选择、状态管理

角色管理

role.png

角色权限配置、菜单权限树、数据权限范围

  • ✨ 现代化的界面设计,支持深色模式
  • 📱 响应式布局,完美适配各种屏幕
  • 🎨 丰富的组件和交互效果

� 完整功能截图

菜单管理

menu.png

菜单树形结构、路由配置、图标选择、权限标识

部门管理

dept.png

组织架构树、部门人员、数据权限

岗位管理

post.png

岗位配置、岗位排序、状态管理

字典管理

dict.png

数据字典维护、字典项配置

参数配置

paramSet.png

系统参数、动态配置、参数分类

通知公告

notice.png

公告发布、通知管理、类型分类

租户管理

tenant.png

多租户列表、套餐配置、租户状态

定时任务

job.png

Cron 任务配置、执行日志、任务管理

系统监控

monitor.png

服务器状态、CPU/内存使用率、磁盘信息

缓存监控

monitorCache.png

Redis 缓存信息、命令统计、键值管理

在线用户

online.png

实时在线用户、会话管理、强制下线

操作日志

operlog.png

操作记录、请求参数、响应结果、异常捕获

登录日志

loginLog.png

登录历史、IP 地址、浏览器信息、登录状态

缓存列表

cacheList.png

缓存键管理、过期时间、缓存清理

主题配置

theme.png

多主题切换、深色模式、主题色配置、布局模式

✨ 核心特性

🏢 多租户 SaaS 架构

这是本项目的一大亮点。实现了完整的租户数据隔离

// 所有数据库查询自动按租户过滤
const users = await prisma.sysUser.findMany({
  where: { name: 'John' }
  // tenantId 会自动注入,无需手动添加
});

// 跳过租户过滤(特殊场景)
@IgnoreTenant()
async getAllTenants() {
  return await this.prisma.tenant.findMany();
}

技术实现

  • 基于 Prisma Extension 实现透明的租户过滤
  • 通过 TenantGuard 从请求头自动识别租户
  • 超级管理员(租户 000000)可跨租户管理

适用场景

  • SaaS 平台
  • 企业内部多部门系统
  • 白标产品

🔐 RBAC 权限管理

不只是简单的角色权限,而是多层级、细粒度的权限控制:

@Controller('users')
export class UserController {
  // 需要特定权限才能访问
  @RequirePermission('system:user:add')
  @Post()
  create(@Body() dto: CreateUserDto) {
    return this.userService.create(dto);
  }
  
  // 需要特定角色
  @RequireRole('admin')
  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.userService.remove(id);
  }
}

权限层级

  1. 菜单级别:控制菜单显示/隐藏
  2. 按钮级别:控制页面内按钮权限
  3. 数据级别:全部/本部门/仅本人等数据范围

前端权限控制

<template>
  <!-- 按钮级权限 -->
  <n-button v-if="hasPermission('system:user:add')">
    添加用户
  </n-button>
</template>

🔒 请求加密机制

敏感数据传输采用 AES + RSA 混合加密

加密流程

  1. 前端生成随机 AES 密钥
  2. 用 AES-CBC 加密请求数据
  3. 用服务端 RSA 公钥加密 AES 密钥
  4. 发送 {encryptedKey, encryptedData} + header x-encrypted: true
// 后端自动解密
@Post('login')
async login(@Body() dto: LoginDto) {
  // dto 已自动解密,直接使用
  return this.authService.login(dto);
}

// 跳过解密(非敏感接口)
@SkipDecrypt()
@Get('captcha')
async getCaptcha() {
  return this.captchaService.generate();
}

优势

  • 保护密码、Token 等敏感信息
  • 防止中间人攻击
  • 对业务代码透明,由拦截器统一处理

📊 完善的日志监控

基于 Pino 实现的高性能结构化日志:

// 自动记录操作日志
@Operlog({
  businessType: BusinessTypeEnum.UPDATE,
  title: '修改用户'
})
@Put(':id')
async update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
  return this.userService.update(id, dto);
}

监控能力

  • 结构化日志:自动记录 requestId、tenantId、username
  • 敏感字段脱敏:password、token 等自动隐藏
  • 操作审计:记录谁在什么时间做了什么
  • 登录日志:登录历史、IP、浏览器等信息
  • 健康检查:K8s liveness/readiness 探针
  • Prometheus 指标:暴露 /api/metrics 端点

日志输出示例

{
  "level": "info",
  "time": "2025-12-22T10:30:00.000Z",
  "requestId": "a1b2c3d4",
  "tenantId": "000001",
  "username": "admin",
  "method": "POST",
  "url": "/api/system/user",
  "statusCode": 200,
  "duration": 45
}

🎭 演示账户系统

专为产品演示设计的只读账户机制:

// Demo 账户拦截器
@UseInterceptors(DemoInterceptor)
@Controller('users')
export class UserController {
  @Post()  // Demo 账户会被自动拦截
  create(@Body() dto: CreateUserDto) {
    return this.userService.create(dto);
  }
  
  @Get()  // 查询操作不受影响
  findAll() {
    return this.userService.findAll();
  }
}

特性

  • 52 个只读权限,可查看所有模块
  • 自动拦截所有写操作(POST/PUT/DELETE/PATCH)
  • 返回友好的提示信息
  • 适合演示站点、产品 Demo

🌐 国际化支持

前后端完整的 i18n 方案:

// 后端
throw new ApiException(ErrorEnum.USER_NOT_FOUND);
// 自动返回对应语言的错误信息

// 前端
const { t } = useI18n();
console.log(t('system.user.name')); // 根据语言返回对应文本

支持中文、英文,可轻松扩展其他语言。


🛠️ 技术栈详解

后端技术栈

技术 版本 核心应用场景 技术亮点
NestJS 10.x 企业级框架,构建可扩展的服务端应用 • 依赖注入
• 模块化设计
• 装饰器语法
• 内置 TypeScript
Prisma 5.x 类型安全的数据库 ORM • 自动生成类型
• 数据库迁移
• 强大的查询构建器
• 多数据库支持
PostgreSQL 14+ 主数据库,存储核心业务数据 • ACID 事务
• JSON 支持
• 丰富的数据类型
• 强大的查询优化
Redis 7+ 缓存、Session、分布式锁 • 高性能缓存
• 数据过期策略
• 发布订阅
• 分布式锁
JWT - 无状态身份认证 • Token 认证
• Refresh Token
• 跨域认证
• 移动端友好
Passport - 认证中间件 • 策略模式
• 多种认证方式
• 易于扩展
Pino - 高性能结构化日志 • JSON 日志
• 低开销
• 日志轮转
• 多传输通道
Swagger - API 文档自动生成 • 交互式文档
• 自动类型推导
• 在线测试
Terminus - 健康检查与监控 • K8s 探针
• 数据库检查
• 内存监控
• 自定义检查
class-validator - 数据验证 • 装饰器验证
• 自定义规则
• 嵌套验证
class-transformer - 数据转换 • DTO 转换
• 类型映射
• 数据脱敏
@nestjs/schedule - 定时任务调度 • Cron 表达式
• 间隔任务
• 超时控制
nestjs-cls - 请求上下文管理 • 请求追踪
• 用户上下文
• 租户上下文

前端技术栈

技术 版本 核心应用场景 技术亮点
Vue 3 3.5+ 渐进式前端框架 • Composition API
• 响应式系统
• 虚拟 DOM
• 组件化开发
Vite 7+ 新一代构建工具 • 极速冷启动
• HMR 热更新
• 按需编译
• Rollup 打包
Naive UI 最新 企业级组件库 • Vue 3 组合式 API
• TypeScript 支持
• 主题定制
• 200+ 组件
UnoCSS 最新 即时原子化 CSS 引擎 • 零运行时
• 高性能
• 预设系统
• 按需生成
Pinia 最新 下一代状态管理 • 轻量级
• TypeScript 支持
• 模块化
• DevTools 支持
Vue Router 4+ 官方路由管理 • 动态路由
• 路由守卫
• 懒加载
• 嵌套路由
Axios 最新 HTTP 请求库 • Promise API
• 拦截器
• 请求取消
• 自动转换
TypeScript 5.x 类型安全的 JavaScript • 静态类型检查
• IDE 智能提示
• 重构支持
• 接口定义
VueUse 最新 Vue 组合式函数集合 • 常用 Hooks
• 响应式工具
• 浏览器 API 封装
Elegant Router 最新 基于文件的路由系统 • 自动生成路由
• 约定式路由
• 类型安全
ECharts 5+ 数据可视化图表 • 丰富的图表类型
• 响应式设计
• 主题定制
CryptoJS - 加密算法库 • AES 加密
• RSA 加密
• MD5/SHA 哈希

🏗️ 系统架构详解

📐 整体架构图

                        ┌─────────────────────────────────┐
                        │      用户/客户端层              │
                        │  ┌──────────┐  ┌─────────────┐ │
                        │  │  浏览器   │  │  移动端App  │ │
                        │  └──────────┘  └─────────────┘ │
                        └──────────────┬──────────────────┘
                                       │ HTTPS
                        ┌──────────────▼──────────────────┐
                        │      CDN / Nginx 网关            │
                        │  • 静态资源缓存                  │
                        │  • 反向代理                      │
                        │  • 负载均衡                      │
                        │  • SSL 证书                      │
                        └──────────────┬──────────────────┘
                                       │
                ┌──────────────────────┴──────────────────────┐
                │                                              │
┌───────────────▼────────────────┐       ┌───────────────────▼──────────┐
│        前端应用 (Vue3)          │       │     后端应用 (NestJS)         │
│                                 │       │                               │
│  ┌──────────────────────────┐  │       │  ┌────────────────────────┐  │
│  │     UI 层 (Naive UI)     │  │       │  │   控制器层 (Controllers)│  │
│  │  • 组件库                │  │       │  │   • 路由定义           │  │
│  │  • 主题定制              │  │       │  │   • 请求验证           │  │
│  │  • 响应式布局            │  │       │  │   • 参数转换           │  │
│  └──────────────────────────┘  │       │  └────────────────────────┘  │
│                                 │       │              │                │
│  ┌──────────────────────────┐  │       │  ┌───────────▼─────────────┐ │
│  │   状态层 (Pinia Store)   │  │       │  │   守卫层 (Guards)       │ │
│  │  • 全局状态              │  │       │  │   1. TenantGuard       │ │
│  │  • 用户信息              │  │       │  │   2. AuthGuard         │ │
│  │  • 权限数据              │  │       │  │   3. RolesGuard        │ │
│  └──────────────────────────┘  │       │  │   4. PermissionGuard   │ │
│                                 │       │  └───────────┬─────────────┘ │
│  ┌──────────────────────────┐  │       │              │                │
│  │   路由层 (Vue Router)    │  │       │  ┌───────────▼─────────────┐ │
│  │  • 动态路由              │  │       │  │  拦截器层 (Interceptors)│ │
│  │  • 路由守卫              │  │       │  │  1. DecryptInterceptor │ │
│  │  • 懒加载                │  │       │  │  2. DemoInterceptor    │ │
│  └──────────────────────────┘  │       │  │  3. TransformInter...  │ │
│                                 │       │  │  4. LoggingInterceptor │ │
│  ┌──────────────────────────┐  │       │  └───────────┬─────────────┘ │
│  │   请求层 (Axios)         │  │       │              │                │
│  │  • 请求拦截              │◄─┼───────┼──────────────┤                │
│  │  • 响应拦截              │  │       │  ┌───────────▼─────────────┐ │
│  │  • 错误处理              │  │       │  │   业务层 (Services)     │ │
│  │  • 请求加密              │  │       │  │  • 系统管理 Service     │ │
│  └──────────────────────────┘  │       │  │  • 权限管理 Service     │ │
│                                 │       │  │  • 监控管理 Service     │ │
└─────────────────────────────────┘       │  │  • 租户管理 Service     │ │
                                          │  └───────────┬─────────────┘ │
                                          │              │                │
                                          │  ┌───────────▼─────────────┐ │
                                          │  │   数据访问层 (Prisma)   │ │
                                          │  │  • Schema 定义          │ │
                                          │  │  • 类型生成             │ │
                                          │  │  • 查询构建器           │ │
                                          │  │  • 租户扩展             │ │
                                          │  └───────────┬─────────────┘ │
                                          └──────────────┼────────────────┘
                                                         │
                        ┌────────────────────────────────┼────────────────┐
                        │                                │                │
            ┌───────────▼──────────┐         ┌──────────▼──────────┐     │
            │  PostgreSQL 数据库    │         │   Redis 缓存        │     │
            │  • 主数据存储         │         │   • 会话存储        │     │
            │  • 事务支持           │         │   • 数据缓存        │     │
            │  • 索引优化           │         │   • 分布式锁        │     │
            └───────────────────────┘         └─────────────────────┘     │
                                                                           │
            ┌──────────────────────────────────────────────────────────┐  │
            │                   外部服务集成                            │  │
            │  ┌─────────────┐  ┌──────────────┐  ┌────────────────┐  │  │
            │  │  OSS 对象存储│  │  邮件服务    │  │  短信服务      │  │  │
            │  │  • 阿里云    │  │  • SMTP      │  │  • 阿里云      │  │  │
            │  │  • 七牛云    │  │  • SendGrid  │  │  • 腾讯云      │  │  │
            │  │  • MinIO     │  └──────────────┘  └────────────────┘  │  │
            │  └─────────────┘                                          │  │
            │  ┌──────────────────────────────────────────────────────┐│  │
            │  │           监控与日志                                  ││  │
            │  │  • Prometheus (指标采集)                              ││  │
            │  │  • Grafana (可视化)                                   ││  │
            │  │  • Pino Logger (日志)                                 ││  │
            │  │  • Terminus (健康检查)                                ││  │
            │  └──────────────────────────────────────────────────────┘│  │
            └──────────────────────────────────────────────────────────┘  │
                                                                           │
                        ┌──────────────────────────────────────────────────┘
                        │        部署环境 (可选)
                        │  ┌──────────────────────────────┐
                        │  │      Docker 容器化            │
                        │  │  • 应用容器                   │
                        │  │  • 数据库容器                 │
                        │  │  • Redis 容器                 │
                        │  └──────────────────────────────┘
                        │  ┌──────────────────────────────┐
                        │  │   Kubernetes 编排             │
                        │  │  • Pod 管理                   │
                        │  │  • Service 暴露               │
                        │  │  • Ingress 路由               │
                        │  └──────────────────────────────┘
                        └───────────────────────────────────

🔄 完整请求处理流程

┌─────────────┐
│  1. 客户端   │  发起 HTTP 请求
└──────┬──────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  2. 前端请求拦截器                       │
│  • 添加 Authorization Token              │
│  • 添加租户 ID (x-tenant-id)            │
│  • 敏感数据 AES+RSA 加密                 │
│  • 添加请求 ID (x-request-id)           │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  3. Nginx 网关                           │
│  • SSL 终止                              │
│  • 静态资源服务                          │
│  • 反向代理到后端                        │
│  • 请求日志记录                          │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  4. NestJS 中间件层                      │
│  • CORS 处理                             │
│  • Body 解析                             │
│  • Helmet 安全头                         │
│  • Request ID 生成                       │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  5. 守卫层 (Guards) - 按顺序执行        │
│  ┌───────────────────────────────────┐  │
│  │ 5.1 TenantGuard                   │  │
│  │  • 提取请求头中的租户 ID           │  │
│  │  • 验证租户是否存在且有效          │  │
│  │  • 设置租户上下文 (CLS)            │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 5.2 JwtAuthGuard                  │  │
│  │  • 验证 JWT Token 有效性           │  │
│  │  • 解析用户信息                    │  │
│  │  • 设置用户上下文                  │  │
│  │  • 检查 Token 是否过期             │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 5.3 RolesGuard (可选)             │  │
│  │  • 检查用户角色                    │  │
│  │  • 验证是否满足角色要求            │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 5.4 PermissionGuard               │  │
│  │  • 检查用户权限                    │  │
│  │  • 验证是否有接口访问权限          │  │
│  │  • 支持权限组合 (AND/OR)           │  │
│  └───────────────────────────────────┘  │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  6. 拦截器层 (Interceptors) - 前置      │
│  ┌───────────────────────────────────┐  │
│  │ 6.1 DecryptInterceptor            │  │
│  │  • 检测加密请求头                  │  │
│  │  • RSA 解密 AES 密钥               │  │
│  │  • AES 解密请求体                  │  │
│  │  • 替换原始请求数据                │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 6.2 DemoInterceptor               │  │
│  │  • 检测是否为演示账户              │  │
│  │  • 拦截写操作 (POST/PUT/DELETE)    │  │
│  │  • 返回友好提示信息                │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 6.3 LoggingInterceptor (开始)     │  │
│  │  • 记录请求开始时间                │  │
│  │  • 记录请求基本信息                │  │
│  └───────────────────────────────────┘  │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  7. Pipe 管道验证                        │
│  • ValidationPipe (DTO 验证)            │
│  • ParseIntPipe (参数转换)              │
│  • 自定义验证管道                        │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  8. Controller 控制器                    │
│  • 接收请求参数                          │
│  • 调用 Service 方法                     │
│  • 应用装饰器 (@Operlog 等)             │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  9. Service 业务层                       │
│  • 业务逻辑处理                          │
│  • 数据验证                              │
│  • 调用 Prisma 查询                      │
│  • 缓存操作 (Redis)                      │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  10. Prisma ORM 层                       │
│  ┌───────────────────────────────────┐  │
│  │ 10.1 Tenant Extension             │  │
│  │  • 自动注入租户过滤条件            │  │
│  │  • 所有查询自动添加 tenantId       │  │
│  │  • 支持 @IgnoreTenant 跳过         │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 10.2 查询执行                     │  │
│  │  • 参数化查询 (防 SQL 注入)        │  │
│  │  • 事务支持                        │  │
│  │  • 查询优化                        │  │
│  └───────────────────────────────────┘  │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  11. 数据库层                            │
│  • PostgreSQL 查询执行                   │
│  • 索引查找                              │
│  • 事务提交/回滚                         │
└──────┬──────────────────────────────────┘
       │
       ▼ (返回数据)
┌─────────────────────────────────────────┐
│  12. 拦截器层 (Interceptors) - 后置      │
│  ┌───────────────────────────────────┐  │
│  │ 12.1 TransformInterceptor         │  │
│  │  • 统一响应格式                    │  │
│  │  • {code, msg, data, timestamp}    │  │
│  │  • 脱敏处理                        │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 12.2 LoggingInterceptor (结束)    │  │
│  │  • 计算请求耗时                    │  │
│  │  • 记录响应状态码                  │  │
│  │  • 记录到操作日志表                │  │
│  │  • 输出结构化日志                  │  │
│  └───────────────────────────────────┘  │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  13. 异常过滤器 (Exception Filters)      │
│  • 捕获异常                              │
│  • 格式化错误响应                        │
│  • 记录错误日志                          │
│  • 返回友好错误信息                      │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  14. 响应返回客户端                      │
│  • HTTP Response                         │
│  • 状态码 + 响应体                       │
└─────────────────────────────────────────┘

🏢 多租户架构图

┌──────────────────────────────────────────────────────────┐
                     租户 A 用户                           
                   (tenantId: 000001)                      
└────────────────────┬─────────────────────────────────────┘
                     
┌──────────────────────────────────────────────────────────┐
                     租户 B 用户                           
                   (tenantId: 000002)                      
└────────────────────┬─────────────────────────────────────┘
                     
                     
┌──────────────────────────────────────────────────────────┐
              NestJS 应用 - TenantGuard                    
  ┌────────────────────────────────────────────────────┐  
    1. 提取请求头中的租户 ID (x-tenant-id)              
    2. 验证租户是否存在且状态为启用                     
    3. 将租户 ID 存入请求上下文 (CLS)                   
    4. 后续所有操作自动使用该租户上下文                 
  └────────────────────────────────────────────────────┘  
└────────────────────┬─────────────────────────────────────┘
                     
                     
┌──────────────────────────────────────────────────────────┐
           Prisma Client - Tenant Extension                
  ┌────────────────────────────────────────────────────┐  
    所有数据库查询自动注入租户过滤:                     
                                                        
    原始查询:                                           
      prisma.sysUser.findMany({ where: { ... } })      
                                                        
    自动转换为:                                         
      prisma.sysUser.findMany({                        
        where: {                                       
          tenantId: '000001',  // 自动注入             
          ...                                          
        }                                              
      })                                               
                                                        
    支持的操作:                                         
     findMany / findUnique / findFirst                
     create / createMany                              
     update / updateMany                              
     delete / deleteMany                              
     count / aggregate                                
  └────────────────────────────────────────────────────┘  
└────────────────────┬─────────────────────────────────────┘
                     
                     
┌──────────────────────────────────────────────────────────┐
                PostgreSQL 数据库                          
                                                           
  ┌──────────────────────┐      ┌──────────────────────┐ 
      租户 A 数据                  租户 B 数据         
    ┌────────────────┐          ┌────────────────┐   
     tenantId: 0001            tenantId: 0002    
     user_id: 1                user_id: 100      
     name: 张三                name: 李四        
    └────────────────┘          └────────────────┘   
    ┌────────────────┐          ┌────────────────┐   
     tenantId: 0001            tenantId: 0002    
     user_id: 2                user_id: 101      
     name: 王五                name: 赵六        
    └────────────────┘          └────────────────┘   
  └──────────────────────┘      └──────────────────────┘ 
                                                           
   数据完全隔离,互不干扰                                 
   共享数据库,降低成本                                   
   tenantId 字段建立索引,查询高效                        
└───────────────────────────────────────────────────────────┘

特殊场景: 超级管理员查询
┌──────────────────────────────────────────────────────────┐
  @IgnoreTenant()  // 跳过租户过滤                         
  async getAllTenants() {                                  
    return await this.prisma.tenant.findMany();           
  }                                                        
  // 可以查询所有租户的数据                                
└──────────────────────────────────────────────────────────┘

🔐 权限控制架构图

┌────────────────────────────────────────────────────────────┐
│                      用户登录成功                           │
└────────────────────┬───────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────────────────────────┐
│           JWT Token 生成 (包含用户基本信息)                 │
│  {                                                          │
│    userId: 1,                                               │
│    username: 'admin',                                       │
│    tenantId: '000000',                                      │
│    roles: ['admin'],                                        │
│    exp: 1640000000  // 过期时间                             │
│  }                                                          │
└────────────────────┬───────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────────────────────────┐
│                  前端存储 Token                             │
│  • localStorage.setItem('token', token)                     │
│  • 每次请求自动携带: Authorization: Bearer ${token}        │
└────────────────────┬───────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────────────────────────┐
│            后端接收请求 - 权限检查流程                      │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ Step 1: JwtAuthGuard                                 │  │
│  │  ├─ 验证 Token 签名                                  │  │
│  │  ├─ 检查 Token 是否过期                              │  │
│  │  ├─ 解析用户信息                                     │  │
│  │  └─ 从 Redis 加载完整用户权限                        │  │
│  │                                                      │  │
│  │     ┌──────────────────────────────────┐            │  │
│  │     │ Redis 缓存的用户权限数据         │            │  │
│  │     │ user:permissions:1               │            │  │
│  │     │ {                                │            │  │
│  │     │   roles: ['admin', 'user'],     │            │  │
│  │     │   permissions: [                │            │  │
│  │     │     'system:user:add',          │            │  │
│  │     │     'system:user:edit',         │            │  │
│  │     │     'system:user:delete',       │            │  │
│  │     │     'system:user:query',        │            │  │
│  │     │     'system:role:*',  // 通配符 │            │  │
│  │     │     ...                         │            │  │
│  │     │   ],                            │            │  │
│  │     │   menuIds: [1,2,3,4,5,...],    │            │  │
│  │     │   dataScope: 'DEPT_AND_CHILD'  │            │  │
│  │     │ }                               │            │  │
│  │     └──────────────────────────────────┘            │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ Step 2: RolesGuard (可选)                            │  │
│  │  @RequireRole('admin')                               │  │
│  │  ├─ 检查用户是否拥有指定角色                         │  │
│  │  ├─ 支持多角色: @RequireRole('admin', 'manager')    │  │
│  │  └─ 支持角色组合: AND / OR                           │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ Step 3: PermissionGuard                              │  │
│  │  @RequirePermission('system:user:edit')              │  │
│  │  ├─ 提取接口所需权限                                 │  │
│  │  ├─ 检查用户权限列表                                 │  │
│  │  ├─ 支持通配符匹配 (system:user:*)                   │  │
│  │  └─ 支持权限组合: AND / OR                           │  │
│  └──────────────────────────────────────────────────────┘  │
└────────────────────┬───────────────────────────────────────┘
                     │
         ┌───────────┴───────────┐
         │ 权限验证通过           │ 权限验证失败
         ▼                       ▼
┌──────────────────┐    ┌──────────────────────┐
│  执行业务逻辑     │    │  返回 403 Forbidden   │
│  • Controller    │    │  { code: 403,         │
│  • Service       │    │    msg: '无权限访问' }│
│  • Prisma        │    └──────────────────────┘
└──────────────────┘

数据权限控制 (Data Scope):
┌────────────────────────────────────────────────────────────┐
│  在 Service 层根据用户的 dataScope 过滤数据:                │
│                                                             │
│  async findUsers(query, user) {                             │
│    const where = { ...query };                              │
│                                                             │
│    if (user.dataScope === 'ALL') {                          │
│      // 查询所有数据 (超级管理员)                           │
│    } else if (user.dataScope === 'DEPT_AND_CHILD') {       │
│      // 查询本部门及子部门数据                              │where.deptId = { in: user.deptIds };                   │
│    } else if (user.dataScope === 'DEPT') {                  │
│      // 只查询本部门数据                                    │where.deptId = user.deptId;                            │
│    } else if (user.dataScope === 'SELF') {                  │
│      // 只查询自己的数据                                    │where.userId = user.userId;                            │
│    }                                                         │
│                                                             │
│    return await this.prisma.user.findMany({ where });       │
│  }                                                           │
└────────────────────────────────────────────────────────────┘

🔒 请求加密架构图

┌────────────────────────────────────────────────────────────┐
│                    前端 - 加密流程                          │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 1. 准备敏感数据                                      │  │
│  │    const data = {                                    │  │
│  │      username: 'admin',                              │  │
│  │      password: 'admin123'  // 敏感信息               │  │
│  │    };                                                │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 2. 生成随机 AES 密钥 (每次请求都不同)                │  │
│  │    const aesKey = CryptoJS.lib.WordArray.random(16); │  │
│  │    // 例: "a1b2c3d4e5f6g7h8"                         │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 3.AES 密钥加密数据                                │  │
│  │    const encryptedData = CryptoJS.AES.encrypt(       │  │
│  │      JSON.stringify(data),                           │  │
│  │      aesKey,                                         │  │
│  │      { mode: CryptoJS.mode.CBC, iv: randomIV }       │  │
│  │    );                                                │  │
│  │    // 结果: "U2FsdGVkX1+..."  (Base64)               │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 4. 用服务端 RSA 公钥加密 AES 密钥                     │  │
│  │    const encryptedKey = RSA.encrypt(                 │  │
│  │      aesKey.toString(),                              │  │
│  │      serverPublicKey  // 服务端提供的公钥            │  │
│  │    );                                                │  │
│  │    // RSA 2048 加密                                  │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 5. 发送加密请求                                      │  │
│  │    POST /api/login                                   │  │
│  │    Headers:                                          │  │
│  │      x-encrypted: true  // 标识加密请求              │  │
│  │    Body:                                             │  │
│  │      {                                               │  │
│  │        encryptedKey: "MIIBIj...",  // RSA 加密的密钥 │  │
│  │        encryptedData: "U2FsdG..."  // AES 加密的数据 │  │
│  │      }                                               │  │
│  └──────────────────────────────────────────────────────┘  │
└────────────────────┬───────────────────────────────────────┘
                     │
                     │ HTTPS 加密传输
                     │
                     ▼
┌────────────────────────────────────────────────────────────┐
│                    后端 - 解密流程                          │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 1. DecryptInterceptor 拦截请求                       │  │
│  │    if (request.headers['x-encrypted'] === 'true') {  │  │
│  │      // 执行解密流程                                 │  │
│  │    }                                                 │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 2. 用服务端 RSA 私钥解密 AES 密钥                     │  │
│  │    const aesKey = RSA.decrypt(                       │  │
│  │      encryptedKey,                                   │  │
│  │      serverPrivateKey  // 服务端的私钥               │  │
│  │    );                                                │  │
│  │    // 得到: "a1b2c3d4e5f6g7h8"                       │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 3.AES 密钥解密数据                                │  │
│  │    const decryptedData = AES.decrypt(                │  │
│  │      encryptedData,                                  │  │
│  │      aesKey                                          │  │
│  │    );                                                │  │
│  │    // 得到原始数据                                   │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 4. 解析 JSON 并替换 request.body                      │  │
│  │    request.body = JSON.parse(decryptedData);         │  │
│  │    // {                                              │  │
│  │    //   username: 'admin',                           │  │
│  │    //   password: 'admin123'                         │  │
│  │    // }                                              │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 5. 后续流程使用解密后的数据                          │  │
│  │    ControllerService 直接使用 request.body       │  │
│  │    完全透明,无需关心加解密逻辑                       │  │
│  └──────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────┘

安全优势:
┌────────────────────────────────────────────────────────────┐
│  ✓ 双重加密: AES (对称) + RSA (非对称)                      │
│  ✓ 每次请求的 AES 密钥都不同,无法重放攻击                   │
│  ✓ RSA 私钥只存在服务端,前端无法解密                        │
│  ✓ 即使 HTTPS 被破解,数据仍然加密                           │
│  ✓ 密码等敏感信息永不明文传输                               │
└────────────────────────────────────────────────────────────┘

🎯 核心功能模块详解

1️⃣ 系统管理模块

👤 用户管理

完整的用户生命周期管理,支持企业级用户体系:

  • 用户 CRUD:新增、编辑、删除、批量操作
  • 角色分配:支持一个用户多个角色
  • 部门归属:关联组织架构,实现数据权限隔离
  • 密码管理:密码重置、密码强度验证、定期修改提醒
  • 状态管理:启用/禁用、锁定/解锁
  • 导入导出:批量导入用户、导出 Excel
  • 用户画像:登录统计、操作记录、权限视图
🎭 角色管理

基于 RBAC 的灵活权限控制:

  • 权限分配:菜单权限树选择,支持半选状态
  • 数据权限:全部数据/本部门/本部门及子部门/仅本人
  • 角色继承:支持角色间的权限继承关系
  • 动态权限:权限变更实时生效,无需重新登录
  • 权限预览:可视化展示角色拥有的所有权限
📋 菜单管理

动态菜单配置,支持无限层级:

  • 树形结构:可视化的菜单树编辑
  • 菜单类型:目录、菜单、按钮三种类型
  • 图标选择:内置图标库,支持自定义
  • 路由配置:前端路由路径、组件路径
  • 权限标识:按钮级权限控制标识
  • 显示控制:菜单显示/隐藏、缓存控制
  • 外链菜单:支持外部链接菜单
🏢 部门管理

企业组织架构管理:

  • 树形结构:无限层级的部门树
  • 部门负责人:设置部门负责人
  • 排序控制:自定义部门显示顺序
  • 数据权限:基于部门的数据隔离
  • 人员统计:部门人员数量统计
💼 岗位管理

岗位体系管理:

  • 岗位定义:岗位名称、编码、排序
  • 状态管理:启用/停用岗位
  • 人员关联:查看岗位下的所有人员
📖 字典管理

系统数据字典统一管理:

  • 字典类型:定义字典分类(如:用户状态、性别等)
  • 字典数据:维护具体的字典项
  • 缓存支持:字典数据自动缓存,提升性能
  • 前端使用:前端统一调用字典接口
⚙️ 参数配置

系统参数动态配置:

  • 配置项管理:新增、编辑、删除配置项
  • 配置分类:系统配置、业务配置等
  • 缓存刷新:配置变更自动刷新缓存
  • 配置校验:支持配置值格式验证
📢 通知公告

系统公告发布与管理:

  • 公告发布:富文本编辑器,支持图文混排
  • 公告类型:通知、公告、系统消息
  • 发布控制:立即发布、定时发布
  • 阅读状态:已读/未读状态跟踪

2️⃣ 系统监控模块

👥 在线用户

实时监控在线用户状态:

  • 在线列表:显示当前所有在线用户
  • 用户信息:用户名、IP 地址、登录时间、浏览器
  • 强制下线:管理员可强制用户下线
  • 会话管理:查看用户会话信息
  • 实时统计:在线用户数量统计
📝 操作日志

完整的操作审计系统:

  • 自动记录:通过装饰器自动记录操作
  • 详细信息:操作人、操作时间、操作类型、请求参数、响应结果
  • 异常捕获:自动记录异常操作和错误堆栈
  • 条件查询:按用户、时间、模块、操作类型查询
  • 数据导出:导出日志用于审计
🔐 登录日志

登录安全审计:

  • 登录记录:成功/失败的登录记录
  • 安全信息:IP 地址、地理位置、浏览器、操作系统
  • 异常检测:异常登录行为提醒
  • 统计分析:登录时段分析、地域分析
⏰ 定时任务

灵活的任务调度系统:

  • Cron 表达式:支持标准 Cron 表达式
  • 任务管理:启动、停止、立即执行
  • 执行日志:任务执行历史、成功/失败记录
  • 并发控制:任务并发执行控制
  • 超时设置:任务执行超时时间设置
  • 错误重试:失败自动重试机制
🖥️ 系统监控

服务器运行状态监控:

  • CPU 监控:CPU 使用率、核心数
  • 内存监控:内存使用情况、JVM 信息
  • 磁盘监控:磁盘使用率、读写速度
  • 网络监控:网络流量统计
  • 进程信息:进程 PID、运行时长
💚 健康检查

K8s 友好的健康检查:

  • Liveness 探针:应用存活检查
  • Readiness 探针:应用就绪检查
  • 数据库检查:PostgreSQL 连接状态
  • Redis 检查:Redis 连接状态
  • 磁盘检查:磁盘空间检查
  • 内存检查:内存使用检查
  • Prometheus 指标:暴露 /api/metrics 端点
📁 文件上传

多存储支持的文件管理:

  • 本地存储:存储到服务器本地
  • 阿里云 OSS:存储到阿里云对象存储
  • 七牛云:存储到七牛云
  • MinIO:私有化对象存储
  • 文件预览:图片、PDF 等在线预览
  • 缩略图:自动生成图片缩略图

3️⃣ 多租户管理模块

🏘️ 租户管理

完整的 SaaS 租户管理:

  • 租户 CRUD:新增、编辑、删除租户
  • 租户信息:租户名称、联系人、到期时间
  • 套餐绑定:为租户分配功能套餐
  • 状态管理:启用、停用、过期控制
  • 数据隔离:自动的租户数据隔离
  • 容量限制:用户数、存储空间限制
📦 租户套餐

灵活的套餐体系:

  • 套餐定义:基础版、标准版、企业版
  • 功能权限:菜单权限按套餐分配
  • 资源限制:用户数、存储空间限制
  • 套餐升级:支持套餐在线升级
🔒 数据隔离

安全的多租户数据隔离:

  • 自动过滤:数据库查询自动按租户过滤
  • 跨租户查询:超级管理员可查询所有租户
  • 租户切换:支持切换查看不同租户数据
  • 数据迁移:租户数据导入导出

4️⃣ 演示账户系统

专为产品演示设计的安全机制:

  • 只读权限:52 个查询权限,可查看所有模块
  • 写操作拦截:自动拦截所有 POST/PUT/DELETE/PATCH 请求
  • 友好提示:操作被拦截时给出友好提示
  • 灵活配置:基于 RBAC 可随时调整权限范围
  • 演示重置:定时重置演示数据(可选)

🚀 快速开始

环境要求

  • Node.js >= 20.19.0
  • PostgreSQL >= 14
  • Redis >= 7
  • pnpm >= 8.0

安装步骤

1. 克隆项目

git clone https://github.com/your-repo/nest-admin-soybean.git
cd nest-admin-soybean

2. 后端配置

cd server
pnpm install

# 生成 RSA 密钥对(用于加密)
pnpm generate:keys

# 配置数据库连接
# 编辑 src/config/index.ts 中的数据库配置

# 初始化数据库
pnpm prisma:seed

3. 前端配置

cd admin-naive-ui
pnpm install

# 配置后端接口地址
# 编辑 .env.development 文件

4. 启动项目

# 启动后端 (8080端口)
cd server
pnpm start:dev

# 启动前端 (9527端口)
cd admin-naive-ui
pnpm dev

5. 访问系统

默认账号

  • 超级管理员:admin / admin123 (租户 000000)
  • 演示账户:demo / demo123 (租户 000000)

💪 技术亮点详解

1. 多租户实现原理

核心思路:通过 Prisma Extension 在 ORM 层面实现透明的租户过滤。

// tenant.extension.ts
export function tenantExtension(tenantId: string) {
  return Prisma.defineExtension({
    query: {
      // 对所有模型的查询自动添加租户过滤
      $allModels: {
        async findMany({ args, query }) {
          args.where = { ...args.where, tenantId };
          return query(args);
        },
        // ... findUnique, create, update, delete 同理
      }
    }
  });
}

// prisma.service.ts
get client() {
  const tenantId = TenantContext.getTenantId();
  if (!tenantId) return this._client;
  return this._client.$extends(tenantExtension(tenantId));
}

优势

  • 业务代码无需关心租户逻辑
  • 避免忘记添加租户条件导致的数据泄露
  • 统一管理,易于维护

2. 请求加密的实现

前端加密

// encryption.ts
export function encryptRequest(data: any) {
  // 1. 生成随机 AES 密钥
  const aesKey = CryptoJS.lib.WordArray.random(16);
  
  // 2. AES 加密数据
  const encryptedData = CryptoJS.AES.encrypt(
    JSON.stringify(data), 
    aesKey,
    { mode: CryptoJS.mode.CBC }
  ).toString();
  
  // 3. RSA 加密 AES 密钥
  const encryptedKey = rsaEncrypt(aesKey.toString(), serverPublicKey);
  
  return { encryptedKey, encryptedData };
}

后端解密

// decrypt.interceptor.ts
@Injectable()
export class DecryptInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    const request = context.switchToHttp().getRequest();
    
    if (request.headers['x-encrypted'] === 'true') {
      const { encryptedKey, encryptedData } = request.body;
      
      // 1. RSA 解密 AES 密钥
      const aesKey = rsaDecrypt(encryptedKey, privateKey);
      
      // 2. AES 解密数据
      const decryptedData = aesDecrypt(encryptedData, aesKey);
      
      // 3. 替换 body
      request.body = JSON.parse(decryptedData);
    }
    
    return next.handle();
  }
}

3. 权限系统的设计

采用装饰器 + 守卫的组合模式:

// permission.guard.ts
@Injectable()
export class PermissionGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const requiredPermission = this.reflector.get(
      'permission',
      context.getHandler()
    );
    
    if (!requiredPermission) return true;
    
    const user = context.switchToHttp().getRequest().user;
    return user.permissions.includes(requiredPermission);
  }
}

// 使用
@RequirePermission('system:user:edit')
@Put(':id')
updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) {
  return this.userService.update(id, dto);
}

权限数据结构

// 权限标识:模块:功能:操作
'system:user:add'      // 添加用户
'system:user:edit'     // 编辑用户
'system:user:delete'   // 删除用户
'system:user:query'    // 查询用户

4. 日志系统的优化

自动日志收集

// logging.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  constructor(private logger: Logger) {}
  
  intercept(context: ExecutionContext, next: CallHandler) {
    const request = context.switchToHttp().getRequest();
    const startTime = Date.now();
    
    return next.handle().pipe(
      tap(() => {
        const duration = Date.now() - startTime;
        this.logger.info({
          requestId: request.id,
          tenantId: request.tenantId,
          username: request.user?.username,
          method: request.method,
          url: request.url,
          statusCode: context.switchToHttp().getResponse().statusCode,
          duration,
        });
      })
    );
  }
}

敏感字段脱敏

// 自动隐藏敏感字段
const sensitiveFields = ['password', 'token', 'secret'];
this.logger.info(redactSensitive(data, sensitiveFields));

🎨 前端特色

1. 文件路由系统

使用 @elegant-router/vue 实现基于文件的路由:

src/views/
├── system/
│   ├── user/
│   │   └── index.vue     → /system/user
│   ├── role/
│   │   └── index.vue     → /system/role
│   └── menu/
│       └── index.vue     → /system/menu

自动生成路由

pnpm gen-route

2. 原子化 CSS

使用 UnoCSS,支持 Tailwind 风格:

<template>
  <div class="flex items-center justify-between p-4 bg-white dark:bg-dark">
    <span class="text-lg font-bold">用户管理</span>
    <n-button type="primary">添加用户</n-button>
  </div>
</template>

3. 状态管理

Pinia setup 语法:

// useAuthStore.ts
export const useAuthStore = defineStore('auth', () => {
  const token = ref(getToken());
  const userInfo = ref<UserInfo | null>(null);
  
  async function login(credentials: LoginDto) {
    const { token: newToken, user } = await api.login(credentials);
    token.value = newToken;
    userInfo.value = user;
    setToken(newToken);
  }
  
  return { token, userInfo, login };
});

4. 请求封装

统一的 Axios 封装,支持自动加密:

// api.ts
export function fetchUserList(params: UserQueryDto) {
  return request<PageResult<User>>({
    url: '/system/user',
    method: 'GET',
    params
  });
}

export function createUser(data: CreateUserDto) {
  return request({
    url: '/system/user',
    method: 'POST',
    data,
    encrypt: true  // 自动加密
  });
}

🔮 未来规划

  • 微服务架构:拆分为独立的微服务
  • 消息队列:集成 RabbitMQ/Kafka
  • 分布式追踪:接入 OpenTelemetry
  • GraphQL API:提供 GraphQL 接口
  • 移动端:开发配套的移动应用
  • 低代码平台:可视化表单设计器
  • 更多数据库:支持 MySQL、MongoDB
  • 云原生:K8s Helm Chart

如果觉得不错,欢迎 Star ⭐️:github.com/linlingqin7…

#NestJS #Vue3 #后台管理系统 #全栈开发 #开源项目

Three.js 入门指南:揭开 3D 网页的魔法面纱

作者 zYear
2025年12月23日 16:16

系列介绍

在 webgl 没出现之前,如果我们要在页面展示 3D 动画的效果,我们需要借助一些浏览器的插件进行实现。因此在 2011 年, webgl 诞生了,这套应用与 3D 应用的 API 能够让开发者使用 JS 语言与 GPU 进行通信,从而实现各式各样的 3D 效果。而本系列将借由 Three.js 对 webgl 进行介绍说明。

Three.js

Three.js 是一个基于 webgl 开发的开源库,它就像 echarts 建立于 canvas 上,通过 Three.js 我们可以不用直接接触最底层的内容,包括: 着色器、缓冲区、矩阵变换的内容,从而大大减小入门的难度。但本系列在通过介绍 Three.js 的过程中,也会插入一些 webgl 的基础知识,从而帮助你更好的理解。

本文的参考资料

Three.js

Three.js中文网 Three.js – JavaScript 3D Library www.webgl3d.cn/

webgl

WebGL 理论基础 WebGL:web 中的 2D 的 3D 图形 - Web API | MDN Three.js中文网

Three.js 的三大基础概念

Three.js 的运行机制

在 Three.js 中,要实现渲染一个3D的物体,首先我们需要一个 虚拟的场景,然后通过将我们需要展示3D物体添加到场景中,于是便有了一个“虚拟的世界”,而想要看这个虚拟的世界,便需要通过 摄像机 去拍摄,最后通过 渲染器 解析当前摄像机看到的内容,从而展示给用户。而这也使得摄像头被移动之后,仍需要重新进行渲染才可以展示出内容。

大白话

你可以理解为你在给一辆跑车拍一张海报,此时首先需要 一辆车(物体)一个展台(场景),然后我们拿着摄像机,选好我们的站位,瞄准我们需要拍摄的车,此时 摄像机显示屏(摄像机) 就展示我们能看到的东西。最后我们需要通 按下快门键(渲染器)**,才能看到我们的海报。

场景

场景 Scene

场景的创建在 Three.js 中也十分的简单,我们可以通过 Three.Scene() 进行创建,并且将他赋值给一个变量,于是我们便可以通过 add 方法将物体放置到场景中。

// 你可以通过 Scene 方法创建一个场景
const Scene = new THREE.Scene()

物体

物体,是我们在开发3D场景中极为重要的内容,毕竟场景终究只是一个空壳,具体的样子和内容还需要通过物体来展示,因此物体和场景可以放在一起进行说明。而要创建一个物体,我们首先需要对一个物体进行 外形材质 的描述。

webgl 渲染管线

由于 Three.js 是基于 WebGL 构建的高级 3D 库,理解 WebGL 的渲染管线有助于我们更深入地掌握 Three.js 中物体的渲染机制。因此,在介绍 Three.js 中的物体、几何体和材质之前,我们先简要了解 WebGL 的渲染管线。

顶点着色器阶段

顶点着色器是渲染管线的第一个阶段,他会通过 GPU 进行计算,将各个顶点转换为坐标值(裁剪空间坐标值),并且处理法线、颜色和纹理坐标等顶点数据。最终绘制顶点到相应的坐标

图元装配器

在 webgl 中,图元装配器 可以通过你输入的类型(三角面,线段,连续路径,...) 。然后将所有的顶点坐标转换为不同类型的组,从而使得后面光栅化能够对其进行材质处理(光栅化与上色)。注意: 在 webgl 中,任何面都是由三角面组合而成的

光栅化阶段

这一阶段你可以理解为绘制,也就是通过像素将内容绘制出来。通过对前面已经组成好的图形进行基础(不带颜色的)像素填充,并且每一个图形中都包含它对应的颜色、纹理等信息。

片元着色器阶段

片元着色器阶段是可以理解为上色阶段,它会处理每个图形中的信息,对其进行上色等操作。 ![[Pasted image 20251218101056.png]]

几何形状 Geometry

在 Three.js 中,你可以将几何形状理解为Three.js 对于顶点着色器的顶点位置的重新封装,我们只需要通过对应几何形状的 API 直接生成相应的顶点数据对象,然后交由图元装配器进行装配。我们在视图中看到模型的形状就是其决定的。

// 创建一个正方形
const geometry = new THREE.BoxGeometry(100, 100, 100)
材质 Material

Material 的作用是对于一个物体的外观的描述,例如: 表面的材质,颜色,透明度等,它决定了我们在视图中看到的模型的样式。其中,在 Three.js 中的面是分为正反两面的,当材质为单面时,我们只能从正面看到该物体。

const material = new THREE.side: THREE.DoubleSide,({
color: 0x00ffff,
side: THREE.DoubleSide, // 决定你的材质的面是单面还是双面
})
模型(物体) Objecet

在 Three.js 中,Mesh(网格)、Line(线)和 Points(点云)是三种常见的可渲染对象。它们都由 Geometry(几何体) 定义形状,由 Material(材质) 定义外观。只有将这些对象添加到场景(Scene)中,并通过渲染器(Renderer)进行绘制,我们才能在屏幕上看到由 Geometry 和 Material 共同定义的视觉结果。

// 创建一个网格模型对象
const mesh = new THREE.Mesh(geometry, material)
// 将网格模型对象添加到三维场景中
scene.add(mesh)

摄像机

在 Three.js 中摄像机可以理解为相机的显示器,它的类型,位置,透视将决定了我们能看到的内容,并且我们也可以随时通过移动摄像机来更换我们想看到的场景内容。

PerspectiveCamera(透视摄像机)

这是摄像机中的一种类型,通过对这个摄像机来了解一些摄像机的基本概念。

  • fov: 摄像机竖直方向的角度
  • aspect: 摄像机的长宽比(画幅)
  • near: 摄像机的近截面(你的摄像机最近的界限)
  • far: 摄像机的远截面(你的摄像机最远的界限)
// 创建透视相机
const width = 800
const height = 500
const camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000)

// 摄像机的起始位置
camera.position.set(200, 200, 200)
// 摄像机对准的位置
camera.lookAt(0, 0, 0)

渲染

渲染就像我们按下摄像机的快门。当我们对当前的场景和摄像机放到同一个渲染器中时,我们便可以通过渲染器将当前摄像机看到的内容进行渲染,渲染过后渲染器会生成一个canvas,然后放置到页面中。然后我们便可以在页面中看到我们的模型。

// 创建渲染器
const render = new THREE.WebGLRenderer()
render.setSize(width, height)
// 渲染出内容
render.render(scene, camera)
// 输出内容
document.body.appendChild(render.domElement)

案例

接下来,让我们通过一个基础的立方体来回顾我们前面的知识

// 创建三维场景
  const scene = new THREE.Scene()

  // 创建一个正方形
  const geometry = new THREE.BoxGeometry(100, 100, 100)
  // 创建一个基础材质
  const material = new THREE.MeshBasicMaterial({
color: 0x00ffff,
side: THREE.DoubleSide, // 决定你的材质是单面还是双面
  })

  // 创建一个坐标轴,便于观察模型位置
  const axios = new THREE.AxesHelper(300)
  scene.add(axios)

  // 创建一个网格模型对象
  const mesh = new THREE.Mesh(geometry, material)
  // 将网格模型对象添加到三维场景中
  scene.add(mesh)

  // 创建透视相机
  const width = 800
  const height = 500
  const camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000)

  // 摄像机的起始位置
  camera.position.set(200, 200, 200)
  // 摄像机对准的位置
  camera.lookAt(0, 0, 0)

  // 创建渲染器
  const render = new THREE.WebGLRenderer()
  render.setSize(width, height)
  // 渲染出内容
  render.render(scene, camera)
  // 输出内容
  document.body.appendChild(render.domElement)     
下章预告

---《Three.js 入门指南:让网页“立体”起来的秘密》

Ant Design 6.0 尝鲜:上手现代化组件开发|得物技术

作者 得物技术
2025年12月23日 14:12

一、引言

组件体验的革新

在前端开发领域,Ant Design 一直是企业级 React 应用的首选 UI 库之一。随着 Ant Design 6.0 的发布,我们又见证了一次聚焦于组件功能与用户体验的革新。本次更新不仅引入了多个全新组件,更对现有核心组件进行了功能性增强,使开发者能够以更少的代码实现更丰富的交互效果。

二、Masonry 瀑布流组件:智能动态布局

传统网格布局在处理高度不一的元素时常出现大量空白区域,Masonry(瀑布流)布局则完美解决了这一问题。Ant Design 6.0 全新推出的 Masonry 组件让实现这种流行布局变得异常简单。

基础实现与响应式配置

import { useState, useEffect, useRef } from "react";
import { Masonry, Card, Image, Spin } from "antd";
/**
 * Masonry瀑布流页面
 */
export default () => {
  const [isLoading, setIsLoading] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);
  const isLoadingRef = useRef(false);
  const imageList = [
    "https://images.xxx.com/photo-xxx-4b4e3d86bf0f",
    ...
    "https://images.xxx.com/photo-xxx-98f7befd1a60",
  ];
  const titles = [
    "山间日出",
    ...
    "自然风光",
  ];
  const descriptions = [
    "清晨的第一缕阳光",
    ...
    "色彩鲜艳的料理",
  ];
  const heights = [240260280300320350380400];
  // Mock数据生成函数
  const generateMockData = (startIndex: number, count: number) => {
    return Array.from({ length: count }, (_, index) => ({
      id: startIndex + index + 1,
      src: imageList[Math.floor(Math.random() * imageList.length)],
      title: titles[(startIndex + index) % titles.length],
      description: descriptions[(startIndex + index) % descriptions.length],
      height: heights[Math.floor(Math.random() * heights.length)],
    }));
  };
  // 初始数据:20条
  const [photoItems, setPhotoItems] = useState(() => generateMockData(0, 20));
  // 滚动监听
  useEffect(() => {
    isLoadingRef.current = isLoading;
  }, [isLoading]);
  useEffect(() => {
    const loadMoreData = async () => {
      if (isLoadingRef.current) return;
      isLoadingRef.current = true;
      setIsLoading(true);
      // 模拟API请求延迟
      await new Promise((resolve) => setTimeout(resolve, 500));
      setPhotoItems((prev) => {
        const newItems = generateMockData(prev.length, 10);
        return [...prev, ...newItems];
      });
      isLoadingRef.current = false;
      setIsLoading(false);
    };
    const checkScroll = () => {
      if (isLoadingRef.current) return;
      const container = containerRef.current;
      if (!container) return;
      const scrollTop = container.scrollTop;
      const scrollHeight = container.scrollHeight;
      const clientHeight = container.clientHeight;
      // 当滚动到距离底部100px时触发加载
      if (scrollTop + clientHeight >= scrollHeight - 100) {
        loadMoreData();
      }
    };
    const handleWindowScroll = () => {
      if (isLoadingRef.current) return;
      const windowHeight = window.innerHeight;
      const documentHeight = document.documentElement.scrollHeight;
      const scrollTop =
        window.pageYOffset || document.documentElement.scrollTop;
      // 当滚动到距离底部100px时触发加载
      if (scrollTop + windowHeight >= documentHeight - 100) {
        loadMoreData();
      }
    };
    const container = containerRef.current;
    // 初始检查一次,以防内容不足一屏
    setTimeout(() => {
      checkScroll();
      handleWindowScroll();
    }, 100);
    // 监听容器滚动
    if (container) {
      container.addEventListener("scroll", checkScroll);
    }
    // 同时监听 window 滚动(作为备选)
    window.addEventListener("scroll", handleWindowScroll);
    return () => {
      if (container) {
        container.removeEventListener("scroll", checkScroll);
      }
      window.removeEventListener("scroll", handleWindowScroll);
    };
  }, []);
  return (
    <div ref={containerRef} className="w-full h-[100vh] overflow-auto p-[24px]">
      <Masonry
        // 响应式列数配置
        columns={{ xs: 2, sm: 3, md: 4, lg: 5 }}
        // 列间距与行间距
        gutter={16}
        items={photoItems as any}
        itemRender={(item: any) => (
          <Card
            key={item.id}
            hoverable
            cover={
              <div style={{ height: item.height, overflow: "hidden" }}>
                <Image
                  src={item.src}
                  alt={item.title}
                  height={item.height}
                  width="100%"
                  style={{
                    width: "100%",
                    height: "100%",
                    objectFit: "cover",
                  }}
                  preview={{
                    visible: false,
                  }}
                />
              </div>
            }
            styles={{
              body: {
                padding: "12px",
              },
            }}
          >
            <Card.Meta title={item.title} description={item.description} />
            <div
              className="mt-[8px] text-[12px] text-[#999]"
            >
              图片 #{item.id}
            </div>
          </Card>
        )}
      />
      {isLoading && (
        <div
          className="flex items-center justify-center p-[20px] text-[#999]"
        >
          <Spin style={{ marginRight: "8px" }} />
          <span>加载中...</span>
        </div>
      )}
    </div>
  );
};

布局效果说明

Masonry 组件会根据设定的列数自动将元素排列到高度最小的列中。与传统的网格布局相比,这种布局方式能有效减少内容下方的空白区域,特别适合展示高度不一的内容块。

对于图片展示类应用,这种布局能让用户的视线自然流动,提高浏览的沉浸感和内容发现率。

三、Tooltip 平滑移动:优雅的交互引导

在 Ant Design 6.0 中,Tooltip 组件引入了独特的平滑过渡效果,通过 ConfigProvider 全局配置的 tooltip.unique 配置项,当用户在多个带有提示的元素间移动时,提示框会以流畅的动画跟随,而不是突然消失和重现。

实现平滑跟随效果

import { Tooltip, Button, Card, ConfigProvider } from "antd";
import {
  UserOutlined,
  SettingOutlined,
  BellOutlined,
  MailOutlined,
  AppstoreOutlined,
} from "@ant-design/icons";
import { TooltipPlacement } from "antd/es/tooltip";
/**
 * Tooltip 示例
 */
export default () => {
  const buttonItems = [
    {
      icon: <UserOutlined />,
      text: "个人中心",
      tip: "查看和管理您的个人资料",
      placement: "top",
    },
    {
      icon: <SettingOutlined />,
      text: "系统设置",
      tip: "调整应用程序参数和偏好",
      placement: "top",
    },
    {
      icon: <BellOutlined />,
      text: "消息通知",
      tip: "查看未读提醒和系统消息",
      placement: "top",
    },
    {
      icon: <MailOutlined />,
      text: "邮箱",
      tip: "收发邮件和管理联系人",
      placement: "bottom",
    },
    {
      icon: <AppstoreOutlined />,
      text: "应用中心",
      tip: "探索和安装更多应用",
      placement: "bottom",
    },
  ];
  return (
    <div className="w-full h-[100vh] overflow-auto p-[24px] space-y-5">
      <ConfigProvider
        tooltip={{
          unique: true,
        }}
      >
        <Card title="平滑过渡导航工具栏" bordered={false}>
          <div className="flex justify-center gap-6 py-10 px-5 bg-gradient-to-br from-[#f5f7fa] to-[#c3cfe2] rounded-3xl">
            {buttonItems.map((item, index) => (
              <Tooltip
                placement={item.placement as TooltipPlacement}
                key={index}
                title={
                  <div>
                    <div className="font-bold mb-1">{item.text}</div>
                    <div className="text-xs text-[#fff]/60">{item.tip}</div>
                  </div>
                }
                color="#1677ff"
              >
                <Button
                  type="primary"
                  shape="circle"
                  icon={item.icon}
                  size="large"
                  className="w-[60px] h-[60px] text-2xl shadow-md transition-all duration-300 ease-in-out"
                />
              </Tooltip>
            ))}
          </div>
          <div className="mt-5 p-4 bg-green-50 border border-green-300 rounded-md">
            <div className="flex items-center">
              <div className="w-3 h-3 rounded-full bg-green-500 mr-2"></div>
              <span>
                提示:尝试将鼠标在不同图标间快速移动,观察 Tooltip
                的平滑过渡效果
              </span>
            </div>
          </div>
        </Card>
      </ConfigProvider>
      <Card title="非平滑过渡导航工具栏" bordered={false}>
        <div className="flex justify-center gap-6 py-10 px-5 bg-gradient-to-br from-[#f5f7fa] to-[#c3cfe2] rounded-3xl">
          {buttonItems.map((item, index) => (
            <Tooltip
              key={index}
              placement={item.placement as TooltipPlacement}
              title={
                <div>
                  <div className="font-bold mb-1">{item.text}</div>
                  <div className="text-xs text-[#fff]/60">{item.tip}</div>
                </div>
              }
              color="#1677ff"
            >
              <Button
                type="primary"
                shape="circle"
                icon={item.icon}
                size="large"
                className="w-[60px] h-[60px] text-2xl shadow-md transition-all duration-300 ease-in-out"
              />
            </Tooltip>
          ))}
        </div>
        <div className="mt-5 p-4 bg-green-50 border border-green-300 rounded-md">
          <div className="flex items-center">
            <div className="w-3 h-3 rounded-full bg-green-500 mr-2"></div>
            <span>
              提示:尝试将鼠标在不同图标间快速移动,观察 Tooltip 的非平滑过渡效果
            </span>
          </div>
        </div>
      </Card>
    </div>
  );
};

2.gif

交互效果说明

当 tooltip.unique 设置为 true 时,用户在不同元素间移动鼠标时,Tooltip 会呈现以下行为:

  1. 平滑位置过渡:Tooltip 不会立即消失,而是平滑移动到新目标位置

  2. 内容无缝切换:提示内容在新位置淡入,旧内容淡出

  3. 视觉连续性:保持同一时刻只有一个 Tooltip 显示,避免界面混乱

这种设计特别适合工具栏、导航菜单等元素密集的区域,能有效降低用户的认知负荷,提供更加流畅的交互体验。

四、InputNumber 拨轮模式:直观的数字输入

数字输入框是表单中的常见组件,但传统的上下箭头控件在小屏幕或触摸设备上操作不便。Ant Design 6.0 的 InputNumber 组件新增了 mode="spinner" 属性,提供了更直观的“加减按钮”界面。

拨轮模式实现

import { InputNumber, Card, Row, Col, Typography, Space } from "antd";
import {
  ShoppingCartOutlined,
  DollarOutlined,
  GiftOutlined,
} from "@ant-design/icons";
const { Title, Text } = Typography;
/**
 * InputNumber 示例
 */
export default () => {
  return (
    <div className="w-full h-[100vh] overflow-auto p-[24px] space-y-5">
      <Card title="商品订购面板" bordered={false}>
        <Row gutter={[2424]}>
          <Col span={8}>
            <div className="text-center">
              <div className="w-[80px] h-[80px] mx-auto mb-4 rounded-3xl bg-[#f0f5ff] flex items-center justify-center text-[32px] text-[#1677ff]">
                <ShoppingCartOutlined />
              </div>
              <Title level={5} className="mb-3">
                购买数量(非数字拨轮)
              </Title>
              <InputNumber
                min={1}
                max={50}
                defaultValue={1}
                size="large"
                className="w-[250px]!"
                addonBefore="数量"
              />
              <div className="mt-2 text-xs text-gray-600">限购50件</div>
            </div>
          </Col>
          <Col span={8}>
            <div className="text-center">
              <div className="w-[80px] h-[80px] mx-auto mb-4 rounded-3xl bg-[#fff7e6] flex items-center justify-center text-[32px] text-[#fa8c16]">
                <DollarOutlined />
              </div>
              <Title level={5} className="mb-3">
                折扣力度(数字拨轮)
              </Title>
              <InputNumber
                min={0}
                max={100}
                defaultValue={10}
                mode="spinner"
                size="large"
                formatter={(value) => `${value ?? 0}%`}
                parser={(value) =>
                  Number.parseFloat(value?.replace("%", "") ?? "0") as any
                }
                className="w-[250px]!"
                addonBefore="折扣"
              />
              <div className="mt-2 text-xs text-gray-600">0-100%范围</div>
            </div>
          </Col>
          <Col span={8}>
            <div className="text-center">
              <div className="w-[80px] h-[80px] mx-auto mb-4 rounded-3xl bg-[#f6ffed] flex items-center justify-center text-[32px] text-[#52c41a]">
                <GiftOutlined />
              </div>
              <Title level={5} className="mb-3">
                礼品数量(数字拨轮,自定义加减按钮)
              </Title>
              <Space.Compact block className="justify-center!">
                <Space.Addon>
                  <span>礼品</span>
                </Space.Addon>
                <InputNumber
                  min={0}
                  max={10}
                  defaultValue={0}
                  mode="spinner"
                  size="large"
                  className="w-[250px]!"
                  controls={{
                    upIcon: <span className="text-base">➕</span>,
                    downIcon: <span className="text-base">➖</span>,
                  }}
                />
              </Space.Compact>
              <div className="mt-2 text-xs text-gray-600">每单最多10份</div>
            </div>
          </Col>
        </Row>
        <div className="mt-8 p-4 bg-[#fff0f6] rounded-lg border border-dashed border-[#ffadd2]">
          <Text type="secondary">
            <strong>设计提示:</strong>
            拨轮模式相比传统箭头控件,提供了更大的点击区域和更明确的视觉反馈,特别适合触摸设备和需要频繁调整数值的场景。加减按钮的分离式设计也降低了误操作的可能性。
          </Text>
        </div>
      </Card>
    </div>
  );
};

3.gif

交互优势分析

拨轮模式相比传统数字输入框具有明显优势:

  1. 触摸友好:更大的按钮区域适合移动端操作

  2. 意图明确:“+”和“-”符号比小箭头更直观

  3. 快速调整:支持长按连续增减数值

  4. 视觉反馈:按钮有明确的状态变化(按下、悬停)

在电商、数据仪表盘、配置面板等需要频繁调整数值的场景中,这种设计能显著提升用户的操作效率和满意度。

五、Drawer 拖拽调整:灵活的侧边面板

抽屉组件常用于移动端导航或详情面板,但固定尺寸有时无法满足多样化的内容展示需求。Ant Design 6.0 为 Drawer 组件新增了 resizable 属性,允许用户通过拖拽边缘实时调整面板尺寸。

可调整抽屉实现

import { Drawer, Button, Card, Typography, Divider, List, Flex } from "antd";
import {
  DragOutlined,
  CalendarOutlined,
  FileTextOutlined,
  TeamOutlined,
  CommentOutlined,
  PaperClipOutlined,
} from "@ant-design/icons";
import { useState } from "react";
import { DrawerResizableConfig } from "antd/es/drawer";
const { Title, Text, Paragraph } = Typography;
/**
 * Drawer 示例
 */
export default () => {
  const [open, setOpen] = useState(false);
  const [drawerWidth, setDrawerWidth] = useState(400);
  const [resizable, setResizable] = useState<boolean | DrawerResizableConfig>(
    false,
  );
  const tasks = [
    { id: 1, title: "完成项目需求文档", time: "今天 10:00", priority: "high" },
    { id: 2, title: "团队周会", time: "今天 14:30", priority: "medium" },
    { id: 3, title: "代码审查", time: "明天 09:00", priority: "high" },
    { id: 4, title: "客户演示准备", time: "后天 15:00", priority: "medium" },
  ];
  const showDrawerWithResizable = () => {
    setOpen(true);
    setDrawerWidth(400);
    setResizable({
      onResize: (size) => {
        setDrawerWidth(size);
      },
    });
  };
  const showDrawerWithoutResizable = () => {
    setOpen(true);
    setDrawerWidth(600);
    setResizable(false);
  };
  const onClose = () => {
    setOpen(false);
  };
  return (
    <div className="w-full h-[100vh] flex items-center justify-center overflow-auto p-[24px] space-y-5">
      <Card
        title="任务管理面板"
        variant="outlined"
        className="max-w-[800px] mx-auto"
      >
        <div className="py-10 px-5 text-center">
          <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-[#1677ff] flex items-center justify-center text-[36px] text-white">
            <DragOutlined />
          </div>
          <Title level={3}>可调整的任务详情面板</Title>
          <Paragraph type="secondary" className="max-w-[600px] my-4 mx-auto">
            点击下方按钮打开一个可拖拽调整宽度的抽屉面板。尝试拖动抽屉左侧边缘,根据内容需要调整面板尺寸。
          </Paragraph>
          <Flex justify="center" gap={10}>
            <Button
              type="primary"
              size="large"
              onClick={showDrawerWithResizable}
              icon={<CalendarOutlined />}
              className="mt-6"
            >
              打开任务抽屉(可拖拽宽度)
            </Button>
            <Button
              type="primary"
              size="large"
              onClick={showDrawerWithoutResizable}
              icon={<CalendarOutlined />}
              className="mt-6"
            >
              打开任务抽屉(不可拖拽宽度)
            </Button>
          </Flex>
        </div>
      </Card>
      <Drawer
        title={
          <div className="flex items-center">
            <CalendarOutlined className="mr-2 text-[#1677ff]" />
            <span>任务详情与计划</span>
            {resizable && (
              <div className="ml-3 py-0.5 px-2 bg-[#f0f5ff] rounded-[10px] text-xs text-[#1677ff]">
                可拖拽调整宽度
              </div>
            )}
          </div>
        }
        placement="right"
        onClose={onClose}
        open={open}
        size={drawerWidth}
        resizable={resizable}
        extra={
          <Button type="text" icon={<DragOutlined />}>
            {resizable ? "拖拽边缘调整" : "不可拖拽"}
          </Button>
        }
        styles={{
          body: {
            paddingTop: "12px",
          },
          header: {
            borderBottom: "1px solid #f0f0f0",
          },
        }}
      >
        <div className="mb-6">
          <div className="flex items-center mb-4">
            <FileTextOutlined className="mr-2 text-[#52c41a]" />
            <Title level={5} className="m-0">
              当前任务
            </Title>
          </div>
          <Card size="small">
            <List
              itemLayout="horizontal"
              dataSource={tasks}
              renderItem={(item) => (
                <List.Item>
                  <List.Item.Meta
                    avatar={
                      <div
                        className={`w-8 h-8 rounded-md flex items-center justify-center ${
                          item.priority === "high"
                            ? "bg-[#fff2f0] text-[#ff4d4f]"
                            : "bg-[#f6ffed] text-[#52c41a]"
                        }`}
                      >
                        {item.priority === "high" ? "急" : "常"}
                      </div>
                    }
                    title={<a>{item.title}</a>}
                    description={<Text type="secondary">{item.time}</Text>}
                  />
                </List.Item>
              )}
            />
          </Card>
        </div>
        <Divider />
        <div className="mb-6">
          <div className="flex items-center mb-4">
            <TeamOutlined className="mr-2 text-[#fa8c16]" />
            <Title level={5} className="m-0">
              团队动态
            </Title>
          </div>
          <Paragraph>
            本周团队主要聚焦于项目第三阶段的开发工作,前端组已完成了核心组件的重构,后端组正在进行性能优化。
          </Paragraph>
        </div>
        <div className="mb-6">
          <div className="flex items-center mb-4">
            <CommentOutlined className="mr-2 text-[#722ed1]" />
            <Title level={5} className="m-0">
              最新反馈
            </Title>
          </div>
          <Card size="small" type="inner">
            <Paragraph>
              "新的界面设计得到了客户的积极反馈,特别是可调整的面板设计,让不同角色的用户都能获得适合自己工作习惯的布局。"
            </Paragraph>
            <Text type="secondary">—— 产品经理,XXX</Text>
          </Card>
        </div>
        <div>
          <div className="flex items-center mb-4">
            <PaperClipOutlined className="mr-2 text-[#eb2f96]" />
            <Title level={5} className="m-0">
              使用提示
            </Title>
          </div>
          <div className="p-3 bg-[#f6ffed] rounded-md border border-[#b7eb8f]">
            <Text type="secondary">
              当前抽屉宽度: <strong>{drawerWidth}px</strong>。您可以: 1.
              拖动左侧边缘调整宽度 2. 内容区域会根据宽度自动重新布局 3.
              适合查看不同密度的信息
            </Text>
          </div>
        </div>
      </Drawer>
    </div>
  );
};

拖拽交互的价值

可调整抽屉的设计带来了明显的用户体验提升:

  1. 自适应内容:用户可以根据当前查看的内容类型调整面板尺寸

  2. 个性化布局:不同用户或场景下可以设置不同的面板大小

  3. 多任务处理:宽面板适合详情查看,窄面板适合边操作边参考

  4. 渐进式披露:可以从紧凑视图逐步展开到详细视图

六、Modal 背景模糊:朦胧美学的视觉升级

在传统 Web 应用中,模态框的遮罩层往往是简单的半透明黑色,视觉效果单调且缺乏现代感。而在 iOS 和 macOS 等系统中,毛玻璃(frosted glass)效果已成为标志性的设计语言效果样式。Ant Design 6.0 为所有弹层组件引入了原生背景模糊支持,并提供了强大的语义化样式定制能力,让开发者能轻松打造出高级感十足的视觉效果。

背景模糊与语义化样式定制

以下示例展示了如何结合 Ant Design 6.0 的背景模糊特性和 antd-style 库,实现两种不同风格的模态框:

import { useState } from "react";
import { Button, Flex, Modal, Card, Image, Typography, Space } from "antd";
import type { ModalProps } from "antd";
import { createStyles } from "antd-style";
const { Title, Text } = Typography;
// 使用 antd-style 的 createStyles 定义样式
const useStyles = createStyles(({ token }) => ({
  // 用于模态框容器的基础样式
  container: {
    borderRadius: token.borderRadiusLG * 1.5,
    overflow: "hidden",
  },
}));
// 示例用的共享内容
const sharedContent = (
  <Card size="small" bordered={false}>
    <Image
      height={300}
      src="https://gw.alipayobjects.com/zos/antfincdn/LlvErxo8H9/photo-1503185912284-5271ff81b9a8.webp"
      alt="示例图片"
      preview={false}
      className="mx-auto!"
    />
    <Text type="secondary" style={{ display: "block", marginTop: 8 }}>
      Ant Design 6.0 默认的模糊背景与 antd-style
      定制的毛玻璃面板相结合,营造出深邃而富有层次的视觉体验。
    </Text>
  </Card>
);
export default () => {
  const [blurModalOpen, setBlurModalOpen] = useState(false);
  const [gradientModalOpen, setGradientModalOpen] = useState(false);
  const { styles: classNames } = useStyles();
  // 场景1:背景玻璃模糊效果(朦胧美学)
  const blurModalStyles: ModalProps["styles"] = {
    body: {
      padding: 24,
    },
  };
  // 场景2:渐变色背景模态框(无模糊效果)
  const gradientModalStyles: ModalProps["styles"] = {
    mask: {
      backgroundImage: `linear-gradient(
        135deg, 
        rgba(99, 102, 241, 0.8) 0%, 
        rgba(168, 85, 247, 0.6) 50%, 
        rgba(236, 72, 153, 0.8) 100%
      )`,
    },
    body: {
      padding: 24,
    },
    header: {
      background: "linear-gradient(to right, #6366f1, #a855f7)",
      color: "#fff",
      borderBottom: "none",
    },
    footer: {
      borderTop: "1px solid #e5e7eb",
      textAlign: "center",
    },
  };
  // 共享配置
  const sharedProps: ModalProps = {
    centered: true,
    classNames,
  };
  return (
    <div className="w-full h-[100vh] overflow-auto p-[24px] space-y-5">
      <Card
        title="Ant Design 6 模态框样式示例"
        bordered={false}
        extra={
          <Text type="secondary" className="text-sm">
            朦胧美学 + 渐变背景,高级感拉满!
          </Text>
        }
      >
        <Flex
          gap="middle"
          align="center"
          justify="center"
          style={{ padding: 40, minHeight: 300 }}
        >
          <Button
            type="primary"
            size="large"
            onClick={() => setBlurModalOpen(true)}
          >
            🌫️ 背景玻璃模糊效果
          </Button>
          <Button size="large" onClick={() => setGradientModalOpen(true)}>
            🎨 渐变色背景模态框
          </Button>
          {/* 模态框 1:背景玻璃模糊效果(朦胧美学) */}
          <Modal
            {...sharedProps}
            title="背景玻璃模糊效果"
            styles={blurModalStyles}
            open={blurModalOpen}
            onOk={() => setBlurModalOpen(false)}
            onCancel={() => setBlurModalOpen(false)}
            okText="太美了"
            cancelText="关闭"
            mask={{ enabled: true, blur: true }}
            width={600}
          >
            {sharedContent}
            <div
              style={{
                marginTop: 16,
                padding: 16,
                background: "rgba(255, 255, 255, 0.6)",
                borderRadius: 8,
                backdropFilter: "blur(10px)",
              }}
            >
              <Text type="secondary">
                <strong>💡 设计亮点:</strong>
                启用了 mask=&#123;&#123; blur: true &#125;&#125;,
                背景会自动应用模糊效果,营造出朦胧美学的高级质感。
              </Text>
            </div>
          </Modal>
          {/* 模态框 2:渐变色背景(无模糊效果) */}
          <Modal
            {...sharedProps}
            title="渐变色背景模态框"
            styles={gradientModalStyles}
            open={gradientModalOpen}
            onOk={() => setGradientModalOpen(false)}
            onCancel={() => setGradientModalOpen(false)}
            okText="好看"
            cancelText="关闭"
            mask={{ enabled: true, blur: false }}
            width={600}
          >
            {sharedContent}
            <div
              style={{
                marginTop: 16,
                padding: 16,
                background: "linear-gradient(135deg, #fef3c7 0%, #fce7f3 100%)",
                borderRadius: 8,
                border: "1px solid rgba(168, 85, 247, 0.2)",
              }}
            >
              <Text type="secondary">
                <strong>🎨 设计亮点:</strong>
                通过 styles.mask 设置渐变背景色,同时 styles.header
                应用了渐变头部,打造独特的视觉体验。
              </Text>
            </div>
          </Modal>
        </Flex>
        <div className="mt-6 p-5 bg-gradient-to-r from-blue-50 to-purple-50 rounded-xl border border-purple-200">
          <Title level={5} className="mb-3">
            📚 技术要点
          </Title>
          <Space direction="vertical" size="small" className="w-full">
            <Text>
              • <strong>玻璃模糊:</strong>使用 mask=&#123;&#123; blur: true &#125;&#125; 启用原生模糊效果
            </Text>
            <Text>
              • <strong>渐变背景:</strong>通过 styles.mask.backgroundImage 设置渐变色
            </Text>
            <Text>
              • <strong>语义化定制:</strong>使用 styles.header/body/footer 精准控制各部分样式
            </Text>
            <Text>
              • <strong>antd-style 集成:</strong>使用 createStyles 定义可复用的样式类名
            </Text>
          </Space>
        </div>
      </Card>
    </div>
  );
};

核心特性解析

1. 背景模糊开关

通过 mask 属性的 blur 配置项,可以一键开启/关闭背景模糊效果:

  • mask={{ enabled: true, blur: true }}:启用毛玻璃效果
  • mask={{ enabled: true, blur: false }}:使用传统半透明遮罩

2. 语义化样式定制

styles 属性允许精准控制组件各个部分的样式,无需编写复杂的 CSS 选择器:

  • styles.mask:遮罩层样式(可设置渐变背景)
  • styles.header:头部样式(可定制颜色、边框)
  • styles.body:内容区样式(可调整间距)
  • styles.footer:底部样式(可设置对齐方式)

3. antd-style 集成

结合 antd-style 可以创建主题感知的样式:

  • 访问 Design Token(如 token.borderRadiusLG)
  • 样式自动响应主题切换(亮色/暗色模式)
  • 通过 classNames 属性应用 CSS 类名

视觉效果对比

使用背景模糊和语义化样式定制后,Modal 的视觉呈现发生了显著变化:

1. 背景模糊效果: 遮罩层从单调的半透明黑色变为毛玻璃效果,背景内容呈现柔和的模糊感

2. 精准样式控制: 通过 styles.mask/header/body/footer 可以像搭积木一样组装出品牌化的对话框

3. 主题联动: 结合 antd-style 后,样式会自动响应全局主题切换,无需手动维护暗色模式样式

4. 维护性提升: 告别 .ant-modal .ant-modal-content .ant-modal-header 这样的深层选择器,样式意图清晰明确

这种设计让 Ant Design 6.0 的组件定制从"CSS 覆盖战争"升级为"API 声明式配置",显著降低了样式维护成本,同时保持了高度的灵活性。

七、Card 赛博朋克风格:霓虹科技美学的呈现

在传统的企业级应用中,卡片组件往往采用简洁素雅的设计。但对于游戏、科技、创意类产品,开发者往往需要更具视觉冲击力的效果。Ant Design 6.0 的 Card 组件配合 antd-style,可以轻松实现赛博朋克风格的霓虹发光边框、深色内阴影和动态动画效果,让你的界面充满未来感和科技感。

赛博朋克卡片实现

以下示例展示了如何使用 antd-style 的 CSS-in-JS 能力,为 Card 组件打造完整的赛博朋克视觉风格:

import { Card, Typography, Button, Space, Avatar, Row, Col } from "antd";
import { createStyles } from "antd-style";
import {
  ThunderboltOutlined,
  RocketOutlined,
  FireOutlined,
  StarOutlined,
  TrophyOutlined,
} from "@ant-design/icons";
const { Title, Text, Paragraph } = Typography;
// 使用 antd-style 创建赛博朋克风格样式
const useStyles = createStyles(({ css }) => ({
  // 赛博朋克卡片 - 紫色霓虹
  cyberpunkCard: css`
    background: rgba(1515350.9);
    border2px solid #a855f7;
    border-radius16px;
    overflow: hidden;
    position: relative;
    transition: all 0.3s ease;
    /* 发光边框效果 */
    box-shadow0 0 20px rgba(168852470.5),
      inset 0 0 20px rgba(168852470.1);
    &:hover {
      transformtranslateY(-5px);
      box-shadow0 0 30px rgba(168852470.8),
        inset 0 0 30px rgba(168852470.2);
      border-color#c084fc;
    }
    /* 顶部霓虹灯条 */
    &::before {
      content"";
      position: absolute;
      top0;
      left0;
      right0;
      height3px;
      backgroundlinear-gradient(
        90deg,
        transparent,
        #a855f7,
        #c084fc,
        #a855f7,
        transparent
      );
      animation: neonFlow 3s ease-in-out infinite;
    }
    @keyframes neonFlow {
      0%100% { opacity1; }
      50% { opacity0.5; }
    }
  `,
  // 霓虹文字
  neonText: css`
    color: #fff;
    text-shadow0 0 10px currentColor,
                 0 0 20px currentColor,
                 0 0 30px currentColor;
    font-weight: bold;
  `,
  // 霓虹按钮
  neonButton: css`
    background: transparent !important;
    border2px solid currentColor !important;
    color: inherit !important;
    text-shadow0 0 10px currentColor;
    box-shadow0 0 10px currentColor,
                inset 0 0 10px rgba(2552552550.1);
    transition: all 0.3s ease !important;
    &:hover {
      transformscale(1.05);
      box-shadow0 0 20px currentColor,
                  inset 0 0 20px rgba(2552552550.2!important;
    }
  `,
  // 数据面板
  dataPanel: css`
    background: rgba(0000.3);
    border1px solid rgba(2552552550.1);
    border-radius8px;
    padding16px;
    backdrop-filterblur(10px);
  `,
}));
export default () => {
  const { styles } = useStyles();
  return (
    <Row gutter={[24, 24]}>
      <Col span={8}>
        <Card
          className={styles.cyberpunkCard}
          hoverable
          styles={{ body: { padding24 } }}
        >
          <div style={{ position"relative", zIndex: 1 }}>
            {/* 头部 */}
            <div style={{ display"flex", alignItems: "center", marginBottom: 16 }}>
              <Avatar
                size={64}
                icon={<ThunderboltOutlined />}
                style={{
                  background: "linear-gradient(135deg, #a855f7, transparent)",
                  border: "2px solid #a855f7",
                  color: "#a855f7",
                  filter: "drop-shadow(0 0 10px #a855f7)",
                }}
              />
            </div>
            {/* 标题 */}
            <Title level={4} className={styles.neonText} style={{ color"#a855f7" }}>
              QUANTUM PROCESSOR
            </Title>
            <Text style={{ color"#888", display: "block", marginBottom: 16 }}>
              量子处理器
            </Text>
            {/* 描述 */}
            <Paragraph style={{ color"#bbb", marginBottom: 20 }}>
              第九代量子处理器,采用纳米级光刻技术,配备AI神经网络加速引擎。
            </Paragraph>
            {/* 数据面板 */}
            <div className={styles.dataPanel} style={{ marginBottom: 20 }}>
              <Space direction="vertical" style={{ width"100%" }} size={12}>
                <div style={{ display"flex", justifyContent: "space-between" }}>
                  <Text style={{ color"#888" }}>处理速度</Text>
                  <Text strong style={{ color"#a855f7" }}>5.2 PHz</Text>
                </div>
                <div style={{ display"flex", justifyContent: "space-between" }}>
                  <Text style={{ color"#888" }}>核心数量</Text>
                  <Text strong style={{ color"#a855f7" }}>128 核</Text>
                </div>
              </Space>
            </div>
            {/* 能量条 */}
            <div style={{ marginBottom: 20 }}>
              <div style={{ display"flex", justifyContent: "space-between", marginBottom: 8 }}>
                <Text style={{ color"#888", fontSize: 12 }}>POWER LEVEL</Text>
                <Text strong style={{ color"#a855f7", fontSize: 12 }}>9999</Text>
              </div>
              <div style={{
                height6,
                background: "rgba(0, 0, 0, 0.3)",
                borderRadius: 3,
                overflow: "hidden",
                border: "1px solid rgba(255, 255, 255, 0.1)",
              }}>
                <div style={{
                  height"100%",
                  width: "92%",
                  background: "linear-gradient(90deg, #a855f7, transparent)",
                  boxShadow: "0 0 10px #a855f7",
                }} />
              </div>
            </div>
            {/* 操作按钮 */}
            <Space style={{ width"100%" }}>
              <Button
                type="primary"
                className={styles.neonButton}
                style={{ color"#a855f7", flex: 1 }}
                icon={<StarOutlined />}
              >
                激活
              </Button>
              <Button
                className={styles.neonButton}
                style={{ color"#a855f7" }}
                icon={<TrophyOutlined />}
              >
                详情
              </Button>
            </Space>
          </div>
        </Card>
      </Col>
    </Row>
  );
};

6.gif

核心技术要点

1. 霓虹发光边框

通过多层 box-shadow 实现外发光和内阴影的叠加效果:

box-shadow: 
  0 0 20px rgba(168852470.5),        /* 外发光 */
  inset 0 0 20px rgba(168852470.1);  /* 内阴影 */

2. 动态霓虹灯条

使用伪元素和渐变动画创建流动的霓虹灯效果:

&::before {
  content"";
  backgroundlinear-gradient(90deg, transparent, #a855f7, transparent);
  animation: neonFlow 3s ease-in-out infinite;
}

3. 霓虹文字效果

通过 text-shadow 的多层叠加模拟霓虹灯文字:

text-shadow: 
  0 0 10px currentColor,
  0 0 20px currentColor,
  0 0 30px currentColor;

4. 毛玻璃数据面板

结合半透明背景和 backdrop-filter 实现毛玻璃效果:

background: rgba(0000.3);
backdrop-filter: blur(10px);

5. 交互动画

hover 时同步触发多个动画效果:

  • 卡片上浮:transform: translateY(-5px)
  • 发光增强:box-shadow 强度提升
  • 边框颜色变化:border-color 过渡

样式定制优势

使用 Ant Design 6.0 + antd-style 实现赛博朋克风格的优势:

1. CSS-in-JS 强大能力: 支持嵌套、伪元素、动画等高级特性,无需额外 CSS 文件

2. 类型安全: TypeScript 提供完整的类型提示,减少样式错误

3. 动态主题: 可以轻松切换不同颜色的霓虹主题(紫色、青色、粉色等)

4. 组件封装: 样式与组件逻辑共存,便于复用和维护

5. 性能优化: antd-style 自动处理样式注入和缓存,性能优秀

这种设计风格通过强烈的视觉冲击力和独特的科技感,能够有效吸引用户注意力,提升品牌记忆度,特别适合面向年轻用户群体的产品。

八、升级建议与实践策略

对于考虑升级到 Ant Design 6.0 的团队,建议采取以下策略:

1.渐进式升级路径

  1. 新项目直接使用:全新项目建议直接使用 6.0 版本,享受所有新特性

  2. 现有项目评估:评估项目依赖和定制程度,制定分阶段升级计划

  3. 组件逐步替换:可以先替换使用新功能的组件,再逐步迁移其他部分

2.兼容性注意事项

  1. 检查废弃 API:Ant Design 6.0 移除了之前版本已标记为废弃的 API

  2. 样式覆盖检查:如果项目中有深度定制样式,需要检查与新版本的兼容性

  3. 测试核心流程:升级后重点测试表单提交、数据展示等核心用户流程

九、总结

Ant Design 6.0 的组件功能更新聚焦于解决实际开发中的痛点,通过引入 Masonry 瀑布流布局、Tooltip 平滑移动、InputNumber 拨轮模式、Drawer 拖拽调整、Modal 背景模糊以及 Card 深度定制等特性,显著提升了开发效率和用户体验。

这些更新体现了现代前端设计的几个核心趋势:

1. 交互流畅性: 如 Tooltip 的平滑过渡,减少界面跳跃感

2. 设备适配性: 如 InputNumber 的触摸友好设计

3. 布局灵活性: 如 Masonry 的动态布局和 Drawer 的可调整尺寸

4. 视觉现代化: 如 Modal 的背景模糊效果,营造朦胧美学的高级质感

5. 样式可控性: 通过 classNamesstyles API 实现精准的组件定制

6. 风格多样性: 结合 antd-style 可实现从企业风到赛博朋克等多样化视觉风格

特别是与 antd-style 的深度集成,让开发者能够充分发挥 CSS-in-JS 的强大能力,从简洁的企业级设计到炫酷的赛博朋克风格,都能轻松实现。这些改进让 Ant Design 6.0 不仅保持了企业级应用的稳定性和专业性,还增加了更多现代化、人性化的交互细节和视觉创意空间,是构建下一代 Web 应用的理想选择。

往期回顾

1.Java 设计模式:原理、框架应用与实战全解析|得物技术

2.Go语言在高并发高可用系统中的实践与解决方案|得物技术 

3.从0到1搭建一个智能分析OBS埋点数据的AI Agent|得物技术

4.数据库AI方向探索-MCP原理解析&DB方向实战|得物技术

5.项目性能优化实践:深入FMP算法原理探索|得物技术

文 /三七

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

Next.js第十五章(Image)

作者 小满zs
2025年12月22日 10:17

Image组件

该组件是Next.js内置的图片组件,是基于原生img标签进行扩展,并不代表原生img标签不能使用。

  • 尺寸优化:支持使用现代化图片格式,如webpavifapng等,并自动根据设备提供正确的尺寸。
  • 视觉稳定性:防止图片加载时发生布局偏移,具体参考CLS
  • 懒加载:在图片进入视口才会加载,使用浏览器原生懒加载,并可选择添加模糊显示占位符。
  • 灵活性:可按需调整图像大小,即使是存储在远程服务器上的图像也可以调整。

图片引入

1. src本地图片引入

Next.js建议我们把图片放在根目录下的public文件夹中,然后使用/开头访问。

public.png

import Image from "next/image"
export default function Home() {
    return (
        <div>
            <h1>Home</h1>
            <Image
                src="/1.png"
                width={100}
                height={100}
                alt="1"
            />
        </div>
    )
}
2. import静态引入

使用import引入图片,是不需要填写宽度和高度,Next.js会自动确定图片的尺寸。

{
    "compilerOptions": {
        "paths": {
            "@/*": ["./src/*"],
            "@/public/*": ["./public/*"] // 新增这一行代码,配置图片路径。
        }
    }
}

使用静态import引入图片,你会发现无需填写宽度和高度,Next.js会自动确定图片的尺寸。

import Image from "next/image"
import test from '@/public/1.png'
export default function Home() {
    return (
        <div>
            <h1>Home</h1>
            <Image
                src={test}
                alt="1"
            />
        </div>
    )
}
3. 远程图片引入
import Image from "next/image"
export default async function Home() {
    const len = 20;
    return (
        <div>
            <h1>Home</h1>
            {Array.from({ length: len }).map((_, index) => (
                <Image
                    key={index}
                    src={`https://eo-img.521799.xyz/i/pc/img${index + 1}.webp`}
                    alt="1"
                    width={192}
                    height={108}
                />
            ))}
        </div>
    )
}

当我们直接使用远程图片引入的时候Next.js会报错,因为Next.js默认只允许加载本地图片,如果需要加载远程图片,需要配置next.config.js文件。

image-loader.ts:86 Uncaught Error: Invalid src prop (eo-img.521799.xyz/i/pc/img1.w…) on next/image, hostname "eo-img.521799.xyz" is not configured under images in your next.config.js

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  images: {
    remotePatterns: [
      {
        protocol: 'https', // 协议
        hostname: 'eo-img.521799.xyz', // 主机名
        pathname: '/i/pc/**', // 路径
        port: '', // 端口
      },
    ],
  },
};
4. LCP警告

如果图片是首屏或者LCP图片,需要添加loading="eager"属性,否则会触发LCP警告,因为Image组件默认是懒加载的。

  • lazy: 懒加载,默认值,在图片进入视口才会加载。
  • eager: 立即加载,在图片进入视口就会加载。

Image with src "eo-img.521799.xyz/i/pc/img1.w…" was detected as the Largest Contentful Paint (LCP). Please add the loading="eager" property if this image is above the fold. Read more: nextjs.org/docs/app/ap…

import Image from "next/image"
export default async function Home() {
    const len = 20
    return (
        <div>
            <h1>Home</h1>
            {Array.from({ length: len }).map((_, index) => (
                <Image
                    key={index}
                    src={`https://eo-img.521799.xyz/i/pc/img${index + 1}.webp`}
                    alt="1"
                    width={192}
                    height={108}
                    loading="eager" // 立即加载
                />
            ))}
        </div>
    )
}

第二种解决方案使用preload属性加载图片,表示提前预加载图片,不过Next.js还是更加推荐使用loading="eager"属性加载图片。

import Image from "next/image"
export default async function Home() {
    const len = 20
    return (
        <div>
            <h1>Home</h1>
            {Array.from({ length: len }).map((_, index) => (
                <Image
                    key={index}
                    src={`https://eo-img.521799.xyz/i/pc/img${index + 1}.webp`}
                    alt="1"
                    width={192}
                    height={108}
                    preload={index < 10} // 优先加载策略
                />
            ))}
        </div>
    )
}
5. 图片格式优化

Next.js 会通过请求Accept头自动检测浏览器支持的图像格式,以确定最佳输出格式

Accept:image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8

我们可以同时启用 AVIF 和 WebP 格式。对于支持 AVIF 的浏览器,系统将优先使用 AVIF 格式,WebP 格式作为备选方案。目前AVIF格式最优。

const nextConfig: NextConfig = {
  /* config options here */
  images: {
    formats: ['image/avif', 'image/webp'], //默认是 ['image/webp']
  },
};
6. 设备适配

如果你的老板告诉你要兼容哪些设备,你可以使用deviceSizesimageSizes属性来配置。

const nextConfig: NextConfig = {
  /* config options here */
  images: {
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], // 设备尺寸
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], // 图片尺寸
  },
};

这两个属性结合会最终生成一个srcset属性,用于浏览器选择最佳图片。

srcset.png

那为什么需要两个数组去实现呢?

我们观察上图可以发现,imageSizes用于生成小图片尺寸例如(缩略图,头像等),而deviceSizes用于生成大图片尺寸例如(横幅图、背景图、全屏展示图)。

import Image from "next/image"

// 头像 - 固定 64px
export function Avatar() {
  return (
    <Image
      src="/avatar.jpg"
      width={64}
      height={64}
      alt="用户头像"
      sizes="64px"  // ← 告诉浏览器这张图只需要 64px
    />
  )
}

// 横幅图 - 响应式全宽
export function Banner() {
  return (
    <Image
      src="/banner.jpg"
      width={1920}
      height={600}
      alt="横幅"
      sizes="100vw"  // ← 占满整个视口宽度使用 deviceSizes
    />
  )
}

// 响应式内容图
export function ContentImage() {
  return (
    <Image
      src="/content.jpg"
      width={1200}
      height={800}
      alt="内容图"
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 1200px"
      // ↑ 手机上 100% 宽度平板上 50%,桌面最大 1200px
    />
  )
}

Props

以下是 Image 组件可用的属性:

必需属性
属性 类型 示例 说明
src String src="/profile.png" 图片源路径,支持本地路径或远程 URL
alt String alt="Picture of the author" 图片替代文本,用于无障碍访问和 SEO
尺寸相关
属性 类型 示例 说明
width Integer (px) width={500} 图片宽度,静态导入时可选
height Integer (px) height={500} 图片高度,静态导入时可选
fill Boolean fill={true} 填充父容器,替代 width 和 height
sizes String sizes="(max-width: 768px) 100vw" 响应式图片尺寸
优化相关
属性 类型 示例 说明
quality Integer (1-100) quality={80} 图片压缩质量,默认为 75
loader Function loader={imageLoader} 自定义图片加载器函数
unoptimized Boolean unoptimized={true} 禁用图片优化,使用原图
加载相关
属性 类型 示例 说明
loading String loading="lazy" 加载策略,"lazy" 或 "eager"
preload Boolean preload={true} 是否预加载,用于 LCP 元素
placeholder String placeholder="blur" 占位符类型,"blur" 或 "empty"
blurDataURL String blurDataURL="data:image/jpeg..." 模糊占位符的 Data URL
事件回调
属性 类型 示例 说明
onLoad Function onLoad={e => done()} 图片加载完成时的回调
onError Function onError={e => fail()} 图片加载失败时的回调
其他属性
属性 类型 示例 说明
style Object style={{objectFit: "contain"}} 内联样式对象
overrideSrc String overrideSrc="/seo.png" 覆盖 src,用于 SEO 优化
decoding String decoding="async" 解码方式,"async"/"sync"/"auto"

nest.js / hono.js 一起学!日志功能/统一返回格式/错误处理

作者 孟祥_成都
2025年12月22日 10:14

前言

在继续搭建 NestJSHono.js 的通用框架之前,可以先回顾一下前几篇文章:

本篇文章主要实现 NestJS 与 Hono.js 的以下功能:

  1. 打印日志 & 收集日志
  2. 统一返回前端的数据格式
  3. 统一错误处理

这些功能在很多 NestJS 教程中都有覆盖,但我们会在此基础上做一些增强:

  • 日志打印增加 traceId 功能

    • 在 Node.js 高并发场景下,每个请求的 traceId 必须独立,因为它是单线程异步模型,如果我们像写传统同步代码那样直接定义一个全局变量来存 traceId,就会发生严重的“串号”现象。

案例:

假设我们有一个 Web 服务,同时收到两个用户请求:

let traceId; // 全局变量

function handleRequest(user) {
  traceId = `trace-${Math.floor(Math.random() * 1000)}`; // 给当前请求生成 traceId
  console.log(`[${traceId}] 开始处理 ${user} 的请求`);

  setTimeout(() => {
    // 异步操作,可能晚于其他请求执行
    console.log(`[${traceId}] 完成处理 ${user} 的请求`);
  }, Math.random() * 1000);
}

// 模拟两个用户几乎同时发起请求
handleRequest('Alice');
handleRequest('Bob');

可能的输出(串号)

[trace-532] 开始处理 Alice 的请求
[trace-764] 开始处理 Bob 的请求
[trace-764] 完成处理 Alice 的请求   ← 错误,日志 traceId 被覆盖,因为全局目前的 traceId 已经是 trace-764 了
[trace-764] 完成处理 Bob 的请求

以上的问题有些笨办法可以处理,但我们最终会借助 node.js AsyncLocalStorage(在 nest.js 有对应模块,简单实现,在 hono.js 中我们自己封装一个类似功能的模块。)

还有一个必须了解的,这个 traceId 是服务端必须有的,因为我们的处理一个用户请求会经过很多中间件很多不同的模块处理,如何判断经过这么多模块的某个请求是同一个请求,就要用这个 traceId 来记录。

  • 统一返回前端的数据格式,例如,我们希望所有接口返回:

    ```
    {
      "code": 0,
      "data": {...},
      "message": "success"
    }
    ```
    
    • 可选配置:某些路由可以跳过统一格式返回。

接下来,我们将先从 nest.js 框架开始,逐步实现这些功能。

nest.js 配置

链路日志追踪功能

这套系统的灵魂在于 nestjs-clsWinston 的结合。

为什么要这么做?

在 Node.js 异步环境中,传统的全局变量无法区分哪个日志属于哪个用户请求。

  • ClsModule (上下文存储) :就像给每个进站的乘客(请求)发一个专属的“透明信封”。这个信封里装着 traceId
  • Winston (记录员) :当程序需要记录日志时,记录员会从这个“信封”里掏出 traceId 盖在日志条目上。

这样,即便服务器同时处理 1000 个请求,你只需要在日志里搜索特定的 traceId,就能看到该请求从“进入”到“报错”的所有心路历程。


代码功能深度拆解

我们将代码逻辑分为三个核心模块来理解:

第一部分:CLS 上下文初始化 (AppModule)

这是全链路追踪的起点。在根 module 配置,也就是 app.module.ts中增加:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClsModule } from 'nestjs-cls';
import { randomUUID } from 'crypto';

@Module({
  imports: [
    // 之前文章讲的环境变量配置模块
    ConfigModule.forRoot({
      isGlobal: true, // 全局可用,无需在每个模块导入
      load: [initConfig], // 使用自定义加载器
    }),
    // 1. 初始化 CLS 上下文,并自动生成 traceId
    ClsModule.forRoot({
      global: true,
      middleware: {
        mount: true,
        generateId: true,
        idGenerator: (req: any) =>
          (req.headers['x-request-id'] as string) ?? randomUUID(), // 优先使用 header 中的 ID
      },
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

通过优先读取 x-request-id,我们可以实现跨系统的全链路追踪(如果你的前端或上游网关也带了这个 ID,就能实现真正的端到端打通)。

这里我们简单说下 ClsModule 实现的基本原理:

我们分三步来实现:

  • 第一步:封装 ALS 工具类

这是我们的“储物柜”管理器。

// cls.service.ts
import { AsyncLocalStorage } from 'async_hooks';

export class MyClsService {
  // 1. 创建一个物理存储对象
  private static storage = new AsyncLocalStorage<Map<string, any>>();

  // 2. 启动上下文(包裹请求)
  static run(callback: () => void) {
    const store = new Map();
    return this.storage.run(store, callback);
  }

  // 3. 存储数据
  static set(key: string, value: any) {
    const store = this.storage.getStore();
    if (store) store.set(key, value);
  }

  // 4. 获取数据
  static get(key: string) {
    const store = this.storage.getStore();
    return store?.get(key);
  }
}
  • 编写中间件 (Middleware)

在 NestJS 中,中间件是请求进来的第一站,我们在这里“开启”储物柜。

// cls.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { MyClsService } from './cls.service';

@Injectable()
export class MyClsMiddleware implements NestMiddleware {
  use(req: any, res: any, next: () => void) {
    // 关键点:将后续所有的逻辑(next)都运行在 MyClsService.run 的回调里
    MyClsService.run(() => {
      const traceId = req.headers['x-request-id'] || randomUUID();
      MyClsService.set('traceId', traceId); // 存入 traceId
      next();
    });
  }
}

这个中间件添加之后,意味着所有的请求都会有一个 traceId 值,那在其它地方如何调用呢?请看

  • 第三步:在任何地方使用

由于 AsyncLocalStorage 跟踪的是异步调用栈,你不再需要通过参数传递 traceId

// any.service.ts
@Injectable()
export class AnyService {
  doSomething() {
    // 像变魔术一样,直接拿!
    const traceId = MyClsService.get('traceId');
    console.log(`[当前任务] TraceID 是: ${traceId}`);
  }
}

我们接着讲日志功能,

第二部分:Winston 动态工厂 (common/logger)

这里体现了环境差异化处理的思想。

特性 开发环境 (Development) 生产环境 (Production)
输出目标 只有控制台 (Console) 控制台 + 每日滚动文件 (Daily File)
展示样式 漂亮的彩色、缩进、Nest 风格 严谨的 JSON 格式(方便日志分析系统 ELK 抓取)
日志保留 随启随看 保留 14 天,单文件 20MB(防止硬盘爆掉)

第三部分:异步注入 (WinstonModule.forRootAsync)

这是 NestJS 的高级用法,确保日志配置是在获取到系统配置(ConfigService)之后才生成的。

在 app.module.ts 中增加 winston配置

import { WinstonModule } from 'nest-winston';
import { winstonConfigFactory } from './common/logger';

@Module({
  imports: [
     // ... 其它配置
    // 2. 注入 Winston
    // 3. 使用 forRootAsync 异步加载配置
    WinstonModule.forRootAsync({
      imports: [ConfigModule], // 导入 ConfigModule 以便使用 ConfigService
      inject: [ConfigService], // 注入 ConfigService
      useFactory: (configService: ConfigService) => {
        // 调用我们抽离出去的配置函数
        return winstonConfigFactory(configService);
      },
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

其中 winstonConfigFactory 的代码如下:

import winston, { transports, format } from 'winston';
import 'winston-daily-rotate-file';
import { ConfigService } from '@nestjs/config';
import { utilities as nestWinstonModuleUtilities } from 'nest-winston';
import { ClsServiceManager } from 'nestjs-cls';
import { DEFAULT_LOG_DIR, DIR_NAME } from 'src/config/constants';

// 保持之前的 format 定义不变
const appendRequestId = format((info) => {
  const cls = ClsServiceManager.getClsService();
  const traceId = cls?.get<string>('traceId');
  if (traceId) {
    info.traceId = traceId;
  }
  return info;
});

// 核心修改:导出一个 Factory 函数,而不是静态对象
export const winstonConfigFactory = (configService: ConfigService) => {
  // 1. 从 ConfigService 获取配置,提供默认值
  const logDir = configService.get<string>(DIR_NAME, DEFAULT_LOG_DIR);
  const isProduction = configService.get<string>('NODE_ENV') === 'production';

  return {
    transports: isProduction
      ? [
          // 2. 定义 DailyRotateFile (使用动态路径)
          // 错误日志按天滚动,保留 14 天,每个文件最大 20MB
          // 仍然把错误打印到控制台(k8s / docker 日志)
          new winston.transports.Console({
            level: 'error',
            format: winston.format.combine(
              winston.format.colorize(),
              winston.format.printf((info: any) => {
                const trace = info.traceId ? ` [trace:${info.traceId}]` : '';
                return `${info.timestamp} ${info.level}${trace}: ${info.message}`;
              }),
            ),
          }),
          new transports.DailyRotateFile({
            dirname: logDir, // <--- 这里使用了配置中的路径
            filename: 'error-%DATE%.log',
            datePattern: 'YYYY-MM-DD',
            maxSize: '20m',
            maxFiles: '14d',
            level: 'error',
            format: format.combine(
              appendRequestId(),
              format.timestamp(),
              format.json(),
            ),
          }),
        ]
      : [
          // 3. 定义 Console Transport
          // 开发环境通常只需要 Console,也可以按需加 File
          new transports.Console({
            level: 'info',
            format: format.combine(
              format.timestamp(),
              nestWinstonModuleUtilities.format.nestLike('MyApp', {
                colors: true,
                prettyPrint: true,
              }),
            ),
          }),
        ],
  };
};

注意,上面的例子,我们后续例如单机使用 docker-compose 部署也好,还是使用k8s/k3s部署也好,就不需要使用winston本身的 DailyRotateFile 功能了,可以借助 docker 或者 k8s/k3s 本身的日志轮转功能实现

为了让这套系统更完美,在实际应用中,你可以这样使用:

如何在 Service 中打印带 TraceId 的日志?

这里使用 this.logger.log 的时候,你不需要手动去取 ID,因为我们在 winstonConfigFactory 里的 appendRequestId 已经自动处理了。所以会自带 traceId 属性在日志里。

import { Logger, Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  private readonly logger = new Logger(AppService.name);

  doSomething() {
    this.logger.log('这是一条带有 traceId 的业务日志!'); 
    // 输出结果会自动带上: {"traceId":"...", "message":"...", "timestamp":"..."}
  }
}

接下来实现第二个功能,将返回前端的格式统一

统一返回格式

统一格式,在 nest.js 中有拦截器实现(hono.js就没有拦截器的概念),很简单:

nest.js 统一返回格式:用拦截器实现标准响应结构

在前后端分离的业务架构中,统一接口返回格式已成为标配:
无论后端返回什么,都应该在一层标准的格式中输出,例如:

{
  "code": 0,
  "message": "OK",
  "data": {...},
  "traceId": "xxx"
}

这样做有几个直接收益:

  1. 前端更容易做异常和展示逻辑,不需要每个接口单独处理。
  2. 服务端可以统一标记 traceId,方便链路排查问题。
  3. 协议一致后,可快速扩展错误码体系。

在 nest.js 中,我们可以使用 Interceptors(拦截器) 来完成这一目标。下面是一段完整的拦截器代码。

拦截器核心代码示例

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { map } from 'rxjs/operators';
import { ClsService } from 'nestjs-cls';
import { ErrorCodes } from '../constants';

export interface StandardResponse<T> {
  code: number;
  message: string;
  data: T;
  traceId?: string;
}

@Injectable()
export class StandardResponseInterceptor<T> implements NestInterceptor<
  T,
  StandardResponse<T>
> {
  constructor(private readonly cls: ClsService) {}

  intercept(context: ExecutionContext, next: CallHandler) {
    if (context.getType() !== 'http') {
      return next.handle();
    }

    const handler = context.getHandler();
    if (Reflect.getMetadata('skip_transform', handler)) {
      return next.handle();
    }

    const traceId = this.cls.getId();

    return next.handle().pipe(
      map((data) => ({
        code: ErrorCodes.SUCCESS,
        message: 'OK',
        data,
        traceId,
      })),
    );
  }
}

说明:

  • ClsService 用于获取当前请求的 traceId
  • map() 包裹所有返回内容
  • 拦截器只在 HTTP 请求上执行(GraphQL、Microservice 可以忽略)
  • 可通过自定义 Decorator 跳过包装

如何注册拦截器(全局启用)

main.ts 中:

app.useGlobalInterceptors(new StandardResponseInterceptor(app.get(ClsService)));

这样所有路由默认都统一格式输出。

某些接口不想被统一格式怎么办?

例如导出文件等特殊接口,不希望包裹。

你可以加自定义装饰器:

import 'reflect-metadata';

export const SkipTransform = () => SetMetadata('skip_transform', true);

使用方式:

@Get('upload')
@SkipTransform()
getCaptcha() {
  return someRawBinary;
}

当前路由会跳过统一包裹。

实际效果展示

原本控制器返回:

@Get('user')
getUser() {
  return { id: 1, name: 'Tom' };
}

最终前端收到:

{
  "code": 0,
  "message": "OK",
  "data": {
    "id": 1,
    "name": "Tom"
  },
  "traceId": "abc-123-xyz"
}

通用错误处理

在后端系统中,错误处理往往经历三个阶段:

  1. 能跑(框架兜底)
  2. 能返回(让前端知道错哪里)
  3. 能排障(日志链路、定位效率)

nestjs 的 ExceptionFilter 为第三阶段提供了完整治理能力。本节通过自定义 AllExceptionsFilter,从“拦截错误”开始,一层层增强错误输出、业务语义、日志能力和 traceId 支持。

第一层:拦截所有异常,而不是依赖框架默认行为

默认情况下,框架会尝试处理异常,但格式、内容和处理方式不可控。
通过 @Catch() 拦截全场景错误,我们将接管所有异常通道:

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {

借助 ArgumentsHost,我们拿到 HTTP 请求上下文,后续就能构造专属响应:

const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();

此时的目标是“掌控入口”。

第二层:区分 HTTP 状态码,让协议正确表达成功或失败

框架层能力拦截之后,下一层诉求是协议语义正确。
对客户端返回的 HTTP Status 必须准确,否则代理、浏览器、监控系统均无法判断请求情况:

const httpStatus =
  exception instanceof HttpException
    ? exception.getStatus()
    : HttpStatus.INTERNAL_SERVER_ERROR;

简单讲:

  • 可预期的业务错误维持 4xx
  • 其它错误不想让前端知道具体错误,直接返回 INTERNAL_SERVER_ERROR

这实现了“系统对外的协议治理”。

第三层:建立业务错误码体系,与协议语义解耦

HTTP Status 永远不适合作为业务判断依据。
因此体系化项目会分离“协议错误码”和“业务错误码”。这一步实现业务语义治理:

let businessCode: number;
if (httpStatus >= 400 && httpStatus < 500) {
  businessCode = ErrorCodes.BUSINESS_ERROR;
} else {
  businessCode = ErrorCodes.SYSTEM_ERROR;
}

从此,前端不再需要看 400/500 决策,而是看业务码 1000/5000 做逻辑分支(如果需要有业务错误码的话,一般可以不加业务状态码)。

第四层:控制返回给用户的 message,不泄漏内部实现

继续向下走,我们需要控制“用户可见信息”。

对于 HttpException,返回异常信息安全可控;
而普通异常有堆栈、内部错误,不可直给客户端:

let userMessage: string;
if (exception instanceof HttpException) {
  const exceptionResponse = exception.getResponse() as any;
  userMessage = exceptionResponse.message || exception.message;
} else {
  userMessage = 'Server Internal Error';
}

第五层:注入 traceId,将错误纳入链路追踪体系

协议治理与安全治理之后,我们需要可观测性。
依赖 ClsService 获取 traceId,使任意异常都能与一次请求绑定:

const traceId = this.cls.getId();

这让后续日志检索有了线索,从“出错”变为“定位在哪个请求出错”。


第六层:构造统一错误返回体,让前端接收结构稳定

业务响应格式统一之后,客户端解析与 UI 逻辑才能标准化:

const finalClientResponse = {
  code: businessCode,
  message: userMessage,
  traceId,
  error: {
    timestamp: new Date().toISOString(),
    path: request.url,
    method: request.method,
    httpStatus,
  },
};

这一层,从“不固定字段”变为“返回契约统一”。


第七层:按严重程度结构化日志,结合 traceId 可查可追

有了 traceId 后,日志才具备上下文意义。
对非预期错误记录堆栈并升为 error,对客户端调用错误降级为 warn

if (httpStatus >= 500) {
  this.logger.error(`[${httpStatus}] Unhandled Exception: ${userMessage}`, {
    clientResponse: finalClientResponse,
    stack: (exception as Error).stack,
    traceId,
  });
} else {
  this.logger.warn(`[${httpStatus}] Client Error: ${userMessage}`, {
    clientResponse: finalClientResponse,
    traceId,
  });
}

此处是“日志治理 + 可观测性治理”。


第八层:保持 HTTP 状态原样返回,确保协议链路正常识别

最终输出必须保留协议语义,否则监控、负载均衡、浏览器都将错误判断请求:

response.status(httpStatus).json(finalClientResponse);

接下来就可以在 main.ts 中使用这个全局错误管理器了。

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ClsService } from 'nestjs-cls';
import { AllExceptionsFilter } from './common/filters/http-exception.filter';
import { type NestExpressApplication } from '@nestjs/platform-express';
import { type LoggerService } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule, {
    bufferLogs: true, // 先缓存日志,直到 Logger 替换完成
  });

  const configService = app.get(ConfigService);

  // 1. 替换 Nest 默认 Logger 为 Winston
  const logger: LoggerService = app.get(WINSTON_MODULE_NEST_PROVIDER);

  app.useLogger(logger);

  // 获取 CLS 服务实例
  const clsService = app.get(ClsService);

  // 4. 注册全局异常过滤器:捕获所有异常并打印日志 + 返回统一结构
  app.useGlobalFilters(new AllExceptionsFilter(clsService, logger));

  const port = configService.get<number>('port') ?? 3000;

  await app.listen(port, () => {
    logger.log(`Server on :${port}`);
  });
}
bootstrap();

honojs 配置

第一部分:链路日志追踪功能

首先我们封装记录 traceId 的功能,我们通过编写一个中间件来实现,这个中间件是所有请求过来,经过的第一步,这样所有请求就带了 traceId,具体实现如下:

首先我们要创建一个 AsyncLocalStorage 实例,让后续的中间件调用其 run 方法,为每个请求创建独立的上下文。

import { AsyncLocalStorage } from "node:async_hooks";

export interface RequestContext {
  traceId: string;
}

// 初始化全局存储
export const ctx = new AsyncLocalStorage<RequestContext>();

// 封装一个获取 traceId 的快捷方法
export const getTraceId = (): string | undefined => {
  const store = ctx.getStore();
  return store?.traceId;
};

以下是 middleware 的实现

import { createMiddleware } from "hono/factory";
import { ctx } from "../context"; // 导入您的 ctx

// 确保在 Node.js 环境下使用
import { randomUUID } from "node:crypto";

export const traceMiddleware = createMiddleware(async (c, next) => {
  // 1. 生成唯一的 Trace ID
    const traceId = c.req.header("x-request-id") || randomUUID();

  // 2. 将 Trace ID 存储在 AsyncLocalStorage 中,并运行后续的处理链
  await ctx.run({ traceId }, async () => {
    // 3. (可选) 记录请求开始日志
    // logger.info(`Request started: ${c.req.method} ${c.req.url}`, {
    //   traceId: getTraceId(),
    // });

    // 4. 继续处理链
    await next();

    // 5. (可选) 设置响应头
    c.res.headers.set("X-Trace-ID", traceId);

    // // 6. (可选) 记录请求结束日志
    // logger.info(`Request finished: ${c.req.method} ${c.req.url}`, {
    //   traceId: getTraceId(),
    //   status: c.res.status,
    // });
  });
});

通过优先读取 x-request-id,我们可以实现跨系统的全链路追踪(如果你的前端或上游网关也带了这个 ID,就能实现真正的端到端打通)。

主要代码就是 ctx.run({ traceId }, async () => { xxx }), 将后所有内容,包含在 AsyncLocalStorage 上下文中,也就是 traceId 会伴随整个请求的生命周期。

第二部分:Winston 动态工厂 (common/logger)

这里体现了环境差异化处理的思想。

特性 开发环境 (Development) 生产环境 (Production)
输出目标 只有控制台 (Console) 控制台 + 每日滚动文件 (Daily File)
展示样式 漂亮的彩色、缩进、Nest 风格 严谨的 JSON 格式(方便日志分析系统 ELK 抓取)
日志保留 随启随看 保留 14 天,单文件 20MB(防止硬盘爆掉)

以上的功能,我们封装到一个 logger.ts 中,代码如下:

import { format, transports, createLogger } from "winston";
import "winston-daily-rotate-file";
import { getTraceId } from "../context";
import { PROD_ENV } from "../../../constant";

// 1. 自定义 Format:自动从 ALS 获取 Trace ID
const appendTraceId = format((info) => {
  // 使用 ctx.getStore() 获得
  const traceId = getTraceId();
  if (!traceId) {
    info.traceId = traceId;
  }
  return info;
});

/// 2. 基础配置
const logFormat = format.combine(
  appendTraceId(),
  format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
  format.errors({ stack: true }), // 自动捕获 error stack
  format.json()
);

const devTransport = new transports.Console({
  level: "info",
  format: format.combine(
    format.colorize(),
    format.printf(({ timestamp, level, message, traceId, stack }) => {
      const tid = traceId ? `[Trace: ${traceId}]` : "";
      const output = `${timestamp} ${level} ${tid}: ${message}`;
      return stack ? `${output}\n${stack}` : output;
    })
  ),
});

// 导出 Logger 实例
export const logger = createLogger({
  format: logFormat,
  transports:
    process.env.NODE_ENV === PROD_ENV
      ? // 生产环境 Transports
        [
          new transports.DailyRotateFile({
            filename: "logs/error-%DATE%.log",
            datePattern: "YYYY-MM-DD",
            level: "error",
            maxSize: "20m",
            maxFiles: "14d",
          }),
          new transports.Console({
            level: "error",
            format: format.combine(
              format.colorize(),
              format.printf(({ timestamp, level, message, traceId, stack }) => {
                const tid = traceId ? `[Trace: ${traceId}]` : "";
                const output = `${timestamp} ${level} ${tid}: ${message}`;
                return stack ? `${output}\n${stack}` : output;
              })
            ),
          }),
        ]
      : // 开发环境 Transport
        [devTransport],
});

注意,上面的例子,我们后续例如单机使用 docker-compose 部署也好,还是使用k8s/k3s部署也好,就不需要使用winston本身的 DailyRotateFile 功能了,可以借助 docker 或者 k8s/k3s 本身的日志轮转功能实现

如何在 Service 中打印带 TraceId 的日志?

如下,我们就可以在路由中使用 logger 功能,就会自带 traceId 了

import { Hono } from "hono";
import { logger } from "./common/logger";
import { standardResponse } from "./utils";
import { getTraceId } from "./context";

const app = new Hono();

app.get("/users", async (c) => {
  logger.info("开始处理 /users 请求");

  const list = [{ id: 1, name: "Tom" }];

  logger.info(`查询用户数量=${list.length}`);

  return standardResponse(c, { list });
});

访问结果输出(示例):

2025-01-05 22:11:01 info [Trace: 099f42...] 开始处理 /users 请求
2025-01-05 22:11:01 info [Trace: 099f42...] 查询用户数量=1

在 hono.js 中封装统一返回格式

与 nest.js 不同,hono.js 没有 Interceptor 的概念,因此无法在框架层自动包装所有路由的返回值。hono 更偏向极简设计:框架提供 Context(c)与响应方法(如 c.json()),开发者通过 middleware 或工具函数扩展能力。

在这种模式下,一个常见策略是:在 utils 层封装一个标准响应函数,每当路由处理完成业务逻辑后,调用该函数输出标准格式。示例如下:

// utils/response.ts
import { type Context } from "hono";
import { getTraceId } from "../common/context";
import { CODES } from "../common/constants/codes";
import type { ContentfulStatusCode } from "hono/utils/http-status";

const SUCCESS_MESSAGE = "OK";

export const standardResponse = <T>(
  c: Context,
  data: T,
  httpStatus: ContentfulStatusCode = 200
): Response => {
  const traceId = getTraceId();

  const finalResponse = {
    code: CODES.SUCCESS,
    message: SUCCESS_MESSAGE,
    data,
    traceId,
  };
  
   // 使用 c.json() 返回 Response
  // c.json() 负责设置 Content-Type: application/json
  return c.json(finalResponse, httpStatus);
};

这一函数实现了几个能力:

  1. 统一业务结构:code / message / data 统一格式化。
  2. 链路可观测性:自动从 ALS 中获取 traceId,可跨路由跟踪调用链路。
  3. 控制协议行为:可指定 HTTP status,而不影响业务 code。
  4. 无框架侵入性:不需要全局 patch,路由自由决定是否使用。

实际使用方式非常直接:在路由处理函数中调用该函数包裹输出即可:

import { standardResponse } from "./utils/response";

app.get("/user", (c) => {
  const user = { id: 1, name: "Tom" };
  return standardResponse(c, user);
});

输出结构:

{
  "code": 0,
  "message": "OK",
  "data": {
    "id": 1,
    "name": "Tom"
  },
  "traceId": "abc-123-xyz"
}

错误处理

hono.js 并没有 nest.js 中那样的 exception(异常)处理器,但 hono 官方提供了两个方法来处理全局异常。

  • Error Handling 回调函数
  • Not Found 回调函数

其中 app.onError 允许我们处理未捕获的错误并返回自定义响应。例如

const app = new Hono();
app.onError((err, c) => {
  console.error(`${err}`);
  return c.text("Custom Error Message", 500);
});

接下来我们实现一个生产级别的全局错误处理函数,代替上面的 onError 中的函数:

阶段一:只实现兜底,确保不会爆栈到框架

目标:任何异常都返回 500,避免框架直接输出默认错误页面。

export const customErrorHandler = (exception: unknown, c: Context) => {
  return c.json(
    {
      message: "Internal Server Error",
    },
    500,
  );
};

此阶段的函数虽然简陋,但实现了基本“拦截能力”。技术人员可以理解到:不允许异常直接泄漏给框架。

阶段二:支持错误分类(业务可控错误 vs 系统错误)

需求出现:业务层需要显式抛出 4xx,不应该全部变成 500。因此我们引入 Hono 的 HTTPException。

import { HTTPException } from "hono/http-exception";

export const customErrorHandler = (exception: unknown, c: Context) => {
  let httpStatus = 500;
  let message = "Internal Server Error";

  if (exception instanceof HTTPException) {
    httpStatus = exception.status;
    message = exception.message;
  }

  return c.json({ message }, httpStatus);
};

团队到此能看到两个差异:

  • 不再一刀切地用 500
  • 支持框架级错误语义

阶段三:支持普通 Error,并提取堆栈

当后端代码抛出 Error 对象时,我们不希望把内部堆栈暴露给客户端,但日志需要保留:

if (exception instanceof Error) {
  httpStatus = 500;
  message = exception.message;
  stack = exception.stack;
}

与第二阶段结合后,代码局部已经具备三类来源:

if (exception instanceof HTTPException) {
  // 合法业务错误
} else if (exception instanceof Error) {
  // 非预期系统错误
} else {
  // 字符串或其他类型,兜底转字符串
}

这一阶段解决“所有类型异常都能进入统一通路”。

阶段四:对外 Message 安全脱敏

生产会提出安全要求:500 段错误不能暴露内部 message。

const userMessage =
  httpStatus >= 500 ? "Server Internal Error" : exceptionMessage;

阶段五:加入 Trace ID,实现可观测能力

在前几阶段之后,团队开始发现:仅凭日志很难定位请求调用链。因此引入 Trace ID:

import { getTraceId } from "../context";

const traceId = getTraceId();

并将其放入响应,用于 API 与日志上下游联动。

阶段六:构建统一响应协议

现在错误信息、trace 信息都有了,我们需要形成一致返回结构,便于前端或 API 网关解析:

const finalClientResponse = {
  code: 500,
  message: userMessage,
  traceId: traceId,
  error: {
    timestamp: new Date().toISOString(),
    path: c.req.path,
    method: c.req.method,
    httpStatus: httpStatus,
  },
};

这样可以回复格式统一的响应。

阶段七:结构化日志分级输出

生产要求日志可检索、可告警,因此需要按状态区分 error 与 warn,并携带堆栈:

if (httpStatus >= 500) {
  logger.error(`[${httpStatus}] Unhandled Exception: ${exceptionMessage}`, {
    clientResponse: finalClientResponse,
    stack: exceptionStack,
    traceId: traceId,
    path: c.req.path,
  });
} else {
  logger.warn(`[${httpStatus}] Client Error: ${exceptionMessage}`, {
    clientResponse: finalClientResponse,
    traceId: traceId,
    path: c.req.path,
  });
}

团队能看到:

  • 500+ 是告警级别
  • 4xx 只是用户请求问题,不污染告警系统

阶段八:最终闭环,按状态返回响应

return c.json(finalClientResponse, httpStatus);

请求闭环如下:

  1. 识别异常
  2. 分类
  3. 安全脱敏
  4. 绑定 Trace
  5. 构建协议
  6. 结构化日志
  7. 返回状态码与响应体

最终成果(合并段落)即为我们起初提供的完整生产代码:

import { type Context } from "hono";
import { getTraceId } from "../context";
import { logger } from "../logger";
import { HTTPException } from "hono/http-exception";
import { type ContentfulStatusCode } from "hono/utils/http-status";

export const customErrorHandler = (exception: unknown, c: Context) => {
  let httpStatus: ContentfulStatusCode;
  let exceptionMessage: string = "Internal Server Error";
  let exceptionStack: string | undefined;

  if (exception instanceof HTTPException) {
    httpStatus = exception.status;
    exceptionMessage = exception.message;
    exceptionStack = exception.stack;
  } else if (exception instanceof Error) {
    httpStatus = 500;
    exceptionMessage = exception.message;
    exceptionStack = exception.stack;
  } else {
    httpStatus = 500;
    exceptionMessage = String(exception);
  }

  const userMessage =
    httpStatus >= 500 ? "Server Internal Error" : exceptionMessage;

  const traceId = getTraceId();

  const finalClientResponse = {
    code: 500,
    message: userMessage,
    traceId: traceId,
    error: {
      timestamp: new Date().toISOString(),
      path: c.req.path,
      method: c.req.method,
      httpStatus: httpStatus,
    },
  };

  if (httpStatus >= 500) {
    logger.error(`[${httpStatus}] Unhandled Exception: ${exceptionMessage}`, {
      clientResponse: finalClientResponse,
      stack: exceptionStack,
      traceId: traceId,
      path: c.req.path,
    });
  } else {
    logger.warn(`[${httpStatus}] Client Error: ${exceptionMessage}`, {
      clientResponse: finalClientResponse,
      traceId: traceId,
      path: c.req.path,
    });
  }

  return c.json(finalClientResponse, httpStatus);
};

接下来我们来处理全局捕获的 404 回调,也就是自定义 Not Found Response, 个人感觉生产环境就简单返回 404 状态码就足够了,所以函数定义如下:

export const notFoundHandler = (c: Context) => {
  return c.json({ message: "Resource not found" }, 404);
};

欢迎一起交流

欢迎大家一起进群讨论前端全栈技术和 ai agent ,一起进步!

PinK(Cocos4.0?)生成飞机大战,抢先体验全流程!

2025年12月22日 08:54

来源于Cocos官方论坛

引言

哈喽大家好,原小道消息PinK将于18号公测,但是白天一整天迟迟未见。

终于在晚上19点左右PinK 0.0.1-alpha.1版发布了!不过alpha测试仅对部分开发者开放:

来源于Cocos官方论坛

论坛瞬间炸开了锅,一码难求。

笔者为了宠粉,鼓起勇气直接私信了产品经理,幸运地拿到了想要的码,连夜进行了测评。

看看简单的介绍:

来源于Cocos官方论坛

言归正传,小伙伴们跟随笔者,一起来体验下PinK(Cocos4.0)以及用它生成飞机大战实战全过程!

1. 安装和激活

下载完成后,直接解压运行PinK.exe,据说是基于VSCode开发,意味着坐拥VSCode所有插件!

首次打开需要填入我们拿到的码进行激活,粉红色的椰子头格外耀眼,符合主题PinK

2. 界面

完整界面如下,非常熟悉了,大家看看都有啥!

左上角一排图标分别是Scene(场景)、Hierarchy(层级管理器)、Inspector(属性检查器)、Asset(资源管理器)等等Creator常用的面板,不过目前测试点击无效果。

正上方的就是和Creator一样的运行工具。

右上角也是一排工具按钮,不怎么见过,不介绍了。

其余剩下的部分就是与VSCode一致的,左侧资源管理器,右侧是我们的AI聊天面板。

3.安装SDK

使用之前要在PinK里面安装cocos-sdk

总大小接近5G

里面包括我们熟悉的引擎源码:

4.PinK飞机大战实战

前面有关注笔者的小伙伴,笔者通过DeepSeek和生图软件去简单粗暴地开发一款飞机大战游戏,收获了非常多的点赞。

里面明确指出AI并不能通过Chat全自动利用Creator完成开发,还是要扎实掌握许多基本功,即使通过AI操作Scene文件和Pfefab文件,目前都还是比较难。

下面我们用PinK实战对比,看看有什么不同。

1.创建工程

首先通过Create Project按钮创建我们的工程PinkTest

创建完成后会自动生成我们熟悉的工程目录结构。

2.选择模型

目前测试阶段,PinK自掏腰包内置Gemini3,给予好评。

3.实战开始

二话不说,笔者先来个下马威:

PinK反手就丢给我3个md文件,分别是:

  • GD.md : 游戏设计文档。
  • ART.md : 美术需求文档。
  • TECH.md : 技术需求文档。

整体流程就靠上面3个文档驱动,生成完成后等待老板下发指令开工。

由于笔者没有配置API Key,没办法直接生成所需的美术资源,一直卡在生成图片阶段。

只能重新让他先做好游戏,后续我再根据美术需求补充资源。

开启Auto Approve之后,笔者就去洗漱了。

回来时,他告诉我已经做好了。

我们上次生成的资源改成指定的名字放在指定目录。

PinK神启动!

果不其然,事情并没有那么简单,运行之后一片空白。乍眼一看脚本还有报错。

笔者好奇地打开Creator看看生成的场景和节点长什么样,节点也没有生成(到后面发现其实是笔者搞错了场景)。

于是我让他修复一下报错和挂载GameManager

打开Main场景可以看到,节点已生成,代码也挂载好了(妥妥解决了痛点)。

最后让他运行游戏。

在控制台可以清晰看到Cocos Creator v4.0.0字样,这是不是就说明PinK就是Cocos4.0?

这次他自动打开了内置的简单浏览器,但是游戏画面还是没有出来。

查看报错可以知道图片的导入格式不对。

报错信息复制过去,并且告诉他格式不对。

然后他就自动调整格式了。

最后运行,画面终于出来了,泪流满面,就是图片有点太大了,不太协调。

让他调小一点。

4.效果演示

5.体验感受

  • 全程零代码、零编辑场景,全自动生成游戏(有引擎的),虽然游戏简单,后面会尝试生成更复杂的游戏,欢迎关注。
  • PinK并不只是代码编辑器,全程可以不需要写代码和打开Creator(笔者好奇自己打开),根据界面布局推断,Creator的功能终将全部移植到PinK
  • 由于只是alpha测试,看群里反馈的问题,BUG也不少,笔者觉得可以理解,需要点时间让它飞一会。
  • 此外PinK上手有点难度,一开始看会比较懵,不知道从哪里开始动手。
  • 笔者尝试用PinK打开旧的项目(Creator3.8.7),也存在一些问题,都反馈到群里了。

结语

总的来说,PinK让笔者眼前一亮,持看好态度,个人看法,轻喷!

小伙伴们看完后觉得如何?还想笔者测评哪些内容?欢迎来评论区发表看法!


我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐文章:

Cocos游戏如何接入安卓穿山甲广告变现?

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

Cocos游戏如何快速接入抖音小游戏广告变现?

如何在CocosCreator3.8中实现割绳子游戏效果

如何在CocosCreator3.8中实现动态切割模型?

我如何用 AI 处理历史遗留代码:MiniMax M2.1 升级体验

作者 阮一峰
2025年12月23日 19:28

一、

最近,我写了好几篇 AI 教程,就收到留言,要我谈谈我自己的 AI 编程。

今天就来分享我的 AI 编程,也就是大家说的"氛围编程"(vibe coding)。

声明一下,我只是 AI 初级用户,不是高手。除了不想藏私,更多是为了抛砖引玉,跟大家交流。

二、

平时,我很少用 AI 生成新项目。因为每次看 AI 产出的代码,我总觉得那是别人的代码,不是我的。

如果整个项目都用 AI 生成,潜意识里,我感觉不到那是自己的项目。我的习惯是,更愿意自己写新项目的主体代码。

我主要把 AI 用在别人的项目和历史遗留代码,这可以避免读懂他人代码的巨大时间成本。

就拿历史遗留代码为例,(1)很多时候没有足够的文档,也没有作者的说明,(2)技术栈和工具库都过时了,读懂代码还要翻找以前的标准,(3)最极端的情况下,只有构建产物,没有源代码,根本无法着手。

AI 简直就是这类代码的救星,再古老的代码,它都能读懂和修改,甚至还能对构建产物进行逆向工程。

下面就是我怎么用 AI 处理历史遗留代码,平时我基本就是这样来 AI 编程。

三、

我的 AI 编程工具是 Claude Code。因为命令行对我更方便,也容易跟其他工具集成。

我使用的 AI 模型,大部分时间是国产的 MiniMax M2。我测过它的功能,相当不错,能够满足需要,它的排名也很靠前。

另外,它有包月价(29元人民币),属于最便宜的编程模型之一,可以放心大量使用,反复试错。要是改用大家都趋之若鹜的 Claude 系列模型,20美元的 Pro 套餐不够用,200美元的 Max 套餐又太贵。

MiniMax 接入 Claude Code 的方法,参考我的这篇教程

四、

就在我写这篇文章的时候,MiniMax 本周进行了一次大升级,M2 模型升级到了 M2.1

因为跟自己相关,我特别关注这次升级。

根据官方的发布声明,这次升级特别加强了"多语言编程能力",对于常用编程语言(Rust、Java、Golang、C++、Kotlin、Objective-C、TypeScript、JavaScript 等)有专门强化。

它的 WebDev 与 AppDev 开发能力因此有大幅提升,可以用来开发复杂的 Web 应用和 Android/iOS 的原生 App。

"在软件工程相关场景的核心榜单上,MiniMax M2.1 相比于 M2 有了显著的提升,尤其是在多语言场景上,超过 Claude Sonnet 4.5 和 Gemini 3 Pro,并接近 Claude Opus 4.5。"

根据上面这段介绍,它的编程能力,超出或接近了国外旗舰模型。

这个模型已经上线了,现在就能用。那么,这篇文章正好测一下,官方的介绍是否准确,它的 Web 开发能力到底有没有变强。

至于价格,跟原来一样。但是,官方表示"响应速度显著提升,Token 消耗明显下降",也算变相降价了。

M2.1 接入 Claude Code,我的参数如下。

五、

我这次选择的历史遗留项目是 wechat-format,一个 Web 应用,将 Markdown 文本转为微信公众号的样式。

上图左侧的文本框输入 Markdown 文本,右侧立刻显示自动渲染的结果,可以直接复制到微信公众号的编辑器。

它非常好用,大家可以去试试看。我的公众号现在就用它做排版,效果不错(下图)。

问题是,原作者六年前就放弃了,这个项目不再更新了。我看过源码,它用的是老版本的 Vue.js 和 CodeMirror 编辑器,没有任何文档和说明,还经过了编译工具的处理,注释都删掉了。

如果不熟悉它的技术栈,想要修改这些代码是很困难的,可能要投入大量时间。

那么废话少说,直接让 AI 上场,把这些代码交给 MiniMax M2.1 模型。

六、

接手老项目的第一步,是对项目进行一个总体的了解。

我首先会让 AI 生成项目概述。大家可以跟着一起做,跟我的结果相对照。


# 克隆代码库
$ git clone git@github.com:ruanyf/wechat-format.git

# 进入项目目录
$ cd wechat-format

# 启动 Claude Code
$ claude-minimax

上面的claude-minimax是我的自定义命令,用来在 Claude Code 里面调用 MiniMax 模型(参见教程)。

输入"生成这个仓库的概述"。

AI 很快就给出了详细说明,包括项目的总体介绍、核心功能、技术栈和文件结构(下图)。

有了总体了解以后,我会让 AI 解释主要脚本文件的代码。

【提示词】解释 index.html 文件的代码

它会给出代码结构和页面布局(上图),然后是 JS 脚本加载顺序和 Vue 应用逻辑,甚至包括了流程图(下图),这可是我没想到的。

做完这一步,代码库的大致情况应该就相当了解了,而 AI 花费的时间不到一分钟。

七、

既然这个模型号称有"多语言编程能力",我就让它把项目语言从 JavaScript 改成 TypeScript。

对于很多老项目来说,这也是常见需求,难度不低。

它先制定了迁移计划,然后生成了 tsconfig.json 和 types.d.ts,并逐个将 JS 文件转为对应的 TS 文件(下图)。

修改完成后,它试着运行这个应用,发现有报错(下图),于是又逐个解决错误。

最终,迁移完成,它给出了任务总结(下图)。

我在浏览器运行这个应用,遇到了两个报错:CodeMirror 和 FuriganaMD 未定义。

我把报错信息提交给模型,它很快修改了代码,这次就顺利在浏览器跑起来了。

至此,这个多年前的 JavaScript 应用就成功改成了 TypeScript 应用,并且所有内部对象都有了完整的类型定义。

你还可以接着添加单元测试,这里就省略了。

八、

简单的测试就到此为止,我目前的 AI 编程大概就到这个程度,用 AI 来解释和修改代码。我也建议大家,以后遇到历史遗留代码,一律先交给 AI。

虽然这个测试比较简单,不足以考验 MiniMax M2.1 的能力上限,但如果人工来做上面这些事情,可能一个工作日还搞不定,但是它只需要十几分钟。

总体上,我对它的表现比较满意。大家都看到了,我的提示词很简单,就是一句话,但是它正确理解了意图,如果一次没有成功,最多再修改一两次就正确了。

而且,就像发布说明说的一样,它运行速度很快,思考过程和生成过程最多也就两三分钟,不像有的模型要等很久。

另外,不管什么操作,它都会给出详细的讲解和代码注释。

总之,就我测试的情况来看,这个模型的 Web 开发能力确实很不错,可以用于实际工作。

最后,说一点题外话。著名开发者 Simon Willison 最近说,评测大模型越来越困难,"我识别不出两个模型之间的实质性差异",因为主流的新模型都已经足够强大,足以解决常见任务,只有不断升级评测的难度,才能测出它们的强弱。

这意味着,对于普通程序员的常见编程任务,不同模型不会构成重大差异,没必要迷信国外的旗舰模型,国产模型就很好用。

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2025年12月23日

你以为 Props 只是传参? 不,它是 React 组件设计的“灵魂系统”

2025年12月23日 00:55

90% 的 React 初学者,都低估了 Props。
他们以为它只是“从父组件往子组件传点数据”。

但真正写过复杂组件、设计过通用组件的人都知道一句话:

Props 决定了一个组件“好不好用”,而不是“能不能用”。

这篇文章,我们不讲 API 清单、不背概念,
而是围绕 Props 系统的 5 个核心能力,一次性讲透 React 组件化的底层逻辑:

  • Props 传递
  • Props 解构
  • 默认值(defaultProps / 默认参数)
  • 类型校验(PropTypes)
  • children 插槽机制(React 的核武器)

👉 看完你会明白:
React 真正厉害的不是 JSX,而是 Props 设计。


一、Props 的本质:组件的“对外接口”

先抛一个结论:

React 组件 ≈ 一个函数 + 一套 Props 接口

来看一个最简单的组件 👇

function Greeting(props) {
  return <h1>Hello, {props.name}</h1>
}

使用时:

<Greeting name="白兰地" />

很多人到这里就停了,但问题是:

name 到底是什么?

答案是:

name 不是变量,是组件对外暴露的能力。

Props 本质上是:

  • 父组件 👉 子组件的输入
  • 组件作者 👉 使用者的约定

二、Props 解构:不是语法糖,而是“设计声明”

对比两种写法 👇

❌ 不推荐

function Greeting(props) {
  return <h1>Hello, {props.name}</h1>
}

✅ 推荐

function Greeting({ name }) {
  return <h1>Hello, {name}</h1>
}

为什么?

解构不是为了少写字,而是为了表达意图。

当你看到函数签名:

function Greeting({ name, message, showIcon }) {}

你立刻就知道:

  • 这个组件“需要什么”
  • 组件的“输入边界”在哪里

👉 好的组件,从函数签名就能读懂。


三、Props 默认值:组件“健壮性”的第一步

看这个组件 👇

function Greeting({ name, message }) {
  return (
    <div>
      <h1>Hello, {name}</h1>
      <p>{message}</p>
    </div>
  )
}

如果使用者这么写:

<Greeting name="空瓶" />

会发生什么?

message === undefined

这时候就轮到 默认值 出场了。


方式一:defaultProps(经典)

Greeting.defaultProps = {
  message: 'Welcome!'
}

方式二:解构默认值(更推荐)

function Greeting({ name, message = 'Welcome!' }) {}

💡默认值不是兜底,而是组件设计的一部分。

它代表的是:

  • “在你不配置的情况下”
  • “组件应该表现成什么样”

四、Props 类型校验:组件的“自说明文档”

来看一段很多人忽略、但非常值钱的代码 👇

import PropTypes from 'prop-types'

Greeting.propTypes = {
  name: PropTypes.string.isRequired,
  message: PropTypes.string,
  showIcon: PropTypes.bool,
}

很多人会说:

“这不是可有可无吗?”

但在真实项目里,它解决的是:

  • ❌ 参数传错没人发现
  • ❌ 新人不知道组件怎么用
  • ❌ 组件一多,全靠猜

🔍 PropTypes 的真正价值

不是防 bug,而是“降低理解成本”。

当你看到 propTypes,就等于看到一份说明书:

  • 哪些 props 必须传?
  • 哪些是可选?
  • 类型是什么?

👉 一个没有 propTypes 的通用组件,本质上是“黑盒”。


五、children:React Props 系统的“王炸”

如果只能选一个 Props 机制,我会毫不犹豫选:

🧨 children

来看一个 Card 组件 👇

const Card = ({ children, className = '' }) => {
  return (
    <div className={`card ${className}`}>
      {children}
    </div>
  )
}

使用时:

<Card className="user-card">
  <h2>张三</h2>
  <p>高级前端工程师</p>
  <button>查看详情</button>
</Card>

这里发生了一件非常重要的事情:

组件不再关心“内容是什么”。


🧠 children 的设计哲学

组件负责“骨架”,使用者负责“填充”。

  • Card 只负责:边框、阴影、间距
  • children 决定:展示什么内容

这让组件具备了两个特性:

  • ✅ 高度复用
  • ✅ 永不过期

六、children + Props = 通用组件的终极形态

再看一个更高级的例子:Modal 👇

<Modal HeaderComponent={MyHeader} FooterComponent={MyFooter}>
  <p>这是一个弹窗</p>
  <p>你可以在这里显示任何 JSX</p>
</Modal>

Modal 的实现:

function Modal({ HeaderComponent, FooterComponent, children }) {
  return (
    <div>
      <HeaderComponent />
      {children}
      <FooterComponent />
    </div>
  )
}

这背后是一个非常高级的思想:

Props 不只是数据,也可以是组件。


七、请记住这 5 条 Props 设计铁律

🔥 如果你只能记住一段话,请记住这里

  1. Props 是组件的“对外接口”,不是随便传的变量
  2. 解构 Props,是在声明组件的能力边界
  3. 默认值,决定组件的“基础体验”
  4. 类型校验,让组件自带说明书
  5. children,让组件从“可用”变成“好用”

八、写在最后

当你真正理解 Props 之后,你会发现:

  • React 不只是 UI 库
  • 它在教你如何设计 API
  • 如何让别人“用得爽”

Props 写得好不好,决定了一个人 React 水平的上限。

每日一题-两个最好的不重叠活动🟡

2025年12月23日 00:00

给你一个下标从 0 开始的二维整数数组 events ,其中 events[i] = [startTimei, endTimei, valuei] 。第 i 个活动开始于 startTimei ,结束于 endTimei ,如果你参加这个活动,那么你可以得到价值 valuei 。你 最多 可以参加 两个时间不重叠 活动,使得它们的价值之和 最大 。

请你返回价值之和的 最大值 。

注意,活动的开始时间和结束时间是 包括 在活动时间内的,也就是说,你不能参加两个活动且它们之一的开始时间等于另一个活动的结束时间。更具体的,如果你参加一个活动,且结束时间为 t ,那么下一个活动必须在 t + 1 或之后的时间开始。

 

示例 1:

输入:events = [[1,3,2],[4,5,2],[2,4,3]]
输出:4
解释:选择绿色的活动 0 和 1 ,价值之和为 2 + 2 = 4 。

示例 2:

Example 1 Diagram

输入:events = [[1,3,2],[4,5,2],[1,5,5]]
输出:5
解释:选择活动 2 ,价值和为 5 。

示例 3:

输入:events = [[1,5,3],[1,5,1],[6,6,5]]
输出:8
解释:选择活动 0 和 2 ,价值之和为 3 + 5 = 8 。

 

提示:

  • 2 <= events.length <= 105
  • events[i].length == 3
  • 1 <= startTimei <= endTimei <= 109
  • 1 <= valuei <= 106
❌
❌