自己开发组件库
搭建vue3 & ts组件库脚手架
目标
pnpm
搭建monorepo
项目,和使用workspace
测试组件库- 组件支持
typescript
,可以被使用的项目识别 - 组件支持整体导入、按需自动导入
环境要求
node ≥ 18
, pnpm ≥ 8
, vue ≥ 3.3
初始化项目模板
首先使用vite
和pnpm
创建一个项目模板,这里使用pnpm
,方便后面monorepo
的使用。
pnpm create vite
在交互命令中填写项目名称,选择 Vue + Typescript 模板
然后进入项目目录,使用 pnpm install
安装依赖
使用monorepo管理组件库
使用 monorepo
可以将多个包放在一下项目下维护,包之间可以互相引用,相同的依赖和配置也可以统一维护起来。除了组件库,可能后面还会新增工具库和插件库,使用monorepo
可以更好的进行管理。
创建过程如下:
-
首先指定
monorepo
目录。在项目根目录创建packages
文件夹和pnpm-workspace.yaml
文件,文件的内容为:packages: - 'packages/**'
这样就可以指定项目
packages
下的文件夹为子包。 -
在
packages
文件夹下新建components
文件夹,并在新建的文件夹中新建一个package.json
文件,初始内容如下:{ "name": "@giegie/components", "version": "0.0.1", "description": "练习了2年半的高性能组件库", "scripts": {} }
其中
@giegie/component
是npm
包的名称,@giegie
是包的作用域,可以避免包的冲突。
创建第一个组件
先来创建一个简单的 Input
组件用作测试,如图所示,需要在src
下建立一个Input
文件夹,且需要创建几个固定的文件:
-
style/index.scss
— 用于定义组件的样式。在里面补充一点简单的样式:.gie-input { &__control { color: red; } }
为什么样式要拆开而不是直接写在Input组件里呢? 因为需要在构建时打包成一个css文件用于组件库整体导入。按需导入时,样式放在约定的目录,也方便让按需导入的插件自动引入样式。
-
Input.ts
— 用于定义类型文件,如Input
的props
类型,emit
类型和instance
类型等,内容如下:import Input from './Input.vue'; /** * 定义props类型 */ export interface InputProps { modelValue: string; disabled?: boolean; } /** * 定义emit类型 */ export type InputEmits = { 'update:modelValue': [value: string]; }; /** * 定义instance类型 */ export type InputInstance = InstanceType<typeof Input>;
InputInstance
是用来干啥的? 在写公共组件时,会使用defineExpose暴露一些方法。如在element-plus
中,就会使用formRef.validate
来校验表单,instance
里就有暴露方法的类型签名。 -
Input.vue
— 组件文件。内容如下:<template> <div class="gie-input"> <input v-model="state" ref="inputRef" class="gie-input__control" type="text" :disabled="props.disabled" /> </div> </template> <script setup lang="ts"> import { computed, ref } from 'vue'; import type { InputEmits, InputProps } from './Input'; defineOptions({ name: 'GieInput', }); const emit = defineEmits<InputEmits>(); const props = withDefaults(defineProps<InputProps>(), { modelValue: '', disabled: false, }); const state = computed({ get: () => props.modelValue, set: (val) => { emit('update:modelValue', val); }, }); const inputRef = ref<HTMLInputElement>(); function focus() { inputRef.value?.focus(); } defineExpose({ focus, }); </script>
在该组件中简单的定义了组件名、代理了一下
v-model
,并暴露出了一个方法focus
。 -
index.ts
— 定义Input
组件的入口文件import { withInstall } from '../utils/install'; import Input from './Input.vue'; export const GieInput = withInstall(Input); export default GieInput; export * from './Input.vue'; export * from './Input';
在入口文件中,使用
withInstall
封装了一下导入的Input
组件,并默认导出。且在下面导出了所有类型文件。这个
withInstall
函数的作用就是把组件封装成了一个可被安装,带install
方法的vue
插件,这个函数是直接从element-plus
项目复制的😂。import type { App, Plugin } from 'vue'; export type SFCWithInstall<T> = T & Plugin; export const withInstall = <T, E extends Record<string, any>>(main: T, extra?: E) => { (main as SFCWithInstall<T>).install = (app): void => { for (const comp of [main, ...Object.values(extra ?? {})]) { app.component(comp.name, comp); } }; if (extra) { for (const [key, comp] of Object.entries(extra)) { (main as any)[key] = comp; } } return main as SFCWithInstall<T> & E; };
完善打包入口文件
-
style.scss
— 这个样式文件用来导入所有组件的样式,之后会通过编译生成一个包含所有组件样式的css文件,用于整体导入@import './Input/style/index.scss';
-
components.ts
— 这个文件用来代理导出组件里的vue
文件和类型声明,内容如下:export * from './Input';
这样做的目的,是为了之后可以在项目里对组件或类型进行导入,如:
<template> <gie-input v-model="state" ref="inputRef" /> </template> <script setup lang="ts"> import { ref } from 'vue'; import { GieInput } from '@giegie/components'; import type { InputInstance } from '@giegie/components'; const state = ref(''); const inputRef = ref<InputInstance>(); </script>
-
installs.ts
— 将组件的默认导出,也就是经过withInstall
处理的vue
组件插件导入进来,封装成一个数组,给下面的入口文件使用import GieInput from './Input'; export default [GieInput];
-
index.ts
— 组件库入口文件,在这个文件里,需要导出components.ts
里代理的vue组件和类型,并将installs.ts
导出的插件数组交给makeInstaller
处理成一个支持整体导入的插件:import { makeInstaller } from './utils/install'; import installs from './installs'; export * from './components'; export default makeInstaller([...installs]);
makeInstaller
实际上也是一个vue
插件,他将组件插件循环进行安装,也是从element-plus
复制的😂。import type { App, Plugin } from 'vue'; export const makeInstaller = (components: Plugin[] = []) => { const install = (app: App) => { console.log(components); components.forEach((c) => app.use(c)); }; return { install, }; };
-
global.d.ts
— 这个文件位于components
包的根目录,用于给vscode
的volar
插件提示组件的属性的类型declare module 'vue' { export interface GlobalComponents { GieInput: (typeof import('@giegie/components'))['GieInput']; } interface ComponentCustomProperties {} } export {};
编写打包配置
最终的目标是使用vite
打包出 es、lib、types
3个目录,lib
下的组件是commonjs
版的,es
下的组件是 es module
版的,types
里是类型声明文件。而且打包出来的文件目录要和src
源码的文件目录保持一致,这样才能方便的按需导入。
对于样式,使用gulp
和sass
进行既对目录下的单独scss
文件进行编译,最后也合并成一个文件。
使用gulp不仅用来处理sass文件,更重要的是可以用来控制打包流程。
-
先安装一些依赖
vite-plugin-dts
用来生成类型声明文件:pnpm add vite-plugin-dts -wD
gulp
和相关依赖安装到components
子包下pnpm add gulp gulp-sass sass gulp-autoprefixer shelljs -D --filter components
-
在
components
下 新建一个vite.config.ts
文件,配置和说明如下:import { defineConfig } from 'vite'; import type { UserConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import dts from 'vite-plugin-dts'; export default defineConfig(() => { return { build: { rollupOptions: { // 将vue模块排除在打包文件之外,使用用这个组件库的项目的vue模块 external: ['vue'], // 输出配置 output: [ { // 打包成 es module format: 'es', // 重命名 entryFileNames: '[name].js', // 打包目录和开发目录对应 preserveModules: true, // 输出目录 dir: 'es', // 指定保留模块结构的根目录 preserveModulesRoot: 'src', }, { // 打包成 commonjs format: 'cjs', // 重命名 entryFileNames: '[name].js', // 打包目录和开发目录对应 preserveModules: true, // 输出目录 dir: 'lib', // 指定保留模块结构的根目录 preserveModulesRoot: 'src', }, ], }, lib: { // 指定入口文件 entry: 'src/index.ts', // 模块名 name: 'GIE_COMPONENTS', }, }, plugins: [ vue(), dts({ // 输出目录 outDir: ['types'], // 将动态引入转换为静态(例如:`import('vue').DefineComponent` 转换为 `import { DefineComponent } from 'vue'`) staticImport: true, // 将所有的类型合并到一个文件中 rollupTypes: true, }), ], } as UserConfig; });
-
在components文件夹下新建
build
文件夹,用于编写打包流程控制逻辑,文件和内容如下:// index.js import gulp from 'gulp'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; import dartSass from 'sass'; import gulpSass from 'gulp-sass'; import autoprefixer from 'gulp-autoprefixer'; import shell from 'shelljs'; const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../'); const { src, dest } = gulp; const sass = gulpSass(dartSass); // 删除打包产物 export const removeDist = async () => { shell.rm('-rf', `${componentPath}/lib`); shell.rm('-rf', `${componentPath}/es`); shell.rm('-rf', `${componentPath}/types`); }; // 构建css export const buildRootStyle = () => { return src(`${componentPath}/src/style.scss`) .pipe(sass()) .pipe(autoprefixer()) .pipe(dest(`${componentPath}/es`)) .pipe(dest(`${componentPath}/lib`)); }; // 构建每个组件下单独的css export const buildStyle = () => { return src(`${componentPath}/src/**/style/**.scss`) .pipe(sass()) .pipe(autoprefixer()) .pipe(dest(`${componentPath}/es`)) .pipe(dest(`${componentPath}/lib`)); }; // 打包组件 export const buildComponent = async () => { shell.cd(componentPath); shell.exec('vite build'); };
// gulpfile.js import gulp from 'gulp'; import { removeDist, buildRootStyle, buildStyle, buildComponent } from './index.js'; const { series } = gulp; export default series(removeDist, buildComponent, buildStyle, buildRootStyle);
-
在components文件夹下新建一个tsconfig.json文件,内容如下:
{ "extends": "../../tsconfig.json", "include": ["src"], "compilerOptions": { "moduleResolution": "node", "baseUrl": "." } }
这里主要是将
moduleResolution
改为node
,使打包出来的类型产物都可以正确的写入到一个文件里 -
修改components包下的package.json文件,添加一些配置:
{ "name": "@giegie/components", "version": "0.0.1", "description": "练习了2年半的高性能组件库", "main": "lib", "module": "es", "type": "module", "types": "types/index.d.ts", "files": ["es", "lib", "types", "global.d.ts"], "scripts": { "build": "gulp -f build/gulpfile.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "gulp": "^4.0.2", "gulp-autoprefixer": "^8.0.0", "gulp-sass": "^5.1.0", "sass": "^1.67.0", "shelljs": "^0.8.5" } }
具体修改内容为:
- main指定cjs入口
- module指定esm入口
- type字段的值设置为"module"时,表示该项目是一个ES模块项目
- types表示类型声明文件位置
- files表示发包时哪些文件将上传
- scripts添加build打包命令
-
在根目录的
package.json
中加入build
命令"scripts": { "build": "pnpm --filter=@giegie/* run build" }
这个
build
命令的意思是,执行所有的以@giegie
开头的子包的build
命令 -
准备工作做好后执行
npm run build
命令,没有报错的话,会和生成出一样的产出物
整体导入
目前打包出来的产物已经可以直接用来整体导入了,使用pnpm
的workspace
特性,不需要先发布包就可以直接用pnpm
安装这个包用作测试
-
使用命令安装
@giegie/components
组件库到根项目pnpm add @giegie/components@* -w
-
在项目根目录的
tsconfig.json
添加组件类型文件:{ "compilerOptions": { "types": ["@giegie/components/global"] } }
-
在src的
main.ts
文件中整体导入组件库和样式import { createApp } from 'vue'; import '@giegie/components/es/style.css'; import App from './App.vue'; import GieComponents from '@giegie/components'; console.log(GieComponents); createApp(App).use(GieComponents).mount('#app');
-
在App.vue中编写测试代码
<template> <div> <gie-input v-model="state" ref="inputRef" /> {{ state }} <button @click="onFocus">focus</button> </div> </template> <script setup lang="ts"> import type { InputInstance } from '@giegie/components'; import { ref } from 'vue'; const state = ref(''); const inputRef = ref<InputInstance>(); function onFocus() { inputRef.value?.focus(); } </script>
-
运行npm run dev 命令,可以在浏览器中看到效果
按需自动导入
完整导入所有组件会使项目打包出来的产物非常大,在element-plus
中可以使用unplugin-vue-components
和 unplugin-auto-import
按需自动导入需要的组件,文档地址。这个插件提供了多个组件的resolver
,可以模仿他们的格式,自己写一个解析组件的resolver
在packages
新建一个子包,命名为resolver
,并创建下面2个文件
-
index.js
— 解析插件的入口文件function GieResolver() { return { type: 'component', resolve: (name) => { if (name.startsWith('Gie')) { const partialName = name.slice(3); return { name: 'Gie' + partialName, from: `@giegie/components`, sideEffects: `@giegie/components/es/${partialName}/style/index.css`, }; } }, }; } module.exports = { GieResolver, };
上面的代码大概意思是,解析到一个组件以“Gie”开头时,返回组件名称、组件位置、组件样式位置给
unplugin-vue-components
和unplugin-auto-import
自动导入。 -
package.json
{ "name": "@giegie/resolver", "version": "0.0.1", "description": "组件库自动导入插件", "main": "./index", "author": "", "license": "ISC" }
安装自动导入插件和编写的解析插件到根项目
pnpm add unplugin-vue-components unplugin-auto-import @giegie/resolver@* -Dw
在根目录的vite.config.ts
中,加入配置
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
import AutoImport from 'unplugin-auto-import/vite';
import { GieResolver } from '@giegie/resolver';
// <https://vitejs.dev/config/>
export default defineConfig({
plugins: [
vue(),
Components({
resolvers: [GieResolver()],
}),
AutoImport({
resolvers: [GieResolver()],
}),
],
});
将根目录的 tsconfig.json
中types
改成如下文件
{
"compilerOptions": {
"types": ["./components.d.ts", "./auto-imports.d.ts"]
}
}
注释掉main.ts
中的完整导入代码
import { createApp } from 'vue';
// import '@giegie/components/es/style.css'
import App from './App.vue';
// import GieComponents from '@giegie/components'
// console.log(GieComponents)
createApp(App).mount('#app');
// .use(GieComponents)
运行 npm run dev
,可以看到类型和网页上的内容都已经成功导入了近来。
组件库版本的管理和发布
目标
- 使用
verdaccio
搭建私有npm
库 - 使用
pnpm
的changesets
发布版本
搭建私有npm库
基于安全和速度的考虑,在公司内部发布npm
包都会去搭建私有库。
如果只用来测试没有敏感内容的话,可以跳过这段,直接发到公网也没什么问题。
现在可以用来搭建私有库的工具有很多,这里使用verdaccio
来进行搭建。
安装
npm install --global verdaccio
假设将数据存储到/data/verdaccio
的话,按下面的方法配置
配置文件
mkdir /data/verdaccio
vim /data/verdaccio/config.yaml
编辑配置内容:
storage: /data/verdaccio/storage
auth:
htpasswd:
file: /data/verdaccio/htpasswd
algorithm: bcrypt
rounds: 10
max_users: -1
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
'@*/*':
access: $authenticated
publish: $authenticated
proxy: npmjs
'**':
access: $authenticated
publish: $authenticated
proxy: npmjs
web:
enable: true
title: 鸽鸽的前端私有库
login: true
listen: 0.0.0.0:4873
max_body_size: 100mb
log: { type: stdout, format: pretty, level: http }
配置说明: 需要管理员手动新增用户,只有授权的用户才能查看和上传包,上游为npm官方库。
启动
新建启动脚本
vim /data/verdaccio/start.sh
内容:
verdaccio --config /data/verdaccio/config.yaml
添加执行权限
chmod +x /data/verdaccio/start.sh
使用pm2管理服务
npm i -g pm2
pm2 start /data/verdaccio/start.sh -n verdaccio
开机自启
pm2 startup
pm2 save
添加用户
生成 Bcrypt
htpasswd
的文件并添加用户
htpasswd -bBc /data/verdaccio/htpasswd user password
继续添加一个用户的话用下面的命令
htpasswd -bB /data/verdaccio/htpasswd user2 password2
到这里搭建就结束了,服务在4873端口,可以用浏览器打开。可以用新建的用户测试一下能否登录。
修改项目配置
接入私有库
私有npm库搭建好后,在项目根目录新建一个.npmrc
文件,内容如下:
registry = http://xxx.xxx.xxx.xxx:4873
这一行的意思是修改该项目的npm的源为自己搭建的私有库地址。
修改完成后,使用 pnpm login
命令输入账号、密码登录私有库,最后使用 pnpm i
重新拉取一遍。
指定要发布的包
在上一篇文章里,有一个主包和两个子包。其中两个子包components
和resolver
希望发布到npm
库里去。主包是用来测试的,不可以发到库里。
根项目下的主包是用来测试用的,需要将主包改为私有禁止其提交到库里,可以在根目录的package.json
中修改private
为true
。
{
"private": true
}
使用changesets发包
pnpm
推荐使用changesets
来进行包的管理,使用changesets
可以轻松的管理版本和changelog
记录的生成。
安装和初始化changesets
输入以下命令:
pnpm add -Dw @changesets/cli
pnpm changeset init
安装完成后会在根目录生成.changeset
文件夹,这个文件夹要随git一起提交上去。
需要注意的是changesets默认需要在分支main上运行,可以去
.changeset/config.json
文件下修改baseBranch
的值来改变主分支
修改版本和编写changelog
提交代码并切换分支到main合并后,运行pnpm changeset add
命令选择要发布的包。
这里把两个子包用空格键都选上
下一步需要升级版本号,先来看看npm包版本号有那些规则:
npm版本号由三部分组成:主版本号(Major)、次版本号(Minor)和修订号(Patch),格式为"Major.Minor.Patch"。
- 主版本号(Major):当进行不兼容的API修改时增加,表示向后不兼容的更新。
- 次版本号(Minor):当进行向下兼容的功能性新增时增加,表示向后兼容的更新。
- 修订号(Patch):当进行向下兼容的问题修复时增加,表示向后兼容的更新。
现在只用把修订号加一即可
运行pnpm changeset version
命令:
刚开始是选择是否是Major更新,这里什么都不选,直接按回车跳过。接下来是Minor,也跳过。最后默认的就是Patch版本号加1。版本号确定后,需要填写更新内容:
填写完成后会看到在子包下生成了CHANGELOG.md
文件,里面记录了选择的版本号和输入的更新内容
发布包
提交代码后,运行pnpm publish -r
,没有问题的哈,终端会输出发布信息。去私有库网页查看的话,包应该已经发上去了。
在组件库中封装element-plus
安装element-plus
正常情况下,项目中会安装有自己的element-plus
版本。如果再将element-plus
安装到组件库的话,那么项目安装依赖时会下载多个element-plus
的版本。
实际上希望的是组件库能够使用项目的element-plus
版本即可。
在这种情况下可以使用package.json
的peerDependencies
来声明外部依赖。
在组件库的 package.json
里添加声明
// /packages/components/package.json
{
"peerDependencies": {
"element-plus": "^2.3.9"
}
}
然后在测试项目也就是根目录的package.json
文件中安装element-plus
// /package.json
{
"dependencies": {
"@giegie/components": "workspace:*",
"vue": "^3.3.4",
"element-plus": "^2.3.9"
}
}
配置都写好后,使用 pnpm install
进行安装
修改项目打包配置
排除打包依赖
在之前的文章中,在打包时排除了vue
依赖。这里也一样,要排除element-plus
有关的依赖,在组件库的vite.config.ts
中进行修改
// /packages/components/vite.config.ts
export default defineConfig(() => {
return {
external: ['vue', 'element-plus', '@element-plus/icons-vue', /\.scss/],
};
});
其中@element-plus/icons-vue
是element-plus
图标相关的依赖
排除掉 scss
是因为要在组件中引入element-plus
的样式,但是这个样式也要从外部项目的element-plus
依赖中获取
为什么是scss而不是css呢? 因为在项目内通常会定制化element-plus的样式,这都是通过修改element-plus的scss变量来完成的。如果想定制的样式能影响封装在组件库中element-plus组件的话,后面在组件也必须要引入element-plus的scss样式。
自动引入element-plus的样式
在编写组件库的组件时,需要使用按需加载的方式引入element-plus
组件,如:
<template> <el-input /> </template>
<script setup lang="ts">
import { ElInput } from 'element-plus';
import 'element-plus/theme-chalk/src/base.scss';
import 'element-plus/theme-chalk/src/input.scss';
</script>
可以看到不仅要引入组件,还需要引入基础样式和组件样式,这个需要的element-plus
组件变多的话,非常麻烦。
需要使用unplugin-element-plus
帮助自动引入样式
安装unplugin-element-plus
到组件库的包下
pnpm add unplugin-element-plus -D --filter components
在vite
配置文件里添加下面配置
// /packages/components/vite.config.ts
import ElementPlus from 'unplugin-element-plus/vite';
export default defineConfig(() => {
return {
plugins: [
// ...
ElementPlus({
// 导入scss而不是css
useSource: true,
}),
],
};
});
配置好后,编写组件时只用向下面这样就行
<template> <el-input /> </template>
<script setup lang="ts">
import { ElInput } from 'element-plus';
</script>
简单封装el-input组件
将之前封装好的gie-input
中的input
换成el-input
组件,功能和之前一样,最终文件如下
<template>
<div class="gie-input">
<el-input v-model="state" ref="inputRef" type="text" :disabled="props.disabled" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ElInput } from 'element-plus';
import type { InputEmits, InputProps } from './Input';
defineOptions({
name: 'GieInput',
});
const emit = defineEmits<InputEmits>();
const props = withDefaults(defineProps<InputProps>(), {
modelValue: '',
disabled: false,
});
const state = computed({
get: () => props.modelValue,
set: (val) => {
emit('update:modelValue', val);
},
});
const inputRef = ref<InstanceType<typeof ElInput>>();
function focus() {
inputRef.value?.focus();
}
defineExpose({
focus,
});
</script>
运行打包和预览命令
npm run build
npm run dev
在浏览器中可以看到结果
将组件接入到el-form的表单校验
在组件库里封装的组件,大概分为3类。表单组件、数据展示组件和布局组件。
其中表单组件一般都会和element-plus
里的el-form
组件结合使用。假如自己封装一个富文本组件,当输入后失去焦点且字段要求必填时,会自动触发el-form
的校验。此时需要显示错误提示和自己封装的富文本的边框变红,这应该如何实现呢?
首先需要在组件里获取到el-form-item
组件的实例,element-plus
暴露了一个contextKey
,可以让方便的将el-form-item
实例注入进来:
<script setup lang="ts">
import { formItemContextKey } from 'element-plus';
const elFormItem = inject(formItemContextKey);
</script>
然后通过元素的blur
事件调用校验方法:
<template>
<div contenteditable="true" @blur="onBlur"></div>
</template>
<script setup lang="ts">
import { formItemContextKey } from 'element-plus';
const elFormItem = inject(formItemContextKey);
const onBlur = () => {
elFormItem!.validate?.('blur').catch((err) => console.warn(err));
};
</script>
可以看到上面validate
方法的参数为blur
,这个表示执行项目中规则的trigger
为blur
的校验。
const rules = reactive<FormRules<RuleForm>>({
test: [{ required: true, message: '请输入文字', trigger: 'blur' }],
});
到这里错误提示可以在失去焦点并校验失败时自动显示出来,下面开始修改错误时的边框样式。
可以看到,当校验失败时,el-form-item
上会加上is-error
的class
,只用通过class
来修改边框即可
.gie-richtext{
flex: 1;
&__control{
border: 1px solid #aaa;
background: #eee;
border-radius: 5px;
padding: 16px;
min-height: 100px;
.is-error & {
border-color: red;
}
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南