feat: add preventParentPostCascadeQuery option to control visibility of child category posts (#6083)

#### What type of PR is this?
/kind feature
/area core
/milestone 2.17.x

#### What this PR does / why we need it:
此次变更为文章分类引入了一个新的 `preventParentPostCascadeQuery` 布尔属性,用于控制分类及其子分类下的文章显示方式。具体变更包括:

- 在分类结构中增加了 `preventParentPostCascadeQuery` 属性。
- 当分类的 `preventParentPostCascadeQuery` 属性设置为 `true` 时,该分类的文章数量不会汇总到父分类中。
- 更新了树结构遍历逻辑,以支持对 `preventParentPostCascadeQuery` 属性的处理。
- 确保独立分类中的文章显示受控,不向上级分类进行聚合。
- 增加了相应的测试用例,以验证在不同树结构中 `preventParentPostCascadeQuery` 属性的功能性。

#### Which issue(s) this PR fixes:
Fixes #5663 
Fixes #4923
Fixes https://github.com/halo-dev/halo/issues/3418

#### Does this PR introduce a user-facing change?
```release-note
新增独立分类选项用于控制关联的子分类下的文章显示以提供更灵活的内容管理方式
```
pull/6113/head^2
guqing 2024-06-21 12:08:10 +08:00 committed by GitHub
parent db9e0f4ac7
commit 8bdde317e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 810 additions and 56 deletions

View File

@ -3205,6 +3205,14 @@
"schema": {
"type": "string"
}
},
{
"description": "Posts filtered by category including sub-categories.",
"in": "query",
"name": "categoryWithChildren",
"schema": {
"type": "string"
}
}
],
"responses": {
@ -13989,6 +13997,14 @@
"schema": {
"type": "string"
}
},
{
"description": "Posts filtered by category including sub-categories.",
"in": "query",
"name": "categoryWithChildren",
"schema": {
"type": "string"
}
}
],
"responses": {
@ -14971,6 +14987,9 @@
"maxLength": 255,
"type": "string"
},
"preventParentPostCascadeQuery": {
"type": "boolean"
},
"priority": {
"type": "integer",
"format": "int32",
@ -19001,12 +19020,12 @@
},
"visible": {
"type": "string",
"default": "PUBLIC",
"enum": [
"PUBLIC",
"INTERNAL",
"PRIVATE"
]
],
"default": "PUBLIC"
}
}
},
@ -20702,12 +20721,12 @@
},
"visible": {
"type": "string",
"default": "PUBLIC",
"enum": [
"PUBLIC",
"INTERNAL",
"PRIVATE"
]
],
"default": "PUBLIC"
}
}
},

View File

@ -69,6 +69,16 @@ public class Category extends AbstractExtension {
private Integer priority;
private List<String> children;
/**
* <p>if a category is queried for related posts, the default behavior is to
* query all posts under the category including its subcategories, but if this field is
* set to true, cascade query behavior will be terminated here.</p>
* <p>For example, if a category has subcategories A and B, and A has subcategories C and
* D and C marked this field as true, when querying posts under A category,all posts under A
* and B will be queried, but C and D will not be queried.</p>
*/
private boolean preventParentPostCascadeQuery;
}
@JsonIgnore

View File

@ -0,0 +1,10 @@
package run.halo.app.content;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Flux;
import run.halo.app.core.extension.content.Category;
public interface CategoryService {
Flux<Category> listChildren(@NonNull String categoryName);
}

View File

@ -57,6 +57,12 @@ public class PostQuery extends IListRequest.QueryListRequest {
return Post.PostPhase.from(publishPhase);
}
@Nullable
public String getCategoryWithChildren() {
var value = queryParams.getFirst("categoryWithChildren");
return StringUtils.defaultIfBlank(value, null);
}
@Nullable
@Schema(description = "Posts filtered by keyword.")
public String getKeyword() {
@ -140,6 +146,12 @@ public class PostQuery extends IListRequest.QueryListRequest {
.name("keyword")
.description("Posts filtered by keyword.")
.implementation(String.class)
.required(false))
.parameter(parameterBuilder()
.in(ParameterIn.QUERY)
.name("categoryWithChildren")
.description("Posts filtered by category including sub-categories.")
.implementation(String.class)
.required(false));
}
}

View File

@ -0,0 +1,34 @@
package run.halo.app.content.impl;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.CategoryService;
import run.halo.app.core.extension.content.Category;
import run.halo.app.extension.ReactiveExtensionClient;
@Component
@RequiredArgsConstructor
public class CategoryServiceImpl implements CategoryService {
private final ReactiveExtensionClient client;
@Override
public Flux<Category> listChildren(@NonNull String categoryName) {
return client.fetch(Category.class, categoryName)
.expand(category -> {
var children = category.getSpec().getChildren();
if (children == null || children.isEmpty()) {
return Mono.empty();
}
return Flux.fromIterable(children)
.flatMap(name -> client.fetch(Category.class, name))
.filter(this::isNotIndependent);
});
}
private boolean isNotIndependent(Category category) {
return !category.getSpec().isPreventParentPostCascadeQuery();
}
}

View File

@ -20,6 +20,7 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.content.AbstractContentService;
import run.halo.app.content.CategoryService;
import run.halo.app.content.ContentRequest;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.Contributor;
@ -36,6 +37,7 @@ import run.halo.app.core.extension.content.Tag;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.MetadataOperator;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
@ -57,20 +59,23 @@ public class PostServiceImpl extends AbstractContentService implements PostServi
private final ReactiveExtensionClient client;
private final CounterService counterService;
private final UserService userService;
private final CategoryService categoryService;
public PostServiceImpl(ReactiveExtensionClient client, CounterService counterService,
UserService userService) {
UserService userService, CategoryService categoryService) {
super(client);
this.client = client;
this.counterService = counterService;
this.userService = userService;
this.categoryService = categoryService;
}
@Override
public Mono<ListResult<ListedPost>> listPost(PostQuery query) {
return client.listBy(Post.class, query.toListOptions(),
return buildListOptions(query)
.flatMap(listOptions -> client.listBy(Post.class, listOptions,
PageRequestImpl.of(query.getPage(), query.getSize(), query.getSort())
)
))
.flatMap(listResult -> Flux.fromStream(listResult.get())
.map(this::getListedPost)
.concatMap(Function.identity())
@ -82,6 +87,26 @@ public class PostServiceImpl extends AbstractContentService implements PostServi
);
}
Mono<ListOptions> buildListOptions(PostQuery query) {
var categoryName = query.getCategoryWithChildren();
if (categoryName == null) {
return Mono.just(query.toListOptions());
}
return categoryService.listChildren(categoryName)
.collectList()
.map(categories -> {
var categoryNames = categories.stream()
.map(Category::getMetadata)
.map(MetadataOperator::getName)
.toList();
var listOptions = query.toListOptions();
var newFiledSelector = listOptions.getFieldSelector()
.andQuery(in("spec.categories", categoryNames));
listOptions.setFieldSelector(newFiledSelector);
return listOptions;
});
}
Mono<Stats> fetchStats(Post post) {
Assert.notNull(post, "The post must not be null.");
String name = post.getMetadata().getName();

View File

@ -17,6 +17,7 @@ import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Category;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
@ -114,7 +115,9 @@ public class CategoryFinderImpl implements CategoryFinder {
}
}
});
return listToTree(nameIdentityMap.values(), name);
var tree = listToTree(nameIdentityMap.values(), name);
recomputePostCount(tree);
return tree;
});
}
@ -139,6 +142,40 @@ public class CategoryFinderImpl implements CategoryFinder {
.collect(Collectors.toList());
}
private CategoryTreeVo dummyVirtualRoot(List<CategoryTreeVo> treeNodes) {
Category.CategorySpec categorySpec = new Category.CategorySpec();
categorySpec.setSlug("/");
return CategoryTreeVo.builder()
.spec(categorySpec)
.postCount(0)
.children(treeNodes)
.metadata(new Metadata())
.build();
}
void recomputePostCount(List<CategoryTreeVo> treeNodes) {
var rootNode = dummyVirtualRoot(treeNodes);
recomputePostCount(rootNode);
}
private int recomputePostCount(CategoryTreeVo rootNode) {
if (rootNode == null) {
return 0;
}
int originalPostCount = rootNode.getPostCount();
for (var child : rootNode.getChildren()) {
int childSum = recomputePostCount(child);
if (!child.getSpec().isPreventParentPostCascadeQuery()) {
rootNode.setPostCount(rootNode.getPostCount() + childSum);
}
}
return rootNode.getSpec().isPreventParentPostCascadeQuery() ? originalPostCount
: rootNode.getPostCount();
}
static Comparator<CategoryTreeVo> defaultTreeNodeComparator() {
Function<CategoryTreeVo, Integer> priority =
category -> Objects.requireNonNullElse(category.getSpec().getPriority(), 0);

View File

@ -2,6 +2,7 @@ package run.halo.app.theme.finders.impl;
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.in;
import java.util.Comparator;
import java.util.List;
@ -15,6 +16,8 @@ import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.CategoryService;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
@ -52,6 +55,8 @@ public class PostFinderImpl implements PostFinder {
private final ReactiveQueryPostPredicateResolver postPredicateResolver;
private final CategoryService categoryService;
@Override
public Mono<PostVo> getByName(String postName) {
return postPredicateResolver.getPredicate()
@ -141,13 +146,24 @@ public class PostFinderImpl implements PostFinder {
@Override
public Mono<ListResult<ListedPostVo>> listByCategory(Integer page, Integer size,
String categoryName) {
var fieldQuery = QueryFactory.all();
if (StringUtils.isNotBlank(categoryName)) {
fieldQuery = and(fieldQuery, equal("spec.categories", categoryName));
return listChildrenCategories(categoryName)
.map(category -> category.getMetadata().getName())
.collectList()
.flatMap(categoryNames -> {
var listOptions = new ListOptions();
var fieldQuery = in("spec.categories", categoryNames);
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return postPublicQueryService.list(listOptions, getPageRequest(page, size));
});
}
private Flux<Category> listChildrenCategories(String categoryName) {
if (StringUtils.isBlank(categoryName)) {
return client.listAll(Category.class, new ListOptions(),
Sort.by(Sort.Order.asc("metadata.creationTimeStamp"),
Sort.Order.desc("metadata.name")));
}
var listOptions = new ListOptions();
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return postPublicQueryService.list(listOptions, getPageRequest(page, size));
return categoryService.listChildren(categoryName);
}
@Override

View File

@ -53,6 +53,7 @@ public class CategoryTreeVo implements VisualizableTreeNode<CategoryTreeVo>, Ext
@Override
public String nodeText() {
return String.format("%s (%s)", getSpec().getDisplayName(), getPostCount());
return String.format("%s (%s)%s", getSpec().getDisplayName(), getPostCount(),
spec.isPreventParentPostCascadeQuery() ? " (Independent)" : "");
}
}

View File

@ -36,7 +36,7 @@ public class CategoryVo implements ExtensionVoOperator {
.metadata(category.getMetadata())
.spec(category.getSpec())
.status(category.getStatus())
.postCount(category.getStatusOrDefault().visiblePostCount)
.postCount(category.getStatusOrDefault().getVisiblePostCount())
.build();
}
}

View File

@ -6,18 +6,22 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.core.type.TypeReference;
import java.io.IOException;
import java.nio.file.Files;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.json.JSONException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.data.domain.Sort;
import org.springframework.util.ResourceUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Category;
@ -73,7 +77,8 @@ class CategoryFinderImplTest {
"children": [
"C1",
"C2"
]
],
"preventParentPostCascadeQuery": false
}
}
""",
@ -126,14 +131,97 @@ class CategoryFinderImplTest {
List<CategoryTreeVo> treeVos = categoryFinder.listAsTree().collectList().block();
String s = visualizeTree(treeVos);
assertThat(s).isEqualTo("""
(5)
FIT2CLOUD (2)
(7)
FIT2CLOUD (4)
DataEase (0)
Halo (2)
MeterSphere (0)
JumpServer (0)
(3)
""");
""");
}
@Nested
class CategoryPostCountTest {
/**
* <p>Structure below.</p>
* <pre>
* (35)
* FIT2CLOUD (15)
* DataEase (10)
* SubNode1 (4)
* Leaf1 (2)
* Leaf2 (2)
* SubNode2 (6) (independent)
* IndependentChild1 (3)
* IndependentChild2 (3)
* IndependentNode (5) (independent)
* IndependentChild3 (2)
* IndependentChild4 (3)
* AnotherRootChild (20)
* Child1 (8)
* SubChild1 (3)
* DeepNode1 (1)
* DeepNode2 (1)
* DeeperNode (1)
* SubChild2 (5)
* DeepNode3 (2) (independent)
* DeepNode4 (1)
* DeepNode5 (1)
* Child2 (12)
* IndependentSubNode (12) (independent)
* SubNode3 (6)
* SubNode4 (6)
* </pre>
*/
private List<Category> categories;
@BeforeEach
void setUp() throws IOException {
var file = ResourceUtils.getFile("classpath:categories/independent-post-count.json");
var json = Files.readString(file.toPath());
categories = JsonUtils.jsonToObject(json, new TypeReference<>() {
});
when(client.listAll(eq(Category.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(Flux.fromIterable(categories));
}
@Test
void computePostCountFromTree() {
var treeVos = categoryFinder.toCategoryTreeVoFlux("全部")
.collectList().block();
assertThat(treeVos).hasSize(1);
String s = visualizeTree(treeVos.get(0).getChildren());
assertThat(s).isEqualTo("""
(84)
AnotherRootChild (51)
Child1 (19)
SubChild1 (6)
DeepNode1 (1)
DeepNode2 (2)
DeeperNode (1)
SubChild2 (5)
DeepNode3 (4) (Independent)
DeepNode4 (1)
DeepNode5 (1)
Child2 (12)
IndependentSubNode (24) (Independent)
SubNode3 (6)
SubNode4 (6)
FIT2CLOUD (33)
DataEase (18)
SubNode1 (8)
Leaf1 (2)
Leaf2 (2)
SubNode2 (12) (Independent)
IndependentChild1 (3)
IndependentChild2 (3)
IndependentNode (10) (Independent)
IndependentChild3 (2)
IndependentChild4 (3)
""");
}
}
private List<Category> categoriesForTree() {
@ -204,7 +292,6 @@ class CategoryFinderImplTest {
return stringBuilder.toString();
}
private List<Category> categories() {
Category category2 = JsonUtils.deepCopy(category());
category2.getMetadata().setName("c2");
@ -411,4 +498,4 @@ class CategoryFinderImplTest {
return JsonUtils.jsonToObject(s, new TypeReference<>() {
});
}
}
}

View File

@ -0,0 +1,422 @@
[
{
"spec": {
"displayName": "全部",
"children": ["FIT2CLOUD", "AnotherRootChild"]
},
"status": {
"visiblePostCount": 35
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "全部",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "FIT2CLOUD",
"children": ["DataEase", "IndependentNode"]
},
"status": {
"visiblePostCount": 15
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "FIT2CLOUD",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "DataEase",
"children": ["SubNode1", "SubNode2"]
},
"status": {
"visiblePostCount": 10
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "DataEase",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "SubNode1",
"children": ["Leaf1", "Leaf2"]
},
"status": {
"visiblePostCount": 4
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "SubNode1",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "Leaf1",
"children": []
},
"status": {
"visiblePostCount": 2
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "Leaf1",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "Leaf2",
"children": []
},
"status": {
"visiblePostCount": 2
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "Leaf2",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "SubNode2",
"preventParentPostCascadeQuery": true,
"children": ["IndependentChild1", "IndependentChild2"]
},
"status": {
"visiblePostCount": 6
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "SubNode2",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "IndependentChild1",
"children": []
},
"status": {
"visiblePostCount": 3
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "IndependentChild1",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "IndependentChild2",
"children": []
},
"status": {
"visiblePostCount": 3
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "IndependentChild2",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "IndependentNode",
"preventParentPostCascadeQuery": true,
"children": ["IndependentChild3", "IndependentChild4"]
},
"status": {
"visiblePostCount": 5
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "IndependentNode",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "IndependentChild3",
"children": []
},
"status": {
"visiblePostCount": 2
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "IndependentChild3",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "IndependentChild4",
"children": []
},
"status": {
"visiblePostCount": 3
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "IndependentChild4",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "AnotherRootChild",
"children": ["Child1", "Child2"]
},
"status": {
"visiblePostCount": 20
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "AnotherRootChild",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "Child1",
"children": ["SubChild1", "SubChild2"]
},
"status": {
"visiblePostCount": 8
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "Child1",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "SubChild1",
"children": ["DeepNode1", "DeepNode2"]
},
"status": {
"visiblePostCount": 3
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "SubChild1",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "DeepNode1",
"children": []
},
"status": {
"visiblePostCount": 1
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "DeepNode1",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "DeepNode2",
"children": ["DeeperNode"]
},
"status": {
"visiblePostCount": 1
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "DeepNode2",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "DeeperNode",
"children": []
},
"status": {
"visiblePostCount": 1
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "DeeperNode",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "SubChild2",
"children": ["DeepNode3"]
},
"status": {
"visiblePostCount": 5
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "SubChild2",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "DeepNode3",
"preventParentPostCascadeQuery": true,
"children": ["DeepNode4", "DeepNode5"]
},
"status": {
"visiblePostCount": 2
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "DeepNode3",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "DeepNode4",
"children": []
},
"status": {
"visiblePostCount": 1
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "DeepNode4",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "DeepNode5",
"children": []
},
"status": {
"visiblePostCount": 1
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "DeepNode5",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "Child2",
"children": ["IndependentSubNode"]
},
"status": {
"visiblePostCount": 12
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "Child2",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "IndependentSubNode",
"preventParentPostCascadeQuery": true,
"children": ["SubNode3", "SubNode4"]
},
"status": {
"visiblePostCount": 12
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "IndependentSubNode",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "SubNode3",
"children": []
},
"status": {
"visiblePostCount": 6
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "SubNode3",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
},
{
"spec": {
"displayName": "SubNode4",
"children": []
},
"status": {
"visiblePostCount": 6
},
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {
"name": "SubNode4",
"version": 0,
"creationTimestamp": "2024-06-14T06:17:47.589181Z"
}
}
]

View File

@ -1,9 +1,12 @@
<script lang="ts" setup>
// core libs
import { computed, nextTick, onMounted, ref } from "vue";
import SubmitButton from "@/components/button/SubmitButton.vue";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { setFocus } from "@/formkit/utils/focus";
import { FormType } from "@/types/slug";
import { apiClient } from "@/utils/api-client";
// components
import useSlugify from "@console/composables/use-slugify";
import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme";
import type { Category } from "@halo-dev/api-client";
import {
IconRefreshLine,
Toast,
@ -11,20 +14,10 @@ import {
VModal,
VSpace,
} from "@halo-dev/components";
import SubmitButton from "@/components/button/SubmitButton.vue";
// types
import type { Category } from "@halo-dev/api-client";
// libs
import { setFocus } from "@/formkit/utils/focus";
import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import useSlugify from "@console/composables/use-slugify";
import { useI18n } from "vue-i18n";
import { FormType } from "@/types/slug";
import { useQueryClient } from "@tanstack/vue-query";
import { cloneDeep } from "lodash-es";
import { computed, nextTick, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
@ -54,6 +47,7 @@ const formState = ref<Category>({
postTemplate: "",
priority: 0,
children: [],
preventParentPostCascadeQuery: false,
},
status: {},
apiVersion: "content.halo.run/v1alpha1",
@ -274,6 +268,21 @@ const { handleGenerateSlug } = useSlugify(
:accepts="['image/*']"
validation="length:0,1024"
></FormKit>
<FormKit
v-model="formState.spec.preventParentPostCascadeQuery"
:label="
$t(
'core.post_category.editing_modal.fields.prevent_parent_post_cascade_query.label'
)
"
:help="
$t(
'core.post_category.editing_modal.fields.prevent_parent_post_cascade_query.help'
)
"
type="checkbox"
name="preventParentPostCascadeQuery"
></FormKit>
<FormKit
v-model="formState.spec.description"
name="description"

View File

@ -1,4 +1,8 @@
<script lang="ts" setup>
import { apiClient } from "@/utils/api-client";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import type { Category } from "@halo-dev/api-client";
import {
Dialog,
IconList,
@ -8,17 +12,14 @@ import {
VEntityField,
VStatusDot,
} from "@halo-dev/components";
import { VueDraggable } from "vue-draggable-plus";
import { type CategoryTree, convertCategoryTreeToCategory } from "../utils";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import { useQueryClient } from "@tanstack/vue-query";
import type { PropType } from "vue";
import { ref } from "vue";
import CategoryEditingModal from "./CategoryEditingModal.vue";
import type { Category } from "@halo-dev/api-client";
import { VueDraggable } from "vue-draggable-plus";
import { useI18n } from "vue-i18n";
import { apiClient } from "@/utils/api-client";
import { useQueryClient } from "@tanstack/vue-query";
import GridiconsLinkBreak from "~icons/gridicons/link-break";
import { convertCategoryTreeToCategory, type CategoryTree } from "../utils";
import CategoryEditingModal from "./CategoryEditingModal.vue";
const { currentUserHasPermission } = usePermission();
@ -135,6 +136,18 @@ const handleDelete = async (category: CategoryTree) => {
/>
</template>
</VEntityField>
<VEntityField v-if="category.spec.preventParentPostCascadeQuery">
<template #description>
<GridiconsLinkBreak
v-tooltip="
$t(
'core.post_category.list.fields.prevent_parent_post_cascade_query'
)
"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
</template>
</VEntityField>
<VEntityField
:description="
$t('core.common.fields.post_count', {

View File

@ -312,10 +312,11 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (confi
* @param {Array<string>} [sort] Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.
* @param {ListPostsPublishPhaseEnum} [publishPhase] Posts filtered by publish phase.
* @param {string} [keyword] Posts filtered by keyword.
* @param {string} [categoryWithChildren] Posts filtered by category including sub-categories.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listPosts: async (page?: number, size?: number, labelSelector?: Array<string>, fieldSelector?: Array<string>, sort?: Array<string>, publishPhase?: ListPostsPublishPhaseEnum, keyword?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
listPosts: async (page?: number, size?: number, labelSelector?: Array<string>, fieldSelector?: Array<string>, sort?: Array<string>, publishPhase?: ListPostsPublishPhaseEnum, keyword?: string, categoryWithChildren?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/apis/api.console.halo.run/v1alpha1/posts`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -364,6 +365,10 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (confi
localVarQueryParameter['keyword'] = keyword;
}
if (categoryWithChildren !== undefined) {
localVarQueryParameter['categoryWithChildren'] = categoryWithChildren;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -742,11 +747,12 @@ export const ApiConsoleHaloRunV1alpha1PostApiFp = function(configuration?: Confi
* @param {Array<string>} [sort] Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.
* @param {ListPostsPublishPhaseEnum} [publishPhase] Posts filtered by publish phase.
* @param {string} [keyword] Posts filtered by keyword.
* @param {string} [categoryWithChildren] Posts filtered by category including sub-categories.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listPosts(page?: number, size?: number, labelSelector?: Array<string>, fieldSelector?: Array<string>, sort?: Array<string>, publishPhase?: ListPostsPublishPhaseEnum, keyword?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ListedPostList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listPosts(page, size, labelSelector, fieldSelector, sort, publishPhase, keyword, options);
async listPosts(page?: number, size?: number, labelSelector?: Array<string>, fieldSelector?: Array<string>, sort?: Array<string>, publishPhase?: ListPostsPublishPhaseEnum, keyword?: string, categoryWithChildren?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ListedPostList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listPosts(page, size, labelSelector, fieldSelector, sort, publishPhase, keyword, categoryWithChildren, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1PostApi.listPosts']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -899,7 +905,7 @@ export const ApiConsoleHaloRunV1alpha1PostApiFactory = function (configuration?:
* @throws {RequiredError}
*/
listPosts(requestParameters: ApiConsoleHaloRunV1alpha1PostApiListPostsRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise<ListedPostList> {
return localVarFp.listPosts(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.publishPhase, requestParameters.keyword, options).then((request) => request(axios, basePath));
return localVarFp.listPosts(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.publishPhase, requestParameters.keyword, requestParameters.categoryWithChildren, options).then((request) => request(axios, basePath));
},
/**
* Publish a post.
@ -1110,6 +1116,13 @@ export interface ApiConsoleHaloRunV1alpha1PostApiListPostsRequest {
* @memberof ApiConsoleHaloRunV1alpha1PostApiListPosts
*/
readonly keyword?: string
/**
* Posts filtered by category including sub-categories.
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1PostApiListPosts
*/
readonly categoryWithChildren?: string
}
/**
@ -1312,7 +1325,7 @@ export class ApiConsoleHaloRunV1alpha1PostApi extends BaseAPI {
* @memberof ApiConsoleHaloRunV1alpha1PostApi
*/
public listPosts(requestParameters: ApiConsoleHaloRunV1alpha1PostApiListPostsRequest = {}, options?: RawAxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1PostApiFp(this.configuration).listPosts(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.publishPhase, requestParameters.keyword, options).then((request) => request(this.axios, this.basePath));
return ApiConsoleHaloRunV1alpha1PostApiFp(this.configuration).listPosts(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.publishPhase, requestParameters.keyword, requestParameters.categoryWithChildren, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@ -170,10 +170,11 @@ export const UcApiContentHaloRunV1alpha1PostApiAxiosParamCreator = function (con
* @param {Array<string>} [sort] Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.
* @param {ListMyPostsPublishPhaseEnum} [publishPhase] Posts filtered by publish phase.
* @param {string} [keyword] Posts filtered by keyword.
* @param {string} [categoryWithChildren] Posts filtered by category including sub-categories.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listMyPosts: async (page?: number, size?: number, labelSelector?: Array<string>, fieldSelector?: Array<string>, sort?: Array<string>, publishPhase?: ListMyPostsPublishPhaseEnum, keyword?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
listMyPosts: async (page?: number, size?: number, labelSelector?: Array<string>, fieldSelector?: Array<string>, sort?: Array<string>, publishPhase?: ListMyPostsPublishPhaseEnum, keyword?: string, categoryWithChildren?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/apis/uc.api.content.halo.run/v1alpha1/posts`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -222,6 +223,10 @@ export const UcApiContentHaloRunV1alpha1PostApiAxiosParamCreator = function (con
localVarQueryParameter['keyword'] = keyword;
}
if (categoryWithChildren !== undefined) {
localVarQueryParameter['categoryWithChildren'] = categoryWithChildren;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -461,11 +466,12 @@ export const UcApiContentHaloRunV1alpha1PostApiFp = function(configuration?: Con
* @param {Array<string>} [sort] Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.
* @param {ListMyPostsPublishPhaseEnum} [publishPhase] Posts filtered by publish phase.
* @param {string} [keyword] Posts filtered by keyword.
* @param {string} [categoryWithChildren] Posts filtered by category including sub-categories.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listMyPosts(page?: number, size?: number, labelSelector?: Array<string>, fieldSelector?: Array<string>, sort?: Array<string>, publishPhase?: ListMyPostsPublishPhaseEnum, keyword?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ListedPostList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listMyPosts(page, size, labelSelector, fieldSelector, sort, publishPhase, keyword, options);
async listMyPosts(page?: number, size?: number, labelSelector?: Array<string>, fieldSelector?: Array<string>, sort?: Array<string>, publishPhase?: ListMyPostsPublishPhaseEnum, keyword?: string, categoryWithChildren?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ListedPostList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listMyPosts(page, size, labelSelector, fieldSelector, sort, publishPhase, keyword, categoryWithChildren, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['UcApiContentHaloRunV1alpha1PostApi.listMyPosts']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -564,7 +570,7 @@ export const UcApiContentHaloRunV1alpha1PostApiFactory = function (configuration
* @throws {RequiredError}
*/
listMyPosts(requestParameters: UcApiContentHaloRunV1alpha1PostApiListMyPostsRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise<ListedPostList> {
return localVarFp.listMyPosts(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.publishPhase, requestParameters.keyword, options).then((request) => request(axios, basePath));
return localVarFp.listMyPosts(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.publishPhase, requestParameters.keyword, requestParameters.categoryWithChildren, options).then((request) => request(axios, basePath));
},
/**
* Publish my post.
@ -708,6 +714,13 @@ export interface UcApiContentHaloRunV1alpha1PostApiListMyPostsRequest {
* @memberof UcApiContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly keyword?: string
/**
* Posts filtered by category including sub-categories.
* @type {string}
* @memberof UcApiContentHaloRunV1alpha1PostApiListMyPosts
*/
readonly categoryWithChildren?: string
}
/**
@ -828,7 +841,7 @@ export class UcApiContentHaloRunV1alpha1PostApi extends BaseAPI {
* @memberof UcApiContentHaloRunV1alpha1PostApi
*/
public listMyPosts(requestParameters: UcApiContentHaloRunV1alpha1PostApiListMyPostsRequest = {}, options?: RawAxiosRequestConfig) {
return UcApiContentHaloRunV1alpha1PostApiFp(this.configuration).listMyPosts(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.publishPhase, requestParameters.keyword, options).then((request) => request(this.axios, this.basePath));
return UcApiContentHaloRunV1alpha1PostApiFp(this.configuration).listMyPosts(requestParameters.page, requestParameters.size, requestParameters.labelSelector, requestParameters.fieldSelector, requestParameters.sort, requestParameters.publishPhase, requestParameters.keyword, requestParameters.categoryWithChildren, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@ -50,6 +50,12 @@ export interface CategorySpec {
* @memberof CategorySpec
*/
'postTemplate'?: string;
/**
*
* @type {boolean}
* @memberof CategorySpec
*/
'preventParentPostCascadeQuery'?: boolean;
/**
*
* @type {number}

View File

@ -375,11 +375,21 @@ core:
description:
label: Description
help: Theme adaptation is required to support
prevent_parent_post_cascade_query:
label: Prevent Parent Post Cascade Query
help: >-
Prevent parent category from including this category and its
subcategories in cascade post queries
post_template:
label: Custom post template
help: >-
Customize the rendering template of posts in the current category,
which requires support from the theme
list:
fields:
prevent_parent_post_cascade_query: >-
Prevent parent category from including this category and its
subcategories in cascade post queries
page:
title: Pages
actions:

View File

@ -337,6 +337,11 @@ core:
description:
label: Descripción
help: Se requiere adaptación del tema para ser compatible
prevent_parent_post_cascade_query:
label: Evitar consulta en cascada de publicación principal
help: >-
Si se selecciona, las publicaciones de las subcategorías no se
agregarán a la categoría principal
page:
title: Páginas
actions:

View File

@ -373,9 +373,15 @@ core:
description:
label: 描述
help: 需要主题适配以支持
prevent_parent_post_cascade_query:
label: 阻止文章级联查询
help: 阻止父级分类在级联文章查询中包含此分类及其子分类
post_template:
label: 自定义文章模板
help: 自定义当前分类下文章的渲染模版,需要主题提供支持
list:
fields:
prevent_parent_post_cascade_query: 阻止父级分类在级联文章查询中包含此分类及其子分类
page:
title: 页面
actions:

View File

@ -353,9 +353,15 @@ core:
description:
label: 描述
help: 需要主題適配以支持
prevent_parent_post_cascade_query:
label: 防止父級聯查詢
help: 阻止父級分類在級聯文章查詢中包含此分類及其子分類
post_template:
label: 自定義文章模板
help: 自定義當前分類下文章的渲染模板,需要主題提供支持
list:
fields:
prevent_parent_post_cascade_query: 阻止父級分類在級聯文章查詢中包含此分類及其子分類
page:
title: 頁面
actions: