阅读视图

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

ant design pro 模版简化工具

2023年8月,我满怀期待的开创了自己的小公司,北京微谏科技有限公司,当时的业务仅有一份前领导介绍的 AI 前端外包。转眼间 2 年就过去了,虽然服务了一些客户,也做了一些有趣的项目,但毕竟不是自己的产品,无法决定产品迭代,也无法持续创造营收。所以我下定决心,一定要做完全属于自己的东西。

之前做外包项目,在开发后台管理系统的时候,采用 ant-design-pro 搭建的项目,生成好后,需要手动调整很多代码,才能让界面回归到一个简单可用的状态。所以我开发的第一款产品就是 ant design pro 模版简化工具,他能让你的后台管理项目快速启动。原本需要半天的时间,用上这款工具后,只需要1分钟。让你专注于业务的开发。

可能对于中,大型公司,这款工具并没有什么作用,但是对于小型的非科技型企业、计算机专业的学生、偶尔接接私活儿的程序员们,不太擅长前端的后端工程师,我觉得还是挺有帮助的。

以下是使用全局安装的 pro 命令初始化的 simple 脚手架,不得不说还是挺漂亮的,但是外包开发主打的就是一个快,漂亮只是加分项。

这里面有一些用不上的部分,包括但不限于 mock 数据,单元测试,Ant 相关Logo/提示信息,多语言,欢迎页,管理页。所以我们需要先把他删除掉。然后增添的一个基础的,可以增删改查的模块就ok了,大体思路参考下方代码。

import { execSync } from 'child_process';
import fs from 'fs';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';

// 获取脚本路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// 执行脚本路径
const mockPath = path.join(__dirname, '../mock');
const testsPath = path.join(__dirname, '../tests');
const jestPath = path.join(__dirname, '../jest.config.ts');
const srcUserLoginTest = path.join(__dirname, '../src/pages/User/Login/login.test.tsx');
const srcUserLoginSnapshots = path.join(__dirname, '../src/pages/User/Login/__snapshots__');
const typesPath = path.join(__dirname, '../types');
const srcComponentsFooter = path.join(__dirname, '../src/components/Footer');
const srcComponentsRightContent = path.join(__dirname, '../src/components/RightContent/index.tsx');
const srcLocales = path.join(__dirname, '../src/locales');
const srcPagesAdmin = path.join(__dirname, '../src/pages/Admin.tsx');
const srcPagesWelCome = path.join(__dirname, '../src/pages/Welcome.tsx');
const srcPagesTableList = path.join(__dirname, '../src/pages/TableList');
const srcPages404 = path.join(__dirname, '../src/pages/404.tsx');
const srcServices = path.join(__dirname, '../src/services');
const srcConfigOneApi = path.join(__dirname, '../config/oneapi.json');
const srcAccess = path.join(__dirname, '../src/access.ts');
const publicIcons = path.join(__dirname, '../public/icons');
const publicCNAME = path.join(__dirname, '../public/CNAME');
const publicIco = path.join(__dirname, '../public/favicon.ico');
const publicSvg = path.join(__dirname, '../public/logo.svg');
const publicProIcon = path.join(__dirname, '../public/pro_icon.svg');

// 删除不需要的代码
[
  mockPath,
  testsPath,
  jestPath,
  srcUserLoginTest,
  srcUserLoginSnapshots,
  typesPath,
  srcComponentsFooter,
  srcComponentsRightContent,
  srcLocales,
  srcPagesAdmin,
  srcPagesWelCome,
  srcPagesTableList,
  srcPages404,
  srcServices,
  srcConfigOneApi,
  srcAccess,
  publicIcons,
  publicCNAME,
  publicIco,
  publicSvg,
  publicProIcon,
].forEach((itemPath) => {
  execSync(`rm -rf ${itemPath}`);
});

// 将 replace-source-code 的内容覆盖到 ../ 中
execSync(`cp -r ${path.join(__dirname, './replace-source-code/*')} ${path.join(__dirname, '../')}`);

const args = process.argv.slice(2);

const title = args.length > 0 ? args[0] : '后台管理系统';

// 替换系统标题
execSync(
  `sed -i '' 's/{{title}}/${title}/g' ${path.join(__dirname, '../config/defaultSettings.ts')}`,
);

执行后我们就可以得到一个清爽的后台界面了。

接下来,就可以的专心的进行业务上的开发了。

如果你想体验这款工具,可以访问我的公司官网,目前在线生成开放了100次的免登录生成代码。如果您看到了“今日免登录体验次数已耗尽,请登录后使用”的提示,也请给予我一点信任,免费注册一下我的网站,这样你就可以享受无限次的后台模版生成服务了。

希望可以通过这篇文章与有需求的小伙伴们交个朋友,创业路途遥远,唯有日拱一卒。如果大家对我的工具有什么好的建议,也可以在评论区里提出,感谢大家,祝前程似锦,大展宏图。

我的远程实习(六) | 一个demo讲清Auth.js国外平台登录鉴权👈|nextjs

前言

前些时候 ,远程实习要求实现鉴权登录 , 采用 Auth.js 可用于在 Nextjs 项目中实现登录鉴权 ,今天我写一个 demo , 为大家展示一下 auth 鉴权的数据流转 ~

我们首先介绍一下: NextAuth.js 是一个专为 Next.js 应用设计的灵活且易于使用认证库,它极大地简化了添加登录、注册、登出等认证功能的过程。该库以其灵活性和强大的功能而闻名,支持多种认证方式,包括电子邮件与密码、OAuth 2.0 提供商(如 Google、GitHub、Facebook 等),以及自定义提供商。

主要特点

  • 丰富的内置提供者:支持众多 OAuth 和 OpenID Connect 提供商,方便快捷地与第三方服务集成。
  • 会话管理:提供简明的 API 来处理用户会话,轻松获取当前用户的会话信息。
  • 数据库兼容性:支持与多种数据库集成,适用于存储用户数据,并能无缝对接无头 CMS 和自定义后端。
  • 多语言支持:根据用户的语言偏好显示错误消息及其他文本,增强用户体验。
  • 可定制页面:允许开发者创建符合应用设计风格的自定义登录、注册或错误页面。
  • 安全优先:采用一系列安全默认设置,帮助保护应用免受常见的安全威胁。
  • API 路由整合:利用 Next.js 的 API 路由机制来实现身份验证逻辑,让开发者可以自由创建用于登录、登出等操作的自定义端点。
  • 会话管理选项:提供选择,既可以通过 JSON Web Tokens (JWT) 实现无状态会话管理,也可以选择基于数据库的会话管理。
  • 适配器支持:为了满足将用户数据持久化到数据库的需求,NextAuth.js 提供了与不同数据库系统(如 Prisma、TypeORM 等)集成的适配器。

demo 效果展示

Gitee:gitee.com/luli1314520…

开源不易 , 点个小小赞支持下 ~

认证演示项目登录流程分析

项目结构

项目使用Next.js框架构建,采用App Router架构,主要目录结构:

  • /app - 前端页面和API路由
    • /app/auth/signin - 登录页面
    • /app/api/auth/[...nextauth] - NextAuth API路由
  • /auth - NextAuth配置
  • /services - 业务逻辑层
  • /models - 数据访问层
  • /types - 类型定义
  • /lib - 工具函数

认证方案

项目使用NextAuth.js实现第三方OAuth认证,支持以下登录方式:

  • Google OAuth登录
  • GitHub OAuth登录

认证数据流转图

┌─────────────┐      ┌─────────────────┐      ┌────────────────────┐
│             │      │                 │      │                    │
│  用户界面   │─────▶│  NextAuth API   │─────▶│  OAuth提供商       │
│  (客户端)   │◀─────│  (服务端路由)   │◀─────│  (Google/GitHub)   │
│             │      │                 │      │                    │
└─────────────┘      └─────────────────┘      └────────────────────┘
       │                     │                         │
       │                     │                         │
       ▼                     ▼                         │
┌─────────────┐      ┌─────────────────┐               │
│             │      │                 │               │
│  会话状态   │◀────▶│  数据存储       │◀──────────────┘
│  (JWT)      │      │  (用户信息)     │
│             │      │                 │
└─────────────┘      └─────────────────┘

数据流转详解

  1. 用户界面 → NextAuth API → OAuth提供商
    • 用户在前端界面点击登录按钮,触发认证流程
    • NextAuth API构建OAuth授权URL并重定向用户
    • 用户被引导至第三方OAuth提供商(Google/GitHub)进行身份验证
  2. OAuth提供商 → NextAuth API → 数据存储
    • 用户在OAuth提供商完成身份验证
    • 提供商重定向回NextAuth回调URL,附带授权码
    • NextAuth API使用授权码换取用户信息
    • 用户信息被保存到数据存储中
  3. NextAuth API → 会话状态
    • 认证成功后,NextAuth创建JWT令牌
    • JWT包含必要的用户信息和认证元数据
    • 令牌加密并存储在HTTP-only cookie中
  4. 会话状态 用户界面
    • 前端通过 useSession钩子或 getSession函数获取会话状态
    • 会话状态包含用户身份和权限信息
    • 用户界面根据会话状态调整显示内容(如显示用户资料)
  5. 会话状态 数据存储
    • JWT令牌中包含用户标识符(如UUID)
    • 可通过标识符从数据存储中获取完整用户信息
    • 会话更新时可能涉及数据存储的变更(如更新登录时间)

登录流程(前端到后端)

1. 前端登录入口

文件: app/auth/signin/page.tsx

登录页面是服务端组件,显示登录按钮并检查用户是否已登录:

export default async function SignInPage({
  searchParams,
}: {
  searchParams: { callbackUrl: string | undefined };
}) {
  const session = await auth();
  if (session) {
    return redirect(searchParams.callbackUrl || "/");
  }
  
  // 登录页面UI
}

此组件对应数据流转图中的用户界面(客户端),负责展示登录界面并检查会话状态。

2. 客户端登录按钮

文件: app/auth/signin/client.tsx

客户端组件,处理登录按钮点击事件:

export function SignInButton({ 
  provider, 
  callbackUrl,
  children
}: { 
  provider: string; 
  callbackUrl?: string;
  children: React.ReactNode;
}) {
  return (
    <button
      onClick={() => signIn(provider, { callbackUrl: callbackUrl || '/' })}
      className="..."
    >
      {provider === 'google' && <FaGoogle className="..." />}
      {provider === 'github' && <FaGithub className="..." />}
      <span>{children}</span>
    </button>
  );
}

此组件触发用户界面→NextAuth API的数据流,调用 signIn函数发起认证请求。

3. NextAuth API路由

文件: app/api/auth/[...nextauth]/route.ts

处理NextAuth的API请求:

import NextAuth from "next-auth";
import { authOptions } from "@/auth/config";

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };

此文件对应数据流转图中的NextAuth API(服务端路由),处理所有认证相关的HTTP请求,包括:

  • 认证请求(重定向到OAuth提供商)
  • 回调处理(接收OAuth提供商的回调)
  • 会话查询(前端获取会话状态)

4. NextAuth配置

文件: auth/config.ts

配置NextAuth认证选项,包括:

  • 认证提供商(Google、GitHub)
  • 登录回调
  • JWT处理
  • Session处理

关键代码:

export const authOptions: NextAuthConfig = {
  providers,
  pages: {
    signIn: "/auth/signin",
  },
  callbacks: {
    async signIn({ user, account, profile }) { ... },
    async redirect({ url, baseUrl }) { ... },
    async session({ session, token }) { ... },
    async jwt({ token, user, account }) { ... },
  },
};

这个配置文件定义了NextAuth API如何与OAuth提供商数据存储交互,以及如何处理会话状态

5. NextAuth实例

文件: auth/index.ts

创建NextAuth实例并导出相关函数:

import NextAuth from "next-auth";
import { authOptions } from "./config";

export const { handlers, signIn, signOut, auth } = NextAuth(authOptions);

这些导出的函数促成了数据流转图中的多个流程:

  • signIn:用户界面→NextAuth API
  • auth:NextAuth API→会话状态
  • signOut:用户界面→NextAuth API→会话状态(清除)

6. 用户信息处理

文件: services/user.ts

处理用户信息保存逻辑:

export async function saveUser(user: User) {
  try {
    const existUser = await findUserByEmail(user.email);
    if (!existUser) {
      await insertUser(user);
    } else {
      user.id = existUser.id;
      user.uuid = existUser.uuid;
      user.created_at = existUser.created_at;
    }
    return user;
  } catch (e) {
    throw e;
  }
}

此服务对应数据流转图中的NextAuth API→数据存储流程,负责将OAuth提供商返回的用户信息持久化到数据存储中。

7. 数据存储

文件: models/user.ts

示例项目使用内存数组存储用户信息,实际项目应使用数据库:

// 演示用简化版本,实际项目中应使用数据库
const users: User[] = [];

export async function findUserByEmail(email: string): Promise<User | null> { ... }
export async function findUserByUuid(uuid: string): Promise<User | null> { ... }
export async function insertUser(user: User): Promise<User> { ... }

此模块实现了数据流转图中的**数据存储(用户信息)**组件,提供用户数据的CRUD操作。

登录流程详解

  1. 用户点击登录按钮
    • 前端调用 signIn(provider)函数
    • NextAuth.js将用户重定向到第三方OAuth提供商(Google/GitHub)
    • 数据流:用户界面→NextAuth API→OAuth提供商
  2. 第三方认证
    • 用户在第三方平台完成认证
    • 第三方平台将用户重定向回应用的回调URL
    • 数据流:OAuth提供商→NextAuth API
  3. 处理回调
    • NextAuth.js API接收回调请求
    • 调用 jwt回调处理令牌
    • 保存用户信息到后端存储
    • 数据流:NextAuth API→数据存储NextAuth API→会话状态
  4. 创建会话
    • 调用 session回调构建会话信息
    • 返回包含用户信息的会话对象
    • 数据流:会话状态用户界面
  5. 完成登录
    • 用户被重定向到指定的回调URL或首页
    • 前端可通过 useSession钩子或 auth()函数访问会话信息
    • 数据流:会话状态用户界面会话状态数据存储

JWT与会话状态管理

JWT(JSON Web Token)在认证流程中扮演核心角色,对应数据流转图中的**会话状态(JWT)**节点:

  1. JWT创建
    • 用户成功认证后,NextAuth创建包含用户信息的JWT令牌
    • JWT中存储必要的用户信息(如UUID、头像URL等)
    • 数据流:NextAuth API→会话状态
  2. JWT存储
    • JWT令牌加密后存储在HTTP-only cookie中
    • 浏览器每次请求自动发送cookie,实现无状态认证
    • 数据流:会话状态用户界面
  3. 会话构建
    • 服务端通过 auth()函数解析JWT令牌获取会话信息
    • 客户端通过 useSession()钩子访问会话状态
    • 数据流:会话状态用户界面
  4. 会话与数据存储交互
    • 会话中的用户标识可用于从数据存储获取完整用户信息
    • 可通过会话中的用户ID更新数据存储中的用户信息
    • 数据流:会话状态数据存储

类型扩展

NextAuth类型扩展,支持JWT和Session中的自定义字段:

declare module "next-auth" {
  interface JWT {
    user?: {
      uuid?: string;
      nickname?: string;
      avatar_url?: string;
      created_at?: string;
    };
  }

  interface Session {
    user: {
      uuid?: string;
      email?: string | null;
      name?: string | null;
      nickname?: string | null;
      avatar_url?: string | null;
      created_at?: string | null;
    }
  }
}

用户类型定义,对应数据存储中的用户数据结构:

export interface User {
  id?: number;
  uuid?: string;
  email: string;
  created_at?: string;
  nickname: string;
  avatar_url: string;
  signin_type?: string;
  signin_ip?: string;
  signin_provider?: string;
  signin_openid?: string;
}

安全考虑

  1. 环境变量:OAuth客户端ID和密钥存储在环境变量中
  2. 重定向检查:验证重定向URL的合法性
  3. JWT令牌:使用JWT保存会话状态,加密存储防篡改
  4. 无密码存储:使用OAuth方式不需要存储用户密码
  5. HTTP-only Cookie:JWT存储在HTTP-only cookie中,防止JavaScript访问
  6. CSRF保护:NextAuth内置CSRF令牌验证机制

数据流转优化建议

  1. 数据库集成
    • 将内存存储替换为持久化数据库(MongoDB、PostgreSQL等)
    • 优化数据存储与NextAuth API的交互性能
  2. 令牌刷新机制
    • 实现OAuth访问令牌自动刷新功能
    • 延长用户会话有效期,减少重复登录
  3. 缓存层引入
    • 在数据存储与会话状态之间添加缓存层(如Redis)
    • 减轻数据库负担,提高频繁会话查询性能
  4. 前端状态管理
    • 优化前端会话状态管理,减少不必要的API调用
    • 实现优雅的会话过期处理和自动重新认证

参考

参半再推溶菌酶牙膏系列新品,进一步加码情绪价值 | 最前线

近日,口腔护理品牌参半再推溶菌酶牙膏系列新品——零度清新牙膏。该产品主打10倍长效凉感、24小时清新口气,通过突破性凉感技术、香氛技术及配方、感官体验设计等,解决了市面大部分清新品类牙膏单一香型、时效短暂、辣口等凉感痛点。

参半成立于2018年,产品矩阵覆盖美白、清新、抗敏、护龈等各细分品类,并拥有益生菌(SP)、溶菌酶、羟基磷灰石、沸石等多个明星产品系列,此次参半推出的零度清新牙膏正是其溶菌酶产品家族的又一新品。2024年,参半线上线下销售额整体突破20亿,并成为线上牙膏份额里的第一名。

去年,参半面向市场推出溶菌酶系列,以溶菌酶成分为主要配方体系,分别搭载热感、清新、双氟双锶抗敏技术等,推出39°热感、酶可护、酶植清多款产品,协同解决牙黄、异味等口腔护理需求。

Mordor Intelligence数据显示,中国口腔护理市场规模正以4%年复合增长率稳定增长,预计到2029年将达人民币689.77亿元规模。

在市场扩容背后,消费者对牙膏产品需求不断演化。国内消费者对牙膏的需求演变经历了基础清洁、功能补足到精准强化三个阶段,如今正迈入第四阶段——消费者期望牙膏不仅满足实用性,更能传递情绪价值。

此前,参半创始人尹阔在接受36氪专访时,也提到年轻人在牙膏上需要情绪价值,“一个日用品如果使用时间久了,你会觉得腻。倒也不是说这个产品不好,你就是想换一换,或者和长辈用一样的东西,你想用不一样的,觉得应该有自己的选择。”

因此,在产品研发中,参半除了注重功效提,还注重产品的刷感、口感以及香型、留香时长等方面,以丰富消费者的情绪价值、感官愉悦体验等。

据悉,研发方面,参半与全球香精香料生产商奇华顿进行了深度合作;在凉感方面,参半采用奇华顿EverCool和coolshot凉感技术,结合成熟的释放包裹技术,其口腔凉感持续时长超普通牙膏10倍。在口气清新方面,新品运用奇华顿BreathZap技术来中和口腔异味,还将ScentTrek香氛技术应用其中。此外,该款新品还在溶菌酶基础上,创新添加葡聚糖酶、木瓜蛋白酶,通过三重复合配方达到更好的口腔护理效果。

“我们通过消费者调研发现,在晨间牙膏的需求选择上,有超过65%的消费者会将‘使用时的清凉感’列为重要决策因素,这一比例在18-35岁年轻群体更是高达80%。同时,消费者对凉感的期待已经转向更持久、更具层次感、更具冲击力的清凉体验,这正是零度清新牙膏的研发初衷。”参半口腔产品研发中心负责人说道。

氪星晚报 |永辉上海学习胖东来调改二店开业;鸿蒙版微信迎来重要更新:支持通过分享面板分享文字、链接和文件到微信;美国关税阴影笼罩,意大利工业家联合会下调GDP预期

大公司:

达美乐将接受DoorDash订单,结束Uber独家经营权

达美乐将结束Uber Eats的独家经营权,开始通过美国外卖服务公司DoorDash接受订单。根据达美乐和DoorDash两家公司的声明,从5月开始,美国顾客将可以通过DoorDash订购达美乐披萨。(界面)

鸿蒙版微信迎来重要更新:支持通过分享面板分享文字、链接和文件到微信

4月2日,鸿蒙版微信迎来重要更新 ,新版本支持通过分享面板分享文字、链接和文件到微信,同时支持微信运动、扫码添加企微联系人、长按消息收藏、消息撤回后重新编辑发送、字体大小调节、聊天记录分类搜索、搜索群成员、长按识别网页二维码、转发多个聊天会话、联系人推荐给好友等功能。

新希望:预计一季度盈利4.3亿元至5亿元,同比扭亏为盈

36氪获悉,新希望公告,预计2025年一季度归属于上市公司股东的净利润盈利4.3亿元至5亿元,上年同期亏损19.34亿元。扣除非经常性损益后的净利润盈利4.31亿元至5.01亿元。基本每股收益盈利0.09元/股至0.11元/股。业绩变动原因主要是生猪养殖业务减亏以及饲料业务量利同增。

北京机器人产业发展基金等入股云鲸智能

36氪获悉,天眼查App显示,近日,云鲸智能创新(深圳)有限公司发生工商变更,新增北京机器人产业发展投资基金(有限合伙)、北京首石科幻产业股权投资基金(有限合伙)、Futurobot Holdings, L.P.为股东,同时注册资本由约338万人民币增至约351万人民币。该公司成立于2016年10月,现由东莞云朋企业管理合伙企业(有限合伙)、抖音集团旗下北京量子跃动科技有限公司、广西腾讯创业投资有限公司及上述新增股东等共同持股。官网显示,该公司是一家立足家用机器人领域的公司。

将在中国裁员20%?香奈儿中国:不回应

近日有媒体报道,法国奢侈品牌香奈儿将启动中国区战略调整,计划2025年年内将中国员工总数从462人缩减至373人,裁员幅度接近20%,涉及多个部门。对此,香奈儿中国表示不做回应。(界面)

雷军发布《致所有关心小米SU7事故用户和公众的一封信》?小米:假的

小米SU7高速碰撞燃爆事件继续发酵。4月2日,一篇以雷军之名的《致所有关心小米SU7事故的用户和公众的一封信》在网上传播,信中提到了事故的善后工作包括承诺探寻真相、反思与行动、透明化沟通,以及用十年坚守兑现安全承诺等。很快,有业内人士称这个截图为假,发布者为一个资深车评人,他删除了该图并表示“那个图是假的,抱歉”。记者联系到小米,小米方面回应称此为假消息。目前,小米官微和雷军个人微博并未发布上述信息。4月1日晚间,雷军曾发声表示“将站出来”解决问题。 (证券时报)

爱玛科技:一季度净利润同比预增25.12%

36氪获悉,爱玛科技4月2日晚发布2025年第一季度业绩预告,预计2025年第一季度实现归属于上市公司股东的净利润约为6.05亿元,同比增长25.12%。公司持续专注于电动两轮车、三轮车的研发与制造,始终以用户需求为导向,通过技术创新与精准产品开发推动产品结构优化升级。同时,深化渠道网络覆盖及供应链协同效能,实现高附加值车型占比进一步提升,推动经营业绩稳步增长。

云天励飞:剩余算力服务验收合格

36氪获悉,云天励飞公告,全资子公司励飞科技已完成向德元方惠交付AI训练及推理算力服务及对应交付物,并已完成剩余算力服务(对应合同中约40%的算力规模)的验收相关工作。验收结论为:训练及推理基础算力服务(合计对应合同约40%的算力规模),以及智能算力调度及AI大模型开发服务平台,满足合同约定验收要求。根据合同约定及验收结果,该项目已实现服务费正常结算并已回款。此次剩余算力服务验收合格对公司经营业绩产生积极影响,有利于提升市场竞争力和促进公司健康可持续发展。

张小泉回应控股股东被执行31.3亿:上市公司独立经营,目前一切正常

今日,“张小泉集团及法人等被执行31.3亿”话题登上热搜。记者致电张小泉股份投关部门,相关负责人表示,张小泉集团是张小泉股份的控股股东,双方在财务、业务、人员等方面均是独立经营,上市公司目前的经营一切正常。针对控股股东的债务问题或其涉及的冻结事项,投资者可以关注公司公告,公司一直都有对外披露相关事项。(新浪科技)

永辉上海学习胖东来调改二店开业

36氪获悉,4月2日,永辉超市上海第二家“学习胖东来”自主调改店——浦江万达广场店焕新开业。作为闵行区首家调改门店,其以“环境焕新+商品升级+服务进阶+员工关怀”的崭新面貌登场,不仅是永辉全国调改版图的又一落子,更为上海建设国际消费中心城市注入新动能。据了解,该店开业后,永辉全国调改门店数量将攀升至48家,预计在今年年中调改门店将达100家,全年达200家。

投融资:

“鑫辰佰盛”完成数千万元A轮融资

36氪获悉,近日,厦门鑫辰佰盛互娱传媒有限公司宣布完成数千万元A轮融资,由知化数标(深圳)科技集团股份有限公司投资。本轮资金将用于AI内容生产引擎研发、海外区域运营中心建设及跨产业生态合作开发,AI驱动的创作民主化、短剧IP的跨媒介增值、以及虚实融合的文化消费场景重构。

新产品:

竹芒科技发布彩宝3.0 Pro

4月1日,拥有街电搜电两大品牌的竹芒科技发布了彩宝3.0系列新品和智能运营平台。据介绍,彩宝3.0Pro柜机在硬件防护方面,采用了军工级防护体系。具备IPX4防水设计,行业首创的“控水机架”实现前后干湿分离,即使在潮湿的环境中也能正常运行。此次发布的充电宝3.0 Pro产品拥有10000mAh容量,配合22.5W超级快充,实测30分钟可充至80%,以此大幅提升用户使用体验。智能运营方面,AI调度功能将提升资产流转效率35%,全面优化代理商运维能力。

今日观点:

美国关税阴影笼罩,意大利工业家联合会下调GDP预期

意大利工业家联合会(Confindustria)周三下调了对意大利的经济增长预期,并警告称,美国即将出台的贸易关税可能会进一步恶化该国的经济前景。意大利工业家联合会表示,预计今年意大利国内生产总值将增长0.6%,仅为政府官方预测值1.2%的一半,也低于该协会去年10月预计的0.9%。在去年第三季度经济停滞之后,第四季度环比增长0.1%。大多数分析师预计,短期内意大利经济不会出现显著回升。(新浪财经)

其他值得关注的新闻:

工信部:前2个月云计算大数据服务同比增长8.8%,集成电路设计同比增长13.5%

36氪获悉,工信部发布数据,前2个月,软件产品收入4253亿元,同比增长8.3%。其中工业软件产品收入441亿元,同比增长6.4%;基础软件产品收入276亿元,同比增长6.7%。信息技术服务收入保持两位数增长。前2个月,信息技术服务收入12585亿元,同比增长10.3%。其中,云计算大数据服务同比增长8.8%;集成电路设计同比增长13.5%。前2个月,信息安全产品和服务收入393亿元,同比增长6.8%。前2个月,嵌入式系统软件收入1735亿元,同比增长11.9%。

vue与react的简单问答

1. Vue 4.0 的编译时宏(definePropsdefineEmits)如何通过 AST 转换实现类型安全?对比 TypeScript 泛型的优劣。

实现原理:

在 Vue 的编译时阶段,defineProps 和 defineEmits 会通过 AST(抽象语法树)转换生成类型安全的代码。具体流程如下:

  1. 解析宏‌:编译器识别 defineProps 和 defineEmits,提取它们的参数(如类型定义)。
  2. 类型推断‌:基于参数生成运行时类型校验逻辑,或与 TypeScript 类型系统集成。
  3. 代码生成‌:将类型信息转换为运行时验证代码(如 Props 的校验函数)或静态类型声明。

示例代码:

// 编译前(用户代码)
defineProps<{ count: number }>();
defineEmits<{ (e: 'update', value: string): void }>();

// 编译后(生成代码)
{
  props: { count: { type: Number, required: true } },
  emits: ['update'],
  // 可能生成运行时校验逻辑
}

对比 TypeScript 泛型:

  • 优势‌:

    • 框架集成‌:编译时宏能直接生成框架所需的运行时逻辑(如 Props 校验),而 TypeScript 泛型仅提供静态类型检查。
    • 简洁性‌:无需手动编写类型与运行时代码的映射。
  • 劣势‌:

    • 灵活性‌:TypeScript 泛型支持更复杂的类型操作(如联合类型、条件类型),而编译时宏可能受限于框架设计。
    • 工具链依赖‌:编译时宏需要特定编译器支持,而 TypeScript 泛型是语言原生特性。

2. 在 Vue 4.0 中,如何通过 Composition API 实现跨组件的状态共享?对比 Vuex 的适用场景。

实现方式:

使用 Composition API 的 provide/inject 或工厂函数实现状态共享:

// sharedState.ts
import { ref, provide, inject } from 'vue';

const key = Symbol('sharedState');

export function createSharedState() {
  const state = ref({ count: 0 });
  return { state };
}

export function useSharedState() {
  return inject(key) || createSharedState();
}

// 根组件
provide(key, createSharedState());

// 子组件
const { state } = useSharedState();

对比 Vuex:

  • Composition API 适用场景‌:

    • 中小型应用或局部状态共享。
    • 需要更灵活的状态逻辑组合(如复用逻辑片段)。
  • Vuex 适用场景‌:

    • 大型应用,需集中式状态管理。
    • 需要严格的全局状态变更追踪(如 DevTools 集成、时间旅行调试)。

3. 如何用 Vue 4.0 的 Suspense 实现异步组件的加载状态管理?关键代码及与 React Suspense 的差异。

关键代码:

<template>
  <Suspense>
    <template #default>
      <AsyncComponent />
    </template>
    <template #fallback>
      <div>Loading...</div>
    </template>
  </Suspense>
</template>

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

const AsyncComponent = defineAsyncComponent(() =>
  import('./AsyncComponent.vue')
);
</script>

与 React Suspense 的差异:

  1. 错误处理‌:Vue 使用 <ErrorBoundary> 配合 onErrorCaptured,而 React 直接在 Suspense 边界捕获。
  2. 并发模式‌:React Suspense 支持并发渲染特性(如优先级中断),Vue 目前未实现类似机制。
  3. 组合方式‌:Vue 的 Suspense 需要显式包裹异步组件,React 的 Suspense 可以更灵活地嵌套使用。

4. 设计一个 Vue 4.0 的自定义指令实现图片懒加载,支持 Intersection Observer 的回调。

实现代码:

// lazyLoadDirective.ts
import type { Directive } from 'vue';

const lazyLoad: Directive<HTMLImageElement, string> = {
  mounted(el, binding) {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          el.src = binding.value;
          observer.unobserve(el);
          // 触发自定义回调
          if (binding.arg === 'callback') {
            (binding.instance as any)[binding.modifiers?.callback]?.();
          }
        }
      });
    });
    observer.observe(el);
    el._observer = observer; // 保存 observer 实例以便卸载时使用
  },
  beforeUnmount(el) {
    el._observer?.unobserve(el);
  }
};

export default lazyLoad;

使用方式:

<template>
  <img v-lazy="imageUrl" v-lazy:callback.onVisible />
</template>

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

const imageUrl = ref('path/to/image.jpg');

function onVisible() {
  console.log('Image is visible!');
}
</script>

5. 在 Vue 4.0 中,如何通过 Teleport 实现模态框的全局挂载?关键代码及与 React Portal 的异同。

关键代码:

<template>
  <button @click="showModal = true">Open Modal</button>
  <Teleport to="body">
    <div v-if="showModal" class="modal">
      <p>Modal Content</p>
      <button @click="showModal = false">Close</button>
    </div>
  </Teleport>
</template>

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

const showModal = ref(false);
</script>

与 React Portal 的异同:

  • 相同点‌:

    • 目标:将子组件渲染到 DOM 树的其他位置(如 body 末尾)。
    • 应用场景:模态框、弹出菜单等需要脱离父容器样式限制的场景。
  • 不同点‌:

    • 语法‌:Vue 使用 <Teleport to="selector">,React 使用 createPortal(children, domNode)
    • 动态目标‌:Vue 允许动态绑定 to(如 :to="dynamicTarget"),React 需手动管理容器节点。
    • 组件化‌:Vue 的 Teleport 是内置组件,React Portal 是函数调用。

SvelteKit 最新中文文档教程(17)—— 仅服务端模块和快照

前言

Svelte,一个语法简洁、入门容易,面向未来的前端框架。

从 Svelte 诞生之初,就备受开发者的喜爱,根据统计,从 2019 年到 2024 年,连续 6 年一直是开发者最感兴趣的前端框架 No.1

image.png

Svelte 以其独特的编译时优化机制著称,具有轻量级高性能易上手等特性,非常适合构建轻量级 Web 项目

为了帮助大家学习 Svelte,我同时搭建了 Svelte 最新的中文文档站点。

如果需要进阶学习,也可以入手我的小册《Svelte 开发指南》,语法篇、实战篇、原理篇三大篇章带你系统掌握 Svelte!

欢迎围观我的“网页版朋友圈”、加入“冴羽·成长陪伴社群”,踏上“前端大佬成长之路”

仅服务端模块

SvelteKit 会像一个好朋友一样,保守您的秘密。在同一个代码仓库中编写后端和前端代码时,很容易不小心将敏感数据(例如包含 API 密钥的环境变量)导入到前端代码中。SvelteKit 提供了一种完全防止这种情况发生的方法:仅服务端模块(server-only modules)。

私有环境变量

$env/static/private$env/dynamic/private 模块只能导入到仅在服务端上运行的模块中,例如 hooks.server.js+page.server.js

仅服务端工具

$app/server 模块包含一个 read 函数,用于从文件系统读取资源,同样只能被服务端运行的代码导入。

您的模块

您可以通过两种方式使您的模块成为仅服务端模块:

  • 在文件名中添加 .server,例如 secrets.server.js
  • 将它们放在 $lib/server 中,例如 $lib/server/secrets.js

工作原理

任何时候,当您有公开的代码,导入仅服务端代码时(无论是直接还是间接)...

// @errors: 7005
/// file: $lib/server/secrets.js
export const atlantisCoordinates = [
/* 已编辑 */
];
// @errors: 2307 7006 7005
/// file: src/routes/utils.js
export { atlantisCoordinates } from '$lib/server/secrets.js';

export const add = (a, b) => a + b;
/// file: src/routes/+page.svelte
<script>
import { add } from './utils.js';
</script>

...SvelteKit 将报错:

Cannot import $lib/server/secrets.js into public-facing code:
- src/routes/+page.svelte
  - src/routes/utils.js
    - $lib/server/secrets.js

尽管公开代码 — src/routes/+page.svelte — 只使用了 add 导出而没有使用秘密的 atlantisCoordinates 导出,秘密代码也可能最终出现在浏览器下载的 JavaScript 中,因此这个导入链被认为是不安全的。

此功能也适用于动态导入,甚至是像 await import(`./${foo}.js`) 这样的插值导入,只有一个小注意点:在开发过程中,如果公开代码和仅服务端模块之间存在两个或更多的动态导入,则在第一次加载代码时不会检测到非法导入。

[!NOTE] 像 Vitest 这样的单元测试框架不区分仅服务端代码和公开代码。因此,当运行测试时,非法导入检测会被禁用,这由 process.env.TEST === 'true' 决定。

进一步阅读

快照

临时的 DOM 状态 — 比如侧边栏的滚动位置、<input> 元素的内容等 — 在从一个页面导航到另一个页面时会被丢弃。

例如,如果用户填写了一个表单但在提交之前离开并返回,或者用户刷新页面,他们填写的值将会丢失。在需要保留这些输入的情况下,您可以创建一个 DOM 状态的快照,当用户返回时可以恢复这个状态。

要实现这一点,从 +page.svelte+layout.svelte 中导出一个带有 capturerestore 方法的 snapshot 对象:

<!--- file: +page.svelte --->
<script>
  let comment = $state('');

  /** @type {import('./$types').Snapshot<string>} */
  export const snapshot = {
    capture: () => comment,
    restore: (value) => comment = value
  };
</script>

<form method="POST">
  <label for="comment">评论</label>
  <textarea id="comment" bind:value={comment} />
  <button>发表评论</button>
</form>

当您离开这个页面时,capture 函数会在页面更新之前立即被调用,返回的值会与浏览器历史栈中的当前记录关联。如果您返回此页面,restore 函数会在页面更新后立即被调用,并传入存储的值。

数据必须是可以序列化为 JSON 的,这样它才能被保存到 sessionStorage 中。这样就可以在页面重新加载时,或者用户从其他网站返回时恢复状态。

[!NOTE] 避免从 capture 返回非常大的对象 — 一旦被捕获,对象将在会话期间保留在内存中,在极端情况下可能会因太大而无法保存到 sessionStorage 中。

Svelte 中文文档

点击查看中文文档:

  1. SvelteKit 仅服务端模块
  2. SvelteKit 快照

系统学习 Svelte,欢迎入手小册《Svelte 开发指南》。语法篇、实战篇、原理篇三大篇章带你系统掌握 Svelte!

此外我还写过 JavaScript 系列TypeScript 系列React 系列Next.js 系列冴羽答读者问等 14 个系列文章, 全系列文章目录:github.com/mqyqingfeng…

欢迎围观我的“网页版朋友圈”、加入“冴羽·成长陪伴社群”,踏上“前端大佬成长之路”

AI编程-cursor无限使用, 还有谁不会🎁🎁🎁??

大家好,我是前端小张同学,最近AI是炒的是非常火热,各路神仙都来参与,cursor,Trea,Windsurf,MarsCode 啊等等,今天就给大家带来 cursor 的免费一直使用的技巧吧,希望大家会喜欢,谢谢。

image.png

1:安装cursor and 导入vscode配置和拓展

安装 cursor 想必大家都知道(Cursor - The AI Code Editor),进行下载安装就行了,重要的是可能还有小伙伴不知道怎么导入vscode配置,今天我就给大家一篇文章讲明白。在这里给大家附上 官方教程

1.1: 如何导入vscode配置,三种方法

1:安装完成后,选择导入

image.png

2:如果你没有以上页面,请使用手动导入。 打开 cursor 编辑器 选择 File > preference > cursor Settings

image.png

点击 VScode import 导入你的配置

3:手动执行你的命令去导入 ctrl + shift + p 打开命令行,输入vscode import,选择import VSCode extensions 回车即可,等待自动安装。

image.png

2:重置cursor

2.1:重头戏 如何重置 首先在这里给大家推荐一个 github仓库 [go-cursor-help] (github.com/yuaotian/go…)

首先在这里建议大家使用这条规则,轻松快捷

Solution 2: Account Switch
1. File -> Cursor Settings -> Sign Out (文件 - > 光标设置 - > 注销)
2. Close Cursor (关闭 cursor)
3. Run the machine code reset script(运行机器代码重置脚本)
4. Login with a new account (使用新帐户登录)

1:退出自己的cursor 账号

image.png

2:关闭 cursor软件

3:删除账户,重新登录

image.png

4:运行机器码重置脚本 根据自己的电脑系统决定执行什么脚本。

macOS

curl -fsSL aizaozao.com/accelerate.… -o ./cursor_mac_id_modifier.sh && sudo bash ./cursor_mac_id_modifier.sh && rm ./cursor_mac_id_modifier.sh

Linux

curl -fsSL aizaozao.com/accelerate.… | sudo bash

Windows

irm aizaozao.com/accelerate.… | iex

windows用户 打开 powershell,请注意用 管理员身份打开

到这里,你再继续用原账号登录,恭喜你已刷新使用时长。

3:cursor 的使用技巧

3.1:编辑器的侧边栏怎么设置?

1:打开vscode setting.json文件,加入以下代码。

   "workbench.activityBar.orientation": "vertical",

image.png

3.2 如何与AI对话,指定某些文件进行分析?

在对话输入框中,我们可以通过 @符号去唤起 一些操作 比如说 选择 文件 , 选择目录,选择指定的 code

image.png

这样他就能对我们选中的文件进行分析。

image.png

3.2.1:@git的作用

这也是我比较喜欢的一个功能,它可以帮助你去进行 commit 信息进行对比or 分析。

image.png

就像这样

image.png

3.3:如何选择模型

在对话窗口下选择你想要的模型,默认是 自动选择,目前比较好用的是 claude-3.5-sonnet 和 gpt-4o,建议大家使用。

image.png

好了,以上就是今天给大家分享的内容,我们下期见,我是前端小张同学,期待你的关注。

Caddy Web服务器初体验:简洁高效的现代选择

Caddy简介

Caddy是一款使用Go语言编写的开源Web服务器和反向代理服务器,旨在提供易于使用且高效的性能。它支持HTTP/HTTPS协议,并可作为反向代理服务器、负载均衡器和WebSocket支持等。Caddy的灵活性和模块化架构使其适合容器化环境和微服务架构。

Caddy的主要特点

  1. 默认启用HTTPS:Caddy集成了Let’s Encrypt,可以自动申请、更新和管理SSL证书,无需额外操作。
  2. 配置简洁:Caddy的配置文件(Caddyfile)简洁易读,降低了新手的学习成本。
  3. 动态配置管理:通过REST API,可以在运行时更改Caddy的配置,无需重新启动服务器。
  4. 现代化特性:支持Prometheus metrics,使用结构化的JSON作为访问日志。

Caddy与Nginx的对比

特性 Caddy Nginx
配置方式 Caddyfile, JSON, REST API Nginx配置文件(nginx.conf)
自动HTTPS支持 是,默认启用自动TLS证书管理 否,需手动配置SSL证书
适用范围 7层(应用层),反向代理和Web服务,内置负载均衡 支持4层(传输层)和7层(应用层)反向代理、负载均衡等
扩展性 插件化架构,支持扩展 模块化架构,支持静态编译的模块
性能 较高(适合轻量应用) 非常高(适合高并发应用)
配置简洁性 Caddyfile格式简洁,易于上手 配置相对复杂,灵活但不够直观
系统资源占用 较低 较低,适合高并发处理
编写语言 Go语言 C语言
Access日志格式 结构化,默认JSON格式,支持自定义 非结构化,默认标准日志格式,支持自定义

Caddy的基本用法

安装方式

  1. 二进制安装:下载Caddy的二进制文件并移动到PATH下即可使用。
  2. Docker Compose安装:使用Docker容器快速部署Caddy。

Docker Compose配置示例

version: "3.8"
services:
  caddy:
    image: caddy:latest
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    environment:
      - ACME_AGREE=true

volumes:
  caddy_data:
  caddy_config:

配置方式

  1. Caddyfile配置:简洁易读的配置文件。
  2. JSON配置:适合高级使用场景和动态配置。
  3. REST API配置:动态管理和变更配置。

Caddyfile示例

example.com {
    reverse_proxy 127.0.0.1:3000
    log {
        output file /var/log/caddy/access.log {
            mode 644
        }
        format json
    }
}

JSON配置示例

{
  "apps": {
    "http": {
      "servers": {
        "example": {
          "listen": [":80"],
          "routes": [
            {
              "match": [
                {
                  "host": ["example.com"]
                }
              ],
              "handle": [
                {
                  "handler": "static_response",
                  "body": "Hello, world!"
                }
              ]
            }
          ]
        }
      }
    }
  }
}

常见配置示例

  1. 直接回复

    localhost:2017 {
        respond "Hello, world!"
    }
    
  2. 配置静态文件

    localhost:2016 {
        root * /var/www/mysite
        file_server {
            browse
            hide .git
            precompressed zstd br gzip
        }
    }
    
  3. 配置反向代理

    example.com {
        reverse_proxy localhost:8000
    }
    
  4. 配置负载均衡

    example.com {
        reverse_proxy / backend1.example.com backend2.example.com
    }
    

Caddy的持久化存储

  1. 配置文件:自定义配置文件需要放置在合理的位置。
  2. 数据目录:用于存储TLS证书和其他关键数据。
  3. 配置目录:保存最后一次有效的配置。

在使用Docker容器时,需要挂载这些目录以确保数据持久化。

volumes:
  - ./Caddyfile:/etc/caddy/Caddyfile
  - caddy_data:/data
  - caddy_config:/config

【JS】instanceof 和 typeof 的使用

instanceoftypeof

instanceof

instanceof 用于检查一个对象是否是某个构造函数的实例。换句话说,它会检查对象的原型链上是否存在该构造函数的 prototype 属性。 示例代码

let a = new Number(1)
console.log(a instanceof Number);  // true
console.log(a.__proto__.constructor === Number) // true
console.log(a.__proto__ === Number.prototype) // true
console.log('------')
let b = 1
console.log(b instanceof Number); // false
console.log(b.__proto__.constructor === Number) // true
console.log(b.__proto__ === Number.prototype) // true (临时包装对象)

按照上面的说法
x instanceof Y 检查 x 的原型链上是否有 Y.prototype
可以等效为 x.__proto__ === Y.prototype (但是又不完全等效,因为instanceof会在整个原型链上递归查找)
如果我们仅看这个简单的等效,对比上面的4、9行代码。
a是对象,b是原始类型。严格来说,原始类型是没有__proto__的,但是JS引擎会在访问他们的属性的时候,临时包装成对象,使其看起来有__proto__,所以在第9行,还是会输出 true
所以这里为什么第7行,输出是false呢,不是按照上面的规则来,就检查x.__proto__ === Y.prototype 吗,既然第9行为true,但是第7行为false呢?
这里就涉及到另外一条规则了,如果x是原始类型,那么会直接返回false,因为原始类型没有原型链,上面的第9行是包装之后才有了原型链。

工作原理

x instanceof Y 的完整行为:

  1. 如果 x 是原始类型(如 1"a"true),直接返回 false(因为原始类型没有原型链)。
  2. 如果 x 是对象,则沿着 x 的原型链向上查找,检查是否有 Y.prototype
    • 先检查 x.__proto__ === Y.prototype,如果是,返回 true
    • 如果不是,继续检查 x.__proto__.__proto__ === Y.prototype,依此类推,直到原型链尽头(null)。
class Animal {}
class Dog extends Animal {}

const dog = new Dog();
console.log(dog instanceof Dog);     // true
console.log(dog instanceof Animal);  // true(因为 Dog 继承 Animal)
console.log(dog instanceof Object);  // true(所有对象最终继承 Object)

typeof

用来返回变量的基本类型,以字符串的形式返回,且不会检查原型链

console.log(typeof 42);           // "number"
console.log(typeof "hello");      // "string"
console.log(typeof true);         // "boolean"
console.log(typeof undefined);    // "undefined"
console.log(typeof null);         // "object"(历史遗留 bug)
console.log(typeof {});           // "object"
console.log(typeof []);           // "object"(数组也是对象)
console.log(typeof function() {}); // "function"
console.log(typeof Symbol());     // "symbol"
console.log(typeof 123n);         // "bigint"

其中数组、对象、null都会被判断为object。函数也是对象,但是typeof对其进行了特殊处理,返回了function。

  • typeof null === "object"
    这是 JavaScript 早期的一个 Bug,但由于历史原因无法修复。
  • typeof [] === "object"
    数组本质是对象,无法直接区分数组和普通对象(可以用 Array.isArray() 判断)。
  • typeof function() {} === "function"
    函数虽然是对象,但 typeof 对其特殊处理,返回 "function"

总结

操作符 适用场景 不适用场景
typeof 检查原始类型、undefinedfunction 无法区分对象的具体类型(如数组 vs 普通对象)
instanceof 检查对象是否是某个类的实例(包括继承) 不适用于原始类型

推荐组合使用:

  • 先 typeof 判断是否是原始类型。
  • 如果是对象,再用 instanceof 或 Array.isArray() 进一步判断。

项目中遇到浏览器跨域前端和后端解决方案以及大概过程

前言: ‌浏览器出于安全考虑,要求请求的 ‌协议(HTTP/HTTPS)、域名、端口(如 90/455 ‌ 三者完全一致,否则视为跨域。跨域问题的本质是 ‌浏览器通过同源策略保护用户数据安全‌,强制要求前后端资源同源。开发中因为环境分离、多域名部署等场景就会触发该策略的拦截机制‌。

一. 纯前端解决方案

1. ‌本地开发环境推荐使用代理解决‌‌

1.在Vue/React项目中配置 vue.config.js 或 webpack.config.js(vue3中是vite.config.ts文件)

//vue3的Vite示例
export default defineConfig({
  // 服务端渲染
    server: {
      // 端口号
      port: "8980",
      host: "0.0.0.0",
      // 本地跨域代理 http://192.145.1.95:1216
      proxy: {
        "/admin-api": {
          // 这里填写后端地址
          // target: "https://test.com",
          target: VITE_API_PATH, //或者封装起来
          changeOrigin: true,
          rewrite: path => path.replace(/^\/admin-api/, ""),
          secure: false // 验证 SSL 证书
        }
      },
    },
});
// vue2的webpack示例示例
module.exports = {
    devServer: {
        proxy: {
            '/admin-api': {
                target: 'https://test.com',
                changeOrigin: true
            }
        }
    }
};

// 不同的前端语言大致实现思路都一样

2.重启开发服务器,前端请求本地路径包含 /api 自动代理到后端接口地址。

2. ‌利用WebSocket协议解决‌‌

WebSocket 解决跨域的核心原理是在 HTTP 握手阶段通过服务器主动验证 Origin 字段完成跨域授权,而非依赖浏览器同源策略的默认拦截机制‌

1.前端使用WebSocket建立连接:

const socket = new WebSocket('ws://test.com:9090');
socket.onmessage = (event) => { console.log(event.data); };

2.后端需实现WebSocket服务端(如Socket.io)。

3.postMessage(跨窗口通信)

1.父窗口向iframe子窗口发送消息

const iframe = document.getElementById('child-frame');
iframe.contentWindow.postMessage('data', 'http://child.com');

2.子窗口监听消息:

window.addEventListener('message', (event) => {
    if (event.origin === 'http://parent.com') console.log(event.data);
});

二. 后端解决方案

1. ‌CORS(跨域资源共享)--->生产环境推荐 ‌‌

1.后端在响应头中设置 Access-Control-Allow-Origin,允许指定源访问:

// Spring Boot示例
@CrossOrigin(origins = "http://localhost:9090")
@GetMapping("/api/data")
public String getData() { /*...*/ }

2.若需携带Cookie,需设置 Access-Control-Allow-Credentials: true 并指定具体源(不能为*)。

3.预检请求(如PUT、DELETE)需处理OPTIONS方法,返回允许的HTTP方法和头信息。

2. ‌Nginx反向代理--->生产环境推荐‌‌

1.在Nginx配置文件中添加代理规则:

server {
    listen 90;
    server_name frontend.com;
    location /api {
        proxy_pass http://test.com:9090;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

2.重启Nginx服务,使前端通过统一域名访问后端接口

3. ‌JSONP(仅GET请求) ‌‌

1.前端动态创建<script>标签,指定回调函数名:

function handleData(data) { /*...*/ }
const script = document.createElement('script');
script.src = 'http://test.com/data?callback=handleData';
document.body.appendChild(script);

2.后端返回包裹回调函数的数据:

handleData({ "result": "success" });

三、其他场景方案

同主域不同子域‌:设置 document.domain = 'test.com'(需主域相同)‌

IE兼容性‌:使用XDomainRequest对象替代XMLHttpRequest‌

结尾

推荐方案‌:生产环境优先使用CORS或Nginx反向代理‌,开发环境用本地代理‌更方便快捷(配置简单且无侵入性‌)

慎用方案‌:JSONP仅适用于简单GET请求且安全性较低‌(需注意XSS风险‌),postMessage适用于特定跨窗口场景‌。

如果兄弟们还有其他更方便的解决方案欢迎评论区讨论分享,具体实现还需根据项目来定最适合的解决方案

不同命名风格在 Vue 中后台项目中的使用分析

Vue 中后台项目命名风格实践与分析

在中后台项目开发中,命名风格往往被视为“非核心”细节,但它却直接影响着团队协作效率、代码可读性、工程规范一致性与项目的可维护性。

尤其是在 Vue 驱动的中后台系统中,随着页面模块、字段配置、路由管理日益庞杂,统一命名风格已成为结构化开发的基础。

本文从实战角度出发,分析中后台项目中常见的几种命名风格使用场景,结合项目经验给出推荐规范,适用于绝大多数 Vue(含 Vue 2 / Vue 3)系统。

🧩 1. 路由路径(path):推荐使用 kebab-case

✅ 推荐格式:

path: '/user-center/detail/:id'
path: '/order-manage/list'

🚫 不推荐:

path: '/UserCenter/Detail/:id'

✅ 推荐理由:

  • URL 标准推荐小写字母,避免兼容性问题;
  • Linux 环境下大小写敏感,命名不规范容易出错;
  • kebab-case 可读性强,更便于前端路由维护;
  • 对接 SEO、浏览器插件等工具也更友好。

📛 2. 路由名称(name):推荐使用 camelCase

✅ 推荐:

name: 'userCenterDetail'

🚫 不推荐:

name: 'UserCenterDetail'
name: 'user-center-detail'

✅ 推荐理由:

  • camelCase 是 JS 的原生变量命名方式;
  • 路由跳转中常用 router.push({ name: xxx })
  • 有利于 IDE 自动补全与团队协作。

📦 3. 页面组件文件名:推荐使用 PascalCase

✅ 推荐:

List.vue
Detail.vue
UserInfo.vue

🚫 不推荐:

list.vue
userinfo.vue

✅ 推荐理由:

  • 页面组件和普通组件都是 Vue 单文件组件,统一 PascalCase 更规范;
  • 易于区分组件 vs 工具函数;
  • 配合模块化结构(如 UserCenter/List.vue)视觉更清晰。

🗂 4. 文件夹命名风格对比

文件夹类型 推荐命名 示例 说明
页面模块文件夹 PascalCase UserCenter/ 用于组织具体业务模块页面
功能类文件夹 小写复数 constants/, api/ 存放字段配置、接口封装等
通用组件文件夹 PascalCase components/Common/ 推荐组件内再细分 PascalCase 子模块

🧱 5. 表格字段配置命名(columns)

export const userCode = {
  title: '用户编号',
  dataIndex: 'userCode',
  width: '200px',
  align: 'center',
  scopedSlots: { customRender: 'userCode' },
};

✅ 命名建议:

  • dataIndex: 使用 camelCase
  • 字段对象名与 dataIndex 保持一致;
  • 配置文件统一放入 constants/columns.js,便于复用与查找。

🧭 6. 命名风格对照表(总结)

项目 推荐命名风格 示例
路由路径 path kebab-case /project-config/edit/:id
路由名称 name camelCase projectConfigEdit
页面组件文件名 PascalCase Edit.vue, Detail.vue
页面模块目录 PascalCase ProjectConfig/
功能文件夹 小写复数 constants/, hooks/
字段配置对象名 camelCase userStatus, projectCode

🎁 私藏 Tips:团队项目如何悄悄推进命名规范

  1. 路由路径统一为 kebab-case,命名统一为 camelCase
  2. 页面模块用 PascalCase 文件夹,组件文件用 PascalCase 文件名;
  3. 字段配置集中放在 constants/columns.js,使用统一导出格式;
  4. 字段名与 dataIndex 保持一致,可对接字段推荐系统或自动生成器;
  5. 路由 namepath 命名也可纳入“路由资产库”统一管理;
  6. 项目初期定规范,后期不背锅。

✅ 命名风格不是细节,是架构的一部分

命名风格看似琐碎,但它决定了项目结构是否“可预测”、协作是否“无摩擦”。
统一的命名风格不仅让代码更美观,更是一种工程思维的体现。

你不是在写代码,你是在建立秩序。
命名风格,就是最不引人注意的力量。

💡 路由也能资产化?是的,SBERT 了解一下

未来,我们可以像管理字段资产一样,管理路由资产。

结合语义向量技术(如 SBERT),可以为每条路由路径与路由名称生成语义向量,实现:

  • 🔍 通过自然语言搜索页面(如“编辑用户资料” → /user/edit/:id
  • 🔐 权限分配时智能推荐页面(根据路由语义匹配用户角色)
  • 🤖 自动生成路由配置片段(低代码辅助工具)
  • 🧭 检测语义重复路由、结构异常等质量问题

当你的路由也是“结构化数据 + 语义向量”,整个系统将拥有前所未有的自我感知和可治理性。

这不是幻想,而是工程智能化时代的必经之路。


📌 如果你也在思考如何统一命名、构建前端资产体系,欢迎点赞、收藏或私信交流,我们一起把命名变成项目最强大的隐形护盾。

echarts 实现环形渐变

前言

最近产品在ecahrts官网上找到一个 饼图 想要实现这种从头到尾的渐变交互效果,一开始以为非常简单,echarts应该是提供了类似的配置项,知道做起来才发现,这其中没那么简单。

官网案例分析

官网例子中的渐变并不是echarts提供的配置项实现的,而是通过一张 图片 作为背景实现的渐变,所以一开始想着是先来实现一个渐变的饼图,然后通过多个饼图进行拼接来实现类似指针一样的效果,这样就能够实现自定义这个渐变的颜色,并且也很快就写出来一个demo

认识 Echarts 渐变色

在 echarts 的渐变色中,提供了三种类型,包括线性渐变(linear gradient)、径向渐变(radial gradient)和纹理填充(pattern)。

主要了解了一下 线性渐变 以及 径向渐变 的实现效果,在这之后,也意识到了一个严重的问题:通过echarts提供的颜色填充,貌似没办法实现案例里面这种从头到尾的渐变效果,通过线性渐变能够实现下面这种效果

image-20250402170925813.png

这种固定方向的渐变,但是并不符合我们的要求,

并且我也上网找了一些饼图渐变的案例,发现都是通过这种线性渐变来实现的,只不过会去计算这个渐变的角度,来实现类似从头到尾的渐变,但是一旦进度的幅度较大,就马上露馅了。

  • 例子

image-20250402171205454.png

image-20250402171307616.png

可以看到一旦我调大某一个区域的比例,就会发现最后的实现原理还是线性渐变,只不过动态的计算了角度,这种适合多个比例差不多的饼图,但是一旦有某个块比例过大,就还是会出现样式不够美观

奇思妙想

突然意识到,我们最终的目的是自定义这个圆环的起点和终点的颜色,这并不是非得用echarts提供的渐变功能,图片本身并没有问题,图片最大的限制就在于颜色是定好的,但是我们是不是可以让图片的颜色变成动态生成的?

当然可以!

与似乎,就有了下面的方案,通过 canvas 动态生成渐变背景,在讲这张背景图作为圆环的背景图,这样我们就能够实现自定义圆环的起点颜色和终点颜色了

canvas 生成渐变背景

canvas生成背景这个并不是什么难事,百度一下就能够找到类似的案例,然后丢给ai进行美化一下,修改参数变成自己想要的一个函数,我定义的是能够通过传入起点角度,起点颜色,终点颜色 图片大小 四个参数生成一张 base64 的图片

/**
 * 创建圆形渐变图片
 * @param startAngle 起始角度
 * @param startColor 起始颜色
 * @param endColor 结束颜色
 * @param size 大小
 * @returns
 */
export function createCircularGradientImage(startAngle = 0, startColor = '#fff', endColor = 'blue', size = 200) {
  // 创建一个canvas元素
  const canvas = document.createElement('canvas')
  // 设置canvas的宽度
  canvas.width = size
  // 设置canvas的高度
  canvas.height = size
  // 获取2D绘图上下文
  const ctx = canvas.getContext('2d')
  // 检查是否成功获取上下文
  if (!ctx) {
    throw new Error('ctx is null')
  }
  // 创建圆锥渐变
  // 参数:起始角度,圆心x坐标,圆心y坐标
  const gradient = ctx.createConicGradient(startAngle, size / 2, size / 2)
  // 添加渐变的起始颜色
  gradient.addColorStop(0, startColor)
  // 添加渐变的结束颜色
  gradient.addColorStop(1, endColor)
  // 设置填充样式并绘制矩形
  ctx.fillStyle = gradient
  ctx.fillRect(0, 0, size, size)
  // 将canvas转换为base64格式的图片数据
  const res = canvas.toDataURL('image/png')
  // 从DOM中移除canvas元素
  canvas.remove()
  // 返回生成的图片数据
  return res
}

最终我们能够得到一张类似这样的图片

image-20250402151912179.png

结果

接下来的步骤就简单了,参考官网的案例,我们只不过是替换了图片的来源,这样就能够通过传参获得一个自定义颜色的结果。

const _panelImageURL = createCircularGradientImage(0, '#E5E5FF', 'red')

最后的效果:

image-20250402151912179.png

至于文字颜色和阴影颜色,这些都有着很明显的配置项,这里就不做过多的赘述了,本文主要是分享一下通过canvas构造图片来实现渐变的这种思路

如果有大佬有更好的实现渐变的思路欢迎评论区留言!

一个例子直观的告诉你flutter中key的作用

笔者之前是java后端开发,转flutter之后又被公司裁员,当时对flutter知之甚少,去面试没看面经遂被第一题考倒:flutter中widget的key有什么作用?

查阅了网上很多描述都不太理解,最近在某视频学习网站看了一个讲解视频才略有了解。

没有key的情况下

先上代码,以下代码自定义了组件Box,Box中用GestureDetector包裹了一个有定义颜色的Container,每点击Box一次就会使的里面的数字+1.

import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp(
      home: MyHome(),
    ),
  );
}

class MyHome extends StatefulWidget {
  const MyHome({super.key});

  @override
  State<MyHome> createState() => _MyHomeState();
}

class _MyHomeState extends State<MyHome> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: Column(
          children: [
            Box(Colors.red),
            Box(Colors.yellow),
            Box(Colors.blue),
          ],
        ),
      ),
    );
  }
}

class Box extends StatefulWidget {
  const Box(this.color); // 接收并传递 key
  final Color color;

  @override
  State<Box> createState() => _BoxState();
}

class _BoxState extends State<Box> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        _counter++;
        setState(() {});
      },
      child: GestureDetector(
        onTap: () {
          _counter++;
          setState(() {});
        },
        child: Container(
          width: 80,
          height: 80,
          color: widget.color,
            child: Text("$_counter"),
          ),
      ),
    );
  }
}

把以上代码运行起来,再从上往下依次点击1、2、3次,我们会得到这么一个页面

image.png

此时我们再把_MyHomeState中的builder方法里的Box(Colors.yellow)注释掉,再hot reload一下,各位读者可以猜下页面会变成什么样

class _MyHomeState extends State<MyHome> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: Column(
          children: [
            Box(Colors.red),
            // Box(Colors.yellow),
            Box(Colors.blue),
          ],
        ),
      ),
    );
  }
}

如果各位读者像我一样对flutter的key不甚了解,可能会认为,页面上从上往下依次是:红1、蓝3,然而页面变成了这样

image.png

为什么会变成这样呢,这正是因为没有指定key导致的。如下图所示,我们在创建widget树的时候也会创建对应的element树,而状态是保存在elemnet树中的

image.png

当我们删除了黄色的box之后,蓝色的box就顺理成章上移动一格,匹配到右边的的state = 2的box element。而state = 3的boxelement因为找不到对应的widget也被释放掉了。

image.png

正是因为上图所说没匹配到的elemnet被释放了,我们如果把之前注释的代码加回来,再hot reload一下,得到的不是红1、黄2、蓝3,而是红1、黄2、蓝0(初始值为0)

image.png

添加了key的情况下

简单修改代码(26、27、28、37行),给Box传递参数key

import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp(
      home: MyHome(),
    ),
  );
}

class MyHome extends StatefulWidget {
  const MyHome({super.key});

  @override
  State<MyHome> createState() => _MyHomeState();
}

class _MyHomeState extends State<MyHome> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: Column(
          children: [
            Box(Colors.red, key: ValueKey(1),),
            Box(Colors.yellow, key: ValueKey(2),),
            Box(Colors.blue, key: ValueKey(3),),
          ],
        ),
      ),
    );
  }
}

class Box extends StatefulWidget {
  const Box(this.color, {super.key});
  final Color color;

  @override
  State<Box> createState() => _BoxState();
}

class _BoxState extends State<Box> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        _counter++;
        setState(() {});
      },
      child: GestureDetector(
        onTap: () {
          _counter++;
          setState(() {});
        },
        child: Container(
          width: 80,
          height: 80,
          color: widget.color,
          child: Text("$_counter"),
        ),
      ),
    );
  }
}

运行程序,从上往下分别点击1、2、3次,得到红1黄2蓝3

image.png

此时再把黄色的box注释掉,hot reload一下,就会得到红1蓝3,符合我们的预期

image.png

这是因为加了key之后,widget和element不再通过顺序匹配了,而是通过key来匹配,所以这次是key=2的element被释放了

image.png

前端ai对话框架semi-design-vue

对于前端使用ai框架探索

semi-design-vue

实现功能 -

sse格式输出

接收指定命令处理

思考过程可折叠 - 适配deepseek等模型

清除上下文

请求错误返回

fetch请求封装

这是一个组件,可以引入vue3项目的任何一个页面里

import { Chat, MarkdownRender, Spin, Toast, Avatar, AvatarGroup, Tooltip, Space,Collapse } from '@kousum/semi-ui-vue';
import { defineComponent, ref, onMounted } from 'vue';
import { IconChevronUp } from '@kousum/semi-icons-vue';
import { getNewAgentSessionApi, sendMessageApi } from "../api/baseinfo";
import http from '../config/httpConfig';
import EventStreamRequest from '../config/httpFetch';
import { baseUrl } from '../config/baseUrl';

// 请求成功
const successMessage = {
  role: 'assistant',
  id: '1',
  createAt: 1715676751920,
  content: "请求成功"
};
// 等待中
const wattingMessage = {
  id: 'loading',
  role: 'assistant',
  status: 'loading'
};
// 请求失败
const errorMessage = {
  role: 'assistant',
  id: 'error',
  content: '请求错误',
  status: 'error'
};
const defaultMessage = [
  {
    role: 'assistant',
    id: '1',
    createAt: 1715676751919,
    content: ASSISTANT
  }
]
const roleInfo = ROLE_INFO;
const commonOuterStyle = {
  border: '1px solid var(--semi-color-border)',
  borderRadius: '16px',
  minHeight: '100%',
  height: '100%',
  margin: '0 auto',
  width: '100%',
  boxSizing: 'border-box'
};
let id = 0;
function getId() {
  return `id-${id++}`;
}
// 上传文件地址
const uploadProps = {
  action: 'https://api.semi.design/upload'
};

let post_message = ref('');// 指令输出结果
let post_switch = ref(true);//是指令输出还是问答输出
let post_think = ref(false); // 是否有思考过程


const CustomRender = defineComponent(() => {
  const sessionId = localStorage.getItem('chatSessionId');
  const intervalId = ref();
  const message = ref(defaultMessage);
  const onChatsChange = (chats) => {
    message.value = (chats);
  };
  const onMessageSend = async (content, attachment) => {
    message.value = [
      ...message.value,
      {
        role: 'assistant',
        status: 'loading',
        createAt: Date.now(),
        id: getId()
      }
    ];
    let data = {
      sessionId: sessionId,
      question: content
    };
    const form = new FormData();
    const eventStream = new EventStreamRequest(baseUrl + 'llm/chatStream', {
      data, onEvent: (eventData) => {
        if (eventData.indexOf("is running...") === -1) {
          // 判断是指令输出还是正常问答输出
          if (eventData.length >= 12) {
            // 预检查
            const regex = /^data:\{\"code\"/;
            const flag = regex.test(eventData);
            if (flag) {
              post_switch.value = true;
              // 指令输出
              let msg = eventData.slice(5);
              // let json = JSON.stringify(data);
              post_message.value = msg;
              
              const newAssistantMessage = {
                role: 'assistant',
                id: getId(),
                createAt: Date.now(),
                content: '问题检索完成',
              }
              message.value = [...message.value.slice(0, -1), newAssistantMessage]
            } else {
              const regexEnd = /^data:\[\{\{END\}\}\]/;
              const flagEnd = regexEnd.test(eventData);
              if (flagEnd) {
                if (post_switch.value){
                  // 指令抛出
                  window.parent.postMessage(post_message.value, '*');
                }else{
                  // 问答结束
                }
              }else{
                post_switch.value = false;
                post_message.value = "";
                // 问答输出
                // 空格换成 &sp;; ,换行换成&nl;;
                // 如果有思考过程 - 截取思考过程
                if (eventData.indexOf('<think>') > -1 && eventData.indexOf('</think>') === -1){
                  post_think.value = true;
                  let msgStr = eventData.replace(/&sp;;/g, ' ').replace(/&nl;;/g, '\n')
                  let msg = msgStr.slice(12);
                  if (msg.indexOf('</think>')> -1){
                    // 思考结束
                    let resultStr = msg.slice(msg.indexOf(0,'</think>'));
                    let msgStr = resultStr.replace(/&sp;;/g, ' ').replace(/&nl;;/g, '\n');
                    resultThinkResponse(msgStr, msgStr)
                  }else{
                    // 思考进行中
                    let resultStr = msg.slice(msg.indexOf('<think>') + 1);
                    let msgStr = resultStr.replace(/&sp;;/g, ' ').replace(/&nl;;/g, '\n');
                    resultThinkResponse(msgStr, msgStr)
                  }
                  // let msgStr = eventData.replace(/&sp;;/g, ' ').replace(/&nl;;/g, '<br/>').replace(/<think>/g,'');
                  // let msg = msgStr.slice(5);
                  // generateMockResponse(msg);
                } else if (eventData.indexOf('<think>') > -1 && eventData.indexOf('</think>') > -1){
                  // 思考过程之后的回答结果
                  let msgStr = eventData.replace(/&sp;;/g, ' ').replace(/&nl;;/g, '<br />');
                  let resultStr = msgStr.slice(msgStr.indexOf('</think>') + 8);
                  let thinkStr = msgStr.slice(12,msgStr.indexOf('</think>'));
                  // let msg = msgStr.slice(5);
                  // post_think.value = msg;
                  // resultThinkResponse(msg)
                  console.log(msgStr);
                  
                  resultThinkResponse(thinkStr, resultStr);
                }else{
                  post_think.value = false;
                  // 无思考过程返回值
                  let msgStr = eventData.replace(/&sp;;/g, ' ').replace(/&nl;;/g, '<br/>').replace(/<think>/g,'');
                  let msg = msgStr.slice(5);
                  generateMockResponse(msg);
                }
                
              }
            }
          }else{
            console.log(eventData);
          }
        }
      },onError:(error)=>{
        const newAssistantMessage = {
          role: 'assistant',
          id: getId(),
          createAt: Date.now(),
          status: 'error',
          content: ERROR_TEXT,
        }
        message.value = [...message.value.slice(0, -1), newAssistantMessage]
      }
    });
    eventStream.start();
  };
  // 输出think结果
  const resultThinkResponse = (think,content) => {
    let newMessage = {
      role: 'think',
      id: getId(),
      createAt: Date.now(),
      content: content,
      think: think,
      post_think:true,
    };
    message.value = [...message.value.slice(0, -1), newMessage];
    intervalId.current = id;
  };
  // 输出结果
  const generateMockResponse = (content) => {
    const lastMessage = message.value[message.value.length - 1];
    // console.log(content);
    let newMessage = {
      role: 'assistant',
      id: getId(),
      createAt: Date.now(),
      content: content,
    };
    // console.log(lastMessage);

    message.value = [...message.value.slice(0, -1), newMessage];
    intervalId.current = id;
  };
  // 清除上下文
  const clearContext = () => {
    getNewAgentSessionApi().then((result) => {
      localStorage.setItem('chatSessionId', result);
    }).catch((err) => {
      console.log(err);
    });
  };
  // 重新提问
  const onMessageReset = (msg) => {
    generateMockResponse(msg.content);
  };
  // 停止生成
  const onStopGenerator = (msg) => {
    console.log(msg);
    http.cancelRequest();
    Toast.success('已取消');
    const cancel = {
      role: 'assistant',
      id: 'cancel',
      content: '已取消',
      createAt: 1715676751920,
    }
    setTimeout(() => {
      message.value = [...message.value.slice(0, -1), cancel]
    }, 500)
  }
  // 助手和用户对话背景色
  const renderByRole = ({ role, status }) => {
    if (status === 'error'){
      return { backgroundColor: ERROR_BG_COLOR }//错误消息背景色
    }
    return role === 'assistant'
      ? { backgroundColor: ASSISTANT_BG_COLOR } // 助理消息背景色
      : { backgroundColor: USER_BG_COLOR }; // 用户消息背景色
  }
  // 对话渲染
  const renderContent = (props) => {
    const { role, message, defaultNode, className } = props;
    console.log(message.role, post_think.value);
    if (message.content) {
      return <div class={className} style={renderByRole(message)}>
        {message.post_think && message.post_think===true ?(
          <Collapse expandIconPosition="left">
            <Collapse.Panel header="思考" showArrow={true} itemKey={message.id}>
              <MarkdownRender raw={`<myThink>${message.think}</myThink>`} components={components} />
            </Collapse.Panel>
          </Collapse>
        ):''}
        <MarkdownRender raw={message.content} />
      </div>
    } else {
      return <div class={className}>
        <Spin />
      </div>
    }
  };
  const components = () => {
    const components = {};
    components['myThink'] = ({ children, onClick }) => {
      return <p style={{ marginBottom: "12px" }}> {children} </p>
    }
  }
  const handleBefore = (file)=>{
    console.log(file);

    return
  }
  onMounted(async () => {
    try {
      const result = await getNewAgentSessionApi();
      message.value = defaultMessage;
      localStorage.setItem('chatSessionId', result);
    } catch (err) {
      message.value = [errorMessage];
    }
  });
  return () => (
    <Chat
      style={commonOuterStyle}
      chats={message.value}
      roleConfig={roleInfo}
      chatBoxRenderConfig={{ renderChatBoxContent: renderContent }}
      onChatsChange={onChatsChange}
      onMessageSend={onMessageSend}
      onStopGenerator={onStopGenerator}
      showClearContext={true}
      onClear={clearContext}
      onMessageReset={onMessageReset}
      uploadProps={{ uploadProps: uploadProps, disabled:true }}
      uploadTipProps={{ content :'上传功能开发中...'} }
    />
  );
})
export default CustomRender;

可修改配置文件 - 我是定义在全局中的

// 基础配置
const ROLE_INFO = {
  // 用户头像 - 名称
  user: {
    name: 'User',
    uuid:'user',
    avatar: 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png'
  },
  // 智能助手头像 - 名称
  assistant: {
    name: '智能助手',
    uuid:'assistant',
    avatar: 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png'
  },
  // 暂时不用管
  system: {
    name: '智能',
    uuid:'system',
    avatar: 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png'
  }
};
// 助手招呼用语
const ASSISTANT = "我是通用智能助手,请问有什么可以帮助您的?";
// 助手消息背景色
const ASSISTANT_BG_COLOR = '#ccf0ff';
// 用户消息背景色
const USER_BG_COLOR = '#10a2e0';
// 报错消息背景色
const ERROR_BG_COLOR = '#ff3f33';
// 报错消息提示语
const ERROR_TEXT = '请求错误';

fetch请求封装

export default class EventStreamRequest {
  constructor(url, options = {}) {
    this.url = url;
    this.options = options;
    this.controller = new AbortController();
    this.signal = this.controller.signal;
    this.isListening = false; // 新增的状态标志
    this.retryInterval = this.options.retryInterval || 3000; // 默认重试间隔为3秒
  }
  async start() {
    if (this.isListening) return; // 如果已经在监听,则不再启动新的监听
    this.isListening = true;
    const attemptConnect = async () => {
      try {
        const response = await fetch(this.url, {
          method: 'POST',
          responseType:'text/event-stream; charset=utf-8',
          headers: {
            'Content-Type': 'application/json',
            // ...this.options.headers,
          },
          signal: this.signal,
          body: JSON.stringify(this.options.data)
        });

        if (!response.ok) {
          throw new Error(`Failed to fetch event stream with status ${response.status}`);
        }

        this.processStream(response.body.getReader());
      } catch (error) {
        this.handleError(error);
        // setTimeout(attemptConnect, this.retryInterval); // 错误发生后尝试重新连接
      }
    };

    attemptConnect(); // 尝试连接
  }

  processStream(reader) {
    const decoder = new TextDecoder();
    let buffer = '';

    const processChunk = async ({ done, value }) => {
      if (done) {
        this.isListening = false; // 流结束时更新状态标志
        return;
      }

      buffer += decoder.decode(value, { stream: true });

      let index;
      while ((index = buffer.indexOf('\n\n')) !== -1) {
        const eventData = buffer.slice(0, index).trim();
        buffer = buffer.slice(index + 2);
        this.handleEvent(eventData);
      }

      reader.read().then(processChunk);
    };

    reader.read().then(processChunk);
  }

  handleEvent(eventData) {
    // console.log('Received event:', eventData);
    // 可以在这里调用外部传入的处理器
    if (typeof this.options.onEvent === 'function') {
      this.options.onEvent(eventData);
    }
  }

  handleError(error) {
    if (typeof this.options.onError === 'function') {
      this.options.onError(error);
    }
  }

  abort() {
    if (this.isListening) {
      this.controller.abort();
      this.isListening = false;
      console.log('EventStream request aborted');
    }
  }
}

实现效果:

731c4b625003e9fbd2114c3065c86465.png

b1637096e78666582eedb4c2f525e14f.png

一网打尽浏览器跨标签页通讯

一网打尽浏览器跨标签页通讯

简介

跨标签页通信是指在同一个浏览器中,不同标签页之间进行数据交换和信息同步的技术。由于浏览器的同源策略和安全限制,不同标签页默认是相互隔离的,无法直接通信。但在实际开发中,我们经常需要实现标签页间的数据共享和状态同步,比如用户在一个标签页登录后,其他标签页需要同步更新登录状态。本文将介绍几种常见的跨标签页通信方案,分析它们的优缺点和适用场景。

实现方式

浏览器的多个标签页(Tab)之间的通信可以通过以下几种方式实现:

1. LocalStorage + Storage 事件

localStorage 是浏览器提供的本地存储功能,多个标签页共享同一域的 localStorage 数据。

浏览器会在某个标签页的 localStorage 数据更改时触发 storage 事件,通知其他标签页。

实现方式:

// 在标签页 A 中设置 localStorage
localStorage.setItem('message', 'Hello from Tab A')

// 在标签页 B 中监听 storage 事件
window.addEventListener('storage', event => {
  if (event.key === 'message') {
    console.log('Received message from Tab A:', event.newValue)
  }
})
  • 优点: 简单易用,支持多标签页通信。
  • 缺点: 只能传递字符串,不能发送复杂的对象或二进制数据。

2. BroadcastChannel

BroadcastChannel 是一种专用的 API,通过创建同一频道来实现跨标签页通信,允许同一源的不同标签页之间进行实时通信。

实现方式:

// 在标签页 A 中
const channel = new BroadcastChannel('my_channel')
channel.postMessage('Hello from Tab A')

// 在标签页 B 中
const channel = new BroadcastChannel('my_channel')
channel.onmessage = event => {
  console.log('Received message:', event.data)
}
  • 优点: API 简单,支持发送复杂对象,实时性高。
  • 缺点: 不支持跨源通信,只能在同源的页面之间使用。

3. SharedWorker

SharedWorker 是一种可以在多个标签页之间共享的 Worker,通过 MessagePort 实现通信。

每个标签页通过连接到同一个 SharedWorker,间接实现通信。

实现方式:

// shareWorker.js:
const connections = [];

onconnect = (event) => {
  const port = event.ports[0];
  connections.push(port);

  // 接收到某个标签页的消息后广播给所有连接的标签页
  port.onmessage = (e) => {
    connections.forEach((conn) => {
      if (conn !== port) {
        conn.postMessage(e.data);
      }
    });
  };
};

// 主线程(标签页):
const worker = new SharedWorker('worker.js')
worker.port.start()

// 发送消息到 SharedWorker
worker.port.postMessage('Hello from Tab')

// 接收 SharedWorker 的消息
worker.port.onmessage = event => {
  console.log('Received message:', event.data)
}
  • 优点: 可高效处理复杂计算和逻辑,同时支持多标签页通信。
  • 缺点: 实现稍复杂,需要支持的浏览器较新。

4. Service Worker + MessageChannel

ServiceWorker 可以作为多标签页之间的中转站,通过 MessageChannel 实现双向通信。

实现方式:

// serviceWorker.js:
self.onmessage = event => {
  const { port } = event.data
  port.postMessage('Message received by Service Worker')
}

// 主线程(标签页):
// 向 Service Worker 注册并发送消息
navigator.serviceWorker.ready.then(registration => {
  const messageChannel = new MessageChannel()
  messageChannel.port1.onmessage = event => {
    console.log('Received from Service Worker:', event.data)
  }

  registration.active.postMessage({ port: messageChannel.port2 }, [messageChannel.port2])
})
  • 优点: 功能强大,适合复杂的多标签页通信。

  • 缺点: 需要配置 Service Worker,较复杂。

5. WebSocket

通过 WebSocket,在服务器端中转消息,从而实现不同标签页之间的通信。

实现方式:

// 客户端代码(所有标签页):

const socket = new WebSocket('ws://example.com')

socket.onopen = () => {
  socket.send('Hello from Tab!')
}

socket.onmessage = event => {
  console.log('Received message from server:', event.data)
}

// 服务端代码(Node.js 示例):
const WebSocket = require('ws')
const server = new WebSocket.Server({ port: 8080 })

const clients = []

server.on('connection', socket => {
  clients.push(socket)

  socket.on('message', message => {
    clients.forEach(client => {
      if (client !== socket && client.readyState === WebSocket.OPEN) {
        client.send(message)
      }
    })
  })
})
  • 优点: 实时性强,支持跨设备通信。
  • 缺点: 需要服务端支持。

6. IndexedDB + Polling

通过 IndexedDB 共享存储数据,结合定时轮询同步变化,实现标签页通信。

// 标签页 A:
const dbRequest = indexedDB.open('myDatabase', 1)
dbRequest.onupgradeneeded = function () {
  const db = dbRequest.result
  db.createObjectStore('messages')
}
dbRequest.onsuccess = function () {
  const db = dbRequest.result
  const tx = db.transaction('messages', 'readwrite')
  const store = tx.objectStore('messages')
  store.put('Hello from Tab A', 'message')
}

// 标签页 B:
setInterval(() => {
  const dbRequest = indexedDB.open('myDatabase', 1)
  dbRequest.onsuccess = function () {
    const db = dbRequest.result
    const tx = db.transaction('messages', 'readonly')
    const store = tx.objectStore('messages')
    const request = store.get('message')
    request.onsuccess = function () {
      console.log('Received:', request.result)
    }
  }
}, 1000)
  • 优点: 数据持久化,历史消息可用。
  • 缺点: 实时性差,轮询开销高。

总结

  • LocalStorage + Storage 事件:简单易用,适合基础数据同步,但只能传递字符串且容量有限
  • BroadcastChannel:API 简洁,支持复杂对象,但仅限同源页面
  • SharedWorker:适合复杂计算场景,但实现较复杂
  • Service Worker + MessageChannel:功能强大但配置复杂
  • WebSocket:实时性强且支持跨设备,但依赖服务端
  • IndexedDB + Polling:数据持久化但实时性差

Rust 为什么不适合写 GUI

前言

在当今科技蓬勃发展的时代,Rust 编程语言正崭露头角,逐步为世界上诸多重要基础设施提供动力支持。从存储海量信息到应用于 Linux 内核,Rust 展现出强大的实力。然而,当涉及构建 GUI(图形用户界面)时,Rust 却面临着诸多挑战。据数据显示,超过 56% 的 Rust 开发者认为其 GUI 开发亟待大幅改进,这也是许多人起初不愿采用 Rust 进行相关开发的重要原因。

Rust 的独特之处

Rust 自诞生之初,便以独特的姿态区别于其他编程语言。在众多编程语言中,垃圾回收机制较为常见,它能自动管理内存的分配与释放,极大减轻了开发者的负担。而 Rust 采用了所有权机制,这一机制在编译时生效。也就是说,值由变量拥有,变量可对值进行引用,当拥有变量超出作用域时,其所拥有的值会被自动释放。

此外,Rust 能够有效防范多线程同时访问相同数据的情况,即数据竞争问题。它通过确保同一时刻要么只有一个可变引用,要么有多个不可变引用,保证引用始终有效,并且当存在有效引用时,相关值不能被修改。同时,Rust 并非像 Java、C++ 或 JavaScript 那样的面向对象语言,它不支持抽象类和类继承。

例如,在面向对象语言中,通常会有一个顶层类 Component,其中包含 draw 方法,像按钮(Button)或文本(Text)等组件会继承自这个类并复用其函数。但在 Rust 中,情况有所不同,它使用 traits。开发者可以在库中添加一个名为 draw 的通用 trait,只要按钮对象、文本对象和图像对象实现了这个 Draw trait,它们就会被视为 UI 组件。甚至可以将一个随机的 sandwich 对象添加到 UI 组件库中,只要它实现了 Draw trait,当然这在实际开发中不太可能通过代码评审。

Rust 构建 GUI 之难

那么,究竟是什么让用 Rust 构建 GUI 如此困难呢?前面提到的 Rust 的独特之处,恰恰也是构建 GUI 时的阻碍因素。在编程领域,UI 通常被设计为树状结构,但使用 Rust 的继承机制构建树状结构极为困难。

以构建一个简单的登录界面为例,在 Android 开发中,视图树形结构有着清晰的层级关系。Android 的视图体系基于 View 和 ViewGroup 类。ViewGroup 是一个特殊的 View,它可以包含多个子 View,就像是树枝可以长出许多树叶一样,这就形成了一个树形结构。

比如,登录界面的最外层可能是一个 LinearLayout(线性布局,属于 ViewGroup 的一种),它决定了内部组件的排列方式是水平还是垂直。在这个 LinearLayout 里,可能有两个 EditText(输入框,属于 View)用于输入用户名和密码,还有一个 Button(按钮,同样属于 View)用于触发登录操作。

在代码实现上,开发者会在 XML 布局文件中描述这个树形结构。假设布局文件名为 activity_login.xml,代码可能如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <EditText
        android:id="@+id/username_edit_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="用户名"/>
    <EditText
        android:id="@+id/password_edit_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="密码"
        android:inputType="textPassword"/>
    <Button
        android:id="@+id/login_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="登录"/>
</LinearLayout>

在运行时,Android 系统会根据这个 XML 文件构建出相应的视图树形结构。当用户进行操作,比如点击按钮时,事件会从最上层的视图容器开始,沿着树形结构向下传递,找到对应的按钮 View 并触发相应的点击事件处理逻辑。

而在 Rust 中,由于缺乏像 Android 这种基于类继承的成熟视图体系,构建类似的树形结构就变得复杂许多。Rust 的 trait 不存储数据,导致每个组件需要自行管理其下的子组件,这使得遍历树状结构变得困难。

以构建登录界面为例,假设我们定义一个用于绘制 UI 组件的 trait,比如Draw:

trait Draw {
    fn draw(&self);
}

这里的 Draw trait 规定了实现它的类型必须拥有draw方法,但它并没有为实现它的类型提供存储数据的空间。当我们创建登录界面的各个组件(如输入框和按钮)并让它们实现 Draw trait 时,每个组件都需要自行处理数据存储的问题。

struct LoginButton {
    // 按钮的相关数据,如文本、位置等
    text: String,
    x: i32,
    y: i32,
}
impl Draw for LoginButton {
    fn draw(&self) {
        // 绘制按钮的逻辑,使用自身存储的数据
        println!("Drawing button with text: {}", self.text);
    }
}

在上述代码中,LoginButton 结构体实现了 Draw trait,它需要自己定义和管理数据(text、x、y)。相比之下,在 Android 中,视图类(如 EditText、Button)继承自 View 类,View 类及其父类会为子类提供一些默认的数据存储和管理机制,例如位置、大小等属性,子类可以直接使用或继承这些数据。

除了状态管理的不方便,Rust 的可变性规则也给 UI 组件状态的动态更新带来了挑战。Rust 的可变性规则主要用于确保内存安全和避免数据竞争。简单来说,在同一时间内,一个数据要么有多个不可变引用(可以理解为只读访问),要么只有一个可变引用(可以修改数据),但不能同时存在可变和不可变引用。

例如,在登录界面的场景中,如果我们要根据用户输入实时显示错误提示信息,在 Rust 中实现起来就不像在 Android 开发中那么直观。因为可能会涉及到状态的动态更新,此时遇到可变性规则的挑战。

假设我们有一个登录逻辑,需要根据用户名和密码的输入情况更新错误提示信息:

fn login(username: &str, password: &str) -> String {
    let mut error_message = String::new();
    if username.is_empty() {
        error_message.push_str("用户名不能为空");
    }
    if password.len() < 6 {
        if!error_message.is_empty() {
            error_message.push_str(", ");
        }
        error_message.push_str("密码长度至少为6位");
    }
    error_message
}

在这个例子中,error_message 是可变的,以便在不同的条件下添加错误信息。这个代码在单一线程运行,同一时刻只有一个可变引用指向 error_message,是可以通过编译的。但如果在更复杂的 UI 场景中,多个线程或不同的代码块同时尝试访问和修改 error_message,就会违反 Rust 的可变性规则,导致编译错误。因为 Rust 要保证数据在任何时刻的状态都是可预测的,避免出现数据竞争和未定义行为。

比如下面这样

use std::thread;

fn main() {
    let mut error_message = String::new();

    let handle1 = thread::spawn(move || {
        error_message.push_str("线程 1 产生的错误");
    });

    let handle2 = thread::spawn(move || {
        error_message.push_str("线程 2 产生的错误");
    });

    handle1.join().unwrap();
    handle2.join().unwrap();

    println!("{}", error_message);
}

在这个示例里,多个线程同时尝试修改 error_message,Rust 编译器会检测到这种情况并报错,因为这违反了 Rust 的可变性规则,可能会引发数据竞争问题。

当然,多线程更新字符串的情况不多,也许有人说这个例子不具代表性,再看一个更具场景的情况。在 GUI 开发里,常常需要根据用户的操作动态更新 UI 状态,并且重新渲染视图。假设我们要开发一个简单的计数器界面,用户点击按钮时,计数器的值会增加。

在 Rust 里,为了保证内存安全,可变性规则会对状态更新和视图渲染之间的交互产生影响。以下是一个简化的示例代码:

// 假设这是一个简单的 UI 组件
struct Counter {
    value: u32,
}

impl Counter {
    fn increment(&mut self) {
        self.value += 1;
    }
    fn draw(&self) {
        println!("当前计数器的值: {}", self.value);
    }
}

fn main() {
    let mut counter = Counter { value: 0 };
    // 模拟用户点击按钮
    counter.increment();
    counter.draw();
}

在这个示例中,Counter 结构体表示一个计数器组件,increment 方法用于增加计数器的值,draw 方法用于渲染计数器的当前值。编译正常。

如果稍不注意, main 函数写成下面这样,编译就出错了

fn main() {
    let mut counter = Counter { value: 0 };
    let mut_ref = &mut counter;
    mut_ref.increment();

    // 这里会产生编译错误
    counter.draw(); 
}

上面 main 函数中,我们首先对 counter 进行了可变借用,创建了可变引用 mut_ref,并调用 mut_ref.increment() 方法对 counter 的值进行修改。

接着,我们尝试直接调用 counter.draw() 方法。但由于此时 counter 仍处于被可变借用的状态(mut_ref 的生命周期还未结束),Rust 的可变性规则不允许在可变借用期间对同一个数据进行不可变借用。因此,counter.draw() 这行代码会导致编译错误。

error[E0502]: cannot borrow `counter` as immutable because it is also borrowed as mutable
  --> src/main.rs:17:5
   |
15 |     let mut_ref = &mut counter;
   |                   -------- mutable borrow occurs here
16 |     mut_ref.increment();
17 |     counter.draw(); 
   |     ^^^^^^^ immutable borrow occurs here
18 | }
   | - mutable borrow ends here

通过正确和错误两个版本代码的对比,可以看出

  • 错误版本:对 counter 进行了可变借用,创建了可变引用 mut_ref,并且在可变借用的生命周期内尝试对 counter 进行不可变借用,违反了 Rust 的可变性规则。
  • 正确版本:没有同时存在可变借用和不可变借用的冲突情况。先直接调用 counter.increment() 方法对 counter 进行可变操作,操作完成后,可变借用结束,再调用 counter.draw() 方法进行不可变操作,符合 Rust 的可变性规则。

但在实际的 GUI 应用中,UI 组件的状态可能会受到多个因素的影响,状态更新和视图渲染的逻辑也会更加复杂,很可能需要根据不同的条件更新多个 UI 组件的状态,并且在合适的时机进行视图渲染。Rust 的可变性规则会让这种状态管理变得更加困难,稍有不慎就会出现编译错误,增加了开发者的心智负担。

应对之策与实践探索

尽管困难重重,但并非毫无解决办法。有一个专门的网站 https://areweguiyet.com/ 致力于更新 Rust 在 GUI 开发方面的进展情况。在开源社区,也有许多项目取得了显著进展,比如 ICED 或 Tauri,它们使用 Rust 为原生 Web 视图提供支持。

另一种有效的解决方案是完全摒弃面向对象编程,深入采用 Rust 的方式来处理问题。例如使用 ELM 架构,它由模型(Model)、视图(View)和更新(Update)组成。模型存储视图的所有状态,视图将模型数据转换为屏幕上可见的内容,更新则负责使用程序员定义的对象 “MSGs” 来修改模型。

image.png

这种架构其实就是 Android 近年来推崇的 UDF(单项数据流),在 Rust 中实现这种架构有诸多优势,它是功能性且可变的,开发者无需直接修改数据,因为数据始终通过更新函数进行处理。例如,可以插入一个全新的值,由于模型只有一个单一所有者,不会触发任何警报。此外,Rust 的枚举(Enums)使得确定不同数据类型变得容易,开发者可以在代码中轻松进行模式匹配,ICED 项目就有很好的示例展示如何使用 Rust 枚举通过按钮来增加或减少数字。 但是 ELM 架构并非完美无缺,也有一些尝试对其进行替代的方案,其中一种替代方案是实体组件系统架构(Entity Component System Architecture)。在这种架构中,Entity(实体)和 Component(组件)是两个核心概念。

Entity 可以理解为一个唯一的标识符,它本身不包含任何数据或行为。在 ECS 架构里,Entity 就像是一个容器或者一个 “占位符”,用于将不同的 Component 组合在一起。例如一个游戏中,它代表其中一个角色、一个道具,或者 GUI 界面中的一个按钮、一个文本框等。在 Rust 中,Entity 通常用一个简单的整数 ID 来表示。

Component 是包含数据的最小单元,它只负责存储特定类型的数据,而不包含任何行为。例如,在一个游戏中,可能有表示位置的 PositionComponent、表示速度的 VelocityComponent;在 GUI 开发中,可能有表示文本内容的 TextComponent、表示颜色的 ColorComponent 等。每个 Component 专注于一种特定的属性或状态。

image.png

著名的 Warp 终端项目就采用了这种方式实现。将每个组件称为 view,并赋予其一个唯一的 ID,即 entity id。每个窗口存储实体 ID 到实际视图的映射,通过这种方式存储与视图相关的任何状态,并且可以存储每个视图到父视图的映射,以便在树状结构中向上遍历。这些数据以一系列由系统拥有的映射和列表形式存储,这是目前在 Rust 中模拟面向对象编程语言最接近的方式。

通过这种实现方式,Warp 能够创建丰富的 UI 元素,并且性能几乎可与其他任何终端媲美。如果读者对此感兴趣,可以通过视频描述中的链接免费下载 Warp 来体验其 GUI。

展望

在 Rust 构建 GUI 的领域中,尽管充满挑战,但通过不断探索和创新,开发者们已经找到多种有效的解决途径,并且在实践中取得了不错的成果,未来 Rust 在 GUI 开发方面有望迎来更广阔的发展前景 。

如何用 Node.JS 和 Canvas 自动生成图片

在本指南中,我将展示如何用 Node.JS 生成文章缩略图。以下是我用这种方法生成的一张图片:

本文的完整代码可以在 Git Gist 中找到。

由于 Node.JS ,它本身并不具备 canvas 功能。我们使用一个名为 canvas 的组件,将其导入到我们的 Node.JS 项目中。可以通过运行 npm i canvas 来安装它。

如何在 Canvas 中使用 Emoji

对于我生成的图片,我还想使用 Emoji。因此,我使用了该包的一个分支,名为 @napi-rs/canvas,它支持 Emoji。我使用的版本是 0.1.14,所以如果你在本指南操作时遇到问题,尝试用 npm i @napi-rs/canvas@0.1.14 命令安装它。

现在我们已经了解了基础知识,让我们开始吧。首先,让我们导入所有需要的包。在这里我导入了几个东西:

  • canvas — 这是我们创建图片的方式。
  • fs — 将图片写入服务器并保存。
  • cwebp — 这是我们将图片保存为 webp 文件的方式,这样它就能针对网络进行优化。
  • fonts — 导入 3 种字体——其中两种是 Inter 的不同版本,这是一种很好的字体,最后一种是 Apple Emoji 字体。你可以在 Inter 字体页面 找到 Inter 字体,在 Apple Emoji 字体页面 找到 Apple Emoji 字体。
import canvas from '@napi-rs/canvas' // 用于创建画布。
import fs from 'fs' // 用于为我们的图片创建文件。
import cwebp from 'cwebp' // 用于将图片转换为 webp 格式。

// 加载我们需要的字体
GlobalFonts.registerFromPath('./fonts/Inter-ExtraBold.ttf', 'InterBold');
GlobalFonts.registerFromPath('./fonts/Inter-Medium.ttf','InterMedium');
GlobalFonts.registerFromPath('./fonts/Apple-Emoji.ttf', 'AppleEmoji');

如何用 JavaScript 生成文章缩略图

当我们在 HTML 画布上书写文本时,它通常不会自动换行。相反,我们需要创建一个函数来测量容器的宽度,并决定是否换行。注释后的函数如下所示

// 这个函数接受 6 个参数:
// - ctx: 画布的上下文
// - text: 我们想要换行的文本
// - x: 文本的起始 x 坐标
// - y: 文本的起始 y 坐标
// - maxWidth: 最大宽度,即容器的宽度
// - lineHeight: 每行的高度(由我们定义)
const wrapText = function(ctx, text, x, y, maxWidth, lineHeight) {
    // 首先,按空格分割单词
    let words = text.split(' ');
    // 然后我们创建几个变量来存储行的信息
    let line = '';
    let testLine = '';
    // wordArray 是我们将要返回的数组,它将保存
    // 行文本的信息,以及它的 x 和 y 起始位置
    let wordArray = [];
    // totalLineHeight 将保存行高的信息
    let totalLineHeight = 0;

    // 接下来,我们遍历每个单词
    for(var n = 0; n < words.length; n++) {
        // 测试它的长度
        testLine += `${words[n]} `;
        var metrics = ctx.measureText(testLine);
        var testWidth = metrics.width;
        // 如果太长,则我们开始新的一行
        if (testWidth > maxWidth && n > 0) {
            wordArray.push([line, x, y]);
            y += lineHeight;
            totalLineHeight += lineHeight;
            line = `${words[n]} `;
            testLine = `${words[n]} `;
        }
        else {
            // 否则我们只有一行!
            line += `${words[n]} `;
        }
        // 当所有单词完成后,我们将剩余的内容推入数组
        if(n === words.length - 1) {
            wordArray.push([line, x, y]);
        }
    }

    // 返回包含单词的数组,以及总行高
    // 总行高将是 (总行数 - 1) * 行高
    return [ wordArray, totalLineHeight ];
}

现在我们开始编写 generateMainImage 函数。这个函数将接受我们提供的所有信息,并为你的文章或网站生成一张图片。

在这个函数中,你可以传入任何你想要的颜色,选择权在你手中。

// 这个函数接受 5 个参数:
// canonicalName: 这是我们用来保存图片的名字
// gradientColors: 一个包含两种颜色的数组,例如 [ '#ffffff', '#000000' ],用于我们的渐变
// articleName: 你希望在图片中显示的文章或网站的标题
// articleCategory: 该文章所属的类别——或者文章的副标题
// emoji: 你希望在图片中显示的 emoji
const generateMainImage = async function(canonicalName, gradientColors, articleName, articleCategory, emoji) {

    articleCategory = articleCategory.toUpperCase();
    // gradientColors 是一个数组 [ c1, c2 ]
    if(typeof gradientColors === "undefined") {
        gradientColors = [ "#8005fc", "#073bae"]; // 备用值
    }

    // 创建画布
    const canvas = createCanvas(1342, 853);
    const ctx = canvas.getContext('2d')

    // 添加渐变——我们使用 createLinearGradient 来实现这一点
    let grd = ctx.createLinearGradient(0, 853, 1352, 0);
    grd.addColorStop(0, gradientColors[0]);
    grd.addColorStop(1, gradientColors[1]);
    ctx.fillStyle = grd;
    // 填充我们的渐变
    ctx.fillRect(0, 0, 1342, 853);

    // 在画布上书写我们的 Emoji
    ctx.fillStyle = 'white';
    ctx.font = '95px AppleEmoji';
    ctx.fillText(emoji, 85, 700);

    // 添加我们的标题文本
    ctx.font = '95px InterBold';
    ctx.fillStyle = 'white';
    let wrappedText = wrapText(ctx, articleName, 85, 753, 1200, 100);
    wrappedText[0].forEach(function(item) {
        // 我们将填充数组中的文本 item[0],在坐标 [x, y]
        // x 是数组中的 item[1]
        // y 是数组中的 item[2],减去行高(wrappedText[1]),再减去 emoji 的高度(200px)
        ctx.fillText(item[0], item[1], item[2] - wrappedText[1] - 200); // 200 是 emoji 的高度
    })

    // 将我们的类别文本添加到画布上
    ctx.font = '50px InterMedium';
    ctx.fillStyle = 'rgba(255,255,255,0.8)';
    ctx.fillText(articleCategory, 85, 553 - wrappedText[1] - 100); // 853 - 200 用于 emoji,-100 用于 1 行的行高

    if(fs.existsSync(`./views/images/intro-images/${canonicalName}.png`)) {
        return '图片已存在!我们没有创建任何图片'
    }
    else {
        // 将画布设置为 png 格式
        try {
            const canvasData = await canvas.encode('png');
            // 保存文件
            fs.writeFileSync(`./views/images/intro-images/${canonicalName}.png`, canvasData);
        }
        catch(e) {
            console.log(e);
            return '这次无法创建 png 图片。'
        }
        try {
            const encoder = new cwebp.CWebp(path.join(__dirname, '../', `/views/images/intro-images/${canonicalName}.png`));
            encoder.quality(30);
            await encoder.write(`./views/images/intro-images/${canonicalName}.webp`, function(err) {
                if(err) console.log(err);
            });
        }
        catch(e) {
            console.log(e);
            return '这次无法创建 webp 图片。'
        }

        return '图片已成功创建!';
    }
}

用 Node.JS 生成文章图片

让我们仔细观察一下这个函数,以便完全理解其中的原理。我们首先准备数据——将类别转换为大写,并设置一个默认渐变。然后我们创建画布,并使用 getContext 初始化一个绘制的空间。

articleCategory = articleCategory.toUpperCase();
// gradientColors 是一个数组 [ c1, c2 ]
if(typeof gradientColors === "undefined") {
    gradientColors = [ "#8005fc", "#073bae"]; // 备用值
}

// 创建画布
const canvas = createCanvas(1342, 853);
const ctx = canvas.getContext('2d')

然后绘制渐变:

// 添加渐变——我们使用 createLinearGradient 来实现这一点
let grd = ctx.createLinearGradient(0, 853, 1352, 0);
grd.addColorStop(0, gradientColors[0]);
grd.addColorStop(1, gradientColors[1]);
ctx.fillStyle = grd;
// 填充我们的渐变
ctx.fillRect(0, 0, 1342, 853);

图片上绘制 emoji 文本。

// 在画布上书写我们的 Emoji
ctx.fillStyle = 'white';
ctx.font = '95px AppleEmoji';
ctx.fillText(emoji, 85, 700);

现在我们使用我们的换行函数 wrapText。我们将传入相当长的 articleName,并从图片底部附近的 85, 753 开始。由于 wrapText 返回一个数组,我们将遍历该数组以确定每行的坐标,并将它们绘制到画布上:

    // 添加我们的标题文本
    ctx.font = '95px InterBold';
    ctx.fillStyle = 'white';
    let wrappedText = wrapText(ctx, articleName, 85, 753, 1200, 100);
    wrappedText[0].forEach(function(item) {
        // 我们将填充数组中的文本 item[0],在坐标 [x, y]
        // x 是数组中的 item[1]
        // y 是数组中的 item[2],减去行高(wrappedText[1]),再减去 emoji 的高度(200px)
        ctx.fillText(item[0], item[1], item[2] - wrappedText[1] - 200); // 200 是 emoji 的高度
    })

    // 将我们的类别文本添加到画布上
    ctx.font = '50px InterMedium';
    ctx.fillStyle = 'rgba(255,255,255,0.8)';
    ctx.fillText(articleCategory, 85, 553 - wrappedText[1] - 100); // 853 - 200 用于 emoji,-100 用于 1 行的行高

将画布图片保存到服务器

好了,现在我们已经创建了图片,让我们将它保存到服务器上:

  • 首先,我们将检查文件是否存在。如果存在,我们将返回图片。
  • 如果文件不存在,我们将尝试使用 canvas.encode 创建 png 版本,并使用 fs.writeFileSync 保存它。
  • 如果一切顺利,我们将使用 cwebp 保存一个.webp 版本的文件,这比 .png 版本小得多。
  if(fs.existsSync(`./views/images/intro-images/${canonicalName}.png`)) {
      return '图片已存在!我们没有创建任何图片'
  }
  else {
      // 将画布设置为 png 格式
      try {
          const canvasData = await canvas.encode('png');
          // 保存文件
          fs.writeFileSync(`./views/images/intro-images/${canonicalName}.png`, canvasData);
      }
      catch(e) {
          console.log(e);
          return '这次无法创建 png 图片。'
      }
      try {
          const encoder = new cwebp.CWebp(path.join(__dirname, '../', `/views/images/intro-images/${canonicalName}.png`));
          encoder.quality(30);
          await encoder.write(`./views/images/intro-images/${canonicalName}.webp`, function(err) {
              if(err) console.log(err);
          });
      }
      catch(e) {
          console.log(e);
          return '这次无法创建 webp 图片。'
      }

      return '图片已成功创建!';
  }

要运行这个文件:

node index.js

以下是通过这种方式生成的一张图片的示例:

原文:fjolt.com/article/jav…

❌