docs: refactor documentation of plugin development (#291)

重构和完善插件开发文档。

/kind documentation

Fixes https://github.com/halo-dev/docs/issues/253

```release-note
None
```

---------

Signed-off-by: Ryan Wang <i@ryanc.cc>
Co-authored-by: guqing <i@guqing.email>
wan92hen-patch-1
Ryan Wang 10 months ago committed by GitHub
parent 5e15e35b29
commit 6a253dd896
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,104 +0,0 @@
---
title: 与自定义模型交互
description: 了解如果通过代码的方式操作数据
---
Halo 提供了两个类用于与自定义模型数据交互 `ExtensionClient``ReactiveExtensionClient`
它们的本质就是操作数据库,区别在于 `ExtensionClient` 是阻塞式 API`ReactiveExtensionClient` 是响应式 API接口返回值只有两种 Mono 或 Flux它们由 [reactor](https://projectreactor.io/) 提供。
```java
public interface ReactiveExtensionClient {
/**
* Lists Extensions by Extension type, filter and sorter.
*
* @param type is the class type of Extension.
* @param predicate filters the reEnqueue.
* @param comparator sorts the reEnqueue.
* @param <E> is Extension type.
* @return all filtered and sorted Extensions.
*/
<E extends Extension> Flux<E> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator);
/**
* Lists Extensions by Extension type, filter, sorter and page info.
*
* @param type is the class type of Extension.
* @param predicate filters the reEnqueue.
* @param comparator sorts the reEnqueue.
* @param page is page number which starts from 0.
* @param size is page size.
* @param <E> is Extension type.
* @return a list of Extensions.
*/
<E extends Extension> Mono<ListResult<E>> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator, int page, int size);
/**
* Fetches Extension by its type and name.
*
* @param type is Extension type.
* @param name is Extension name.
* @param <E> is Extension type.
* @return an optional Extension.
*/
<E extends Extension> Mono<E> fetch(Class<E> type, String name);
Mono<Unstructured> fetch(GroupVersionKind gvk, String name);
<E extends Extension> Mono<E> get(Class<E> type, String name);
/**
* Creates an Extension.
*
* @param extension is fresh Extension to be created. Please make sure the Extension name does
* not exist.
* @param <E> is Extension type.
*/
<E extends Extension> Mono<E> create(E extension);
/**
* Updates an Extension.
*
* @param extension is an Extension to be updated. Please make sure the resource version is
* latest.
* @param <E> is Extension type.
*/
<E extends Extension> Mono<E> update(E extension);
/**
* Deletes an Extension.
*
* @param extension is an Extension to be deleted. Please make sure the resource version is
* latest.
* @param <E> is Extension type.
*/
<E extends Extension> Mono<E> delete(E extension);
}
```
### 示例
如果你想在插件中根据 name 参数查询获取到 Person 自定义模型的数据,则可以这样写:
```java
private final ReactiveExtensionClient client;
Mono<Person> getPerson(String name) {
return client.fetch(Person.class, name);
}
```
或者使用阻塞式 API:
```java
private final ExtensionClient client;
Optional<Person> getPerson(String name) {
return client.fetch(Person.class, name);
}
```
我们建议你更多的使用响应式的 `ReactiveExtensionClient` 去替代 `ExtensionClient`

@ -1,127 +0,0 @@
---
title: 自定义模型
description: 了解什么是自定义模型及如何创建
---
Halo 自定义模型主要参考自 [Kubernetes CRD](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/) 。自定义模型遵循 [OpenAPI v3](https://spec.openapis.org/oas/v3.1.0)。设计目的在于为插件提供自定义数据支持。比如某插件需要存储自定义数据,同时也想读取和操作自定义数据。
一个典型的自定义模型 `Java` 代码示例如下:
```java
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupKind;
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@GVK(group = "my-plugin.halo.run",
version = "v1alpha1",
kind = "Person",
plural = "persons",
singular = "person")
public class Person extends AbstractExtension {
@Schema(description = "The description on name field", maxLength = 100)
private String name;
@Schema(description = "The description on age field", maximum = "150", minimum = "0")
private Integer age;
@Schema(description = "The description on gender field")
private Gender gender;
private Person otherPerson;
public enum Gender {
MALE, FEMALE,
}
}
```
要创建一个自定义模型需要三步:
1. 创建一个类继承 `AbstractExtension`
2. 使用 `GVK` 注解。
3. 在插件 `start()` 生命周期方法中注册自定义模型:
```java
@Autowired
private SchemeManager schemeManager;
@Override
public void start() {
schemeManager.register(Person.class);
}
```
有了自定义模型后可以通过在插件项目的 `src/main/resources/extensions` 目录下声明 `yaml` 文件来创建一个实例,此目录下的所有自定义模型 `yaml` 都会在插件启动时被创建:
```yaml
groupVersion: my-plugin.halo.run/v1alpha1
kind: Person
metadata:
name: fake-person
name: halo
age: 18
gender: male
```
:::tip 释义
- @GVK:此注解标识该类为一个自定义模型,同时必须继承 `AbstractExtension`
- kind表示自定义模型所表示的 REST 资源。
- group表示一组公开的资源通常采用域名形式Halo 项目保留使用空组和任何以 `*.halo.run` 结尾的组名供其单独使用。
选择群组名称时,我们建议选择你的群组或组织拥有的子域,例如 `widget.mycompany.com`
- versionAPI 的版本,它与 group 组合使用为 `apiVersion=GROUP/VERSION`,例如`api.halo.run/v1alpha1`。
- singular: 资源的单数名称,这允许客户端不透明地处理复数和单数,必须全部小写,通常为小写的 `kind`
- plural 资源的复数名称,自定义资源在 `/apis/<group>/<version>/.../<plural>` 下提供,必须为全部小写。
- @Schema:属性校验注解,会在创建/修改资源前对资源校验,参考 [schema-validator](https://www.openapi4j.org/schema-validator.html)。
:::
### 自定义模型 API
定义好自定义模型并注册后,会根据 `GVK` 注解自动生成一组 `CRUD` API规则为
`/apis/<group>/<version>/<extension>/{extensionname}/<subextension>`
对于上述 Person 自定义模型将有以下 APIs
```text
GET /apis/my-plugin.halo.run/v1alpha1/persons
PUT /apis/my-plugin.halo.run/v1alpha1/persons/{name}
POST /apis/my-plugin.halo.run/v1alpha1/persons
DELETE /apis/my-plugin.halo.run/v1alpha1/persons/{name}
```
### 自定义 API
在一些场景下,只有自动生成的 `CRUD` API 往往是不够用的,此时可以通过自定义一些 API 来满足功能。
你可以使用 `SpringBoot` 的控制器写法来暴露 API不同的是需要添加 `@ApiVersion` 注解,没有此注解的 `Controller` 将被忽略:
```java
@ApiVersion("v1alpha1")
@RequestMapping("/apples")
@RestController
public class AppleController {
@PostMapping("/starting")
public void starting() {
}
}
```
当插件被启动时Halo 将会为此 AppleController 生成统一路径的 API。API 前缀组成规则如下:
```text
/apis/plugin.api.halo.run/{version}/plugins/{plugin-name}/**
```
示例:
```text
/apis/plugin.api.halo.run/v1alpha1/plugins/my-plugin/apples/starting
```

@ -1,103 +0,0 @@
---
title: API 权限控制
description: 了解如果对插件中的 API 定义角色模板以接入权限控制
---
插件中的 APIs 无论是自定义模型自动生成的 APIs 或者是通过 Controller 自定义的 APIs 都只有超级管理员能够访问,如果想将这些 APIs 授权给其他用户访问则需要定义一些 RoleTemplate 的资源以便可以在用户界面上将其分配给其他角色使用。
RoleTemplate 的 yaml 资源也需要放到 `src/main/resources/extensions` 目录下,文件名称可以任意,它的 kind 为 Role 但需要一个 label `halo.run/role-template: "true"` 来表示它是角色模板。
Halo 的权限控制对同一种资源一般只定义两个 RoleTemplate一个是只读权限另一个是管理权限因此如果没有特殊情况需要更细粒度的控制我们建议你也保持一致
```yaml
apiVersion: v1
kind: Role
metadata:
# 使用 plugin name 作为前缀防止与其他插件冲突
name: my-plugin-role-view-persons
labels:
halo.run/role-template: "true"
annotations:
rbac.authorization.halo.run/module: "Persons Management"
rbac.authorization.halo.run/display-name: "Person Manage"
rbac.authorization.halo.run/ui-permissions: |
["plugin:my-plugin:person:view"]
rules:
- apiGroups: ["my-plugin.halo.run"]
resources: ["my-plugin/persons"]
verbs: ["*"]
---
apiVersion: v1
kind: Role
metadata:
name: my-plugin-role-manage-persons
labels:
halo.run/role-template: "true"
annotations:
rbac.authorization.halo.run/dependencies: |
[ "role-template-view-person" ]
rbac.authorization.halo.run/module: "Persons Management"
rbac.authorization.halo.run/display-name: "Person Manage"
rbac.authorization.halo.run/ui-permissions: |
["plugin:my-plugin:person:manage"]
rules:
- apiGroups: [ "my-plugin.halo.run" ]
resources: [ "my-plugin/persons" ]
verbs: [ "get", "list" ]
```
上述便是根据 [自定义模型](./extension.md) 章节中定义的 Person 自定义模型配置角色模板的示例。
定义了一个用于管理 Person 资源的角色模板 `my-plugin-role-manage-persons`,它具有所有权限,同时定义了一个只允许查询 Person 资源的角色模板 `my-plugin-role-view-persons`
下面让我们回顾一下这些配置:
`rules` 是个数组,它允许配置多组规则:
- `apiGroups` 对应 `GVK` 中的 `group` 所声明的值。
- `resources` 对应 API 中的 resource 部分,`my-plugin` 表示插件名称。
- `verbs` 表示请求动词,可选值为 "create", "delete", "deletecollection", "get", "list", "patch", "update"。对应的 HTTP 请求方式如下表所示:
| HTTP verb | request verb |
| --------- | ------------------------------------------------------------ |
| POST | create |
| GET, HEAD | get (for individual resources), list (for collections, including full object content), watch (for watching an individual resource or collection of resources) |
| PUT | update |
| PATCH | patch |
| DELETE | delete (for individual resources), deletecollection (for collections) |
`metadata.labels` 中必须包含 `halo.run/role-template: "true"` 以表示它此资源要作为角色模板。
`metadata.annotations` 中:
- `rbac.authorization.halo.run/dependencies`:用于声明角色间的依赖关系,例如管理角色必须要依赖查看角色,以避免分配了管理权限却没有查看权限的情况。
- `rbac.authorization.halo.run/module`:角色模板分组名称。在此示例中,管理 Person 的模板角色将和查看 Person 的模板角色将被在 UI 层面归为一组展示。
- `rbac.authorization.halo.run/display-name`:模板角色的显示名称,用于展示为用户可读的名称信息。
- `rbac.authorization.halo.run/ui-permissions`:用于控制 UI 权限,规则为 `plugin:{your-plugin-name}:scope-name`,使用示例为在插件前端部分入口文件 `index.ts` 中用于控制菜单是否显示或者控制页面按钮是否展示:
```javascript
{
path: "",
name: "HelloWorld",
component: DefaultView,
meta: {
permissions: ["plugin:my-plugin:person:view"]
}
}
```
以上定义角色模板的方式适合资源型请求,即需要符合以下规则
- 以 `/api/<version>/<resource>[/<resourceName>/<subresource>/<subresourceName>]` 规则组成 APIs。
- 以 `/apis/<group>/<version>/<resource>[/<resourceName>/<subresource>/<subresourceName>]` 规则组成的 APIs。
注:`[]`包裹的部分表示可选
如果你的 API 不符合以上资源型 API 的规则,则被定型为非资源型 API例如 `/healthz`,需要使用一下配置方式:
```yaml
rules:
# nonResourceURL 中的 '*' 是一个全局通配符
- nonResourceURLs: ["/healthz", "/healthz/*"]
verbs: [ "get", "create"]
```

@ -0,0 +1,202 @@
---
title: 与自定义模型交互
description: 了解如果通过代码的方式操作数据
---
Halo 提供了两个类用于与自定义模型对象交互 `ExtensionClient``ReactiveExtensionClient`
它们提供了对自定义模型对象的增删改查操作,`ExtensionClient` 是阻塞式的用于后台任务如控制器中操作数据,而 `ReactiveExtensionClient` 返回值都是 Mono 或 Flux 是反应式非阻塞的,它们由 [reactor](https://projectreactor.io/) 提供。
```java
public interface ReactiveExtensionClient {
// 已经过时,建议使用 listBy 或 listAll 代替
<E extends Extension> Flux<E> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator);
// 已经过时,建议使用 listBy 或 listAll 代替
<E extends Extension> Mono<ListResult<E>> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator, int page, int size);
<E extends Extension> Flux<E> listAll(Class<E> type, ListOptions options, Sort sort);
<E extends Extension> Mono<ListResult<E>> listBy(Class<E> type, ListOptions options,
PageRequest pageable);
/**
* Fetches Extension by its type and name.
*
* @param type is Extension type.
* @param name is Extension name.
* @param <E> is Extension type.
* @return an optional Extension.
*/
<E extends Extension> Mono<E> fetch(Class<E> type, String name);
Mono<Unstructured> fetch(GroupVersionKind gvk, String name);
<E extends Extension> Mono<E> get(Class<E> type, String name);
/**
* Creates an Extension.
*
* @param extension is fresh Extension to be created. Please make sure the Extension name does
* not exist.
* @param <E> is Extension type.
*/
<E extends Extension> Mono<E> create(E extension);
/**
* Updates an Extension.
*
* @param extension is an Extension to be updated. Please make sure the resource version is
* latest.
* @param <E> is Extension type.
*/
<E extends Extension> Mono<E> update(E extension);
/**
* Deletes an Extension.
*
* @param extension is an Extension to be deleted. Please make sure the resource version is
* latest.
* @param <E> is Extension type.
*/
<E extends Extension> Mono<E> delete(E extension);
}
```
### 示例
如果你想在插件中根据 name 参数查询获取到 Person 自定义模型的数据,则可以这样写:
```java
@Service
@RequiredArgsConstructor
public PersonService {
private final ReactiveExtensionClient client;
Mono<Person> getPerson(String name) {
return client.fetch(Person.class, name);
}
}
```
或者使用阻塞式 Client
```java
@Service
@RequiredArgsConstructor
public PersonService {
private final ExtensionClient client;
Optional<Person> getPerson(String name) {
return client.fetch(Person.class, name);
}
}
```
注意:非阻塞线程中不能调用阻塞式方法。
我们建议你更多的使用响应式的 `ReactiveExtensionClient` 去替代 `ExtensionClient`
### 查询
`ReactiveExtensionClient` 提供了两个方法用于查询数据,`listBy` 和 `listAll`
`listBy` 方法用于分页查询数据,`listAll` 方法用于查询所有数据,它们都需要一个 `ListOptions` 参数,用于传递查询条件:
```java
public class ListOptions {
private LabelSelector labelSelector;
private FieldSelector fieldSelector;
}
```
其中 `LabelSelector` 用于传递标签查询条件,`FieldSelector` 用于传递字段查询条件。
`FieldSelector` 支持比自动生成的 APIs 中更多的查询条件,可以通过 `run.halo.app.extension.index.query.QueryFactory` 来构建。
```java
FieldSelector.of(QueryFactory.and(
QueryFactory.equal("name", "test"),
QueryFactory.equal("age", 18)
))
```
支持的查询条件如下:
| 方法 | 说明 | 示例 |
| ---------------------------- | ---------------- | ----------------------------------------------------------------------------- |
| equal | 等于 | equal("name", "test"), name 是字段名test 是字段值 |
| equalOtherField | 等于其他字段 | equalOtherField("name", "otherName"), name 是字段名otherName 是另一个字段名 |
| notEqual | 不等于 | notEqual("name", "test") |
| notEqualOtherField | 不等于其他字段 | notEqualOtherField("name", "otherName") |
| greaterThan | 大于 | greaterThan("age", 18) |
| greaterThanOtherField | 大于其他字段 | greaterThanOtherField("age", "otherAge") |
| greaterThanOrEqual | 大于等于 | greaterThanOrEqual("age", 18) |
| greaterThanOrEqualOtherField | 大于等于其他字段 | greaterThanOrEqualOtherField("age", "otherAge") |
| lessThan | 小于 | lessThan("age", 18) |
| lessThanOtherField | 小于其他字段 | lessThanOtherField("age", "otherAge") |
| lessThanOrEqual | 小于等于 | lessThanOrEqual("age", 18) |
| lessThanOrEqualOtherField | 小于等于其他字段 | lessThanOrEqualOtherField("age", "otherAge") |
| in | 在范围内 | in("age", 18, 19, 20) |
| and | 且 | and(equal("name", "test"), equal("age", 18)) |
| or | 或 | or(equal("name", "test"), equal("age", 18)) |
| between | 在范围内 | between("age", 18, 20), 包含 18 和 20 |
| betweenExclusive | 在范围内 | betweenExclusive("age", 18, 20), 不包含 18 和 20 |
| betweenLowerExclusive | 在范围内 | betweenLowerExclusive("age", 18, 20), 不包含 18包含 20 |
| betweenUpperExclusive | 在范围内 | betweenUpperExclusive("age", 18, 20), 包含 18不包含 20 |
| startsWith | 以指定字符串开头 | startsWith("name", "test") |
| endsWith | 以指定字符串结尾 | endsWith("name", "test") |
| contains | 包含指定字符串 | contains("name", "test") |
| all | 指定字段的所有值 | all("age") |
`FieldSelector` 中使用的所有字段都必须添加为索引,否则会抛出异常表示不支持该字段。关于如何使用索引请参考 [自定义模型使用索引](./extension.md#using-indexes)。
可以通过 `and``or` 方法组合和嵌套查询条件:
```java
import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.greaterThan;
import static run.halo.app.extension.index.query.QueryFactory.or;
Query query = and(
or(equal("dept", "A"), equal("dept", "B")),
or(equal("age", "19"), equal("age", "18"))
);
FieldSelector.of(query);
```
### 排序
`listBy``listAll` 方法都支持传递 `Sort` 参数,用于传递排序条件。
```java
import org.springframework.data.domain.Sort;
Sort.by(Sort.Order.asc("metadata.name"))
```
通过 `Sort.by` 方法可以构建排序条件,`Sort.Order` 用于指定排序字段和排序方式,`asc` 表示升序,`desc` 表示降序。
排序中使用的字段必须是添加为索引的字段,否则会抛出异常表示不支持该字段。关于如何使用索引请参考 [自定义模型使用索引](./extension.md#using-indexes)。
### 分页
`listBy` 方法支持传递 `PageRequest` 参数,用于传递分页条件。
```java
import run.halo.app.extension.PageRequestImpl;
PageRequestImpl.of(1, 10);
PageRequestImpl.of(1, 10, Sort.by(Sort.Order.asc("metadata.name"));
PageRequestImpl.ofSize(10);
```
通过 `PageRequestImpl.of` 方法可以构建分页条件,具有两个参数的方法用于指定页码和每页数量,具有三个参数的方法用于指定页码、每页数量和排序条件。
`ofSize` 方法用于指定每页数量,页码默认为 1。

@ -0,0 +1,76 @@
---
title: Web 过滤器
description: 为 Web 请求提供过滤器扩展点,可用于对请求进行拦截、修改等操作。
---
在现代的 Web 应用开发中过滤器Filter是一个非常重要的概念。你可以使用 `run.halo.app.security.AdditionalWebFilter` 在服务器处理请求之前或之后执行特定的任务。
通过实现这个接口,开发者可以自定义过滤逻辑,用于处理进入和离开应用程序的 HTTP 请求和响应。
AdditionalWebFilter 能做什么?
1. 认证与授权: AdditionalWebFilter 可以用来检查用户是否登录,或者是否有权限访问某个资源。
2. 日志记录与审计: 在请求处理之前或之后记录日志,帮助了解应用程序的使用情况。
3. 请求重构: 修改请求数据,例如添加、删除或修改请求头或请求参数。
4. 响应处理: 修改响应,例如设置通用的响应头。
5. 性能监控: 记录处理请求所需的时间,用于性能分析。
6. 异常处理: 统一处理请求过程中抛出的异常。
7. ......
## 使用示例
以下是一个使用 `AdditionalWebFilter` 来拦截 `/login` 请求实现用户名密码登录的示例:
```java
@Component
public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
final ServerWebExchangeMatcher requiresMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login");
@Override
@NonNull
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
return this.requiresAuthenticationMatcher.matches(exchange)
.filter((matchResult) -> {
return matchResult.isMatch();
}).flatMap((matchResult) -> {
return this.authenticationConverter.convert(exchange);
}).switchIfEmpty(chain.filter(exchange)
.then(Mono.empty()))
.flatMap((token) -> {
return this.authenticate(exchange, chain, token);
}).onErrorResume(AuthenticationException.class, (ex) -> {
return this.authenticationFailureHandler.onAuthenticationFailure(new WebFilterExchange(exchange, chain), ex);
});
}
@Override
public int getOrder() {
return SecurityWebFiltersOrder.FORM_LOGIN.getOrder();
}
}
```
1. `filter` 方法中的 `chain.filter(exchange)` 表示继续执行后续的过滤器,如果不调用这个方法,请求将不会继续执行后续的过滤器或目标处理程序。
2. `getOrder` 方法用于指定过滤器的执行顺序,比如 `SecurityWebFiltersOrder.FORM_LOGIN.getOrder()` 表示在 Spring Security 的表单登录过滤器之前执行,参考:[SecurityWebFiltersOrder](https://github.com/spring-projects/spring-security/blob/main/config/src/main/java/org/springframework/security/config/web/server/SecurityWebFiltersOrder.java)。
`AdditionalWebFilter` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: additional-webfilter
spec:
className: run.halo.app.security.AdditionalWebFilter
displayName: AdditionalWebFilter
type: MULTI_INSTANCE
description: "Contract for interception-style, chained processing of Web requests that may be used to
implement cross-cutting, application-agnostic requirements such as security, timeouts, and others."
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``additional-webfilter`
以下是一些可以参考的项目示例:
- [OAuth2 第三方登录插件](https://github.com/halo-sigs/plugin-oauth2)
- [Halo 用户名密码登陆](https://github.com/halo-dev/halo/blob/main/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticator.java)

@ -0,0 +1,55 @@
---
title: 附件存储
description: 为附件存储方式提供的扩展点,可用于自定义附件存储方式。
---
附件存储策略扩展点支持扩展附件的上传和存储方式,如将附件存储到第三方云存储服务中。
扩展点接口如下:
```java
public interface AttachmentHandler extends ExtensionPoint {
Mono<Attachment> upload(UploadContext context);
Mono<Attachment> delete(DeleteContext context);
default Mono<URI> getSharedURL(Attachment attachment,
Policy policy,
ConfigMap configMap,
Duration ttl) {
return Mono.empty();
}
default Mono<URI> getPermalink(Attachment attachment,
Policy policy,
ConfigMap configMap) {
return Mono.empty();
}
```
- `upload` 方法用于上传附件,返回值为 `Mono<Attachment>`,其中 `Attachment` 为上传成功后的附件对象。
- `delete` 方法用于删除附件,返回值为 `Mono<Attachment>`,其中 `Attachment` 为删除后的附件对象。
- `getSharedURL` 方法用于获取附件的共享链接,返回值为 `Mono<URI>`,其中 `URI` 为附件的共享链接。
- `getPermalink` 方法用于获取附件的永久链接,返回值为 `Mono<URI>`,其中 `URI` 为附件的永久链接。
`AttachmentHandler` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: attachment-handler
spec:
className: run.halo.app.core.extension.attachment.endpoint.AttachmentHandler
displayName: AttachmentHandler
type: MULTI_INSTANCE
description: "Provide extension points for attachment storage strategies"
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``attachment-handler`
可以参考以下项目示例:
- [S3 对象存储协议的存储插件](https://github.com/halo-dev/plugin-s3)
- [阿里云 OSS 的存储策略插件](https://github.com/halo-sigs/plugin-alioss)
- [又拍云 OSS 的存储策略](https://github.com/AirboZH/plugin-uposs)

@ -0,0 +1,83 @@
---
title: 评论主体展示
description: 用于在管理端评论列表中展示评论的主体内容。
---
评论主体扩展点用于在管理端评论列表中展示评论的主体内容,评论自定义模型是 Halo 主应用提供的,如果你需要使用评论自定义模型,那么评论会统一
展示在管理后台的评论列表中,这时就需要通过评论主体扩展点来展示评论的主体内容便于跳转到对应的页面,如果没有实现该扩展点,那么评论列表中对应的评论的主体会显示为“未知”。
```java
public interface CommentSubject<T extends Extension> extends ExtensionPoint {
Mono<T> get(String name);
default Mono<SubjectDisplay> getSubjectDisplay(String name) {
return Mono.empty();
}
boolean supports(Ref ref);
record SubjectDisplay(String title, String url, String kindName) {
}
}
```
- `get` 方法用于获取评论主体,参数 `name` 是评论主体的自定义模型对象的名称,返回值为 `Mono<T>`,其中 `T` 为评论主体对象,它是使用评论的那个自定义模型。
- `getSubjectDisplay` 方法用于获取评论主体的展示信息,返回值为 `Mono<SubjectDisplay>`,其中 `SubjectDisplay` 为评论主体的展示信息,包含标题、链接和类型名称,用于在主题端展示评论主体的信息。
- `supports` 方法用于判断是否支持该评论主体,返回值为 `boolean`,如果支持则返回 `true`,否则返回 `false`
实现该扩展点后,评论列表会通过 `get` 方法将主体的自定义模型对象带到评论列表中,可以配置前端的扩展点来决定如何展示评论主体的信息,参考:[UI 评论来源显示](../../ui/extension-points//comment-subject-ref-create.md)
例如对于文章是支持评论的,所以文章的评论主体扩展点实现如下:
```java
public class PostCommentSubject implements CommentSubject<Post> {
private final ReactiveExtensionClient client;
private final ExternalLinkProcessor externalLinkProcessor;
@Override
public Mono<Post> get(String name) {
return client.fetch(Post.class, name);
}
@Override
public Mono<SubjectDisplay> getSubjectDisplay(String name) {
return get(name)
.map(post -> {
var url = externalLinkProcessor
.processLink(post.getStatusOrDefault().getPermalink());
return new SubjectDisplay(post.getSpec().getTitle(), url, "文章");
});
}
@Override
public boolean supports(Ref ref) {
Assert.notNull(ref, "Subject ref must not be null.");
GroupVersionKind groupVersionKind =
new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind());
return GroupVersionKind.fromExtension(Post.class).equals(groupVersionKind);
}
}
```
`CommentSubject` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: comment-subject
spec:
className: run.halo.app.content.comment.CommentSubject
displayName: CommentSubject
type: MULTI_INSTANCE
description: "Provide extension points for comment subject display"
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``comment-subject`
可以参考其他使用该扩展点的项目示例:
- [Halo 自定义页面评论主体](https://github.com/halo-dev/halo/blob/main/application/src/main/java/run/halo/app/content/comment/SinglePageCommentSubject.java)
- [瞬间的评论主体](https://github.com/halo-sigs/plugin-moments/blob/096b1b3e4a2ca44b6f858ba1181b62eeff64a139/src/main/java/run/halo/moments/MomentCommentSubject.java#L25)

@ -0,0 +1,41 @@
---
title: 评论组件
description: 用于自定义评论组件,可在主题端使用其他评论组件。
---
评论组件扩展点用于自定义主题端使用的评论组件Halo 通过插件提供了一个默认的评论组件,如果你需要使用其他的评论组件,那么可以通过实现该扩展点来自定义评论组件。
```java
public interface CommentWidget extends ExtensionPoint {
String ENABLE_COMMENT_ATTRIBUTE = CommentWidget.class.getName() + ".ENABLE";
void render(ITemplateContext context, IProcessableElementTag tag,
IElementTagStructureHandler structureHandler);
}
```
其中 `render` 方法用于在主题端模板中渲染评论组件,参数:
- `context` 为模板上下文,包含执行模板的上下文:变量、模板数据等。
- 参数 `tag``<halo:comment />` 标签它包含元素的名称及其属性
- `structureHandler` 是一个特殊的对象,它允许 `CommentWidget` 向引擎发出指令,指示模板引擎应根据处理器的执行而执行哪些操作。
`CommentWidget` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: comment-widget
spec:
className: run.halo.app.theme.dialect.CommentWidget
displayName: CommentWidget
type: SINGLETON
description: "Provides an extension point for the comment widget on the theme-side."
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``comment-widget`
参考:[Thymeleaf IElementTagProcessor 文档](https://www.thymeleaf.org/doc/tutorials/3.1/extendingthymeleaf.html#element-tag-processors-ielementtagprocessor)。
参考:[Halo 默认评论组件的实现](https://github.com/halo-dev/plugin-comment-widget/blob/main/src/main/java/run/halo/comment/widget/DefaultCommentWidget.java)。

@ -0,0 +1,65 @@
---
title: 扩展点
description: Halo 服务端为插件提供的扩展点接口
---
术语:
- 扩展点
- 由 Halo 定义的用于添加特定功能的接口。
- 扩展点应该在服务的核心功能和它所认为的集成之间的交叉点上。
- 扩展点是对服务的扩充,但不是影响服务的核心功能:区别在于,如果没有其核心功能,服务就无法运行,而扩展点对于特定的配置可能至关重要该服务最终是可选的。
- 扩展点应该小且可组合,并且在相互配合使用时,可为 Halo 提供比其各部分总和更大的价值。
- 扩展
- 扩展点的一种具体实现。
使用 Halo 扩展点的必要步骤是:
1. 实现扩展点接口,然后标记上 `@Component` 注解。
2. 对该扩展点的实现类进行 `ExtensionDefinition` 自定义模型对象的声明。
例如: 实现一个通知器的扩展,首先 `implements` ReactiveNotifier 扩展点接口:
```java
@Component
public class EmailNotifier implements ReactiveNotifier {
@Override
public Mono<Void> notify(NotificationContext context) {
// Send notification
}
}
```
然后声明一个 `ExtensionDefinition` 自定义模型对象:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionDefinition
metadata:
name: halo-email-notifier # 指定一个扩展定义的名称
spec:
# 扩展的全限定类名
className: run.halo.app.notification.EmailNotifier
# 所实现的扩展点定义的自定义模型对象名称
extensionPointName: reactive-notifier
# 扩展名称用于展示给用户
displayName: "EmailNotifier"
# 扩展的简要描述,用于让用户了解该扩展的作用
description: "Support sending notifications to users via email"
```
:::tip Note
每一个扩展点都会声明一个对应的 `ExtensionPointDefinition` 自定义模型对象被称之为扩展点定义,用于描述该扩展点的信息,例如:扩展点的名称、描述、扩展点的类型等。
而每一个扩展也必须声明一个对应的 `ExtensionDefinition` 自定义模型对象被称之为扩展定义,用于描述该扩展的信息,例如:扩展的名称、描述、对应扩展点的对象名称等。
:::
关于如何在插件中声明自定义模型对象请参考:[自定义模型](../../server/extension.md#declare-extension-object)
以下是目前已支持的扩展点列表:
```mdx-code-block
import DocCardList from '@theme/DocCardList';
<DocCardList />
```

@ -0,0 +1,60 @@
---
title: 通知器
description: 为以何种方式向用户发送通知提供的扩展点。
---
通知器扩展点是用于扩展为 Halo 通知系统提供更多通知方式的扩展点例如邮件、短信、WebHook 等。
```java
public interface ReactiveNotifier extends ExtensionPoint {
Mono<Void> notify(NotificationContext context);
}
```
`notify` 方法用于发送通知参数context 为通知上下文,包含通知的内容、接收者、通知配置等信息。
除了实现该扩展点并声明 `ExtensionDefinition` 自定义模型对象外,还需要声明一个 `NotifierDescriptor` 自定义模型对象用于描述通知器,例如:
```yaml
apiVersion: notification.halo.run/v1alpha1
kind: NotifierDescriptor
metadata:
name: default-email-notifier
spec:
displayName: '邮件通知'
description: '通过邮件将通知发送给用户'
notifierExtName: 'halo-email-notifier'
senderSettingRef:
name: 'notifier-setting-for-email'
group: 'sender'
#receiverSettingRef:
# name: ''
# group: ''
```
- `notifierExtName` 为通知器扩展的自定义模型对象名称
- `senderSettingRef` 用于声明通知器的发送者配置例如邮件通知器的发送者配置为SMTP 服务器地址、端口、用户名、密码等,如果没有可以不配置,参考:[表单定义](../../../../form-schema.md)
- `name` 为发送者配置的名称,它是一个 `Setting` 自定义模型对象的名称。
- `group` 用于引用到一个具体的配置 Schema 组,它是一个 `Setting` 自定义模型对象中描述的 `formSchema``group`,由于 `Setting` 可以声明多个配置分组但通知器的发送者配置只能有在一个组,因此需要指定一个组。
- `receiverSettingRef` 用于声明通知器的接收者配置,例如:邮件通知器的接收者配置为:接收者邮箱地址,如果没有可以不配置,`name` 和 `group` 配置同 `senderSettingRef`
当配置了 `senderSettingRef` 后,触发通知时 `notify` 方法的 `context` 参数中会包含 `senderConfig` 即为发送者配置的值,`receiverConfig` 同理。
`ReactiveNotifier` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: reactive-notifier
spec:
className: run.halo.app.notification.ReactiveNotifier
displayName: Notifier
type: MULTI_INSTANCE
description: "Provides a way to extend the notifier to send notifications to users."
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``reactive-notifier`
使用案例可以参考:[Halo 邮件通知器](https://github.com/halo-dev/halo/blob/main/application/src/main/java/run/halo/app/notification/EmailNotifier.java)

@ -0,0 +1,43 @@
---
title: 主题端文章内容处理
description: 提供扩展主题端文章内容处理的方法,干预文章内容的渲染。
---
主题端文章内容处理扩展点用于干预文章内容的渲染,例如:在文章内容中添加广告、添加版权信息等。
```java
public interface ReactivePostContentHandler extends ExtensionPoint {
Mono<PostContentContext> handle(@NonNull PostContentContext postContent);
@Data
@Builder
class PostContentContext {
private Post post;
private String content;
private String raw;
private String rawType;
}
}
```
`handle` 方法用于处理文章内容,参数 `postContent` 为文章内容上下文,包含文章自定义模型对象、文章 html 内容、原始内容、原始内容类型等信息。
`ReactivePostContentHandler` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: reactive-post-content-handler
spec:
className: run.halo.app.theme.ReactivePostContentHandler
displayName: ReactivePostContentHandler
type: MULTI_INSTANCE
description: "Provides a way to extend the post content to be displayed on the theme-side."
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``reactive-post-content-handler`
使用案例可以参考:[WebP Cloud 插件](https://github.com/webp-sh/halo-plugin-webp-cloud/blob/a6069dfa78931de0d5b5dfe98fdd18a0da75b09f/src/main/java/se/webp/plugin/WebpCloudPostContentHandler.java#L17)
它的作用是处理主题端文章内容中的所有图片的地址,将其替换为一个 WebP Cloud 的代理地址,从而实现文章内容中的图片都使用 WebP 格式。

@ -0,0 +1,38 @@
---
title: 主题端自定义页面内容处理
description: 提供扩展主题端自定义页面内容处理的方法,干预自定义页面内容的渲染。
---
主题端自定义页面内容处理扩展点,作用同 [主题端文章内容处理](./post-content.md) 扩展点,只是作用于自定义页面。
```java
public interface ReactiveSinglePageContentHandler extends ExtensionPoint {
Mono<SinglePageContentContext> handle(@NonNull SinglePageContentContext singlePageContent);
@Data
@Builder
class SinglePageContentContext {
private SinglePage singlePage;
private String content;
private String raw;
private String rawType;
}
}
```
`ReactiveSinglePageContentHandler` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: reactive-singlepage-content-handler
spec:
className: run.halo.app.theme.ReactiveSinglePageContentHandler
displayName: ReactiveSinglePageContentHandler
type: MULTI_INSTANCE
description: "Provides a way to extend the single page content to be displayed on the theme-side."
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``reactive-singlepage-content-handler`

@ -0,0 +1,30 @@
---
title: 用户名密码认证管理器
description: 提供扩展用户名密码的身份验证的方法
---
用户名密码认证管理器扩展点用于替换 Halo 默认的用户名密码认证管理器实现,例如:使用第三方的身份验证服务,一个例子是 LDAP。
```java
public interface UsernamePasswordAuthenticationManager extends ExtensionPoint {
Mono<Authentication> authenticate(Authentication authentication);
}
```
`UsernamePasswordAuthenticationManager` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: username-password-authentication-manager
spec:
className: run.halo.app.security.authentication.login.UsernamePasswordAuthenticationManager
displayName: Username password authentication manager
type: SINGLETON
description: "Provides a way to extend the username password authentication."
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``username-password-authentication-manager`
可以参考的实现示例 [TOTP 认证](https://github.com/halo-dev/halo/blob/86e688a15d05c084021b6ba5e75d56a8813c3813/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java#L84)

@ -0,0 +1,303 @@
---
title: 自定义模型
description: 了解什么是自定义模型及如何创建
---
Halo 自定义模型主要参考自 [Kubernetes CRD](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/) 。自定义模型遵循 [OpenAPI v3](https://spec.openapis.org/oas/v3.1.0)。设计目的在于提供一种灵活可扩展的数据存储和使用方式,便于为插件提供自定义数据支持。
比如某插件需要存储自定义数据,同时也想读取和操作自定义数据。更多细节请参考 [自定义模型设计](https://github.com/halo-dev/rfcs/tree/main/extension)。
一个典型的自定义模型 `Java` 代码示例如下:
```java
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupKind;
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@GVK(group = "my-plugin.halo.run",
version = "v1alpha1",
kind = "Person",
plural = "persons",
singular = "person")
public class Person extends AbstractExtension {
@Schema(requireMode = Schema.RequireMode.REQUIRED)
private Spec spec;
@Schema(name="PersonSpec")
public static class Spec {
@Schema(description = "The description on name field", maxLength = 100)
private String name;
@Schema(description = "The description on age field", maximum = "150", minimum = "0")
private Integer age;
@Schema(description = "The description on gender field")
private Gender gender;
private Person otherPerson;
}
public enum Gender {
MALE, FEMALE,
}
}
```
要创建一个自定义模型需要三步:
1. 创建一个类继承 `run.halo.app.extension.AbstractExtension`
2. 使用 `GVK` 注解。
3. 在插件 `start()` 生命周期方法中注册自定义模型:
```java
@Autowired
private SchemeManager schemeManager;
@Override
public void start() {
schemeManager.register(Person.class);
}
```
:::tip 释义
- @GVK:此注解标识该类为一个自定义模型,同时必须继承 `AbstractExtension`
- kind表示自定义模型所表示的 REST 资源。
- group表示一组公开的资源通常采用域名形式Halo 项目保留使用空组和任何以 `*.halo.run` 结尾的组名供其单独使用。
选择群组名称时,我们建议选择你的群组或组织拥有的子域,例如 `widget.mycompany.com`,而这里提到的公开并不是指你的自定义资源可以被任何人访问,
而是指你的自定义模型对象可以被以 APIs 的形式访问。
- versionAPI 的版本,它与 group 组合使用为 `apiVersion=GROUP/VERSION`,例如`api.halo.run/v1alpha1`。
- singular: 资源的单数名称,这允许客户端不透明地处理复数和单数,必须全部小写,通常是将 `kind` 的值转换为小写作为 `singular` 的值。
- plural 资源的复数名称,自定义资源在 `/apis/<group>/<version>/.../<plural>` 下提供,必须为全部小写,通常是将 `kind` 的值转换为小写并转为复数形式作为 `plural` 的值。
- @Schema:属性校验注解,会在创建/修改资源前对资源进行简单校验,参考 [schema-validator](https://www.openapi4j.org/schema-validator.html)。
:::
一个自定义模型通常包括以下几个部分:
- `apiVersion`: 用于标识自定义模型的 API 版本,它由 `GVK` 注解的 `group``version` 组合而成。
- `kind`: 用于标识自定义模型的类型,它由 `GVK` 注解的 `kind` 声明。
- `metadata`: 用于标识自定义模型的元数据:
- `name`: 用于标识自定义模型的名称。
- `creationTimestamp`: 用于标识自定义模型的创建时间,无法修改,只在创建时自动生成。
- `version`: 用于标识自定义模型的数据乐观锁版本,无法修改,由更新时自动填充,如果更新时指定了 `version` 且与当前 `version` 不一致则会更新失败。
- `deletionTimestamp`: 用于标识自定义模型的删除时间,表示此自定义模型对象被声明为删除,此时仍然可以通过 API 访问到此对象,参考 [自定义模型对象生命周期](../../basics/framework.md#extension-lifecycle)
- `finalizers`: 用于标识终结器,它是一个字符串集合,用于标识自定义模型对象是否可回收,参考 [自定义模型对象生命周期](../../basics/framework.md#extension-lifecycle)
- `labels`: 用于标识自定义模型的标签,它是一个字符串键值对集合,用于标识自定义模型对象的标签,可以通过标签来查询自定义模型对象。
- `annotations`: 用于存放扩展信息,它是一个字符串键值对集合,用于存放自定义模型对象的扩展信息。
- `spec`: 用于声明自定义模型对象的期望状态,它是声明式的,用户只需要声明期望状态,实际状态由具体的控制器来维护,最终达到用户期望的状态。
- `status`: 用于描述自定义模型对象资源状态的变化,和一些实际状态。
其中 `apiVersion`、`kind`、`metadata`都包含在了 AbstractExtension 类中,所以我们只需要关注 `spec``status` 即可,参考:[Halo 架构概览之自定义模型](../../basics/framework.md#extension)
## 声明自定义模型对象 {#declare-extension-object}
有了自定义模型后可以通过在插件项目的 `src/main/resources/extensions` 目录下声明 `yaml` 文件来创建一个自定义模型对象,
此目录下的所有自定义模型 `yaml` 都会在插件启动后被创建:
```yaml
apiVersion: my-plugin.halo.run/v1alpha1
kind: Person
metadata:
name: fake-person
spec:
name: halo
age: 18
gender: male
```
在该目录下声明自定义模型对象所使用的 `yaml` 文件的文件名是任意的,只根据 `kind``apiVersion` 来确定自定义模型对象的类型。
## 命名规范 {#naming-conventions}
### metadata name {#metadata-name}
`metadata.name` 它是自定义模型对象的唯一标识名,包含不超过 253 个字符,仅包含小写字母、数字或`-`,以字母或数字开头,以字母或数字结尾。
### labels
`labels` 它是一个字符串键值对集合, Key 的基本结构为 `<prefix>/<name>`,完整的 label 键通常包括一个可选的前缀和名称,二者通过斜杠(/)分隔。
- 前缀可选通常是域名的反向表示形式用于避免键名冲突。例如halo.run/post-slug
- 名称:标识 label 的具体含义,如 post-slug。
前缀规则:
- 如果 label 用于特定于一个组织的资源,建议使用一个前缀,如 `plugin.halo.run/plugin-name`
- 前缀必须是一个有效的 DNS 子域名(参考 metadata.name且最多可包含 253 个字符。
- 保留了不带前缀的 label 键以及特定前缀(如 halo.run因此插件不可使用。
名称规则:
- 名称必须是合法的 DNS 标签,最多可包含 63 个字符。
- 必须以字母数字字符开头和结尾。
- 可以包含 `-`、`.`、`_` 和`字母数字`字符。
通用规范:
- 避免使用容易引起混淆或误解的键名。
- 尽量保持简洁明了,易于理解。
- 使用易于记忆和识别的单词或缩写。
一致性和清晰性:
- 在整个项目或组织中保持一致的命名约定。
- labels 应直观地反映其代表的信息或用途。
- 不要在 labels 中包含敏感信息,例如用户凭据或个人识别信息。
## 使用索引 {#using-indexes}
自定义模型虽然带来了很大的灵活性可扩展性,但也引入了查询问题,自定义模型对象存储在数据库中是 `byte[]` 的形式存在的,从而实现不依赖于数据库特性,你可以使用 `MySQL``PostgreSQL``H2` 等数据库来来作为存储介质但查询自定义模型对象时无法使用数据库的索引特性这就导致了查询自定义模型对象的效率问题Halo 自己实现了一套索引机制来解决这个问题。
索引是一种存储数据结构,可提供对数据集中字段的高效查找。索引将自定义模型中的字段映射到数据库行,以便在查询特定字段时不需要完整的扫描。查询数据之前,必须对需要查询的字段创建索引。索引可以包含一个或多个字段的值。索引可以包含唯一值或重复值。索引中的值按照索引中的顺序进行排序。
索引可以提高查询性能,但会占用额外的存储空间,因为它们需要存储索引字段的副本。索引的大小取决于字段的数据类型和索引的类型,因此,创建索引时应该考虑存储成本和性能收益。
你可以通过以下方式在注册自定义模型时声明索引:
```java
@Override
public void start() {
schemeManager.register(Moment.class, indexSpecs -> {
indexSpecs.add(new IndexSpec()
.setName("spec.tags")
.setIndexFunc(multiValueAttribute(Moment.class, moment -> {
var tags = moment.getSpec().getTags();
return tags == null ? Set.of() : tags;
}))
);
// more index spec
}
```
`IndexSpec` 用于声明索引项,它包含以下属性:
- name索引名称在同一个自定义模型的索引中必须唯一一般建议使用字段路径作为索引名称例如 `spec.slug`
- order对索引值的排序方式支持 `ASC``DESC`,默认为 `ASC`
- unique是否唯一索引如果为 `true` 则索引值必须唯一,如果创建自定义模型对象时检测到此索引字段有重复值则会创建失败。
- indexFunc索引函数用于获取索引值接收当前自定义模型对象返回一个索引值索引值必须是字符串任意类型如果不是字符串类型则需要自己转为字符串可以使用 `IndexAttributeFactory` 提供的静态方法来创建 `indexFunc`
- `simpleAttribute()`:用于得到一个返回单个值的索引函数,例如 `moment -> moment.getSpec().getSlug()`
- `multiValueAttribute()`:用于得到一个返回多个值的索引函数,例如 `moment -> moment.getSpec().getTags()`
当注册自定义模型时声明了索引后Halo 会在插件启动时构建索引,在构建索引期间插件出于未启动状态。
Halo 默认会为每个自定义模型建立以下几个索引,因此不需要为下列字段再次声明索引:
- `metadata.name` 创建唯一索引
- `metadata.labels`
- `metadata.creationTimestamp`
- `metadata.deletionTimestamp`
创建了索引的字段可以在查询时使用 `fieldSelector` 参数来查询,参考 [自定义模型 APIs](#extension-apis)。
## 自定义模型 APIs {#extension-apis}
定义好自定义模型并注册后,会根据 `GVK` 注解自动生成一组 `CRUD` APIs规则为
`/apis/<group>/<version>/<extension>/{extensionname}/<subextension>`
对于上述 Person 自定义模型将有以下 APIs
```shell
# 用于列出所有 Person 自定义模型对象
GET /apis/my-plugin.halo.run/v1alpha1/persons
# 用于查询指定名称更新自定义模型对象
PUT /apis/my-plugin.halo.run/v1alpha1/persons/{name}
# 用于创建自定义模型对象
POST /apis/my-plugin.halo.run/v1alpha1/persons
# 用于根据指定名称删除自定义模型对象
DELETE /apis/my-plugin.halo.run/v1alpha1/persons/{name}
```
对于这组自动生成的 `CRUD` APIs你可以通过定义[控制器](../../basics/framework.md#controller)来完成对数据修改后的业务逻辑处理来满足大部分的场景需求。
`GET /apis/my-plugin.halo.run/v1alpha1/persons` 这个 API 用于查询自定义模型对象,它支持以下参数:
- page页码从 1 开始。
- size每页数据量如果不传此参数默认为查询所有。
- sort排序字段格式为 `字段名,排序方式`,例如 `name,desc`,如果不传此参数默认为按照 `metadata.creationTimestamp` 降序排序,排序使用的字段必须是注册为索引的字段。
- labelSelector标签选择器格式为 `key=value`,例如 `labelSelector=name=halo`,如果不传此参数默认为查询所有,此标签选择器筛选的是 `metadata.labels`,支持的操作符有 `=``!=`、`!` 和 `存在检查`
- `=` 表示等于,例如 `labelSelector=name=halo` 表示查询 `metadata.labels``name` 的值等于 `halo` 的自定义模型对象。
- `!=` 表示不等于,例如 `labelSelector=name!=halo` 表示查询 `metadata.labels``name` 的值不等于 `halo`的自定义模型对象。
- `!` 表示不存在 key例如 `labelSelector=!name` 表示查询 `metadata.labels` 不存在 `name` 这个 key 的自定义模型对象。
- `存在检查` 表示查询存在 key 的对象,例如 `labelSelector=name` 表示查询 `metadata.labels` 存在 `name` 这个 key 的自定义模型对象。
- fieldSelector字段选择器格式与 `labelSelector` 类似,但需要确保对应的字段是注册为索引的字段,例如 `fieldSelector=spec.name=slug` 表示查询 `spec.slug` 的值等于 `halo` 的自定义模型对象,支持的操作符有 `=`、`!=` 和 `in`
- `=` 表示等于,例如 `fieldSelector=spec.slug=halo` 表示查询 `spec.slug` 的值等于 `halo` 的自定义模型对象。
- `!=` 表示不等于,例如 `fieldSelector=spec.slug!=halo` 表示查询 `spec.slug` 的值不等于 `halo` 的自定义模型对象。
- `in` 表示在集合中,例如 `fieldSelector=spec.slug=(halo,halo2)` 表示查询 `spec.slug` 的值在 `halo``halo2` 中的自定义模型对象。
这些查询参数是 `AND` 的关系,例如 `page=1&size=10&sort=name,desc&labelSelector=name=halo&fieldSelector=spec.slug=halo` 表示查询 `metadata.labels``name` 的值等于 `halo``spec.slug` 的值等于 `halo` 的自定义模型对象,并按照 `name` 字段降序排序,查询第 1 页,每页 10 条数据。
## 自定义 API
在一些场景下,只有自动生成的 `CRUD` API 往往是不够用的,此时可以通过自定义一些 APIs 来满足功能。
你可以使用 [Spring Framework Web](https://docs.spring.io/spring-framework/reference/web/webflux/new-framework.html) 的 Adaptive 写法来暴露 APIs不同的是需要添加 `@ApiVersion` 注解,没有此注解的 `Controller` 将被忽略:
```java
@ApiVersion("fake.halo.run/v1alpha1")
@RequestMapping("/apples")
@RestController
public class AppleController {
@PostMapping("/starting")
public void starting() {
}
}
```
当插件被启动时Halo 将会为此 AppleController 生成一个 API
```text
/apis/fake.halo.run/v1alpha1/apples/starting
```
但我们**更推荐使用** [Functional Endpoints](https://docs.spring.io/spring-framework/reference/web/webflux-functional.html) 写法来提供 APIs这是一种轻量级函数式编程写法
```java
RouterFunction<ServerResponse> route = route()
.GET("/person/{id}", accept(APPLICATION_JSON), this::getPerson)
.GET("/person", accept(APPLICATION_JSON), this::listPeople)
.POST("/person", this::createPerson)
.add(otherRoute)
.build();
public Mono<ServerResponse> listPeople(ServerRequest request) {
// ...
}
public Mono<ServerResponse> createPerson(ServerRequest request) {
// ...
}
public Mono<ServerResponse> getPerson(ServerRequest request) {
// ...
}
```
HTTP 请求通过 HandlerFunction 处理:这是一个接收 ServerRequest 并返回延迟的 ServerResponse`Mono<ServerResponse>`)的函数。
请求和响应对象都有不可变的约定,它们提供了对 HTTP 请求和响应的 JDK 8 友好访问。HandlerFunction 相当于基于注解的编程模型中 @RequestMapping 方法的主体。
传入的请求通过 RouterFunction 路由到一个处理函数:这是一个接收 ServerRequest 并返回延迟的 HandlerFunction`Mono<HandlerFunction>`)的函数。
当路由函数匹配时,返回一个处理函数;否则返回一个空的 Mono。RouterFunction 相当于 `@RequestMapping` 注解,但主要区别在于路由函数不仅提供数据,还提供行为。
ServerRequest 和 ServerResponse 是不可变的接口,它们提供了对 HTTP 请求和响应的 JDK 8 友好访问。请求和响应都针对主体流提供了
[Reactive Streams](https://www.reactive-streams.org/) 的背压back pressure。请求主体用 Reactor Flux 或 Mono 表示。
响应主体可用任何响应式流发布者Publisher表示包括 Flux 和 Mono。
更多相关信息,请参见 [Reactor 3 Reference Guide](https://projectreactor.io/docs/core/release/reference/) 和 [Webflux](https://docs.spring.io/spring-framework/reference/web/webflux.html)。
操作自定义模型对象的自定义 APIs 的路由规则建议遵循以下规则:
1. 以 `/apis/<group>/<version>/<plural>[/<resourceName>/<subresource>]` 规则组成 APIs。
2. 由于自动生成的 APIs 不能覆盖,因此通过不同的 group 来区分,自定义的 APIs 的 group 建议遵循以下规则:
- 在 Console 端使用的自定义 APIs 的 group 规则 `console.api.<group>`,例如对于 Person 自定义模型需要一个一个在 Console 端使用的自定义 API 的 group 为 `console.api.my-plugin.halo.run`
- 在个人中心使用的自定义 APIs 的 group 规则 `uc.api.<group>`,例如 `uc.api.my-plugin.halo.run`
- 为主题端提供的公开的自定义 APIs 的 group 规则 `api.<group>`,例如 `api.my-plugin.halo.run`

@ -0,0 +1,60 @@
---
title: 为主题提供数据
description: 了解如何为主题提供更多获取和使用数据的方法。
---
当你在插件中创建了自己的自定义模型时,你可能需要在主题模板中使用这些数据。或者,你提供一些额外的数据,以便主题可以使用它们,你可以通过创建一个自定义的 `finder` 来实现这一点。
## 创建一个 Finder
首先,你需要创建一个 `interface`,并在其中定义你需要提供给主题获取数据的方法,方法的返回值可以是 `Mono``Flux` 类型,例如:
```java
public interface LinkFinder {
Mono<LinkVo> get(String linkName);
Flux<LinkVo> listAll();
}
```
然后写一个实现类,实现这个 `interface`,并在类上添加 `@Finder` 注解,例如:
```java
import run.halo.app.theme.finders.Finder;
@Finder("myPluginLinkFinder")
public class LinkFinderImpl implements LinkFinder {
@Override
public Mono<LinkVo> get(String linkName) {
// ...
}
@Override
public Flux<LinkVo> listAll() {
// ...
}
}
```
`@Finder` 注解的值是你在主题中使用的名称,例如,你可以在主题中使用 `myPluginLinkFinder.get('a-link-name')` 来获取数据,`myPluginLinkFinder` 就是你在 `@Finder` 注解中定义的名称。
## Finder 命名
为了避免与其他插件的 `finder` 名称冲突,建议在 `@Finder` 注解中添加一个你插件名称的前缀作为 `finder` 名称且名称需要是驼峰式的,不能包含除了 `_` 之外的其他特殊字符。
例如,你的插件名称是 `my_plugin`,你需要为主题提供一个获取链接的 `finder`,那么你可以这样定义 `@Finder` 注解:
```java
@Finder("myPluginLinkFinder")
```
## 使用 Finder
在主题中,你可以通过 `finder` 名称和方法名及对应的参数来获取数据,例如:
```html
<div th:text="${myPluginLinkFinder.listAll()}">
</div>
```
模板语法参考:[Thymeleaf](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#standard-expression-syntax)。

@ -0,0 +1,268 @@
---
title: 编写控制器
description: 了解如何为自定义模型编写控制器
---
控制器是 Halo 的关键组件,它们负责对每个自定义模型对象进行操作,协调所需状态和当前状态,参考: [控制器概述](../../basics/framework.md#controller)。
控制器通常在具有一般事件序列的控制循环中运行:
1. 观察:每个控制器将被设计为观察一组自定义模型对象,例如文章的控制器会观察文章对象,插件的控制器会观察插件自定义模型对象等。
2. 比较:控制器将对象配置的期望状态与其当前状态进行比较,以确定是否需要更改,例如插件的 `spec.enabled``true`,而插件的当前状态是未启动,则插件控制器会处理启动插件的逻辑。
3. 操作:控制器将根据比较的结果执行相应的操作,以确保对象的实际状态与其期望状态一致,例如插件期望启动,插件控制器会处理启动插件的逻辑。
3. 重复:上述所有步骤都由控制器重复执行直到与期望状态一致。
这是一个描述控制器作用的例子:房间里的温度自动调节器。
当你设置了温度告诉了温度自动调节器你的期望状态Desired State
房间的实际温度是当前状态Current State。 通过对设备的开关控制,温度自动调节器让其当前状态接近期望状态,未到达期望状态则继续调节,直到达到期望状态。
在 Halo 中控制器的运行部分已经有一个默认实现,你只需要编写控制器的调谐的逻辑也就是 [控制器概述](../../basics/framework.md#controller) 中的所说的 Reconciler 即可。
## 编写 Reconciler
Reconciler 是控制器的核心,它是一个接口,你需要实现它的 `reconcile()` 方法,该方法接收一个 `Reconciler.Request` 对象,它包含了当前自定义模型对象的名称,你可以通过它来获取自定义模型对象的当前状态和期望状态,然后编写调谐的逻辑。
```java
@Component
public class PostReconciler implements Reconciler<Reconciler.Request> {
@Override
public Result reconcile(Request request) {
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Post())
.build();
}
}
```
以上是一个简单的 Reconciler 实现,它实现了 `reconcile()` 方法,然后在 `setupWith()` 方法中将其通过 `ControllerBuilder` 构建为一个控制器并指定了
它要观察的自定义模型对象为`Post`,当文章自定义模型对象发生变化时,`reconcile()` 方法就会被调用,从 `Request request` 参数中你可以获得当前发生变化的文章自定义模型对象的名称,然后你就可以通过名称来查询到自定义模型对象进行调谐了。
### Reconciler 的返回值
`reconcile()` 方法的返回值是一个 `Result` 对象,它包含了调谐的结果,你可以通过它来告诉控制器是否需要重试,如果需要重试则控制器会在稍后再次调用 `reconcile()` 方法,而这个过程会一直重复,直到 `reconcile()` 方法返回成功为止这个过程被称之为调谐循环Reconciliation Loop
```java
record Result(boolean reEnqueue, Duration retryAfter) {}
```
`Result` 对象包含了两个属性reEnqueue 和 retryAfterreEnqueue 用于标识是否需要重试retryAfter 用于标识重试的时间间隔,如果 reEnqueue 为 true 则会在 retryAfter 指定的时间间隔后再次调用 `reconcile()` 方法,如果 reEnqueue 为 false 则不会再次调用 `reconcile()` 方法。
在没有特殊需要时,`retryAfter` 可以不指定,控制器会有一套默认的重试策略。
如果直接返回 `null` 则会被视为成功,效果等同于返回 `new Result(false, null)`
### Reconciler 的异常处理
`reconcile()` 方法抛出异常时,控制器会将异常记录到日志中,然后会将 `Request request` 对象重新放入队列中,等待下次调用 `reconcile()` 方法,这个过程会一直重复,直到 `reconcile()` 成功,对于默认重试策略,每次重试间隔会越来越长,直到达到最长间隔后不再增加。
## 控制器示例
本章节将通过一个简单的示例来演示如何编写控制器。
### 场景:事件管理系统
创建一个名为 ”EventTracker“ 的自定义模型,用于管理和追踪组织内的各种事件。这些事件可以是会议、研讨会、社交聚会或任何其他类型的组织活动。
“EventTracker“ 自定义模型将提供一个框架,用于记录事件的详细信息,如时间、地点、参与者和状态。
由于这里的重点是控制器,因此我们将忽略自定义模型的详细信息,只关注控制器的实现,一个可能的 “EventTracker” 数据结构如下:
```yaml
apiVersion: tracker.halo.run/v1alpha1
kind: EventTracker
metadata:
name: event-tracker-1
spec:
eventName: "Halo Meetup"
eventDate: "2024-01-20T12:00:00Z"
location: "Chengdu"
participants: ["@sig-doc", "@sig-console", "@sig-halo"]
description: "Halo Meetup in Chengdu"
status:
phase: "Planned" # Planned, Ongoing, Completed
participants: []
conditions:
- type: "Invalid"
status: "True"
reason: "InvalidEventDate"
message: "Event date is invalid"
```
业务逻辑处理:
1. 事件创建:
- 当新的 EventTracker 资源被创建时,控制器需验证所有必要字段的存在和格式正确性。
- 初始化事件状态为 Planned。
2. 事件更新:
- 检查 eventDate、location 和 participants 字段的变更。
- 如果接近事件日期,自动更新状态为 Ongoing。
3. 状态管理:
- 根据当前日期和事件日期自动管理 phase 字段。
- 当事件日期过去时,将状态更新为 Completed。
4. 数据验证和完整性:
- 确保所有输入数据的格式正确且合理。
- 如有不一致或缺失的重要信息,记录警告或错误。
5. 事件提醒和通知:
- 在事件状态改变或临近事件日期时发送通知。
6. 清理和维护:
- 对于已完成的事件,提供自动清理机制,例如在事件结束后一定时间内删除资源。
首先实现 EventTracker 控制器的协调循环主体,通过依赖注入 `ExtensionClient` 可以用于获取当前变更的对象:
```java
@Component
@RequiredArgsConstructor
public class EventTrackerReconciler implements Reconciler<Reconciler.Request> {
private final ExtensionClient client;
@Override
public Result reconcile(Request request) {
// ...
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new EventTracker())
.build();
}
}
```
然后在 `reconcile()` 方法中根据 `EventTracker` 对象的状态来执行响应的操作,确保执行逻辑是是幂等的,这意味着即使多次执行相同操作,结果也应该是一致的。
```java
public Result reconcile(Request request) {
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
// 获取到当前变更的 EventTracker 对象
// 1. 检查必要字段的存在和格式正确性
// 2. 初始化事件状态为 Planned
if (eventTracker.getStatus() == null) {
eventTracker.setStatus(new EventTracker.Status());
}
var status = eventTracker.getStatus();
if (status.getPhase() == null) {
status.setPhase(EventTracker.Phase.PLANNED);
}
var eventName = eventTracker.getSpec().getEventName();
if (StringUtils.isBlank(eventName)) {
Condition condition = Condition.builder()
.type("Invalid")
.reason("InvalidEventName")
.message("Event name is invalid")
.status(ConditionStatus.FALSE)
.lastTransitionTime(Instant.now())
.build();
status.getConditions().addAndEvictFIFO(condition);
}
client.update(eventTracker);
});
return new Result(false, null);
}
```
上述,我们通过 `client.fetch()` 方法获取到了当前变更的 `EventTracker` 对象,然后根据 `EventTracker` 对象的状态来执行响应的操作,例如初始化事件状态为 Planned检查必要字段的存在和格式正确性等但需要注意控制器的执行是异步的如果我们通过 `EventTracker` 的 API 来创建或更改了一个 `EventTracker` 对象,那么 API 会在控制器执行之前返回结果,这意味着在用户界面看到的结果可能不是最新的,并且可能会在稍后更新。
对于上述校验 `eventName` 的逻辑只是保证后续的执行是可靠的,如果有些字段是必须的,那么我们可以通过 `@Schema` 注解来标注,为了让控制器中校验字段失败的信息能够呈现到用户界面,我们通过向 `status.condtions` 中添加了一条 Condition 记录来用于记录这个事件,再用户界面可以展示这个 Condition 记录的信息以让用户知晓。
最后,我们通过 `client.update()` 方法来更新 `EventTracker` 对象,这个过程就是将实际状态回写到 `EventTracker` 对象并应用到数据库中,这样就完成了一次调谐。
`EventTracker` 对象发生变更时,控制器也会被执行,这时我们可以根据 `EventTracker` 对象的状态来执行响应的操作,例如检查和更新 `eventDate`、`location` 和 `participants` 字段的变更,如果接近事件日期,自动更新状态为 Ongoing。
```java
public Result reconcile(Request request) {
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
// ...此处省略之前的逻辑
if (isApproach(eventTracker.getSpec().getEventDate())) {
status.setPhase(EventTracker.Phase.ONGOING);
sendNotification(eventTracker, "Event is ongoing");
}
});
return new Result(false, null);
}
```
这里我们通过 `isApproach()` 方法来表示判断是否接近事件日期,如果接近则更新状态为 Ongoing并使用 `sendNotification` 来发送发送通知。
> 为了简化示例,我们省略了 `isApproach()``sendNotification` 方法的实现。
还可以根据 `spec.participants` 字段来解析参与者信息,然后将其添加到 `status.participants` 中,这样就可以在用户界面看到参与者信息了。
```java
public Result reconcile(Request request) {
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
// ...此处省略之前的逻辑
var participants = eventTracker.getSpec().getParticipants();
resolveParticipants(participants).forEach(status::addParticipant);
});
return new Result(false, null);
}
```
### 使用 Finalizers
`Finalizers` 允许控制器实现异步预删除钩子。假设您为正在实现的 API 类型的每个对象创建了一个外部资源,例如存储桶,并且您希望在从 Halo 中删除相应对象
时清理外部资源,您可以使用终结器来删除外部资源资源。
比如 `EventTracker` 对象被删除时,我们需要删除 `EventTracker` 对象记录的日志,这时我们可以通过 `Finalizers` 来实现。
首先我们需要在 `reconcile()` 的开头判断 `EventTracker` 对象的 `metadata.deletionTimestamp` 是否存在,如果存在则表示 `EventTracker` 对象被删除了,
这时我们就可以执行清理操作。
```java
public Result reconcile(Request request) {
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
if (ExtensionOperator.isDeleted(eventTracker)) { // 1. 判断是否被删除
// 2. 调用 removeFinalizers 方法移除终结器(稍后会说明)
ExtensionUtil.removeFinalizers(eventTracker.getMetadata(), Set.of(FINALIZER_NAME));
// 3. 执行清理操作
cleanUpLogsForTracker(eventTracker);
// 4. 更新 EventTracker 对象将变更应用到数据库中
client.update(eventTracker);
// 5. return 避免执行后续逻辑
return;
}
// ...此处省略之前的逻辑
});
return new Result(false, null);
}
```
1. `ExtensionOperator.isDeleted` 方法是 Halo 提供的工具方法,用于判断对象是否被删除,它会判断 `metadata.deletionTimestamp` 是否存在,如果存在则表示对象被标记删除了。
关于自定义模型对象的删除可以参考:[自定义模型对象生命周期](../../basics/framework.md#extension-lifecycle)
2. `ExtensionUtil.removeFinalizers` 方法是 Halo 提供的工具方法,用于移除对象的终结器,它接收两个参数,第一个参数是对象的元数据,第二个参数是要移除的终结器名称集合,它来自 `run.halo.app.extension.ExtensionUtil`
3. `cleanUpLogsForTracker` 方法是我们自己实现的,这里的示例是用于清理 `EventTracker` 对象记录的日志,你可以根据自己的业务需求来实现,如清理外部资源等。
经过上述步骤,我们只是写了移除终结器但是发现没有添加终结器的逻辑,添加终结器的逻辑需要在判断删除之后,`metadata.finalizers` 是一个字符串集合,用于标识对象是否可回收,如果 `metadata.finalizers` 不为空则表示对象不可回收,否则表示对象可回收,我们可以通过 `ExtensionUtil.addFinalizers` 方法来添加终结器。
最佳实践是,一个控制器最多添加一个终结器,名称为了防止冲突可以使用当前业务的 `group/终结器名称` 来命名,例如 `tracker.halo.run/finalizer`,例如在 Halo 中文章的控制器使用了一个终结器,但可能插件也会定义一个文章控制器来扩展文章的业务,那么根据最佳实践命名终结器可以避免冲突。
```java
private static final String FINALIZER_NAME = "tracker.halo.run/finalizer";
public Result reconcile(Request request) {
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
if (ExtensionOperator.isDeleted(eventTracker)) {
// ... 省略删除逻辑
}
// 添加终结器
ExtensionUtil.addFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME));
// ...此处省略之前的逻辑
// 会在更新时将终结器的变更写入到数据库中
client.update(eventTracker);
});
}
```

@ -3,18 +3,18 @@ title: 静态资源代理
description: 了解如果使用静态资源代理来访问插件中的静态资源 description: 了解如果使用静态资源代理来访问插件中的静态资源
--- ---
插件中的静态资源如图片等如果想被外部访问到,需要放到 `src/main/resources` 目录下,并通过创建 ReverseProxy 自定义模型来进行静态资源代理访问。 插件中的静态资源如图片等如果想被外部访问到,需要放到 `src/main/resources` 目录下,并通过创建 `ReverseProxy` 自定义模型对象来进行静态资源代理访问。
例如 `src/main/resources` 下的 `static` 目录下有一张 `halo.jpg`: 例如 `src/main/resources` 下的 `static` 目录下有一张 `halo.jpg`:
1. 首先需要在 `src/main/resources/extensions` 下创建一个 `yaml`,文件名可以任意。 1. 首先需要在 `src/main/resources/extensions` 下创建一个 `yaml`,文件名可以任意。
2. 示例配置如下: 2. 声明 `ReverseProxy` 对象如下:
```yaml ```yaml
apiVersion: plugin.halo.run/v1alpha1 apiVersion: plugin.halo.run/v1alpha1
kind: ReverseProxy kind: ReverseProxy
metadata: metadata:
# name 为此资源的唯一标识名称,不允许重复,为了避免与其他插件冲突,推荐带上插件名称前缀 # 为了避免与其他插件冲突,推荐带上插件名称前缀
name: my-plugin-fake-reverse-proxy name: my-plugin-fake-reverse-proxy
rules: rules:
- path: /res/** - path: /res/**
@ -24,7 +24,7 @@ description: 了解如果使用静态资源代理来访问插件中的静态资
filename: halo.jpg filename: halo.jpg
``` ```
插件启动后会根据 `/plugins/{plugin-name}/assets/**` 规则生成 API。 插件启动后会根据 `/plugins/{plugin-name}/assets/**` 规则生成访问路径,
因此该 `ReverseProxy` 的访问路径为: `/plugins/my-plugin/assets/res/halo.jpg` 因此该 `ReverseProxy` 的访问路径为: `/plugins/my-plugin/assets/res/halo.jpg`
- `rules` 下可以添加多组规则。 - `rules` 下可以添加多组规则。

@ -0,0 +1,202 @@
---
title: API 权限控制
description: 了解如果对插件中的 API 定义角色模板以接入权限控制
---
插件中的 APIs 无论是自定义模型自动生成的 APIs 或者是通过 `@Controller` 自定义的 APIs 都只有超级管理员能够访问,如果想将这些 APIs 授权给其他用户访问,
则需要定义一些[角色模板](../../basics/framework.md#rbac)的资源以便可以在用户界面上将其分配给其他角色使用。
定义角色模板就是在插件的 `src/main/resources/extensions` 目录下声明角色的自定义模型资源并标记为模板的过程,文件名称可以任意,它的 kind 为 Role 但需要一个 label `halo.run/role-template: "true"` 来表示它是角色模板。
Halo 的权限控制对同一种资源一般只定义两个角色模板的自定义模型对象,一个是只读权限,另一个是管理权限,因此如果没有特殊情况需要更细粒度的控制,我们建议你也保持一致:
```yaml
apiVersion: v1
kind: Role
metadata:
# 使用 plugin name 作为前缀防止与其他插件冲突,比如这里的 my-plugin
name: my-plugin-role-view-persons
labels:
halo.run/role-template: "true"
annotations:
rbac.authorization.halo.run/module: "Persons Management"
rbac.authorization.halo.run/display-name: "Person Manage"
rbac.authorization.halo.run/ui-permissions: |
["plugin:my-plugin:person:view"]
rules:
- apiGroups: ["my-plugin.halo.run"]
resources: ["my-plugin/persons"]
verbs: ["*"]
---
apiVersion: v1
kind: Role
metadata:
name: my-plugin-role-manage-persons
labels:
halo.run/role-template: "true"
annotations:
rbac.authorization.halo.run/dependencies: |
[ "role-template-view-person" ]
rbac.authorization.halo.run/module: "Persons Management"
rbac.authorization.halo.run/display-name: "Person Manage"
rbac.authorization.halo.run/ui-permissions: |
["plugin:my-plugin:person:manage"]
rules:
- apiGroups: [ "my-plugin.halo.run" ]
resources: [ "my-plugin/persons" ]
verbs: [ "get", "list" ]
```
上述便是根据 [自定义模型](./extension.md) 章节中定义的 Person 自定义模型来配置角色模板的示例。
1. 定义了一个用于管理 Person 自定义模型对象的角色模板 `my-plugin-role-manage-persons`,它具有所有权限。
2. 定义了一个只允许查询 Person 资源的角色模板 `my-plugin-role-view-persons`
3. `metadata.name` 的命名规则参考 [metadata name 命名规范](../server/extension.md#metadata-name)。
下面让我们回顾一下这些配置:
`rules` 是个数组,它允许配置多组规则:
- `apiGroups` 对应 `GVK` 中的 `group` 所声明的值。
- `resources` 对应 API 中的 resource 部分。
- `verbs` 表示请求动词,可选值为 "create", "delete", "deletecollection", "get", "list", "patch", "update"。对应的 HTTP 请求方式如下表所示:
| HTTP verb | request verb |
| --------- | ------------------------------------------------------------ |
| POST | create |
| GET, HEAD | get (for individual resources), list (for collections, including full object content), watch (for watching an individual resource or collection of resources) |
| PUT | update |
| PATCH | patch |
| DELETE | delete (for individual resources), deletecollection (for collections) |
`metadata.labels` 中必须包含 `halo.run/role-template: "true"` 以表示它此资源要作为角色模板。
`metadata.annotations` 中:
- `rbac.authorization.halo.run/dependencies`:用于声明角色间的依赖关系,例如管理角色必须要依赖查看角色,以避免分配了管理权限却没有查看权限的情况。
- `rbac.authorization.halo.run/module`:角色模板分组名称。在此示例中,管理 Person 的模板角色将和查看 Person 的模板角色将被在 UI 层面归为一组展示。
- `rbac.authorization.halo.run/display-name`:模板角色的显示名称,用于展示为用户可读的名称信息。
- `rbac.authorization.halo.run/ui-permissions`:用于控制 UI 权限,规则为 `plugin:{your-plugin-name}:scope-name`,使用示例为在插件前端部分入口文件 `index.ts` 中用于控制菜单是否显示或者控制页面按钮是否展示:
```javascript
{
path: "",
name: "HelloWorld",
component: DefaultView,
meta: {
permissions: ["plugin:my-plugin:person:view"]
}
}
```
以上定义角色模板的方式适合资源型请求,即需要符合以下规则
- 以 `/api/<version>/<resource>[/<resourceName>/<subresource>]` 规则组成 APIs。
- 以 `/apis/<group>/<version>/<resource>[/<resourceName>/<subresource>]` 规则组成的 APIs。
注:`[]`包裹的部分表示可选,只有 Halo 自身使用的 APIs 可以没有 group所以插件的 APIs 都是以 `/apis` 开头的。
:::tip Note
资源型的 API 通常是对自定义模型对象的操作,例如 `/apis/my-plugin.halo.run/v1alpha1/persons`,如果你自定义 APIs 则建议路径层级不要超过 `<subresource>` 层级,否则会导致角色模板无法生效,例如 `/apis/my-plugin.halo.run/v1alpha1/persons/zhangsan/post`,如果 `post` 后面还有路径则会导致角色模板无法生效。
:::
如果你的 API 不符合以上资源型 API 的规则,则被定型为非资源型 API例如 `/healthz`,需要使用一下配置方式:
```yaml
rules:
# nonResourceURL 中的 '*' 是一个全局通配符
- nonResourceURLs: ["/healthz", "/healthz/*"]
verbs: [ "get", "create"]
```
## 默认角色
在 Halo 中,每个访问者都至少有一个角色,包括未登陆的用户(被称为匿名用户)它们会拥有角色为 `anonymous` 的角色,而已登陆的用户则会至少拥有一个角色名为 `authenticated` 的角色,
但这两个角色不会显示在角色列表中。
`anonymous` 角色的定义参考 [anonymous 角色](https://github.com/halo-dev/halo/blob/main/application/src/main/resources/extensions/role-template-anonymous.yaml)。
`authenticated` 角色的定义参考 [authenticated 角色](https://github.com/halo-dev/halo/blob/main/application/src/main/resources/extensions/role-template-authenticated.yaml)。
进入角色列表页面,你会看到一些内置角色,用于方便你快速的分配权限给用户,并可以基于这些角色来创建新的角色:
- 超级管理员:拥有所有权限,不可删除,不可编辑。
- 访客:拥有默认的 `anonymous``authenticated` 角色的权限。
- 投稿者:拥有“允许投稿”的权限。
- 作者:拥有“允许管理自己的文章”和”允许发布自己的文章“的权限。
- 文章管理员:拥有“允许管理所有文章”的权限。
## 角色绑定
角色绑定用于将角色中定义的权限授予一个或一组用户。它包含主体列表(用户)以及对所授予角色的引用。
角色绑定示例:
```yaml
apiVersion: v1alpha1
# 这个角色绑定允许 "guqing" 用户拥有 "post-reader" 角色的权限,你需要在 Halo 中已经定义了一个名为 "post-reader" 的角色。
kind: RoleBinding
metadata:
name: guqing-post-reader-binding
roleRef:
# "roleRef" 指定了绑定到的角色
apiGroup: ''
# 这里必须是 Role
kind: Role
# 这里的 name 必须匹配到一个已经定义的角色
name: post-reader
subjects:
- apiGroup: ''
kind: User
# 这里的 name 是用户的 username
name: guqing
```
在 Halo 中,当你给一个用户分配角色后,实际上就是创建了一个 ”RoleBinding” 对象来完成的。
## 聚合角色
你可以聚合角色来将多个角色的权限聚合到一个已有的角色中,这样你就不需要再为每个用户分配多个角色了。
聚合角色是通过在你定义的角色模板中添加 `"rbac.authorization.halo.run/aggregate-to-` 开头的 label 来实现的,例如
```yaml
apiVersion: v1alpha1
kind: "Role"
metadata:
name: role-template-view-categories
labels:
halo.run/role-template: "true"
rbac.authorization.halo.run/aggregate-to-editor: "true"
annotations:
rbac.authorization.halo.run/ui-permissions: |
[ "system:categories:view", "uc:categories:view" ]
rules:
- apiGroups: [ "content.halo.run" ]
resources: [ "categories" ]
verbs: [ "get", "list" ]
```
`rbac.authorization.halo.run/aggregate-to-editor` 表示将 `role-template-view-categories` 角色聚合到 `editor` 角色中,这样所有拥有 `editor` 角色的用户都会拥有 `role-template-view-categories` 角色的权限。
如果你想将你写的资源型 APIs 公开给所有用户访问,这时你可以通过聚合角色来将你的资源型 APIs 的角色聚合到 `anonymous` 角色中,这样所有用户都可以访问你的资源型 APIs 了。
```yaml
apiVersion: v1
kind: Role
metadata:
name: my-plugin-role-view-persons
labels:
halo.run/role-template: "true"
rbac.authorization.halo.run/aggregate-to-anonymous: "true"
annotations:
rbac.authorization.halo.run/module: "Persons Management"
rbac.authorization.halo.run/display-name: "Person Manage"
rbac.authorization.halo.run/ui-permissions: |
["plugin:my-plugin:person:view"]
rules:
- apiGroups: ["my-plugin.halo.run"]
resources: ["my-plugin/persons"]
verbs: ["*"]
```
`rbac.authorization.halo.run/aggregate-to-anonymous` 的写法就表示将 `my-plugin-role-view-persons` 角色聚合到 `anonymous` 角色中。

@ -0,0 +1,90 @@
---
title: 在插件中提供主题模板
description: 了解如何为主题扩充模板。
---
当你在插件中创建了自己的自定义模型后,你可能需要在主题端提供一个模板来展示这些数据,这一般有两种方式:
1. 插件规定模板名称,由主题选择性适配,如瞬间插件提供了 `/moments` 的路由渲染 `moment.html` 模板,主题可以选择性的提供 `moment.html` 模板来展示瞬间数据。
2. 插件提供默认模板,当主题没有提供对应的模板时,使用默认模板,主题提供了对应的模板时,使用主题提供的模板。
## 创建一个模板
首先,你需要在插件的 `resources` 目录下创建一个 `templates` 目录,然后在 `templates` 目录下提供你的模板,例如:
```text
├── templates
│ ├── moment.html
```
然后提供一个路由用于渲染这个模板,例如:
```java
import run.halo.app.theme.TemplateNameResolver;
@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class MomentRouter {
private final TemplateNameResolver templateNameResolver;
@Bean
RouterFunction<ServerResponse> momentRouterFunction() {
return route(GET("/moments"), this::renderMomentPage).build();
}
Mono<ServerResponse> renderMomentPage(ServerRequest request) {
// 或许你需要准备你需要提供给模板的默认数据,非必须
var model = new HashMap<String, Object>();
model.put("moments", List.of());
return templateNameResolver.resolveTemplateNameOrDefault(request.exchange(), "moments")
.flatMap(templateName -> ServerResponse.ok().render(templateName, model));
}
}
```
使用 `TemplateNameResolver` 来解析模板名称,如果主题提供了对应的模板,那么就使用主题提供的模板,否则使用插件提供的模板,如果直接返回模板名称,那么只会使用主题提供的模板,如果主题没有提供对应的模板,那么会抛出异常。
## 模板片段
如果你的默认模板不止一个,你可能需要通过模板片段来抽取一些公共的部分,例如,你的插件提供了一个 `moment.html` 模板,你可能需要抽取一些公共的部分,例如头部、尾部等,你可以这样做:
```text
├── templates
│ ├── moment.html
│ ├── fragments
│ │ ├── layout.html
```
然后定义一个 `layout.html` 模板,例如:
```html
<!DOCTYPE html th:fragment="layoutHtml(content)">
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${title}">Moment</title>
</head>
<body>
<div class="container">
<th:block th:replace="${content}" />
</div>
</body>
</html>
```
那么使用 `layout.html` 模板中提供的 `fragment` 时,你需要这样做:
```html
<div th:replace="~{plugin:plugin-moment:fragments/layout :: layoutHtml(content = ~{::content})}">
<th:block th:fragment="content"> Hello World </th:block>
</div>
```
`plugin:plugin-moment:fragments/layout` 即为使用 `layout.html` 模板的路径,必须以 `plugin:<your-plugin-name>:`前缀作为开头,`fragments/layout` 为模板相对于 `resources/templates` 的路径,`<your-plugin-name>` 即为你的插件名称。
**总结:**
1. 定义模板片段时与主题端定义模板片段时一样
2. 使用模板片段时,必须以 `plugin:<your-plugin-name>:` 前缀作为开头,后跟模板相对于 `resources/templates` 的路径,例如 `plugin:plugin-moment:fragments/layout``plugin-moment` 即为你的插件名称,`fragments/layout` 为模板相对于 `resources/templates` 的路径。
参考:[Thymeleaf 模板片段](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#including-template-fragments)

@ -0,0 +1,55 @@
---
title: AnnotationsForm
description: 元数据表单组件
---
此组件用于提供统一的 [Annotations 表单](../../../../annotations-form.md),可以根据 `group``kind` 属性自动渲染对应的表单项。
## 使用示例
```html
<script lang="ts" setup>
import { ref } from "vue"
const annotationsFormRef = ref()
const currentAnnotations = ref()
function handleSubmit () {
annotationsFormRef.value?.handleSubmit();
await nextTick();
const { customAnnotations, annotations, customFormInvalid, specFormInvalid } =
annotationsFormRef.value || {};
// 表单验证不通过
if (customFormInvalid || specFormInvalid) {
return;
}
// 合并自定义数据和表单提供的数据
const newAnnotations = {
...annotations,
...customAnnotations,
};
}
</script>
<template>
<AnnotationsForm
ref="annotationsFormRef"
:value="currentAnnotations"
kind="Post"
group="content.halo.run"
/>
<VButton @click="handleSubmit">提交</VButton>
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|---------|------------------------------------|---------|-----------------------------------------|
| `group` | string | 无,必填 | 定义组件所属的分组。 |
| `kind` | string | 无,必填 | 定义组件的种类。 |
| `value` | { [key: string]: string; } \| null | null | 可选,包含键值对的对象或空值,用于存储数据。 |

@ -0,0 +1,25 @@
---
title: AttachmentFileTypeIcon
description: 附件文件类型图标组件
---
此组件用于根据文件名显示文件类型图标。
## 使用示例
```html
<script lang="ts" setup></script>
<template>
<AttachmentFileTypeIcon fileName="example.png" />
</div>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|--------------|---------------------|-----------|------------------------------------|
| `fileName` | string \| undefined | undefined | 文件名,可以是字符串或未定义。 |
| `displayExt` | boolean | true | 可选,是否显示文件扩展名,默认为 true。 |
| `width` | number | 10 | 可选,组件宽度,默认为 10。 |
| `height` | number | 10 | 可选,组件高度,默认为 10。 |

@ -0,0 +1,50 @@
---
title: AttachmentSelectorModal
description: 附件选择组件
---
此组件用于调出附件选择器,以供用户选择附件。
:::info 注意
此组件当前仅在 Console 中可用。
:::
## 使用示例
```html
<script lang="ts" setup>
import { ref } from "vue"
const visible = ref(false)
function onAttachmentSelect (attachments: AttachmentLike[]) {
console.log(attachments)
}
</script>
<template>
<VButton @click="visible = true">选择附件</VButton>
<AttachmentSelectorModal
v-model:visible="visible"
@select="onAttachmentSelect"
/>
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|-----------|----------|---------------|--------------------------|
| `visible` | boolean | false | 控制组件是否可见。 |
| `accepts` | string[] | () => ["*/*"] | 可选,定义可接受的文件类型。 |
| `min` | number | undefined | 可选,定义最小选择数量。 |
| `max` | number | undefined | 可选,定义最大选择数量。 |
## Emits
| 事件名称 | 参数 | 描述 |
|----------------|---------------------------------------------------|---------------------|
| update:visible | `visible`: boolean 类型,表示可见状态。 | 当可见状态更新时触发。 |
| close | 无 | 当弹框关闭时触发。 |
| select | `attachments`: AttachmentLike[] 类型,表示附件数组。 | 当选择确定按钮时触发。 |

@ -0,0 +1,18 @@
---
title: FilterCleanButton
description: 过滤器清除按钮组件
---
## 使用示例
```html
<script lang="ts" setup>
function onClear () {
console.log("clear")
}
</script>
<template>
<FilterCleanButton @click="onClear" />
</template>
```

@ -0,0 +1,48 @@
---
title: FilterDropdown
description: 过滤器下拉组件
---
此组件为通用的下拉筛选组件,可以接收一个对象数组作为选项,并使用 `v-model` 绑定选择的值。
## 使用示例
```html
<script lang="ts" setup>
import { ref } from "vue"
const value = ref("")
const items = [
{
label: "最近创建",
value: "creationTimestamp,desc"
},
{
label: "较晚创建",
value: "creationTimestamp,asc"
}
]
</script>
<template>
<FilterDropdown
v-model="value"
label="排序"
:items="items"
/>
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|--------------|-----------------------------------------------------------|-----------|--------------------------------------------------|
| `items` | { label: string; value?: string \| boolean \| number; }[] | 无,必填 | 包含 `label` 和可选 `value` 的对象数组。 |
| `label` | string | 无,必填 | 组件的标签文本。 |
| `modelValue` | string \| boolean \| number | undefined | 可选,用于绑定到组件的值,可以是字符串、布尔值或数字。 |
## Emits
| 事件名称 | 参数 | 描述 |
|-------------------|--------------------------------------------------------|-------------------|
| update:modelValue | `modelValue`: string \| boolean \| number \| undefined | 当模型值更新时触发。 |

@ -0,0 +1,26 @@
---
title: HasPermission
description: 权限判断组件
---
此组件用于根据权限控制元素的显示与隐藏。
## 使用方式
```html
<script lang="ts" setup>
import { VButton } from "@halo-dev/components"
</script>
<template>
<HasPermission :permissions="['system:posts:manage']">
<VButton type="danger">删除</VButton>
</HasPermission>
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|---------------|----------|------|-----------------------|
| `permissions` | string[] | 无,必填 | 定义组件所需的权限列表。 |

@ -0,0 +1,42 @@
---
title: 组件
description: 在 Halo UI 中可使用的组件。
---
此文档将介绍所有在插件中可用的组件,以及它们的使用方法和区别。
## 基础组件库
我们为 Halo 的前端封装了一个基础组件的库,你可以在插件中使用这些组件。
安装方式:
```bash
pnpm install @halo-dev/components
```
在 Vue 组件中:
```html
<script lang="ts" setup>
import { VButton } from "@halo-dev/components";
</script>
<template>
<VButton>Hello</VButton>
</template>
```
所有可用的基础组件以及文档可查阅:<https://halo-ui-components.pages.dev>
## 业务组件和指令
除了基础组件库,我们还为 Halo 的前端封装了一些业务组件和指令,这些组件已经在全局注册,你可以直接在插件中使用这些组件和指令。
以下是所有可用的业务组件和指令:
```mdx-code-block
import DocCardList from '@theme/DocCardList';
<DocCardList />
```

@ -0,0 +1,33 @@
---
title: SearchInput
description: 搜索输入框组件
---
此组件适用于关键词搜索场景,输入数据的过程中不会触发搜索,只有在输入完成后,点击回车才会触发搜索。
## 使用方式
```html
<script lang="ts" setup>
import { ref } from "vue"
const keyword = ref("")
</script>
<template>
<SearchInput v-model="keyword" placeholder="请输入关键字" />
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|---------------|--------|-----------|----------------------------------|
| `placeholder` | string | undefined | 可选,用于指定输入字段的占位符文本。 |
| `modelValue` | string | 无,必填 | 用于绑定输入字段的值。 |
## Emits
| 事件名称 | 参数 | 描述 |
|-------------------|-------------------------------------|-------------------|
| update:modelValue | `modelValue`: string 类型,表示模型值。 | 当模型值更新时触发。 |

@ -0,0 +1,47 @@
---
title: UppyUpload
description: 文件上传组件
---
## 使用方式
```html
<script lang="ts" setup>
const policyName = ref('my-test-policy')
const groupName = ref('my-test-group')
</script>
<template>
<UppyUpload
endpoint="/apis/api.console.halo.run/v1alpha1/attachments/upload"
:meta="{
policyName: policyName,
groupName: groupName,
}"
/>
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|---------------------|----------------------------------------------------------------|-----------|------------------------------|
| `restrictions` | Restrictions | undefined | 可选,指定任何限制。 |
| `meta` | Record<string, unknown> | undefined | 可选,要发送的额外元数据。 |
| `autoProceed` | boolean | false | 可选,在某些操作后自动继续。 |
| `allowedMetaFields` | string[] | undefined | 可选,指定允许的元数据字段。 |
| `endpoint` | string | 无,必填 | 数据发送到的端点URL。 |
| `name` | string | file | 可选,用于上传的表单字段的名称。 |
| `note` | string | undefined | 可选,任何备注或描述。 |
| `method` | "GET" \| "POST" \| "PUT" \| "HEAD" \| "get" \| "post" \| "put" | post | 可选用于请求的HTTP方法。 |
| `disabled` | boolean | false | 可选,如果为真,则禁用组件。 |
| `width` | string | 750px | 可选,组件的宽度。 |
| `height` | string | 550px | 可选,组件的高度。 |
| `doneButtonHandler` | () => void | undefined | 可选,完成时调用的处理函数。 |
## Emits
| 事件名称 | 参数 | 描述 |
|----------|------------------------------------------------------|---------------------|
| uploaded | `response`: SuccessResponse 类型,表示上传成功的响应。 | 当文件上传成功时触发。 |
| error | `file`: 出错的文件。<br />`response`: 出错时的响应数据。 | 当文件上传出错时触发。 |

@ -0,0 +1,36 @@
---
title: VCodemirror
description: 代码编辑器组件
---
此组件封装了 Codemirror 代码编辑器,适用于一些需要编辑代码的场景。
## 使用方式
```html
<script lang="ts" setup>
import { ref } from "vue"
const value = ref("")
</script>
<template>
<VCodemirror v-model="value" height="300px" language="html" />
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|--------------|-------------------------------------------------|----------|-------------------------------------------|
| `modelValue` | string | "" | 可选,绑定到组件的字符串值,默认为空字符串。 |
| `height` | string | auto | 可选,组件的高度,默认为 `"auto"`。 |
| `language` | keyof typeof presetLanguages \| LanguageSupport | yaml | 代码编辑器的语言支持,默认为 `"yaml"`。 |
| `extensions` | EditorStateConfig["extensions"] | () => [] | 可选,编辑器状态配置的扩展,默认为一个空数组。 |
## Emits
| 事件名称 | 参数 | 描述 |
|-------------------|----------------------------------|-------------------|
| update:modelValue | `value`: string 类型,表示模型值。 | 当模型值更新时触发。 |
| change | `value`: string 类型,表示变更的值。 | 当值发生变化时触发。 |

@ -0,0 +1,18 @@
---
title: v-permission
description: 权限指令
---
与 [HasPermission](./has-permission.md) 组件相同,此指令也是用于根据权限控制元素的显示与隐藏。
## 使用方式
```html
<script lang="ts" setup>
import { VButton } from "@halo-dev/components"
</script>
<template>
<VButton type="danger" v-permission="['system:posts:manage']">删除</VButton>
</template>
```

@ -0,0 +1,18 @@
---
title: v-tooltip
description: Tooltip 指令
---
此指令用于在任何元素上添加一个提示框。
## 使用方式
```html
<script lang="ts" setup>
import { IconDeleteBin } from "@halo-dev/components"
</script>
<template>
<IconDeleteBin v-tooltip="'删除此文档'" />
</template>
```

@ -0,0 +1,98 @@
---
title: 附件数据列表操作菜单
description: 扩展附件数据列表操作菜单 - attachment:list-item:operation:create
---
此扩展点用于扩展附件数据列表的操作菜单项。
![附件数据列表操作菜单](/img/developer-guide/plugin/api-reference/ui/extension-points/attachment-list-item-operation-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"attachment:list-item:operation:create": (
attachment: Ref<Attachment>
): OperationItem<Attachment>[] | Promise<OperationItem<Attachment>[]> => {
return [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {},
action: (item?: Attachment) => {
// do something
},
label: "foo",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
```mdx-code-block
import OperationItem from "./interface/OperationItem.md";
<OperationItem />
```
## 示例
此示例将实现一个下载附件到本地的操作菜单项。
```ts
import { definePlugin, type OperationItem } from "@halo-dev/console-shared";
import { Toast, VDropdownItem } from "@halo-dev/components";
import { markRaw, type Ref } from "vue";
import type { Attachment } from "@halo-dev/api-client";
export default definePlugin({
extensionPoints: {
"attachment:list-item:operation:create": (
attachment: Ref<Attachment>
): OperationItem<Attachment>[] | Promise<OperationItem<Attachment>[]> => {
return [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {},
action: (item?: Attachment) => {
if (!item?.status?.permalink) {
Toast.error("该附件没有下载地址");
return;
}
const a = document.createElement("a");
a.href = item.status.permalink;
a.download = item?.spec.displayName || item.metadata.name;
a.click();
},
label: "下载",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
## 实现案例
- <https://github.com/halo-dev/plugin-s3>
## 类型定义
### Attachment
```mdx-code-block
import Attachment from "./interface/Attachment.md";
<Attachment />
```

@ -0,0 +1,146 @@
---
title: 附件选择选项卡
description: 扩展附件选择组件的选项卡 - attachment:selector:create
---
此扩展点用于扩展附件选择组件的选项卡,目前 Halo 仅包含内置的附件库,你可以通过此扩展点添加自定义的选项卡。
![附件选择选项卡](/img/developer-guide/plugin/api-reference/ui/extension-points/attachment-selector-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"attachment:selector:create": (): AttachmentSelectProvider[]| Promise<AttachmentSelectProvider[]> => {
return [
{
id: "foo",
label: "foo",
component: markRaw(FooComponent),
},
];
},
},
});
```
```ts title="AttachmentSelectProvider"
export interface AttachmentSelectProvider {
id: string; // 选项卡 ID
label: string; // 选项卡名称
component: Component | string; // 选项卡组件
}
```
其中,`component` 可以是组件对象或组件名称,且此组件有以下实现要求:
1. 组件必须包含名称为 `selected``prop`,用于接收当前选中的附件。
```ts
const props = withDefaults(
defineProps<{
selected: AttachmentLike[];
}>(),
{
selected: () => [],
}
);
```
2. 组件必须包含名称为 `update:selected` 的 emit 事件,用于更新选中的附件。
```ts
const emit = defineEmits<{
(event: "update:selected", attachments: AttachmentLike[]): void;
}>();
```
```ts title="AttachmentLike"
export type AttachmentLike =
| Attachment
| string
| {
url: string;
type: string;
};
```
## 示例
为附件选择组件添加 Stickers 选项卡,用于从给定的贴纸列表选择附件。
```ts title="index.ts"
import {
definePlugin,
type AttachmentSelectProvider,
} from "@halo-dev/console-shared";
import { markRaw } from "vue";
import StickerSelectorProvider from "./components/StickerSelectorProvider.vue";
export default definePlugin({
components: {},
routes: [],
extensionPoints: {
"attachment:selector:create": (): AttachmentSelectProvider[] => {
return [
{
id: "stickers",
label: "Stickers",
component: markRaw(StickerSelectorProvider),
},
];
},
},
});
```
```html title="StickerSelectorProvider.vue"
<script lang="ts" setup>
const props = withDefaults(
defineProps<{
selected: AttachmentLike[];
}>(),
{
selected: () => [],
}
);
const emit = defineEmits<{
(event: "update:selected", attachments: AttachmentLike[]): void;
}>();
const stickers = [
{
url: "https://picsum.photos/200?random=1",
},
{
url: "https://picsum.photos/200?random=2",
},
{
url: "https://picsum.photos/200?random=3",
},
];
const selectedStickers = ref<Set<String>>(new Set());
const handleSelect = async (url: string) => {
if (selectedStickers.value.has(url)) {
selectedStickers.value.delete(url);
return;
}
selectedStickers.value.add(url);
emit('update:selected', Array.from(selectedStickers.value));
};
</script>
<template>
<div>
<img v-for="sticker in stickers" :src="sticker.url" @click="handleSelect(sticker.url)" />
</div>
</template>
```
## 实现案例
- <https://github.com/halo-sigs/plugin-unsplash>

@ -0,0 +1,41 @@
---
title: 备份数据列表操作菜单
description: 扩展备份数据列表操作菜单 - backup:list-item:operation:create
---
此扩展点用于扩展备份数据列表的操作菜单项。
![备份数据列表操作菜单](/img/developer-guide/plugin/api-reference/ui/extension-points/backup-list-item-operation-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"backup:list-item:operation:create": (
backup: Ref<Backup>
): OperationItem<Backup>[] | Promise<OperationItem<Backup>[]> => {
return [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {},
action: (item?: Backup) => {
// do something
},
label: "foo",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
```mdx-code-block
import OperationItem from "./interface/OperationItem.md";
<OperationItem />
```

@ -0,0 +1,36 @@
---
title: 备份页面选项卡
description: 扩展备份页面选项卡 - backup:tabs:create
---
此扩展点可以针对备份页面扩展更多关于 UI 的功能,比如定时备份设置、备份到第三方云存储等。
![备份页面选项卡](/img/developer-guide/plugin/api-reference/ui/extension-points/backup-tabs-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"backup:tabs:create": (): BackupTab[] | Promise<BackupTab[]> => {
return [
{
id: "foo",
label: "foo",
component: markRaw(FooComponent),
permissions: [],
},
];
},
},
});
```
```ts title="BackupTab"
export interface BackupTab {
id: string; // 选项卡 ID
label: string; // 选项卡标题
component: Raw<Component>; // 选项卡面板组件
permissions?: string[]; // 选项卡权限
}
```

@ -0,0 +1,114 @@
---
title: 评论来源显示
description: 扩展评论来源显示 - comment:subject-ref:create
---
Console 的评论管理列表的评论来源默认仅支持显示来自文章和页面的评论,如果其他插件中的业务模块也使用了评论,那么就可以通过该拓展点来扩展评论来源的显示。
:::info 提示
此扩展点需要后端配合使用,请参考 [评论主体展示](../../server/extension-points/comment-subject.md)。
:::
![评论来源显示](/img/developer-guide/plugin/api-reference/ui/extension-points/comment-subject-ref-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"comment:subject-ref:create": (): CommentSubjectRefProvider[] => {
return [
{
kind: "Example",
group: "example.halo.run",
resolve: (subject: Extension): CommentSubjectRefResult => {
return {
label: "foo",
title: subject.title,
externalUrl: `/example/${subject.metadata.name}`,
route: {
name: "Example",
},
};
},
},
];
},
},
});
```
```ts title="CommentSubjectRefProvider"
export type CommentSubjectRefProvider = {
kind: string;
group: string;
resolve: (subject: Extension) => CommentSubjectRefResult;
};
```
```ts title="CommentSubjectRefResult"
export interface CommentSubjectRefResult {
label: string;
title: string;
route?: RouteLocationRaw;
externalUrl?: string;
}
```
## 示例
此示例以[瞬间插件](https://github.com/halo-sigs/plugin-moments)为例,目前瞬间插件中的评论是通过 Halo 的内置的评论功能实现的,所以需要扩展评论来源显示。
```ts
import {
definePlugin,
type CommentSubjectRefResult,
} from "@halo-dev/console-shared";
import { markRaw } from "vue";
import type { Moment } from "@/types";
import { formatDatetime } from "./utils/date";
import type { Extension } from "@halo-dev/api-client/index";
export default definePlugin({
extensionPoints: {
"comment:subject-ref:create": () => {
return [
{
kind: "Moment",
group: "moment.halo.run",
resolve: (subject: Extension): CommentSubjectRefResult => {
const moment = subject as Moment;
return {
label: "瞬间",
title: determineMomentTitle(moment),
externalUrl: `/moments/${moment.metadata.name}`,
route: {
name: "Moments",
},
};
},
},
];
},
},
});
const determineMomentTitle = (moment: Moment) => {
const pureContent = stripHtmlTags(moment.spec.content.raw);
const title = !pureContent?.trim()
? formatDatetime(new Date(moment.spec.releaseTime || ""))
: pureContent;
return title?.substring(0, 100);
};
const stripHtmlTags = (str: string) => {
// strip html tags
const stripped = str?.replace(/<\/?[^>]+(>|$)/gi, "") || "";
// strip newlines and collapse spaces
return stripped.replace(/\n/g, " ").replace(/\s+/g, " ");
};
```
## 实现案例
- <https://github.com/halo-sigs/plugin-moments>

@ -0,0 +1,477 @@
---
title: 默认编辑器
description: 扩展默认编辑器 - default:editor:extension:create
---
此扩展点用于扩展默认编辑器的功能。
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"default:editor:extension:create": (): AnyExtension[] | Promise<AnyExtension[]> => {
return [FooExtension];
},
},
});
```
:::info 提示
AnyExtension 类型来自 [Tiptap](https://github.com/ueberdosis/tiptap),这意味着 Halo 默认编辑器的扩展点返回类型与 Tiptap 的扩展完全一致Tiptap 的扩展文档可参考:<https://tiptap.dev/docs/editor/api/extensions>。此外Halo 也为默认编辑器的扩展提供了一些独有的参数,用于实现工具栏、指令等扩展。
:::
### Halo 独有扩展
阅读此文当前请确保已经熟悉了 Tiptap 的扩展文档这里将介绍如何对编辑器的功能进行扩展包括但不限于扩展工具栏、悬浮工具栏、Slash Command、拖拽功能等。
目前支持的所有扩展类型如下所示:
```ts
export interface ExtensionOptions {
// 顶部工具栏扩展
getToolbarItems?: ({
editor,
}: {
editor: Editor;
}) => ToolbarItem | ToolbarItem[];
// Slash Command 扩展
getCommandMenuItems?: () => CommandMenuItem | CommandMenuItem[];
// 悬浮菜单扩展
getBubbleMenu?: ({ editor }: { editor: Editor }) => NodeBubbleMenu;
// 工具箱扩展
getToolboxItems?: ({
editor,
}: {
editor: Editor;
}) => ToolboxItem | ToolboxItem[];
// 拖拽扩展
getDraggable?: ({ editor }: { editor: Editor }) => DraggableItem | boolean;
}
```
#### 1. 顶部工具栏扩展
编辑器顶部功能区域内容的扩展,通常用于增加用户常用操作,例如文本加粗、变更颜色等。
![顶部工具栏扩展](/img/developer-guide/plugin/api-reference/ui/extension-points/default-editor-extension-toolbar.png)
<https://github.com/halo-sigs/richtext-editor/pull/16> 中,我们实现了对顶部工具栏的扩展,如果需要添加额外的功能,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getToolbarItems` 函数即可,如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return []
},
};
},
}
```
其中 `getToolbarItems` 即为对顶部工具栏的扩展。其返回类型为:
```ts
// 顶部工具栏扩展
getToolbarItems?: ({
editor,
}: {
editor: Editor;
}) => ToolbarItem | ToolbarItem[];
// 工具栏
export interface ToolbarItem {
priority: number;
component: Component;
props: {
editor: Editor;
isActive: boolean;
disabled?: boolean;
icon?: Component;
title?: string;
action?: () => void;
};
children?: ToolbarItem[];
}
```
如下为 [`Bold`](https://github.com/halo-dev/halo/blob/main/console/packages/editor/src/extensions/bold/index.ts) 扩展中对于 `getToolbarItems` 的扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 40,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("bold"),
icon: markRaw(MdiFormatBold),
title: i18n.global.t("editor.common.bold"),
action: () => editor.chain().focus().toggleBold().run(),
},
};
},
};
},
```
#### 2. 工具箱扩展
编辑器工具箱区域的扩展,可用于增加编辑器附属操作,例如插入表格,插入第三方组件等功能。
![工具箱扩展](/img/developer-guide/plugin/api-reference/ui/extension-points/default-editor-extension-toolbox.png)
<https://github.com/halo-sigs/richtext-editor/pull/27> 中,我们实现了对编辑器工具箱区域的扩展,如果需要添加额外的功能,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getToolboxItems` 函数即可,如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getToolboxItems({ editor }: { editor: Editor }) {
return []
},
};
},
}
```
其中 `getToolboxItems` 即为对工具箱的扩展。其返回类型为:
```ts
// 工具箱扩展
getToolboxItems?: ({
editor,
}: {
editor: Editor;
}) => ToolboxItem | ToolboxItem[];
export interface ToolboxItem {
priority: number;
component: Component;
props: {
editor: Editor;
icon?: Component;
title?: string;
description?: string;
action?: () => void;
};
}
```
如下为 [`Table`](https://github.com/halo-dev/halo/blob/main/console/packages/editor/src/extensions/table/index.ts) 扩展中对于 `getToolboxItems` 工具箱的扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getToolboxItems({ editor }: { editor: Editor }) {
return {
priority: 15,
component: markRaw(ToolboxItem),
props: {
editor,
icon: markRaw(MdiTablePlus),
title: i18n.global.t("editor.menus.table.add"),
action: () =>
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(),
},
};
},
}
}
```
#### 3. Slash Command 扩展
Slash Command (斜杠命令)的扩展,可用于在当前行快捷执行功能操作,例如转换当前行为标题、在当前行添加代码块等功能。
![Slash Command 扩展](/img/developer-guide/plugin/api-reference/ui/extension-points/default-editor-extension-slash-command.png)
<https://github.com/halo-sigs/richtext-editor/pull/16> 中,我们实现了对 Slash Command 指令的扩展,如果需要添加额外的功能,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getCommandMenuItems` 函数即可,如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getCommandMenuItems() {
return []
},
};
},
}
```
其中 `getCommandMenuItems` 即为对工具箱的扩展。其返回类型为:
```ts
// Slash Command 扩展
getCommandMenuItems?: () => CommandMenuItem | CommandMenuItem[];
export interface CommandMenuItem {
priority: number;
icon: Component;
title: string;
keywords: string[];
command: ({ editor, range }: { editor: Editor; range: Range }) => void;
}
```
如下为 [`Table`](https://github.com/halo-dev/halo/blob/main/console/packages/editor/src/extensions/table/index.ts) 扩展中对于 `getCommandMenuItems` 的扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getCommandMenuItems() {
return {
priority: 120,
icon: markRaw(MdiTable),
title: "editor.extensions.commands_menu.table",
keywords: ["table", "biaoge"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
};
},
}
}
```
#### 4. 悬浮菜单扩展
编辑器悬浮菜单的扩展。可用于支持目标元素组件的功能扩展及操作简化。例如 `Table` 扩展中的添加下一列、添加上一列等操作。
![悬浮菜单扩展](/img/developer-guide/plugin/api-reference/ui/extension-points/default-editor-extension-bubble-menu.png)
<https://github.com/halo-sigs/richtext-editor/pull/38> 中,我们重构了对编辑器悬浮区域的扩展,如果需要对某个块进行支持,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getBubbleMenu` 函数即可,如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getBubbleMenu({ editor }: { editor: Editor }) {
return []
},
};
},
}
```
其中 `getBubbleMenu` 即为对悬浮菜单的扩展。其返回类型为:
```ts
// 悬浮菜单扩展
getBubbleMenu?: ({ editor }: { editor: Editor }) => NodeBubbleMenu;
interface BubbleMenuProps {
pluginKey?: string; // 悬浮菜单插件 Key建议命名方式 xxxBubbleMenu
editor?: Editor;
shouldShow: (props: { // 悬浮菜单显示的条件
editor: Editor;
node?: HTMLElement;
view?: EditorView;
state?: EditorState;
oldState?: EditorState;
from?: number;
to?: number;
}) => boolean;
tippyOptions?: Record<string, unknown>; // 可自由定制悬浮菜单所用的 tippy 组件的选项
getRenderContainer?: (node: HTMLElement) => HTMLElement; // 悬浮菜单所基准的 DOM
defaultAnimation?: boolean; // 是否启用默认动画。默认为 true
}
// 悬浮菜单
export interface NodeBubbleMenu extends BubbleMenuProps {
component?: Component; // 不使用默认的样式,与 items 二选一
items?: BubbleItem[]; // 悬浮菜单子项,使用默认的形式进行,与 items 二选一
}
// 悬浮菜单子项
export interface BubbleItem {
priority: number; // 优先级,数字越小优先级越大,越靠前
component?: Component; // 完全自定义子项样式
props: {
isActive: ({ editor }: { editor: Editor }) => boolean; // 当前功能是否已经处于活动状态
visible?: ({ editor }: { editor: Editor }) => boolean; // 是否显示当前子项
icon?: Component; // 图标
iconStyle?: string; // 图标自定义样式
title?: string; // 标题
action?: ({ editor }: { editor: Editor }) => Component | void; // 点击子项后的操作,如果返回 Component则会将其包含在下拉框中。
};
}
```
如下为 [`Table`](https://github.com/halo-dev/halo/blob/main/console/packages/editor/src/extensions/table/index.ts) 扩展中对于 `getBubbleMenu` 悬浮菜单的部分扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getBubbleMenu({ editor }) {
return {
pluginKey: "tableBubbleMenu",
shouldShow: ({ state }: { state: EditorState }): boolean => {
return isActive(state, Table.name);
},
getRenderContainer(node) {
let container = node;
if (container.nodeName === "#text") {
container = node.parentElement as HTMLElement;
}
while (
container &&
container.classList &&
!container.classList.contains("tableWrapper")
) {
container = container.parentElement as HTMLElement;
}
return container;
},
tippyOptions: {
offset: [26, 0],
},
items: [
{
priority: 10,
props: {
icon: markRaw(MdiTableColumnPlusBefore),
title: i18n.global.t("editor.menus.table.add_column_before"),
action: () => editor.chain().focus().addColumnBefore().run(),
},
},
]
}
}
}
}
```
#### 5. 拖拽功能扩展
拖拽功能的扩展,可用于支持当前块元素的拖拽功能。
![拖拽功能扩展](/img/developer-guide/plugin/api-reference/ui/extension-points/default-editor-extension-drag.png)
<https://github.com/halo-sigs/richtext-editor/pull/48> 中,我们实现了对所有元素的拖拽功能,如果需要让当前扩展支持拖拽,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getDraggable` 函数,并让其返回 true 即可。如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getDraggable() {
return true;
},
};
},
}
```
其中 `getDraggable` 即为为当前扩展增加可拖拽的功能。其返回类型为:
```ts
// 拖拽扩展
getDraggable?: ({ editor }: { editor: Editor }) => DraggableItem | boolean;
export interface DraggableItem {
getRenderContainer?: ({ // 拖拽按钮计算偏移位置的基准 DOM
dom,
view,
}: {
dom: HTMLElement;
view: EditorView;
}) => DragSelectionNode;
handleDrop?: ({ // 完成拖拽功能之后的处理。返回 true 则会阻止拖拽的发生
view,
event,
slice,
insertPos,
node,
}: {
view: EditorView;
event: DragEvent;
slice: Slice;
insertPos: number;
node: Node;
}) => boolean | void;
allowPropagationDownward?: boolean; // 是否允许拖拽事件向内部传播,
}
export interface DragSelectionNode {
$pos?: ResolvedPos;
node?: Node;
el: HTMLElement;
nodeOffset?: number;
dragDomOffset?: {
x: number;
y: number;
};
}
```
> 拖拽会从父 Node 节点开始触发,直到找到一个实现 `getDraggable` 的扩展,如果没有找到,则不会触发拖拽事件。父 Node 可以通过 `allowPropagationDownward` 来控制是否允许拖拽事件向内部传播。如果 `allowPropagationDownward` 设置为 true则会继续向内部寻找实现 `getDraggable` 的扩展,如果没有找到,则触发父 Node 的 `getDraggable` 实现,否则继续进行传播。
如下为 [`Iframe`](https://github.com/halo-dev/halo/blob/main/console/packages/editor/src/extensions/iframe/index.ts) 扩展中对于 `getDraggable` 拖拽功能的扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getDraggable() {
return {
getRenderContainer({ dom, view }) {
let container = dom;
while (
container.parentElement &&
container.parentElement.tagName !== "P"
) {
container = container.parentElement;
}
if (container) {
container = container.firstElementChild
?.firstElementChild as HTMLElement;
}
let node;
if (container.firstElementChild) {
const pos = view.posAtDOM(container.firstElementChild, 0);
const $pos = view.state.doc.resolve(pos);
node = $pos.node();
}
return {
node: node,
el: container as HTMLElement,
};
},
};
},
}
}
```
## 实现案例
- <https://github.com/halo-sigs/plugin-hybrid-edit-block>
- <https://github.com/halo-sigs/plugin-katex>
- <https://github.com/halo-sigs/plugin-text-diagram>

@ -0,0 +1,186 @@
---
title: 编辑器集成
description: 通过实现扩展点为文章提供新的编辑器 - editor:create
---
此扩展点可以为文章提供新的独立编辑器。
![编辑器集成](/img/developer-guide/plugin/api-reference/ui/extension-points/editor-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"editor:create": (): EditorProvider[] | Promise<EditorProvider[]> => {
return [
{
name: "foo",
displayName: "foo",
logo: "/plugins/plugin-foo/assets/logo.png",
component: FooComponent,
rawType: "markdown",
},
];
},
},
});
```
```ts title="EditorProvider"
export interface EditorProvider {
name: string;
displayName: string;
logo?: string;
component: Component;
rawType: string;
}
```
其中,`component` 可以是组件对象或组件名称,且此组件有以下实现要求:
1. 组件必须包含以下 props
1. `raw:string`:用于接收原始内容。
2. `content:string`:用于接收渲染后的内容。
3. `uploadImage?: (file: File) => Promise<Attachment>`:用于上传图片,在编辑器内部获取到 File 之后直接调用此方法即可得到上传后的附件信息。
2. 组件必须包含以下 emit 事件:
1. `update:raw`:用于更新原始内容。
2. `update:content`:用于更新渲染后的内容。
3. `update`:发送更新事件。
## 示例
此示例将实现一个简单的 Markdown 编辑器。
```ts title="index.ts"
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw } from "vue";
import MarkdownEditor from "./components/markdown-editor.vue";
export default definePlugin({
extensionPoints: {
"editor:create": () => {
return [
{
name: "markdown",
displayName: "Markdown 编辑器",
component: markRaw(MarkdownEditor),
rawType: "markdown",
logo: "/plugins/markdown-editor/assets/logo.png",
},
];
},
},
});
```
```html title="./components/markdown-editor.vue"
<script setup lang="ts">
import { marked } from "marked";
import { debounce } from "lodash-es";
import { ref, computed, onMounted } from "vue";
import type { Attachment } from "@halo-dev/api-client";
const props = withDefaults(
defineProps<{
raw: string;
content: string;
uploadImage?: (file: File) => Promise<Attachment>;
}>(),
{
raw: "",
content: "",
uploadImage: undefined,
}
);
const emit = defineEmits<{
(event: "update:raw", value: string): void;
(event: "update:content", value: string): void;
(event: "update", value: string): void;
}>();
const output = computed(() => marked(props.raw));
const update = debounce((e) => {
emit("update:raw", e.target.value);
emit("update:content", marked(e.target.value));
if (e.target.value !== props.raw) {
emit("update", e.target.value);
}
}, 100);
const textareaRef = ref();
onMounted(() => {
textareaRef.value.addEventListener("paste", (e) => {
if (e.clipboardData && e.clipboardData.items) {
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") !== -1) {
const file = items[i].getAsFile();
props.uploadImage?.(file).then((attachment: Attachment) => {
emit(
"update:raw",
props.raw +
`![${attachment.spec.displayName}](${attachment.status?.permalink})`
);
});
}
}
}
});
});
</script>
<template>
<div class="editor">
<textarea ref="textareaRef" class="input" :value="raw" @input="update"></textarea>
<div class="output" v-html="output"></div>
</div>
</template>
<style>
body {
margin: 0;
}
.editor {
height: 100vh;
display: flex;
}
.input,
.output {
overflow: auto;
width: 50%;
height: 100%;
box-sizing: border-box;
padding: 0 20px;
}
.input {
border: none;
border-right: 1px solid #ccc;
resize: none;
outline: none;
background-color: #f6f6f6;
font-size: 14px;
font-family: 'Monaco', courier, monospace;
padding: 20px;
}
code {
color: #f66;
}
</style>
```
> 来源:<https://vuejs.org/examples/#markdown>
## 实现案例
- <https://github.com/halo-sigs/plugin-stackedit>
- <https://github.com/halo-sigs/plugin-bytemd>
- <https://github.com/justice2001/halo-plugin-vditor>

@ -0,0 +1,14 @@
---
title: 扩展点
description: Halo UI 为插件提供的扩展点接口
---
UI 扩展点是用于扩展 Console 和 UC 的界面的接口,通过实现扩展点接口,插件可以在 Console 和 UC 中扩展功能。
以下是目前已支持的扩展点列表:
```mdx-code-block
import DocCardList from '@theme/DocCardList';
<DocCardList />
```

@ -0,0 +1,25 @@
```ts
export interface Attachment {
apiVersion: "storage.halo.run/v1alpha1"
kind: "Attachment"
metadata: {
annotations: {}
creationTimestamp: string
labels: {}
name: string // 附件的唯一标识
version: number
}
spec: {
displayName: string // 附件名称
groupName: string // 附件分组
mediaType: string // 附件类型
ownerName: string // 附件上传者
policyName: string // 附件存储策略
size: number // 附件大小
tags: Array<string>
}
status: {
permalink: string // 附件固定访问地址
}
}
```

@ -0,0 +1,119 @@
```ts
export interface ListedPost {
categories: Array<{ // 文章的分类集合
apiVersion: "content.halo.run/v1alpha1";
kind: "Category";
metadata: {
annotations: {};
creationTimestamp: string;
labels: {};
name: string; // 分类的唯一标识
version: number;
};
spec: {
children: Array<string>; // 子分类
cover: string; // 分类封面图
description: string; // 分类描述
displayName: string; // 分类名称
priority: number; // 分类优先级
slug: string; // 分类别名
template: string; // 分类渲染模板
};
status: {
permalink: string; // 分类的永久链接
postCount: number; // 分类下的文章总数
visiblePostCount: number; // 分类下可见的文章总数
};
}>;
contributors: Array<{ // 文章的贡献者集合
avatar: string; // 贡献者头像
displayName: string; // 贡献者名称
name: string; // 贡献者唯一标识
}>;
owner: { // 文章的作者信息
avatar: string; // 作者头像
displayName: string; // 作者名称
name: string; // 作者唯一标识
};
post: { // 文章信息
apiVersion: "content.halo.run/v1alpha1";
kind: "Post";
metadata: {
annotations: {};
creationTimestamp: string;
labels: {};
name: string; // 文章的唯一标识
version: number;
};
spec: {
allowComment: boolean; // 是否允许评论
baseSnapshot: string; // 内容基础快照
categories: Array<string>; // 文章所属分类
cover: string; // 文章封面图
deleted: boolean; // 是否已删除
excerpt: { // 文章摘要
autoGenerate: boolean; // 是否自动生成
raw: string; // 摘要内容
};
headSnapshot: string; // 内容最新快照
htmlMetas: Array<{}>;
owner: string; // 文章作者的唯一标识
pinned: boolean; // 是否置顶
priority: number; // 文章优先级
publish: boolean; // 是否发布
publishTime: string; // 发布时间
releaseSnapshot: string; // 已发布的内容快照
slug: string; // 文章别名
tags: Array<string>; // 文章所属标签
template: string; // 文章渲染模板
title: string; // 文章标题
visible: string; // 文章可见性
};
status: {
commentsCount: number; // 文章评论总数
conditions: Array<{
lastTransitionTime: string;
message: string;
reason: string;
status: string;
type: string;
}>;
contributors: Array<string>;
excerpt: string; // 最终的文章摘要,根据是否自动生成计算
inProgress: boolean; // 是否有未发布的内容
lastModifyTime: string; // 文章最后修改时间
permalink: string; // 文章的永久链接
phase: string;
};
};
stats: {
approvedComment: number; // 已审核的评论数
totalComment: number; // 评论总数
upvote: number; // 点赞数
visit: number; // 访问数
};
tags: Array<{ // 文章的标签集合
apiVersion: "content.halo.run/v1alpha1";
kind: "Tag";
metadata: {
annotations: {};
creationTimestamp: string;
labels: {};
name: string; // 标签的唯一标识
version: number;
};
spec: {
color: string; // 标签颜色
cover: string; // 标签封面图
displayName: string; // 标签名称
slug: string; // 标签别名
};
status: {
permalink: string; // 标签的永久链接
postCount: number; // 标签下的文章总数
visiblePostCount: number; // 标签下可见的文章总数
};
}>;
}
```

@ -0,0 +1,12 @@
```ts title="OperationItem"
export interface OperationItem<T> {
priority: number; // 排序优先级
component: Raw<Component>; // 菜单项组件
props?: Record<string, unknown>; // 菜单项组件属性
action?: (item?: T) => void; // 菜单项点击事件
label?: string; // 菜单项标题
hidden?: boolean; // 菜单项是否隐藏
permissions?: string[]; // 菜单项 UI 权限
children?: OperationItem<T>[]; // 子菜单项
}
```

@ -0,0 +1,50 @@
```ts
export interface Plugin {
apiVersion: "plugin.halo.run/v1alpha1"
kind: "Plugin"
metadata: {
annotations: {}
creationTimestamp: string // 创建时间
labels: {}
name: string // 唯一标识
version: number
}
spec: {
author: { // 作者信息
name: string
website: string
}
configMapName: string // 关联的 ConfigMap 模型,用于存储配置
description: string // 插件描述
displayName: string // 插件名称
enabled: boolean
homepage: string // 插件主页
license: Array<{ // 插件协议
name: string
url: string
}>
logo: string
pluginDependencies: {}
repo: string // 插件仓库地址
requires: string // 所依赖的 Halo 版本
settingName: string // 关联的 Setting 模型,用于渲染配置表单
version: string // 插件版本
}
status: {
conditions: Array<{
lastTransitionTime: string
message: string
reason: string
status: string
type: string
}>
entry: string
lastProbeState: string
lastStartTime: string
loadLocation: string
logo: string // 插件 Logo 地址
phase: string
stylesheet: string
}
}
```

@ -0,0 +1,63 @@
```ts
export interface Theme {
apiVersion: "theme.halo.run/v1alpha1"
kind: "Theme"
metadata: {
annotations: {}
creationTimestamp: string
labels: {}
name: string // 主题的唯一标识
version: number
}
spec: {
author: { // 主题作者信息
name: string
website: string
}
configMapName: string // 关联的 ConfigMap 模型,用于存储配置
customTemplates: { // 自定义模板信息
category: Array<{
description: string
file: string
name: string
screenshot: string
}>
page: Array<{
description: string
file: string
name: string
screenshot: string
}>
post: Array<{
description: string
file: string
name: string
screenshot: string
}>
}
description: string // 主题描述
displayName: string // 主题名称
homepage: string // 主题主页
license: Array<{ // 主题许可证信息
name: string
url: string
}>
logo: string // 主题 Logo
repo: string // 主题仓库地址
requires: string // 所依赖的 Halo 版本
settingName: string // 关联的 Setting 模型,用于渲染配置表单
version: string // 主题版本
}
status: {
conditions: Array<{
lastTransitionTime: string
message: string
reason: string
status: string
type: string
}>
location: string
phase: string
}
}
```

@ -0,0 +1,44 @@
---
title: 插件安装界面选项卡
description: 扩展插件安装界面选项卡 - plugin:installation:tabs:create
---
目前 Halo 原生支持本地上传和远程下载的方式安装插件,此扩展点用于扩展插件安装界面的选项卡,以支持更多的安装方式。
![插件安装界面选项卡](/img/developer-guide/plugin/api-reference/ui/extension-points/plugin-installation-tabs-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"plugin:installation:tabs:create": (): PluginInstallationTab[] | Promise<PluginInstallationTab[]> => {
return [
{
id: "foo",
label: "foo",
component: markRaw(FooComponent),
props: {},
permissions: [],
priority: 0,
}
];
},
},
});
```
```ts title="PluginInstallationTab"
export interface PluginInstallationTab {
id: string; // 选项卡 ID
label: string; // 选项卡标题
component: Raw<Component>; // 选项卡面板组件
props?: Record<string, unknown>; // 选项卡面板组件属性
permissions?: string[]; // 选项卡 UI 权限
priority: number; // 选项卡排序优先级
}
```
## 实现案例
- <https://github.com/halo-dev/plugin-app-store>

@ -0,0 +1,84 @@
---
title: 插件数据列表显示字段
description: 扩展插件数据列表显示字段 - plugin:list-item:field:create
---
此扩展点用于扩展插件数据列表的显示字段。
![插件数据列表显示字段](/img/developer-guide/plugin/api-reference/ui/extension-points/plugin-list-item-field-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"plugin:list-item:field:create": (plugin: Ref<Plugin>): EntityFieldItem[] | Promise<EntityFieldItem[]> => {
return [
{
priority: 0,
position: "start",
component: markRaw(FooComponent),
props: {},
permissions: [],
hidden: false,
}
];
},
},
});
```
```ts title="EntityFieldItem"
export interface EntityFieldItem {
priority: number;
position: "start" | "end";
component: Raw<Component>;
props?: Record<string, unknown>;
permissions?: string[];
hidden?: boolean;
}
```
## 示例
此示例将添加一个显示插件 requires版本要求的字段。
```ts
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw, type Ref } from "vue";
import type { Plugin } from "@halo-dev/api-client";
import { VEntityField } from "@halo-dev/components";
export default definePlugin({
extensionPoints: {
"plugin:list-item:field:create": (plugin: Ref<Plugin>) => {
return [
{
priority: 0,
position: "end",
component: markRaw(VEntityField),
props: {
description: plugin.value.spec.requires,
},
permissions: [],
hidden: false,
},
];
},
},
});
```
## 实现案例
- <https://github.com/halo-dev/plugin-app-store>
## 类型定义
### Plugin
```mdx-code-block
import Plugin from "./interface/Plugin.md";
<Plugin />
```

@ -0,0 +1,55 @@
---
title: 插件数据列表操作菜单
description: 扩展插件数据列表操作菜单 - plugin:list-item:operation:create
---
此扩展点用于扩展插件数据列表的操作菜单项。
![插件数据列表操作菜单](/img/developer-guide/plugin/api-reference/ui/extension-points/plugin-list-item-operation-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"plugin:list-item:operation:create": (
plugin: Ref<Plugin>
): OperationItem<Plugin>[] | Promise<OperationItem<Plugin>[]> => {
return [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {},
action: (item?: Plugin) => {
// do something
},
label: "foo",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
```mdx-code-block
import OperationItem from "./interface/OperationItem.md";
<OperationItem />
```
## 实现案例
- <https://github.com/halo-dev/plugin-app-store>
## 类型定义
### Plugin
```mdx-code-block
import Plugin from "./interface/Plugin.md";
<Plugin />
```

@ -0,0 +1,90 @@
---
title: 插件详情选项卡
description: 扩展当前插件的详情选项卡 - plugin:self:tabs:create
---
此扩展点用于在 Console 的插件详情页面中添加自定义选项卡,可以用于自定义插件的配置页面。
![插件详情选项卡](/img/developer-guide/plugin/api-reference/ui/extension-points/plugin-self-tabs-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"plugin:self:tabs:create": (): PluginTab[] | Promise<PluginTab[]> => {
return [
{
id: "foo",
label: "foo",
component: markRaw(FooComponent),
permissions: [],
},
];
},
},
});
```
```ts title="PluginTab"
export interface PluginTab {
id: string; // 选项卡 ID不能与设置表单的 group 重复
label: string; // 选项卡标题
component: Raw<Component>; // 选项卡面板组件
permissions?: string[]; // 选项卡权限
}
```
其中,`component` 组件可以注入inject以下属性
- `plugin`:当前插件对象,类型为 Ref<[Plugin](#plugin)>。
## 示例
此示例实现了一个自定义选项卡,用于获取插件的数据并显示名称。
```ts
import { definePlugin, PluginTab } from "@halo-dev/console-shared";
import MyComponent from "./views/my-component.vue";
import { markRaw } from "vue";
export default definePlugin({
components: {},
routes: [],
extensionPoints: {
"plugin:self:tabs:create": () : PluginTab[] => {
return [
{
id: "my-tab-panel",
label: "My Tab Panel",
component: markRaw(MyComponent),
permissions: []
},
];
},
},
});
```
```html title="./views/my-component.vue"
<script lang="ts" setup>
const plugin = inject<Ref<Plugin | undefined>>("plugin");
</script>
<template>
<h1>{{ plugin?.spec.displayName }}</h1>
</template>
```
## 实现案例
- <https://github.com/halo-dev/plugin-app-store>
## 类型定义
### Plugin
```mdx-code-block
import Plugin from "./interface/Plugin.md";
<Plugin />
```

@ -0,0 +1,80 @@
---
title: 文章数据列表显示字段
description: 扩展文章数据列表显示字段 - plugin:list-item:field:create
---
此扩展点用于扩展文章数据列表的显示字段。
![文章数据列表显示字段](/img/developer-guide/plugin/api-reference/ui/extension-points/post-list-item-field-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"post:list-item:field:create": (post: Ref<ListedPost>): EntityFieldItem[] | Promise<EntityFieldItem[]> => {
return [
{
priority: 0,
position: "start",
component: markRaw(FooComponent),
props: {},
permissions: [],
hidden: false,
}
];
},
},
});
```
```ts title="EntityFieldItem"
export interface EntityFieldItem {
priority: number;
position: "start" | "end";
component: Raw<Component>;
props?: Record<string, unknown>;
permissions?: string[];
hidden?: boolean;
}
```
## 示例
此示例将添加一个显示文章 slug别名的字段。
```ts
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw, type Ref } from "vue";
import type { ListedPost } from "@halo-dev/api-client";
import { VEntityField } from "@halo-dev/components";
export default definePlugin({
extensionPoints: {
"post:list-item:field:create": (post: Ref<ListedPost>) => {
return [
{
priority: 0,
position: "end",
component: markRaw(VEntityField),
props: {
description: post.value.post.spec.slug,
},
permissions: [],
hidden: false,
},
];
},
},
});
```
## 类型定义
### ListedPost
```mdx-code-block
import ListedPost from "./interface/ListedPost.md";
<ListedPost />
```

@ -0,0 +1,92 @@
---
title: 文章数据列表操作菜单
description: 扩展文章数据列表操作菜单 - post:list-item:operation:create
---
此扩展点用于扩展文章数据列表的操作菜单项。
![文章数据列表操作菜单](/img/developer-guide/plugin/api-reference/ui/extension-points/post-list-item-operation-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"post:list-item:operation:create": (
post: Ref<ListedPost>
): OperationItem<ListedPost>[] | Promise<OperationItem<ListedPost>[]> => {
return [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {},
action: (item?: ListedPost) => {
// do something
},
label: "foo",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
```mdx-code-block
import OperationItem from "./interface/OperationItem.md";
<OperationItem />
```
## 示例
此示例将实现一个操作菜单项,点击后会将文章内容作为文件下载到本地。
```ts
import type { ListedPost } from "@halo-dev/api-client";
import { VDropdownItem } from "@halo-dev/components";
import { definePlugin } from "@halo-dev/console-shared";
import axios from "axios";
import { markRaw } from "vue";
export default definePlugin({
extensionPoints: {
"post:list-item:operation:create": () => {
return [
{
priority: 21,
component: markRaw(VDropdownItem),
label: "下载到本地",
visible: true,
permissions: [],
action: async (post: ListedPost) => {
const { data } = await axios.get(
`/apis/api.console.halo.run/v1alpha1/posts/${post.post.metadata.name}/head-content`
);
const blob = new Blob([data.raw], {
type: "text/plain;charset=utf-8",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${post.post.spec.title}.${data.rawType}`;
link.click();
},
},
];
},
},
});
```
## 类型定义
### ListedPost
```mdx-code-block
import ListedPost from "./interface/ListedPost.md";
<ListedPost />
```

@ -0,0 +1,91 @@
---
title: 主题数据列表操作菜单
description: 扩展主题数据列表操作菜单 - theme:list-item:operation:create
---
此扩展点用于扩展主题数据列表的操作菜单项。
![主题数据列表操作菜单](/img/developer-guide/plugin/api-reference/ui/extension-points/theme-list-item-operation-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"theme:list-item:operation:create": (
theme: Ref<Theme>
): OperationItem<Theme>[] | Promise<OperationItem<Theme>[]> => {
return [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {},
action: (item?: Theme) => {
// do something
},
label: "foo",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
```mdx-code-block
import OperationItem from "./interface/OperationItem.md";
<OperationItem />
```
## 示例
此示例将实现一个跳转到前台预览主题的操作菜单项。
```ts
import { definePlugin, type OperationItem } from "@halo-dev/console-shared";
import { VButton } from "@halo-dev/components";
import { markRaw, type Ref } from "vue";
import type { Theme } from "@halo-dev/api-client";
export default definePlugin({
extensionPoints: {
"theme:list-item:operation:create": (
theme: Ref<Theme>
): OperationItem<Theme>[] | Promise<OperationItem<Theme>[]> => {
return [
{
priority: 10,
component: markRaw(VButton),
props: {
size: "sm",
},
action: (item?: Theme) => {
window.open(`/?preview-theme=${item?.metadata.name}`);
},
label: "前台预览",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
## 实现案例
- <https://github.com/halo-dev/plugin-app-store>
## 类型定义
### Theme
```mdx-code-block
import Theme from "./interface/Theme.md";
<Theme />
```

@ -0,0 +1,44 @@
---
title: 主题管理界面选项卡
description: 扩展主题管理界面选项卡 - theme:list:tabs:create
---
目前在 Halo 的主题管理中原生支持本地上传和远程下载的方式安装主题,此扩展点用于扩展主题管理界面的选项卡,以支持更多的安装方式。
![主题管理界面选项卡](/img/developer-guide/plugin/api-reference/ui/extension-points/theme-list-tabs-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"theme:list:tabs:create": (): ThemeListTab[] | Promise<ThemeListTab[]> => {
return [
{
id: "foo",
label: "foo",
component: markRaw(FooComponent),
props: {},
permissions: [],
priority: 0,
}
];
},
},
});
```
```ts title="ThemeListTab"
export interface ThemeListTab {
id: string; // 选项卡 ID
label: string; // 选项卡标题
component: Raw<Component>; // 选项卡面板组件
props?: Record<string, unknown>; // 选项卡面板组件属性
permissions?: string[]; // 选项卡 UI 权限
priority: number; // 选项卡排序优先级
}
```
## 实现案例
- <https://github.com/halo-dev/plugin-app-store>

@ -0,0 +1,120 @@
---
title: 路由定义
description: 通过插件为 Console 控制台和 UC 个人中心添加新路由
---
Halo 为插件提供了为 Console 控制台和 UC 个人中心添加新路由的入口,可以用于为插件单独提供一个页面。
此文档将介绍如何定义路由以及侧边菜单项。
## 定义方式
Console 控制台和 UC 个人中心的路由定义基本和 Vue Router 官方的保持一致,为了区分 Console 控制台和 UC 个人中心的路由Halo 为插件提供了两个不同的路由定义入口。
- `routes`Console 控制台路由定义
- `ucRoutes`UC 个人中心路由定义
```ts
import HomeView from "./views/HomeView.vue"
import { IconComputer } from "@halo-dev/components";
export default definePlugin({
routes: [ // Console 控制台路由定义
{
parentName: "Root",
route: {
path: "/foo",
name: "Foo",
component: HomeView,
meta: {
permissions: [""],
menu: {
name: "Foo",
group: "content",
icon: markRaw(IconComputer),
priority: 40
},
},
},
},
],
ucRoutes: [ // UC 个人中心路由定义
{
parentName: "Root",
route: {
path: "/uc-foo",
name: "FooUC",
component: HomeView,
meta: {
permissions: [""],
menu: {
name: "FooUC",
group: "content",
icon: markRaw(IconComputer),
priority: 40
},
},
},
},
]
});
```
## 类型定义
```ts
{
routes?: RouteRecordRaw[] | RouteRecordAppend[];
ucRoutes?: RouteRecordRaw[] | RouteRecordAppend[];
}
```
```ts
export interface RouteRecordAppend {
parentName: RouteRecordName;
route: RouteRecordRaw;
}
```
- `parentName`:父路由名称,主要用于确认页面 Layout如果想要添加到顶级路由可以设置为 `Root`。如果不需要设置父路由,可以完全使用 `RouteRecordRaw` 定义。此外,如果同时设置了 `parentName` 以及其下路由设置了 `meta.menu`,那么此路由的菜单项将成为父菜单的子菜单项,可支持的父路由名称如下:
- Console
- `AttachmentsRoot`(附件)
- `CommentsRoot`(评论)
- `SinglePagesRoot`(页面)
- `PostsRoot`(文章)
- `MenusRoot`(菜单)
- `ThemeRoot`(主题)
- `OverviewRoot`(概览)
- `BackupRoot`(备份)
- `PluginsRoot`(插件)
- `SettingsRoot`(设置)
- `UsersRoot`(用户)
- `ToolsRoot`(工具)
- UC
- `PostsRoot`(文章)
- `NotificationsRoot`(消息)
:::info 提示
`RouteRecordRaw` 来自 Vue Router详见 [API 文档 | Vue Router](https://router.vuejs.org/zh/api/#Type-Aliases-RouteRecordRaw)
:::
此外,为了方便插件在 Console 控制台和 UC 个人中心添加菜单项等操作Halo 为 `RouteRecordRaw` 添加了 `meta` 属性,该属性为 `RouteMeta` 类型,定义如下:
```ts
interface RouteMeta {
title?: string; // 浏览器标题
searchable?: boolean; // 是否可以在 Console 的全局搜索中搜索到
permissions?: string[]; // UI 权限
menu?: { // 侧边菜单配置
name: string; // 菜单名称
group?: CoreMenuGroupId; // 内置菜单分组 ID如果不使用内置的分组也可以直接填写分组名称
icon?: Component; // 菜单图标,类型为 Vue 组件,推荐使用 https://github.com/unplugin/unplugin-icons
priority: number; // 菜单项排序,数字越小越靠前
mobile?: boolean; // 是否在移动端显示
};
}
```
```ts
export type CoreMenuGroupId = "dashboard" | "content" | "interface" | "system" | "tool";
```

@ -0,0 +1,4 @@
---
title: 附录
description: 附录
---

@ -0,0 +1,124 @@
---
title: Devtools
description: 了解 Halo 的 Devtools 插件开发工具的使用
---
Devtools 插件开发工具提供了一些 Task 用于辅助 Halo 插件的运行与调试,使用此工具的前提是需要具有 [Docker](https://docs.docker.com/get-docker/) 环境。
Devtools 还提供了一些其他的构建任务,如插件打包、插件检查等。
## 安装
Devtools 是使用 Java 开发的一个 [Gradle](https://gradle.org/) 插件,如果你使用的 [plugin-starter](https://github.com/halo-sigs/plugin-starter) 创建的插件项目,那么你无需任何操作,它已经默认集成了 Devtools 插件。
你可以在项目的 `build.gradle` 中找到它:
```groovy
plugins {
// ...
id "run.halo.plugin.devtools" version "0.0.7"
}
```
## 使用说明
当在项目中引入了 `devtools` 之后,就可以使用一些额外的构建任务:
- `haloServer`:此构建任务用于启动 Halo 服务并自动将依赖此 `devtools` 的 Halo 插件项目以开发模式加载到 Halo 服务中。
- `watch`:此构建任务用于监视 Halo 插件项目的变化并自动重新加载到 Halo 服务中
- `reloadPlugin`:此构建任务用于重载插件,如果你此时使用的是 `haloServer` 运行的插件,改动代码后可以运行此任务来重载插件代码使用新的改动被应用。
一个可能的使用场景:
正在开发 `plugin-starter` 插件,此时想测试插件的功能如看到默认提供的菜单项,你可以通过 `haloServer` 将插件运行起来:
```shell
./gradlew haloServer
```
看到如下日志时表示 Halo 服务已经启动成功:
```shell
Halo 初始化成功,访问: http://localhost:8090/console
用户名admin
密码admin
```
然后改动了某行代码需要使其生效,可以继续保持 `haloServer` 的运行,然后执行:
```shell
./gradlew reloadPlugin
```
来时新的改动应用到现有服务上。
但如果你使用的 `watch` 任务启动插件则不需要执行 `reloadPlugin` 任务,它会监听文件的改动自动重载插件。
## 配置
`build.gradle` 文件中作出配置可以更改 `devtools` 的行为:
```groovy
halo {
version = '2.9.1'
superAdminUsername = 'admin'
superAdminPassword = 'admin'
externalUrl = 'http://localhost:8090'
docker {
// windows 默认为 npipe:////./pipe/docker_engine
url = 'unix:///var/run/docker.sock'
apiVersion = '1.42'
}
port = 8090
debug = true
debugPort = 5005
}
```
`halo {}` 这个配置对象下面用于配置 Halo 服务器的一些信息,所有配置的默认值如上所示,你可以直接使用默认值而不进行任何配置。
- `version`:表示要使用的 Halo 版本,随着插件 API 的更新你可能需要更高的 Halo 版本来运行插件,可自行更改。
- `superAdminUsername` Halo 的超级管理员用户名,当你启动插件时会自动根据此配置和 `superAdminPassword` 为你初始化 Halo 的超级管理员账户。
- `superAdminPassword`Halo 的超级管理员用户密码。
- `externalUrl`Halo 的外部访问地址,一般默认即可,但如果修改了端口号映射可能需要修改。
- `docker.url`:用于配置连接 Docker 的 url 信息,在 Mac 或 Linux 系统上默认是 `unix:///var/run/docker.sock`,在 windows 上默认是 `npipe:////./pipe/docker_engine`
- `docker.apiVersion`Docker 的 API 版本,使用 `docker version` 命令可以查看到,如果你的 Docker 版本过低可能需要更改此配置,示例:
```shell
➤ docker version 11:38:06
Client:
Version: 24.0.7
API version: 1.43
```
- `port`Halo 服务的端口号,如果你的 Halo 服务端口号不想使用默认的 `8090` 或者想使用多个 Halo 服务,可以修改此配置。
- `debug`:是否开启调试模式,开启后会在启动 Halo 服务时会自动开启调试模式,此时你可以使用 IDE 连接到 Halo 服务进行调试。
- `debugPort`:调试模式下的调试端口号,默认是自动分配端口号,你可以修改此配置来固定调试端口号。
- `suspend`:是否在启动时挂起,如果开启则会在启动时挂起直到有调试器连接到 Halo 服务。
## 调试后端代码
如果你想调试后端代码,可以在 `build.gradle` 中配置
```groovy
halo {
debug = true
}
```
然后通过 IDEA 运行 `haloServer` 以便于配合 `IDEA` 进行调试。
![Use-Devtools](/img/developer-guide/plugin/use-devtools.png)
首先点击上图中 `1` 处的 `haloServer` 运行插件,然后点击 `2` 处的 `Attach debugger` 让 IDEA 连接到 Halo 服务,此时会打开一个调试窗口就可以开始打断点调试了。
可能会因为日志太快而点击不到 `Attach debugger`,那么你可以配置
```groovy
halo {
debug = true
suspend = true
}
```
这样,在点击 `haloServer` 启动插件时会挂起等待在 `Attach debugger` 处,直到你点击 `Attach debugger` 连接调试器后才会继续执行。

@ -0,0 +1,96 @@
---
title: Halo 架构概览
description: Halo 架构概览
---
Halo 是一个基于 Spring Boot 的 Java Web 应用Web 层不再使用 Servlet 技术,而是充分向异步和非阻塞的反应式编程靠拢,使用 Netty 作为 Web 服务器,使用 [Reactor](https://projectreactor.io/) 作为异步编程框架,使用 R2DBC 作为数据库访问框架,使用 WebFlux 作为 Web 层框架。
Halo 由以下几个核心模块组成:
- 安全模块:提供用户认证、授权、用户管理等功能。
- 插件模块:提供插件管理、插件加载、插件通信、扩展点等功能。
- 主题模块:提供主题管理、模板渲染、主题配置等功能。
- 内置内容管理模块:提供文章、分类、标签、评论、附件、页面、菜单、设置等功能。
## Halo 核心概念和 Extension
### 自定义模型 {#extension}
Extension 自定义模型提供了一种声明和管理数据模型的方法,它是 Halo 的核心概念之一。Halo 中的所有数据模型都是通过 Extension 来定义的,包括文章、分类、标签、评论、附件、页面、菜单、设置等,这便于插件系统可以灵活的进行数据模型的扩展,设计文档参考:[自定义模型设计](https://github.com/halo-dev/rfcs/tree/main/extension)。
每个自定义模型都有三大类属性metadata、spec、和 status。
1. metadata 用于标识自定义模型,每个自定义模型都至少有三个 metadata 属性name、creationTimestamp、version除此之外还有 labels 用于标识自定义模型的标签annotations 用于存放扩展信息deletionTimestamp 用于标识自定义模型是否被删除finalizers 用于标识自定义模型的是否可回收。
2. spec 描述用户期望达到的理想状态Desired State比如用户可以配置插件的 `spec.enable` 属性为 `true` 来启用插件或者为 `false` 来停用插件,这就是用户期望达到的理想状态,然后插件控制器会根据用户的期望状态来实现插件的启用或停用,它是声明式的,用户只需要声明期望状态,实际状态由具体的控制器来维护,最终达到用户期望的状态。
3. status 描述当前实际状态Actual State比如用户可以通过 `status.phase` 属性来查看插件启用进行到了哪一步,中间过程可能包含多个步骤,比如插件解析、加载、资源准备等,这些步骤都是由插件控制器来实现的,它是实际状态,只要插件控制器还在运行,它就会一直更新状态,最终达到用户期望的状态。
每个自定义模型注册后都会默认生成 CRUD APIs通过这些 APIs 就可以对自定义模型对象进行增删改查的操作,然后只需要编写控制器来实现自定义模型的业务逻辑即可,这就是 Halo 的异步编程模型。
### 控制器 {#controller}
在 Halo 中用户通过自定义模型定义资源的期望状态Controller 负责监视资源的实际状态当资源的实际状态和“期望状态”不一致时Controller 则对系统进行必要的更改以确保两者一致这个过程被称之为调谐Reconcile而实现调谐的逻辑被称之为 Reconciler。Reconciler 获取对象的名称并返回是否需要重试(例如发生一些错误),如果需要重试,则 Controller 会在稍后再次调用 Reconciler而这个过程会一直重复直到 Reconciler 返回成功为止这个过程被称之为调谐循环Reconciliation Loop
### 自定义模型生命周期 {#extension-lifecycle}
所有 Halo 的自定义模型对象都遵循一个共同的生命周期,可以将其视为状态机,尽管某些特定的自定义模型扩展了这一点并提供了更多状态。要编写正确的控制器,了解公共对象生命周期非常重要。
所有自定义模型对象都存在以下状态之一:
- `DOES_NOT_EXIST`Halo 不知道该对象。该状态不区分“尚未创建”和“已删除”。
- `ACTIVE`Halo 知道该对象并且该对象尚未被删除(未设置 `metadata.deletionTimestamp`。在此状态下任何更新操作PUT、PATCH、服务器端处理等都将导致相同的状态。
- `DELETING`Halo 知道该对象,该对象已被删除,但尚未完全删除。这可能是因为对象有一个或多个终结器(在 `metadata.finilizers` 中),客户端仍然可以访问该对象,并且可以看到它正在删除,因为设置了 `metadata.deleteTimestamp` 字段。当最后一个终结器被删除时,该对象将从存储中删除,并真正不存在。
下图描述了上述状态:
```text
+---- object
| updated
v |
+----------+ |
| +----+
object --------->| ACTIVE |
created | +-----------+
| +---+------+ |
| | |
| | |
+------------+---+ | |
| | object deleted |
| |<--- without finalizers |
| | object deleted
| DOES_NOT_EXIST | with finalizers
| | |
| |<--- finalizers removed |
| | | |
+----------------+ | |
| |
| |
+---+------+ |
| | |
| DELETING |<----------+
| |
+----------+
```
总结:自定义模型对象的删除并不是立即生效的,而是需要经过两个步骤,第一步是将对象的 `metadata.deletionTimestamp` 字段设置为当前时间,第二步是将对象的 `metadata.finalizers` 字段设置为空,这样对象才会真正被删除,第一步是由用户发起的,第二步是由 Halo 控制器发起的。
### Secret {#secret}
Secret 用于解决密码、token、密钥等敏感数据的配置问题而不需要把这些敏感数据暴露到自定义模型的 Spec 中,或 API 响应中。
### ConfigMap {#configmap}
ConfigMap 自定义模型用来保存 key-value pair 配置数据,这个数据可以在 Reconciler 里使用,或者被用来为插件或者主题存储配置数据。
虽然 ConfigMap 跟 Secret 类似,但是 ConfigMap 更方便的处理不含敏感信息的字符串。
### Setting {#setting}
Setting 自定义模型用于提供用户配置声明,用户可以通过 Setting 来声明一些模板需要的配置,比如主题设置、插件设置、系统设置等都可以通过 Setting 来声明,就能在 UI 层面提供配置入口,用户可以通过 UI 来配置这些设置,而不需要修改配置文件。
### 基于角色的访问控制RBAC{#rbac}
Halo 使用基于角色的访问控制Role-based Access ControlRBAC来控制用户对资源的访问权限RBAC 通过将角色分配给用户来实现访问控制,用户可以通过角色来访问资源,角色可以通过权限来访问资源。
RBAC 主要引入了角色Role和角色绑定RoleBinding的抽象概念插件可以通过定义角色来提供用户对资源的分配入口用户可以通过角色绑定来获取角色从而获取资源的访问权限。
而对于底层角色,用户分配起来比较麻烦,因此 Halo 提供了**角色模板**的概念,通过将角色标记为模板来使用一组功能相关的角色,如文章查看的角色可能必须包含标签和分类的查看才算是一组完整的功能,因此可以将文章查看的角色标记为模板,并依赖标签和分类的查看角色,这样用户就可以通过角色模板来获取一组功能相关的角色,而不需要一个一个的分配角色。

@ -0,0 +1,97 @@
---
title: 插件注册和配置
description: 了解插件定义文件 plugin.yaml 如何配置
---
一个典型的插件描述文件 plugin.yaml 如下所示:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: Plugin
metadata:
name: hello-world
spec:
enabled: true
requires: ">=2.0.0"
author:
name: halo-dev
website: https://halo.run
logo: https://halo.run/logo
# settingName: hello-world-settings
# configMapName: hello-world-configmap
homepage: https://github.com/guqing/halo-plugin-hello-world
displayName: "插件 Hello world"
description: "插件开发的 hello world用于学习如何开发一个简单的 Halo 插件"
license:
- name: "MIT"
```
- `apiVersion``kind`:为固定写法,每个插件写法都是一样的不可变更。
- `metadata.name`:它是插件的唯一标识名,包含不超过 253 个字符,仅包含小写字母、数字或`-`,以字母或数字开头,以字母或数字结尾。
- `spec.enabled`:表示是否要在安装时自动启用插件,出于安全性考虑,仅在插件开发模式下有效,生产模式需要由用户手动启用。
- `spec.requires`:插件受支持的 Halo 版本,遵循 [Semantic Range Expressions](https://github.com/zafarkhaja/jsemver#range-expressions),以下是支持的符号及其解释的列表:
- 常规符号:`>`、`>=`、`<`、`<=`、`=`、`!=`
- 通配符范围 ( `*` | `X`| `x`)`1.*` 解释为 `>=1.0.0 && <2.0.0`
- 波形符范围 ( `~` )`~2.5`解释为 `>=1.5.0 && <1.6.0`
- 连字符范围 ( `-` )`0.0-2.0`解释为 `>=1.0.0 && <=2.0.0`
- 插入符范围 ( `^` )`^0.2.3`解释为 `>=0.2.3 && <0.3.0`
- 部分版本范围:`1` 解释为 `1.x``>=1.0.0 && <2.0.0`
- 否定运算符:`!(1.x)` 解释为 `<1.0.0 && >=2.0.0`
- 带括号的表达式:`~1.3 || (1.4.* && !=1.4.5) || ~2`
- `spec.author`:插件作者的名称和可获得支持的网站地址。
- `spec.logo`:插件 logo可以是域名或相对于项目 `src/main/resources` 目录的文件路径,如将 logo 放在 `src/main/resources/logo.png` 则配置为 `logo.png` 即可。
- `spec.settingName`:插件配置表单名称,对应一个 `Setting` 自定义模型资源文件,可为用户提供可视化的配置表单,参考:[表单定义](../../form-schema.md)。如果插件没有配置提供给用户则不需要配置此项,名称推荐为 "插件名-settings",命名规同 `metadata.name`
- `spec.configMapName`:表单定义对应的值标识名,它声明了插件的配置值将存储在哪个 ConfigMap 中,通常我们推荐命名为 "插件名-configmap",没有配置 `settingName` 则不需要配置此项,命名规同 `metadata.name`
:::tip
如果你在 plugin.yaml 中配置了 `settingName` 但确没有对应的 `Setting` 自定义模型资源文件,会导致插件无法启动,原因是 `Setting` 模型 `metadata.name` 为你配置的 `settingName` 的资源无法找到。
:::
- `spec.homepage`:通常为插件的 GitHub 仓库链接,或可联系到插件作者或插件官网或帮助中心链接等。
- `spec.displayName`:插件的显示名称,它通常是以少数几个字来概括插件的用途。
- `spec.description`:插件描述,用一段简短的说明来介绍插件的用途。
- `spec.license`:插件使用的软件协议,参考:<https://en.wikipedia.org/wiki/Software_license>
Halo 的插件可以在两种模式下运行:`development` 和 `deployment`
`deployment`(默认)模式是插件创建的标准工作流程:为每个插件创建一个新的 Gradle 项目,编码插件(声明新的扩展点和/或添加新的扩展),将插件打包成一个 JAR 文件,部署 JAR 文件到 Halo。
这些操作非常耗时,因此引入了 `development` 开发模式。
对于插件开发人员来说,`development` 运行模式的主要优点是不必打包和部署插件。在开发模式下,您可以以简单快速的流程快速开发插件。
### 配置
如果你想以 `deployment` 运行插件则参考 [传统方式运行](../hello-world.md#run-with-traditional-way) 做如下配置:
```yaml
halo:
plugin:
runtime-mode: deployment
```
`deployment` 是默认的运行模式,因此你可以不配置 `runtime-mode`
如果你想以 `development` 运行并开发插件则将 `runtime-mode` 修改为 `development`,同时配置 `fixed-plugin-path` 为插件项目绝对路径,可以配置多个。
```yaml
# macOS / Linux
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件绝对路径
- /path/to/halo-plugin-hello-world
# Windows
halo:
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件绝对路径
- C:\path\to\halo-plugin-hello-world
```
:::tip Note
1. `development` 开发模式下,既可以运行 `fixed-plugin-path` 下的插件,也可以运行通过 `Console` 管理端安装的 JAR 格式的插件。
2. 如果使用 [DevTools 运行方式](../hello-world.md#run-with-devtools) 来开发插件,则不需要配置 `runtime-mode``fixed-plugin-path`
:::

@ -3,7 +3,7 @@ title: 生命周期
description: 了解插件从启动到卸载的过程 description: 了解插件从启动到卸载的过程
--- ---
根据[插件项目文件结构](./structure.md)所展示的 `StarterPlugin.java` 中,具有如下方法: 根据[插件项目文件结构](../../basics/structure.md)所展示的 `StarterPlugin.java` 中,具有如下方法:
```java ```java
@Override @Override
@ -22,6 +22,15 @@ public void delete() {
} }
``` ```
它们就是插件的生命周期方法,分别对应插件的启动、停止和删除。
1. 继承 `run.halo.app.plugin.BasePlugin` 类后,你可以重写这些方法来干预插件的生命周期,例如在插件启动时初始化一些资源,在插件停止时清理掉这些资源。
2. 一个插件项目只允许有一个类继承 `BasePlugin` 类且标记为 Bean此时这个类将被作为插件的后端入口如果有多个类继承了 `BasePlugin` 会导致插件无法启动或生命周期方法无法被调用。
:::tip Note
如果一个类继承了 `BasePlugin` 类但没有标记为 Bean那么它将不会被 Halo 识别到,其中的生命周期方法也不会被调用。
:::
### 插件启动 ### 插件启动
插件被安装后,只加载了插件的 `plugin.yaml`,类及其他资源文件的加载均在启动时进行。 插件被安装后,只加载了插件的 `plugin.yaml`,类及其他资源文件的加载均在启动时进行。

@ -0,0 +1,57 @@
---
title: 插件中的对象管理
description: 了解如何在创建中创建对象和管理对象依赖
---
在插件中你可以使用 [Spring Framework](https://spring.io/projects/spring-framework/) 提供的常用 Bean 注解来标注一个类,然后就能使用依赖注入功能注入其他类的对象。这省去了使用工厂创建类和维护的过程,你可以像开发一个常规的 Spring 项目一样来开发插件,目前支持以下 Spring Framework 的特性:
1. [Core Technologies](https://docs.spring.io/spring-framework/reference/core.html)
2. [Web on Reactive](https://docs.spring.io/spring-framework/reference/web-reactive.html)
3. [Testing](https://docs.spring.io/spring-framework/reference/testing.html)
通过模板插件创建的项目中你会看到 `StarterPlugin` 标注了 `@Component` 注解:
```java
@Component
public class StarterPlugin extends BasePlugin {
}
```
假设项目中有一个 `FruitService`,并将其声明了为了 Bean
```java
@Service
public class FruitService {
}
```
你可以在任何同样声明为 Bean 的类中使用[依赖注入](https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#beans-dependencies)来使用它:
```java
@Component
public class Demo {
private final FruitService fruitService;
public Demo(FruitService fruitService) {
this.fruitService = fruitService;
}
// use it...
}
```
### 依赖注入 Halo 提供的 Bean
目前 Halo 只提供了少数几个 Bean 可以供插件依赖注入:
- run.halo.app.extension.ReactiveExtensionClient用于管理自定义模型对象的增删改查它是反应式的。
- run.halo.app.extension.ExtensionClient用于管理自定义模型对象的增删改查它是阻塞的只能用在非 NIO 线程中,如后台任务。
- run.halo.app.extension.SchemeManager用于管理自定义模型定义的注册和销毁。
- run.halo.app.infra.ExternalUrlSupplier用于获取用户配置的 Halo 外部访问地址。
- run.halo.app.core.extension.service.AttachmentService用于操作附件。
- run.halo.app.notification.NotificationReasonEmitter用于发送通知。
- run.halo.app.notification.NotificationCenter用于管理通知的订阅和取消订阅。
- run.halo.app.infra.ExternalLinkProcessor用于处理将一个相对地址转换为外部访问地址。
- org.springframework.security.web.server.context.ServerSecurityContextRepository用于获取操作用户的认证上下文信息如自动登录场景。
即其他不在上述列表中的类的对象都是不可依赖注入的。

@ -1,12 +1,12 @@
--- ---
title: 项目结构 title: 插件项目结构
description: 了解插件的文件结构 description: 了解插件项目的文件结构
--- ---
新创建的插件项目典型的目录结构如下所示: 新创建的插件项目典型的目录结构如下所示:
```text ```text
├── console ├── ui
│ ├── src │ ├── src
│ │ ├── assets │ │ ├── assets
│ │ │ └── logo.svg │ │ │ └── logo.svg
@ -35,7 +35,6 @@ description: 了解插件的文件结构
│ │ └── style.css │ │ └── style.css
│ └── plugin.yaml │ └── plugin.yaml
├── LICENSE ├── LICENSE
├── OWNERS
├── README.md ├── README.md
├── build.gradle ├── build.gradle
├── gradle.properties ├── gradle.properties
@ -50,13 +49,17 @@ description: 了解插件的文件结构
所有的后端代码都放在 `src` 目录下,它是一个常规的 `Java` 项目目录结构。 所有的后端代码都放在 `src` 目录下,它是一个常规的 `Java` 项目目录结构。
- `StarterPlugin.java` 为插件的后端入口文件。 - `StarterPlugin.java` 为插件的后端入口示例文件,类名可以任意但它必须继承 `run.halo.app.plugin.BasePlugin` 类来标记它作为插件入口
- `resources` 下的 `plugin.yaml` 为插件的资源描述文件,它是必须的。 - `resources` 下的 `plugin.yaml` 为插件的资源描述文件,它是必须的,它描述了插件的基本信息,包括插件的名称、版本、作者、描述、依赖等
- `resources/console` 下的两个文件 `main.js``style.css` 是前端插件部分打包时输出的产物。一个插件可以没有前端部分,因此 `resources/console` 同样可以不存在。 - `resources/console` 下的两个文件 `main.js``style.css` 是前端插件部分打包时输出的产物。一个插件可以没有前端部分,因此 `resources/console` 同样可以不存在。
:::caution 注意
从 2.11 开始Halo 支持了 UC 个人中心,且个人中心和 Console 的插件机制共享,所以为了避免歧义,`resources/console` 在后续版本会被重命名为 `resources/ui`,但同时也会兼容 `resources/console`
:::
### 前端部分 ### 前端部分
`console` 目录下为插件的前端部分的工程目录,包括了源码、配置文件和静态资源文件。 `ui` 目录下为插件的前端部分的工程目录,包括了源码、配置文件和静态资源文件。
同样的,将所有前端项目源码放到 `src` 中。我们建议使用 `TypeScript` 作为编程语言,它可以帮助你在编译时而非运行时捕获错误。 同样的,将所有前端项目源码放到 `src` 中。我们建议使用 `TypeScript` 作为编程语言,它可以帮助你在编译时而非运行时捕获错误。
- `src/index.ts` 作为前端部分的插件的入口文件。 - `src/index.ts` 作为前端部分的插件的入口文件。

@ -0,0 +1,126 @@
---
title: 入口文件
description: UI 扩展部分的入口文件
---
入口文件即 Halo 核心会加载的文件,所有插件有且只有一个入口文件,构建之后会放置在插件项目的 `src/resources/console` 下,名为 `main.js`
为了方便开发者,我们已经在 [halo-dev/plugin-starter](https://github.com/halo-dev/plugin-starter) 配置好了基础项目结构,包括构建配置,后续文档也会以此为准。
## 定义入口文件
```ts title="ui/src/index.ts"
import { definePlugin } from "@halo-dev/console-shared";
export default definePlugin({
components: {},
routes: [],
ucRoutes: [],
extensionPoints: {}
});
```
## 类型定义
```ts
export function definePlugin(plugin: PluginModule): PluginModule {
return plugin;
}
```
```ts title="PluginModule"
import type { Component, Ref } from "vue";
import type { RouteRecordRaw, RouteRecordName } from "vue-router";
import type { FunctionalPage } from "../states/pages";
import type { AttachmentSelectProvider } from "../states/attachment-selector";
import type { EditorProvider, PluginTab } from "..";
import type { AnyExtension } from "@halo-dev/richtext-editor";
import type { CommentSubjectRefProvider } from "@/states/comment-subject-ref";
import type { BackupTab } from "@/states/backup";
import type { PluginInstallationTab } from "@/states/plugin-installation-tabs";
import type { EntityFieldItem } from "@/states/entity";
import type { OperationItem } from "@/states/operation";
import type { ThemeListTab } from "@/states/theme-list-tabs";
import type {
Attachment,
Backup,
ListedPost,
Plugin,
Theme,
} from "@halo-dev/api-client";
export interface RouteRecordAppend {
parentName: RouteRecordName;
route: RouteRecordRaw;
}
export interface ExtensionPoint {
// @deprecated
"page:functional:create"?: () => FunctionalPage[] | Promise<FunctionalPage[]>;
"attachment:selector:create"?: () =>
| AttachmentSelectProvider[]
| Promise<AttachmentSelectProvider[]>;
"editor:create"?: () => EditorProvider[] | Promise<EditorProvider[]>;
"plugin:self:tabs:create"?: () => PluginTab[] | Promise<PluginTab[]>;
"default:editor:extension:create"?: () =>
| AnyExtension[]
| Promise<AnyExtension[]>;
"comment:subject-ref:create"?: () => CommentSubjectRefProvider[];
"backup:tabs:create"?: () => BackupTab[] | Promise<BackupTab[]>;
"plugin:installation:tabs:create"?: () =>
| PluginInstallationTab[]
| Promise<PluginInstallationTab[]>;
"post:list-item:operation:create"?: (
post: Ref<ListedPost>
) => OperationItem<ListedPost>[] | Promise<OperationItem<ListedPost>[]>;
"plugin:list-item:operation:create"?: (
plugin: Ref<Plugin>
) => OperationItem<Plugin>[] | Promise<OperationItem<Plugin>[]>;
"backup:list-item:operation:create"?: (
backup: Ref<Backup>
) => OperationItem<Backup>[] | Promise<OperationItem<Backup>[]>;
"attachment:list-item:operation:create"?: (
attachment: Ref<Attachment>
) => OperationItem<Attachment>[] | Promise<OperationItem<Attachment>[]>;
"plugin:list-item:field:create"?: (
plugin: Ref<Plugin>
) => EntityFieldItem[] | Promise<EntityFieldItem[]>;
"post:list-item:field:create"?: (
post: Ref<ListedPost>
) => EntityFieldItem[] | Promise<EntityFieldItem[]>;
"theme:list:tabs:create"?: () => ThemeListTab[] | Promise<ThemeListTab[]>;
"theme:list-item:operation:create"?: (
theme: Ref<Theme>
) => OperationItem<Theme>[] | Promise<OperationItem<Theme>[]>;
}
export interface PluginModule {
components?: Record<string, Component>;
routes?: RouteRecordRaw[] | RouteRecordAppend[];
ucRoutes?: RouteRecordRaw[] | RouteRecordAppend[];
extensionPoints?: ExtensionPoint;
}
```
- `components`组件列表key 为组件名称value 为组件对象,在此定义之后,加载插件时会自动注册到 Vue App 全局。
- `routes`Console 控制台路由定义,详细文档可参考 [路由定义](../../api-reference/ui/route.md)
- `ucRoutes`UC 个人中心路由定义,详细文档可参考 [路由定义](../../api-reference/ui/route.md)
- `extensionPoints`:扩展点定义,详细文档可参考 [扩展点](../../api-reference/ui/extension-points)

@ -0,0 +1,12 @@
---
title: 介绍
description: 介绍插件 UI 部分的基础知识。
---
Halo 插件体系的 UI 部分可以让开发者在 Console 控制台和 UC 个人中心添加新的页面或者扩展已有的功能。
在开始之前,建议先熟悉或安装以下库和工具:
1. [Node.js 18+](https://nodejs.org)
2. [pnpm 8+](https://pnpm.io)
3. [Vue.js 3](https://vuejs.org)

@ -16,53 +16,130 @@ description: 了解如何构建你的第一个插件并在 Halo 中使用它。
![create-repository-for-hello-world-plugin](/img/create-repository-for-hello-world-plugin.png) ![create-repository-for-hello-world-plugin](/img/create-repository-for-hello-world-plugin.png)
你现在已经基于 Halo 插件模板创建了自己的存储库。接下来,你需要将它克隆到你的计算机上并使用 `IntelliJ IDEA` 打开它。 你现在已经基于 Halo 插件模板创建了自己的存储库。接下来,你需要将它克隆到你的计算机上。
```shell
# clone your repository
git clone https://github.com/<your-username>/halo-plugin-hello-world.git
# enter the directory
cd halo-plugin-hello-world
```
## 运行插件 ## 运行插件
现在有了一个空项目,我们需要让插件能最最小化的运行起来。 现在有了一个空项目,我们需要让插件能最最小化的运行起来,这里提供两种运行方式
这很简单,首先你需要构建插件:只需要在 `halo-plugin-hello-world` 项目目录下执行 Gradle 命令 ### 使用 DevTools 运行(推荐){#run-with-devtools}
```shell Halo 提供了一个用于插件开发的 DevTools它可以帮助你快速的运行和调试插件在模板插件项目中已经集成了 DevTools可查阅 [DevTools 使用说明](./basics/devtools.md)。
./gradlew pnpmInstall
./gradlew build 使用 DevTools 运行插件的前提是需要你的电脑上已经安装了 Docker 环境,这是我们推荐的用户开发时运行插件的方式,只需要执行以下命令即可。
```
1. 执行前端部分的依赖安装命令:
```shell
# macOS / Linux
./gradlew pnpmInstall
# Windows
./gradlew.bat pnpmInstall
```
2. 运行插件:
```shell
# macOS / Linux
./gradlew haloServer
# Windows
./gradlew.bat haloServer
```
执行此命令后,会自动创建一个 Halo 的 Docker 容器并加载当前的插件。
3. 启动成功后,可以看到如下日志输出:
```shell
Halo 初始化成功,访问: http://localhost:8090/console
用户名admin
密码admin
```
然后访问 `http://localhost:8090/console`
或者使用 `IntelliJ IDEA` 提供的 `Gradle build` 即可完成插件项目的构建。 在插件列表将能看到插件已经被正确启动,并且在左侧菜单添加了一个 `示例分组`,其下有一个名 `示例页面` 的菜单
然后使用 `IntelliJ IDEA` 打开 Halo参考 [Halo 开发环境运行](../core/run.md),在 `src/main/resources` 下创建一个 `application-local.yaml` 文件并做如下配置: ![hello-world-in-plugin-list](/img/plugin-hello-world.png)
```yaml ### 传统方式运行 {#run-with-traditional-way}
# macOS / Linux
halo: 如果你的设备上无法安装 Docker 或你对 Docker 不熟悉,可以使用传统方式运行并开发插件。
但由于此方式需要先使用源码运行 Halo 才能启动插件,请确保已经在开发环境运行了 Halo可以参考 [Halo 开发环境运行](../core/run.md)
1. 安装前端部分的依赖
```shell
# macOS / Linux
./gradlew pnpmInstall
# Windows
./gradlew.bat pnpmInstall
```
2. 编译插件
```shell
# macOS / Linux
./gradlew build
# Windows
./gradlew.bat build
```
3. 修改 Halo 配置文件:
```shell
# 进入 Halo 项目根目录后,使用 cd 命令进入配置文件目录
cd application/src/main/resources
# 创建 application-local.yaml 文件
touch application-local.yaml
```
根据你的操作系统,将以下内容添加到 `application-local.yaml` 文件中。
```yaml
# macOS / Linux
halo:
plugin: plugin:
runtime-mode: development runtime-mode: development
fixed-plugin-path: fixed-plugin-path:
# 配置为插件绝对路径 # 配置为插件项目目录绝对路径
- /path/to/halo-plugin-hello-world - /path/to/halo-plugin-hello-world
# Windows # Windows
halo: halo:
plugin: plugin:
runtime-mode: development runtime-mode: development
fixed-plugin-path: fixed-plugin-path:
# 配置为插件绝对路径 # 配置为插件项目目录绝对路径
- C:\path\to\halo-plugin-hello-world - C:\path\to\halo-plugin-hello-world
``` ```
使用此 local profile 启动 Halo 4. 启动 Halo
```shell ```shell
# macOS / Linux # macOS / Linux
./gradlew bootRun --args="--spring.profiles.active=dev,local" ./gradlew bootRun --args="--spring.profiles.active=dev,local"
# Windows # Windows
gradlew.bat bootRun --args="--spring.profiles.active=dev,win,local" gradlew.bat bootRun --args="--spring.profiles.active=dev,win,local"
``` ```
然后访问 `http://localhost:8090/console` 然后访问 `http://localhost:8090/console`
在插件列表将能看到插件已经被正确启动,并且在左侧菜单添加了一个 `示例分组`,其下有一个名 `示例页面` 的菜单。 在插件列表将能看到插件已经被正确启动,并且在左侧菜单添加了一个 `示例分组`,其下有一个名 `示例页面` 的菜单。
![hello-world-in-plugin-list](/img/plugin-hello-world.png) ![hello-world-in-plugin-list](/img/plugin-hello-world.png)

@ -4,25 +4,3 @@ description: 插件开发的准备工作
--- ---
Halo 采用可插拔架构,功能模块之间耦合度低、灵活性提高,支持用户按需安装、卸载插件,操作便捷。同时提供插件开发接口以确保较高扩展性和可维护性,这个系列的文档将帮助你了解如何开发 Halo 插件。 Halo 采用可插拔架构,功能模块之间耦合度低、灵活性提高,支持用户按需安装、卸载插件,操作便捷。同时提供插件开发接口以确保较高扩展性和可维护性,这个系列的文档将帮助你了解如何开发 Halo 插件。
## 插件管理
### 支持
Halo 不提供对第三方应用程序的支持。作为插件的开发者你有责任帮助插件的用户解决技术问题issues
当提交插件到 [awesome-halo](https://github.com/halo-sigs/awesome-halo) 时,
您需要添加服务支持联系人Support contact。这可以是用户可以联系的电子邮件地址也可以是网站或帮助中心的链接。
### 版本控制
为了保持 Halo 生态系统的健康、可靠和安全,每次您对自己拥有的插件进行重大更新时,我们建议在遵循 [semantic versioning spec](http://semver.org/) 的基础上,
发布新版本。遵循语义版本控制规范有助于其他依赖你代码的开发人员了解给定版本的更改程度,并在必要时调整自己的代码。
我们建议你的包版本从1.0.0开始并递增,如下:
| Code status | Stage | Rule | Example version |
| ----------------------------------------- | ------------- | -------------------------------------------- | --------------- |
| First release | New product | 从 1.0.0 开始 | 1.0.0 |
| Backward compatible bug fixes | Patch release | 增加第三位数字 | 1.0.1 |
| Backward compatible new features | Minor release | 增加中间数字并将最后一位重置为零 | 1.1.0 |
| Changes that break backward compatibility | Major release | 增加第一位数字并将中间和最后一位数字重置为零 | 2.0.0 |

@ -1,45 +0,0 @@
---
title: 插件资源文件
description: 了解插件资源文件 plugin.yaml 如何配置
---
一个典型的插件资源文件 plugin.yaml 如下所示:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: Plugin
metadata:
name: hello-world
spec:
enabled: true
requires: ">=2.0.0"
author:
name: halo-dev
website: https://halo.run
logo: https://halo.run/logo
# settingName: hello-world-settings
# configMapName: hello-world-configmap
homepage: https://github.com/guqing/halo-plugin-hello-world
displayName: "插件 Hello world"
description: "插件开发的 hello world用于学习如何开发一个简单的 Halo 插件"
license:
- name: "MIT"
```
- `apiVersion``kind`:为固定写法,每个插件写法都是一样的不可变更。
- `metadata.name`:它是插件的唯一标识名,包含不超过 253 个字符,仅包含小写字母、数字或`-`,以字母或数字开头,以字母或数字结尾。
- `spec.enabled`:表示是否要在安装时自动启用插件,仅在插件开发模式下有效。
- `spec.requires`:支持的 Halo 版本,遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/) 规范。
- `spec.author`:插件作者的名称和可获得支持的网站地址。
- `spec.logo`:插件 logo可以是域名或相对于项目 `src/main/resources` 目录的相对文件路径。
- `spec.settingName`:插件配置表单名称,参考表单定义,不需要表单设置则可删除。
- `spec.configMapName`:表单定义对应的值标识名, 推荐命名为 "插件名-configmap",没有配置 `settingName` 则不需要配置此项。
:::tip
如果你在 plugin.yaml 中配置了 `settingName` 但确没有对应的 `Setting` 自定义模型资源文件,会导致插件无法启动,原因是 `Setting` 模型 `metadata.name` 为你配置的 `settingName` 的资源无法找到。
:::
- `spec.homepage`:通常为插件的 GitHub 仓库链接,或可联系到插件作者或插件官网或帮助中心链接等。
- `spec.displayName`:插件的显示名称,它通常是以少数几个字来概括插件的用途。
- `spec.description`:插件描述,用一段话来介绍插件的用途。
- `spec.license`:插件使用的软件协议,参考:<https://en.wikipedia.org/wiki/Software_license>

@ -1,48 +0,0 @@
---
title: 插件中的对象管理
description: 了解如何在创建中创建对象和管理对象依赖
---
在插件中你可以使用 [Spring](https://spring.io) 提供的常用 Bean 注解来标注一个类,然后就能使用依赖注入功能注入其他类的对象。这省去了使用工厂创建类和维护的过程。
通过模板插件创建的项目中你都可以看到 `StarterPlugin` 标注了 `@Component` 注解:
```java
@Component
public class StarterPlugin extends BasePlugin {
}
```
假设项目中有一个 `FruitService`,并将其声明了为了 Bean
```java
@Service
public class FruitService {
}
```
你可以在任何同样声明为 Bean 的类中使用[依赖注入](https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#beans-dependencies)来使用它:
```java
@Component
public class Demo {
private final FruitService fruitService;
public Demo(FruitService fruitService) {
this.fruitService = fruitService;
}
// use it...
}
```
### 依赖注入 Halo 提供的 Bean
目前 Halo 只提供了少数几个 Bean 可以供插件依赖注入:
- ReactiveExtensionClient
- ExtensionClient
- SchemeManager
- ExternalUrlSupplier
即其他不在上述列表中的类的对象都是不可依赖注入的。

@ -7,8 +7,8 @@ description: 插件开发的准备工作
在创建你的第一个插件之前,请确保你具备以下条件: 在创建你的第一个插件之前,请确保你具备以下条件:
- 你能成功在[开发环境运行 Halo](../core/run.md)。 - 你能通过 [Docker 运行 Halo](../../getting-started/install/docker) 或在[开发环境运行 Halo](../core/run.md)。
- 你应该能够熟练使用 [IntelliJ IDEA](https://www.jetbrains.com/idea) - 你熟悉 Java Web 开发并掌握 [Spring Boot](https://spring.io/projects/spring-boot/) 框架
- 你需要在计算机上安装最新的 LTS 版本的 Node.js如果你还没有Node.js安装你可以在这里下载 [Node.js 18 LTS](https://nodejs.org/)。 - 你需要在计算机上安装最新的 LTS 版本的 Node.js如果你还没有Node.js安装你可以在这里下载 [Node.js 18 LTS](https://nodejs.org/)。
- 你熟悉 Vue 和 TypeScript。 - 你熟悉 Vue 和 TypeScript。
- 你应该熟悉使用 Node.js 包管理器。 - 你应该熟悉使用 Node.js 包管理器。

@ -136,5 +136,25 @@ jobs:
用户可以在你的仓库 Release 下载使用,但为了方便让 Halo 的用户知道你的插件,可以在以下渠道发布: 用户可以在你的仓库 Release 下载使用,但为了方便让 Halo 的用户知道你的插件,可以在以下渠道发布:
1. [halo-sigs/awesome-halo](https://github.com/halo-sigs/awesome-halo):你可以向这个仓库发起一个 PR 提交的插件的信息即可。 1. [halo-sigs/awesome-halo](https://github.com/halo-sigs/awesome-halo):你可以向这个仓库发起一个 PR 提交的插件的信息即可。
2. [Halo 应用市场](https://www.halo.run/store/apps)Halo 官方的应用市场,但目前还不支持开发者注册和发布,如果你想发布到应用市场,可以在 PR 上说明下,我们会暂时帮你发布。 2. [Halo 应用市场](https://www.halo.run/store/apps)Halo 官方的应用市场,但目前还不支持开发者注册和发布,如果你想发布到应用市场,可以在 PR 上说明下,我们会暂时帮你发布。
3. [Halo 论坛](https://bbs.halo.run/t/plugins):你可以在 Halo 官方社区的插件板块发布你的插件。 3. [Halo 论坛](https://bbs.halo.run/t/plugins):你可以在 Halo 官方社区的插件板块发布你的插件。
## 支持
Halo 不提供对第三方应用程序的支持。作为插件的开发者你有责任帮助插件的用户解决技术问题issues
当提交插件到 [awesome-halo](https://github.com/halo-sigs/awesome-halo) 时,
你需要添加服务支持联系人Support contact。这可以是用户可以联系的电子邮件地址也可以是网站或帮助中心的链接。
## 版本控制
为了保持 Halo 生态系统的健康、可靠和安全,每次你对自己拥有的插件进行重大更新时,我们建议在遵循 [semantic versioning spec](http://semver.org/) 的基础上,
发布新版本。遵循语义版本控制规范有助于其他依赖你代码的开发人员了解给定版本的更改程度,并在必要时调整自己的代码。
我们建议你的包版本从1.0.0开始并递增,如下:
| Code status | Stage | Rule | Example version |
| ----------------------------------------- | ------------- | -------------------------------------------- | --------------- |
| First release | New product | 从 1.0.0 开始 | 1.0.0 |
| Backward compatible bug fixes | Patch release | 增加第三位数字 | 1.0.1 |
| Backward compatible new features | Minor release | 增加中间数字并将最后一位重置为零 | 1.1.0 |
| Changes that break backward compatibility | Major release | 增加第一位数字并将中间和最后一位数字重置为零 | 2.0.0 |

@ -1,45 +0,0 @@
---
title: 插件运行模式
description: 了解插件的运行方式
---
Halo 的插件可以在两种模式下运行:`development` 和 `deployment`
`deployment`(默认)模式是插件创建的标准工作流程:为每个插件创建一个新的 Gradle 项目,编码插件(声明新的扩展点和/或添加新的扩展),将插件打包成一个 JAR 文件,部署 JAR 文件到 Halo。
这些操作非常耗时,因此引入了 `development` 运行时模式。
对于插件开发人员来说,`development` 运行时模式的主要优点是不必打包和部署插件。在开发模式下,您可以以简单快速的模式开发插件。
### 配置
如果你想以 `deployment` 运行插件则做如下配置:
```yaml
halo:
plugin:
runtime-mode: deployment
```
插件的 `deployment` 模式只允许通过安装 JAR 文件的方式运行插件。
而如果你想以 `development` 运行插件或开发插件则将 `runtime-mode` 修改为 `development`,同时配置 `fixed-plugin-path` 为插件项目路径,可以配置多个。
```yaml
# macOS / Linux
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件绝对路径
- /path/to/halo-plugin-hello-world
# Windows
halo:
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件绝对路径
- C:\path\to\halo-plugin-hello-world
```
:::tip Note
插件以开发模式运行时由于插件的加载方式与部署模式不同,如果你此时在 Console 安装插件JAR则会提示插件文件找不到而无法启动。
:::

@ -177,6 +177,7 @@ const config = {
prism: { prism: {
theme: darkCodeTheme, theme: darkCodeTheme,
darkTheme: darkCodeTheme, darkTheme: darkCodeTheme,
additionalLanguages: ["java"],
}, },
algolia: { algolia: {
apiKey: "739f2a55c6d13d93af146c22a4885669", apiKey: "739f2a55c6d13d93af146c22a4885669",

@ -113,41 +113,146 @@ module.exports = {
{ {
type: "category", type: "category",
label: "基础", label: "基础",
link: {
type: "generated-index",
},
items: [
"developer-guide/plugin/basics/framework",
"developer-guide/plugin/basics/structure",
"developer-guide/plugin/basics/manifest",
"developer-guide/plugin/basics/devtools",
{
type: "category",
label: "服务端",
link: {
type: "generated-index",
},
items: [
"developer-guide/plugin/basics/server/lifecycle",
"developer-guide/plugin/basics/server/object-management",
],
},
{
type: "category",
label: "UI",
link: {
type: "generated-index",
},
items: [
"developer-guide/plugin/basics/ui/intro",
"developer-guide/plugin/basics/ui/entry",
],
},
],
},
{
type: "category",
label: "API 参考",
link: {
type: "generated-index",
},
items: [
{
type: "category",
label: "服务端",
link: {
type: "generated-index",
},
items: [
"developer-guide/plugin/api-reference/server/extension",
"developer-guide/plugin/api-reference/server/reconciler",
"developer-guide/plugin/api-reference/server/role-template",
"developer-guide/plugin/api-reference/server/extension-client",
"developer-guide/plugin/api-reference/server/reverseproxy",
"developer-guide/plugin/api-reference/server/finder-for-theme",
"developer-guide/plugin/api-reference/server/template-for-theme",
{
type: "category",
label: "扩展点",
link: { link: {
type: "doc", type: "doc",
id: "developer-guide/plugin/structure", id: "developer-guide/plugin/api-reference/server/extension-points/index",
}, },
items: [ items: [
"developer-guide/plugin/structure", "developer-guide/plugin/api-reference/server/extension-points/additional-webfilter",
"developer-guide/plugin/runtime-mode", "developer-guide/plugin/api-reference/server/extension-points/attachment",
"developer-guide/plugin/lifecycle", "developer-guide/plugin/api-reference/server/extension-points/comment-subject",
"developer-guide/plugin/manifest", "developer-guide/plugin/api-reference/server/extension-points/comment-widget",
"developer-guide/plugin/object-management", "developer-guide/plugin/api-reference/server/extension-points/notifier",
"developer-guide/plugin/api-reference/server/extension-points/post-content",
"developer-guide/plugin/api-reference/server/extension-points/singlepage-content",
"developer-guide/plugin/api-reference/server/extension-points/username-password-authentication-manager",
], ],
}, },
],
},
{
type: "category",
label: "UI",
link: {
type: "generated-index",
},
items: [
"developer-guide/plugin/api-reference/ui/route",
{ {
type: "category", type: "category",
label: "示例", label: "扩展点",
link: { link: {
type: "doc", type: "doc",
id: "developer-guide/plugin/examples/todolist", id: "developer-guide/plugin/api-reference/ui/extension-points/index",
}, },
items: ["developer-guide/plugin/examples/todolist"], items: [
"developer-guide/plugin/api-reference/ui/extension-points/attachment-selector-create",
"developer-guide/plugin/api-reference/ui/extension-points/editor-create",
"developer-guide/plugin/api-reference/ui/extension-points/plugin-self-tabs-create",
"developer-guide/plugin/api-reference/ui/extension-points/default-editor-extension-create",
"developer-guide/plugin/api-reference/ui/extension-points/comment-subject-ref-create",
"developer-guide/plugin/api-reference/ui/extension-points/backup-tabs-create",
"developer-guide/plugin/api-reference/ui/extension-points/plugin-installation-tabs-create",
"developer-guide/plugin/api-reference/ui/extension-points/theme-list-tabs-create",
"developer-guide/plugin/api-reference/ui/extension-points/post-list-item-operation-create",
"developer-guide/plugin/api-reference/ui/extension-points/plugin-list-item-operation-create",
"developer-guide/plugin/api-reference/ui/extension-points/backup-list-item-operation-create",
"developer-guide/plugin/api-reference/ui/extension-points/attachment-list-item-operation-create",
"developer-guide/plugin/api-reference/ui/extension-points/theme-list-item-operation-create",
"developer-guide/plugin/api-reference/ui/extension-points/plugin-list-item-field-create",
"developer-guide/plugin/api-reference/ui/extension-points/post-list-item-field-create",
],
}, },
{ {
type: "category", type: "category",
label: "API 参考", label: "组件",
link: { link: {
type: "doc", type: "doc",
id: "developer-guide/plugin/api-reference/extension", id: "developer-guide/plugin/api-reference/ui/components/index",
}, },
items: [ items: [
"developer-guide/plugin/api-reference/extension", "developer-guide/plugin/api-reference/ui/components/uppy-upload",
"developer-guide/plugin/api-reference/role-template", "developer-guide/plugin/api-reference/ui/components/filter-dropdown",
"developer-guide/plugin/api-reference/extension-client", "developer-guide/plugin/api-reference/ui/components/filter-clean-button",
"developer-guide/plugin/api-reference/reverseproxy", "developer-guide/plugin/api-reference/ui/components/annotations-form",
"developer-guide/plugin/api-reference/ui/components/attachment-file-type-icon",
"developer-guide/plugin/api-reference/ui/components/attachment-selector-modal",
"developer-guide/plugin/api-reference/ui/components/has-permission",
"developer-guide/plugin/api-reference/ui/components/search-input",
"developer-guide/plugin/api-reference/ui/components/v-codemirror",
"developer-guide/plugin/api-reference/ui/components/v-tooltip",
"developer-guide/plugin/api-reference/ui/components/v-permission",
],
},
],
},
], ],
}, },
{
type: "category",
label: "案例和最佳实践",
link: {
type: "generated-index",
},
items: ["developer-guide/plugin/examples/todolist"],
},
// "developer-guide/plugin/appendices",
], ],
}, },
{ {

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

@ -136,5 +136,5 @@ jobs:
用户可以在你的仓库 Release 下载使用,但为了方便让 Halo 的用户知道你的插件,可以在以下渠道发布: 用户可以在你的仓库 Release 下载使用,但为了方便让 Halo 的用户知道你的插件,可以在以下渠道发布:
1. [halo-sigs/awesome-halo](https://github.com/halo-sigs/awesome-halo):你可以向这个仓库发起一个 PR 提交的插件的信息即可。 1. [halo-sigs/awesome-halo](https://github.com/halo-sigs/awesome-halo):你可以向这个仓库发起一个 PR 提交的插件的信息即可。
2. [Halo 应用市场](https://www.halo.run/store/apps)Halo 官方的应用市场,但目前还不支持开发者注册和发布,如果你想发布到应用市场,可以在 PR 上说明下,我们会暂时帮你发布。 2. [Halo 应用市场](https://www.halo.run/store/apps)Halo 官方的应用市场,但目前还不支持开发者注册和发布,如果你想发布到应用市场,可以在 PR 上说明下,我们会暂时帮你发布。
3. [Halo 论坛](https://bbs.halo.run/t/plugins):你可以在 Halo 官方社区的插件板块发布你的插件。 3. [Halo 论坛](https://bbs.halo.run/t/plugins):你可以在 Halo 官方社区的插件板块发布你的插件。

Loading…
Cancel
Save