diff --git a/api/src/main/java/run/halo/app/core/extension/content/Issue.java b/api/src/main/java/run/halo/app/core/extension/content/Issue.java
new file mode 100644
index 000000000..16b0db9dc
--- /dev/null
+++ b/api/src/main/java/run/halo/app/core/extension/content/Issue.java
@@ -0,0 +1,106 @@
+package run.halo.app.core.extension.content;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
+import java.time.Instant;
+import java.util.Objects;
+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.GroupVersionKind;
+
+/**
+ *
Issue extension for user-reported issues/problems.
+ *
+ * @author halo-copilot
+ * @since 2.21.0
+ */
+@Data
+@ToString(callSuper = true)
+@GVK(group = Constant.GROUP, version = Constant.VERSION, kind = Issue.KIND,
+ plural = "issues", singular = "issue")
+@EqualsAndHashCode(callSuper = true)
+public class Issue extends AbstractExtension {
+
+ public static final String KIND = "Issue";
+
+ public static final GroupVersionKind GVK = GroupVersionKind.fromExtension(Issue.class);
+
+ public static final String DELETED_LABEL = "content.halo.run/deleted";
+ public static final String OWNER_LABEL = "content.halo.run/owner";
+ public static final String STATUS_LABEL = "content.halo.run/status";
+
+ @Schema(requiredMode = RequiredMode.REQUIRED)
+ private IssueSpec spec;
+
+ @Schema
+ private IssueStatus status;
+
+ public boolean isDeleted() {
+ return Objects.equals(true, spec.getDeleted())
+ || getMetadata().getDeletionTimestamp() != null;
+ }
+
+ @Data
+ public static class IssueSpec {
+ @Schema(requiredMode = RequiredMode.REQUIRED, minLength = 1)
+ private String title;
+
+ @Schema(requiredMode = RequiredMode.REQUIRED)
+ private String description;
+
+ @Schema(requiredMode = RequiredMode.REQUIRED)
+ private String owner;
+
+ @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "OPEN")
+ private String status;
+
+ @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "LOW")
+ private String priority;
+
+ @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "BUG")
+ private String type;
+
+ @Schema(requiredMode = RequiredMode.REQUIRED, defaultValue = "false")
+ private Boolean deleted;
+
+ private Instant resolvedTime;
+
+ private String assignee;
+
+ private String category;
+
+ @Data
+ public static class StatusEnum {
+ public static final String OPEN = "OPEN";
+ public static final String IN_PROGRESS = "IN_PROGRESS";
+ public static final String RESOLVED = "RESOLVED";
+ public static final String CLOSED = "CLOSED";
+ }
+
+ @Data
+ public static class PriorityEnum {
+ public static final String LOW = "LOW";
+ public static final String MEDIUM = "MEDIUM";
+ public static final String HIGH = "HIGH";
+ public static final String CRITICAL = "CRITICAL";
+ }
+
+ @Data
+ public static class TypeEnum {
+ public static final String BUG = "BUG";
+ public static final String FEATURE_REQUEST = "FEATURE_REQUEST";
+ public static final String IMPROVEMENT = "IMPROVEMENT";
+ public static final String QUESTION = "QUESTION";
+ }
+ }
+
+ @Data
+ public static class IssueStatus {
+ private String phase;
+ private String observedVersion;
+ private Instant lastModifyTime;
+ }
+}
\ No newline at end of file
diff --git a/application/src/main/java/run/halo/app/content/IssueQuery.java b/application/src/main/java/run/halo/app/content/IssueQuery.java
new file mode 100644
index 000000000..7ba9d211f
--- /dev/null
+++ b/application/src/main/java/run/halo/app/content/IssueQuery.java
@@ -0,0 +1,119 @@
+package run.halo.app.content;
+
+import java.util.function.Predicate;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.data.domain.Sort;
+import org.springframework.lang.Nullable;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import run.halo.app.core.extension.content.Issue;
+import run.halo.app.extension.ListOptions;
+import run.halo.app.extension.PageRequestImpl;
+import run.halo.app.extension.router.SortableRequest;
+import run.halo.app.extension.router.selector.FieldSelector;
+import run.halo.app.extension.router.selector.LabelSelector;
+
+/**
+ * Query parameters for issues.
+ *
+ * @author halo-copilot
+ * @since 2.21.0
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class IssueQuery extends SortableRequest {
+
+ private final String username;
+
+ public IssueQuery(ServerRequest request) {
+ this(request, null);
+ }
+
+ public IssueQuery(ServerRequest request, @Nullable String username) {
+ super(request.exchange());
+ this.username = username;
+ }
+
+ @Nullable
+ public String getStatus() {
+ return queryParams.getFirst("status");
+ }
+
+ @Nullable
+ public String getPriority() {
+ return queryParams.getFirst("priority");
+ }
+
+ @Nullable
+ public String getType() {
+ return queryParams.getFirst("type");
+ }
+
+ @Nullable
+ public String getKeyword() {
+ return queryParams.getFirst("keyword");
+ }
+
+ /**
+ * Build predicate from query.
+ */
+ public Predicate toPredicate() {
+ Predicate predicate = issue -> true;
+
+ if (username != null) {
+ predicate = predicate.and(issue -> username.equals(issue.getSpec().getOwner()));
+ }
+
+ if (getStatus() != null) {
+ predicate = predicate.and(issue -> getStatus().equals(issue.getSpec().getStatus()));
+ }
+
+ if (getPriority() != null) {
+ predicate = predicate.and(issue -> getPriority().equals(issue.getSpec().getPriority()));
+ }
+
+ if (getType() != null) {
+ predicate = predicate.and(issue -> getType().equals(issue.getSpec().getType()));
+ }
+
+ if (getKeyword() != null && !getKeyword().trim().isEmpty()) {
+ predicate = predicate.and(issue ->
+ issue.getSpec().getTitle().toLowerCase().contains(getKeyword().toLowerCase()) ||
+ issue.getSpec().getDescription().toLowerCase().contains(getKeyword().toLowerCase())
+ );
+ }
+
+ return predicate;
+ }
+
+ /**
+ * Convert to ListOptions for querying.
+ */
+ public ListOptions toListOptions() {
+ var listOptions = new ListOptions();
+
+ var labelSelectorBuilder = LabelSelector.builder();
+
+ // Always exclude deleted issues unless specified
+ labelSelectorBuilder.eq(Issue.DELETED_LABEL, "false");
+
+ if (username != null) {
+ labelSelectorBuilder.eq(Issue.OWNER_LABEL, username);
+ }
+
+ if (getStatus() != null) {
+ labelSelectorBuilder.eq(Issue.STATUS_LABEL, getStatus());
+ }
+
+ listOptions.setLabelSelector(labelSelectorBuilder.build());
+
+ return listOptions;
+ }
+
+ /**
+ * Convert to PageRequest for pagination.
+ */
+ public PageRequestImpl toPageRequest() {
+ return PageRequestImpl.of(getPage(), getSize(), getSort());
+ }
+}
\ No newline at end of file
diff --git a/application/src/main/java/run/halo/app/content/IssueService.java b/application/src/main/java/run/halo/app/content/IssueService.java
new file mode 100644
index 000000000..0fe1fb272
--- /dev/null
+++ b/application/src/main/java/run/halo/app/content/IssueService.java
@@ -0,0 +1,39 @@
+package run.halo.app.content;
+
+import reactor.core.publisher.Mono;
+import run.halo.app.core.extension.content.Issue;
+import run.halo.app.extension.ListResult;
+
+/**
+ * Service for {@link Issue}.
+ *
+ * @author halo-copilot
+ * @since 2.21.0
+ */
+public interface IssueService {
+
+ /**
+ * List issues by query.
+ */
+ Mono> listIssues(IssueQuery query);
+
+ /**
+ * Create issue.
+ */
+ Mono createIssue(Issue issue);
+
+ /**
+ * Update issue.
+ */
+ Mono updateIssue(Issue issue);
+
+ /**
+ * Get issue by name and owner.
+ */
+ Mono getByUsername(String issueName, String username);
+
+ /**
+ * Delete issue by name and owner.
+ */
+ Mono deleteByUsername(String issueName, String username);
+}
\ No newline at end of file
diff --git a/application/src/main/java/run/halo/app/content/ListedIssue.java b/application/src/main/java/run/halo/app/content/ListedIssue.java
new file mode 100644
index 000000000..9a4d576fa
--- /dev/null
+++ b/application/src/main/java/run/halo/app/content/ListedIssue.java
@@ -0,0 +1,27 @@
+package run.halo.app.content;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+import run.halo.app.core.extension.content.Issue;
+
+/**
+ * Listed issue for list result.
+ *
+ * @author halo-copilot
+ * @since 2.21.0
+ */
+@Data
+@Accessors(chain = true)
+public class ListedIssue {
+ private Issue issue;
+ private Stats stats;
+
+ /**
+ * Issue statistic.
+ */
+ @Data
+ public static class Stats {
+ private Integer commentsCount = 0;
+ private Integer upvotesCount = 0;
+ }
+}
\ No newline at end of file
diff --git a/application/src/main/java/run/halo/app/content/impl/IssueServiceImpl.java b/application/src/main/java/run/halo/app/content/impl/IssueServiceImpl.java
new file mode 100644
index 000000000..b8de1602d
--- /dev/null
+++ b/application/src/main/java/run/halo/app/content/impl/IssueServiceImpl.java
@@ -0,0 +1,125 @@
+package run.halo.app.content.impl;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
+import reactor.core.publisher.Mono;
+import run.halo.app.content.IssueQuery;
+import run.halo.app.content.IssueService;
+import run.halo.app.content.ListedIssue;
+import run.halo.app.core.extension.content.Issue;
+import run.halo.app.extension.ListOptions;
+import run.halo.app.extension.ListResult;
+import run.halo.app.extension.PageRequestImpl;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.extension.router.selector.FieldSelector;
+import run.halo.app.extension.router.selector.LabelSelector;
+import run.halo.app.infra.exception.NotFoundException;
+
+import java.time.Instant;
+import java.util.Comparator;
+import java.util.Objects;
+import java.util.function.Function;
+
+/**
+ * Issue service implementation.
+ *
+ * @author halo-copilot
+ * @since 2.21.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class IssueServiceImpl implements IssueService {
+
+ private final ReactiveExtensionClient client;
+
+ @Override
+ public Mono> listIssues(IssueQuery query) {
+ return client.listBy(Issue.class, query.toListOptions(), query.toPageRequest())
+ .flatMap(list -> Mono.fromCallable(() -> {
+ var listedIssues = list.get()
+ .map(this::toListedIssue)
+ .sorted(defaultComparator())
+ .toList();
+ return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), listedIssues);
+ }));
+ }
+
+ @Override
+ public Mono createIssue(Issue issue) {
+ Assert.notNull(issue, "Issue must not be null");
+
+ // Set default values
+ if (issue.getSpec().getStatus() == null) {
+ issue.getSpec().setStatus("OPEN");
+ }
+ if (issue.getSpec().getPriority() == null) {
+ issue.getSpec().setPriority("LOW");
+ }
+ if (issue.getSpec().getType() == null) {
+ issue.getSpec().setType("BUG");
+ }
+ if (issue.getSpec().getDeleted() == null) {
+ issue.getSpec().setDeleted(false);
+ }
+
+ // Set metadata labels
+ var labels = issue.getMetadata().getLabels();
+ if (labels != null) {
+ labels.put(Issue.DELETED_LABEL, "false");
+ labels.put(Issue.OWNER_LABEL, issue.getSpec().getOwner());
+ labels.put(Issue.STATUS_LABEL, issue.getSpec().getStatus());
+ }
+
+ return client.create(issue);
+ }
+
+ @Override
+ public Mono updateIssue(Issue issue) {
+ Assert.notNull(issue, "Issue must not be null");
+ return client.update(issue);
+ }
+
+ @Override
+ public Mono getByUsername(String issueName, String username) {
+ Assert.hasText(issueName, "Issue name must not be blank");
+ Assert.hasText(username, "Username must not be blank");
+
+ return client.get(Issue.class, issueName)
+ .filter(issue -> Objects.equals(issue.getSpec().getOwner(), username))
+ .switchIfEmpty(Mono.error(
+ () -> new NotFoundException("Issue not found or access denied")));
+ }
+
+ @Override
+ public Mono deleteByUsername(String issueName, String username) {
+ return getByUsername(issueName, username)
+ .flatMap(issue -> {
+ issue.getSpec().setDeleted(true);
+ if (issue.getMetadata().getLabels() != null) {
+ issue.getMetadata().getLabels().put(Issue.DELETED_LABEL, "true");
+ }
+ return client.update(issue);
+ });
+ }
+
+ private ListedIssue toListedIssue(Issue issue) {
+ var listedIssue = new ListedIssue()
+ .setIssue(issue);
+
+ // Set stats (placeholder for now)
+ var stats = new ListedIssue.Stats();
+ listedIssue.setStats(stats);
+
+ return listedIssue;
+ }
+
+ private Comparator defaultComparator() {
+ Function creationTime = issue ->
+ issue.getIssue().getMetadata().getCreationTimestamp();
+ return Comparator.comparing(creationTime).reversed();
+ }
+}
\ No newline at end of file
diff --git a/application/src/main/java/run/halo/app/core/endpoint/uc/UcIssueEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/uc/UcIssueEndpoint.java
new file mode 100644
index 000000000..561ce4135
--- /dev/null
+++ b/application/src/main/java/run/halo/app/core/endpoint/uc/UcIssueEndpoint.java
@@ -0,0 +1,195 @@
+package run.halo.app.core.endpoint.uc;
+
+import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
+import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
+import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
+import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route;
+import static org.springframework.web.reactive.function.server.RequestPredicates.path;
+
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.server.RouterFunction;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import org.springframework.web.reactive.function.server.ServerResponse;
+import reactor.core.publisher.Mono;
+import run.halo.app.content.IssueQuery;
+import run.halo.app.content.IssueService;
+import run.halo.app.content.ListedIssue;
+import run.halo.app.core.extension.content.Issue;
+import run.halo.app.core.extension.endpoint.CustomEndpoint;
+import run.halo.app.extension.GroupVersion;
+import run.halo.app.extension.ListResult;
+import run.halo.app.infra.exception.NotFoundException;
+
+/**
+ * User center endpoint for issues.
+ *
+ * @author halo-copilot
+ * @since 2.21.0
+ */
+@Component
+public class UcIssueEndpoint implements CustomEndpoint {
+
+ private final IssueService issueService;
+
+ public UcIssueEndpoint(IssueService issueService) {
+ this.issueService = issueService;
+ }
+
+ @Override
+ public RouterFunction endpoint() {
+ var tag = "IssueV1alpha1Uc";
+ var namePathParam = parameterBuilder().name("name")
+ .description("Issue name")
+ .in(ParameterIn.PATH)
+ .required(true);
+
+ return route().nest(
+ path("/issues"),
+ () -> route()
+ .GET(this::listMyIssues, builder -> {
+ builder.operationId("ListMyIssues")
+ .description("List issues owned by the current user.")
+ .tag(tag)
+ .response(responseBuilder().implementation(
+ ListResult.generateGenericClass(ListedIssue.class)));
+ buildParameters(builder);
+ }
+ )
+ .POST(this::createMyIssue, builder -> builder.operationId("CreateMyIssue")
+ .tag(tag)
+ .description("Create my issue.")
+ .requestBody(requestBodyBuilder().implementation(Issue.class))
+ .response(responseBuilder().implementation(Issue.class))
+ )
+ .GET("/{name}", this::getMyIssue, builder -> builder.operationId("GetMyIssue")
+ .tag(tag)
+ .parameter(namePathParam)
+ .description("Get issue that belongs to the current user.")
+ .response(responseBuilder().implementation(Issue.class))
+ )
+ .PUT("/{name}", this::updateMyIssue, builder ->
+ builder.operationId("UpdateMyIssue")
+ .tag(tag)
+ .parameter(namePathParam)
+ .description("Update my issue.")
+ .requestBody(requestBodyBuilder().implementation(Issue.class))
+ .response(responseBuilder().implementation(Issue.class))
+ )
+ .DELETE("/{name}", this::deleteMyIssue, builder -> builder.tag(tag)
+ .operationId("DeleteMyIssue")
+ .description("Delete my issue.")
+ .parameter(namePathParam)
+ .response(responseBuilder().implementation(Issue.class))
+ )
+ .build(),
+ builder -> {
+ })
+ .build();
+ }
+
+ private Mono listMyIssues(ServerRequest request) {
+ return getCurrentUser()
+ .map(username -> new IssueQuery(request, username))
+ .flatMap(issueService::listIssues)
+ .flatMap(issues -> ServerResponse.ok().bodyValue(issues));
+ }
+
+ private Mono createMyIssue(ServerRequest request) {
+ return getCurrentUser()
+ .flatMap(username -> request.bodyToMono(Issue.class)
+ .doOnNext(issue -> issue.getSpec().setOwner(username))
+ .flatMap(issueService::createIssue)
+ )
+ .flatMap(issue -> ServerResponse.ok().bodyValue(issue));
+ }
+
+ private Mono getMyIssue(ServerRequest request) {
+ var issueName = request.pathVariable("name");
+ return getCurrentUser()
+ .flatMap(username -> issueService.getByUsername(issueName, username))
+ .flatMap(issue -> ServerResponse.ok().bodyValue(issue));
+ }
+
+ private Mono updateMyIssue(ServerRequest request) {
+ var issueName = request.pathVariable("name");
+ return getCurrentUser()
+ .flatMap(username -> issueService.getByUsername(issueName, username)
+ .flatMap(existing -> request.bodyToMono(Issue.class)
+ .doOnNext(updated -> {
+ updated.getMetadata().setName(existing.getMetadata().getName());
+ updated.getMetadata().setVersion(existing.getMetadata().getVersion());
+ updated.getSpec().setOwner(username);
+ })
+ .flatMap(issueService::updateIssue)
+ )
+ )
+ .flatMap(issue -> ServerResponse.ok().bodyValue(issue));
+ }
+
+ private Mono deleteMyIssue(ServerRequest request) {
+ var issueName = request.pathVariable("name");
+ return getCurrentUser()
+ .flatMap(username -> issueService.deleteByUsername(issueName, username))
+ .flatMap(issue -> ServerResponse.ok().bodyValue(issue));
+ }
+
+ private Mono getCurrentUser() {
+ return ReactiveSecurityContextHolder.getContext()
+ .map(SecurityContext::getAuthentication)
+ .map(Authentication::getName);
+ }
+
+ @Override
+ public GroupVersion groupVersion() {
+ return GroupVersion.parseAPIVersion("uc.api.content.halo.run/v1alpha1");
+ }
+
+ private void buildParameters(org.springdoc.core.fn.builders.operation.Builder builder) {
+ builder.parameter(parameterBuilder()
+ .name("page")
+ .in(ParameterIn.QUERY)
+ .required(false)
+ .implementation(Integer.class)
+ .description("The page number. Default is 0.")
+ )
+ .parameter(parameterBuilder()
+ .name("size")
+ .in(ParameterIn.QUERY)
+ .required(false)
+ .implementation(Integer.class)
+ .description("Size of each page. Default is 20.")
+ )
+ .parameter(parameterBuilder()
+ .name("status")
+ .in(ParameterIn.QUERY)
+ .required(false)
+ .implementation(String.class)
+ .description("Filter by status. e.g. OPEN, IN_PROGRESS, RESOLVED, CLOSED")
+ )
+ .parameter(parameterBuilder()
+ .name("priority")
+ .in(ParameterIn.QUERY)
+ .required(false)
+ .implementation(String.class)
+ .description("Filter by priority. e.g. LOW, MEDIUM, HIGH, CRITICAL")
+ )
+ .parameter(parameterBuilder()
+ .name("type")
+ .in(ParameterIn.QUERY)
+ .required(false)
+ .implementation(String.class)
+ .description("Filter by type. e.g. BUG, FEATURE_REQUEST, IMPROVEMENT, QUESTION")
+ )
+ .parameter(parameterBuilder()
+ .name("keyword")
+ .in(ParameterIn.QUERY)
+ .required(false)
+ .implementation(String.class)
+ .description("Filter by keyword in title or description")
+ );
+ }
+}
\ No newline at end of file
diff --git a/ui/src/locales/en.yaml b/ui/src/locales/en.yaml
index b2dcf8490..fe0c1a89c 100644
--- a/ui/src/locales/en.yaml
+++ b/ui/src/locales/en.yaml
@@ -42,6 +42,7 @@ core:
profile: Profile
notification: Notifications
posts: Posts
+ issues: Issues
operations:
console:
tooltip: Console
@@ -2066,6 +2067,34 @@ core:
setting_modal:
title: Post settings
title: My posts
+ uc_issue:
+ title: My Issues
+ issue:
+ empty:
+ title: No issues found
+ message: You haven't created any issues yet, or try other filter conditions.
+ filters:
+ status:
+ items:
+ open: Open
+ in_progress: In Progress
+ resolved: Resolved
+ closed: Closed
+ status:
+ open: Open
+ in_progress: In Progress
+ resolved: Resolved
+ closed: Closed
+ priority:
+ low: Low
+ medium: Medium
+ high: High
+ critical: Critical
+ type:
+ bug: Bug
+ feature_request: Feature Request
+ improvement: Improvement
+ question: Question
tool:
title: Tools
empty:
diff --git a/ui/uc-src/modules/contents/issues/IssueList.vue b/ui/uc-src/modules/contents/issues/IssueList.vue
new file mode 100644
index 000000000..f52604d9f
--- /dev/null
+++ b/ui/uc-src/modules/contents/issues/IssueList.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("core.common.buttons.refresh") }}
+
+
+
+
+
+ {{ $t("core.common.buttons.new") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/uc-src/modules/contents/issues/components/IssueListItem.vue b/ui/uc-src/modules/contents/issues/components/IssueListItem.vue
new file mode 100644
index 000000000..1e01222e8
--- /dev/null
+++ b/ui/uc-src/modules/contents/issues/components/IssueListItem.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+ {{ $t(`core.issue.status.${issue.issue.spec.status.toLowerCase()}`) }}
+
+
+ {{ $t(`core.issue.priority.${issue.issue.spec.priority.toLowerCase()}`) }}
+
+
+ {{ $t(`core.issue.type.${issue.issue.spec.type.toLowerCase()}`) }}
+
+
+
+
+
+
+
+
+
+ {{ formatDatetime(issue.issue.metadata.creationTimestamp) }}
+ •
+ {{ relativeTimeTo(issue.issue.metadata.creationTimestamp) }}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/uc-src/modules/contents/issues/module.ts b/ui/uc-src/modules/contents/issues/module.ts
new file mode 100644
index 000000000..043fe9855
--- /dev/null
+++ b/ui/uc-src/modules/contents/issues/module.ts
@@ -0,0 +1,34 @@
+import { IconBookRead } from "@halo-dev/components";
+import { definePlugin } from "@halo-dev/console-shared";
+import BasicLayout from "@uc/layouts/BasicLayout.vue";
+import { markRaw } from "vue";
+import IssueList from "./IssueList.vue";
+
+export default definePlugin({
+ ucRoutes: [
+ {
+ path: "/issues",
+ name: "IssuesRoot",
+ component: BasicLayout,
+ meta: {
+ title: "core.uc_issue.title",
+ searchable: true,
+ permissions: ["uc:issues:manage"],
+ menu: {
+ name: "core.uc_sidebar.menu.items.issues",
+ group: "content",
+ icon: markRaw(IconBookRead),
+ priority: 1,
+ mobile: true,
+ },
+ },
+ children: [
+ {
+ path: "",
+ name: "Issues",
+ component: IssueList,
+ },
+ ],
+ },
+ ],
+});
\ No newline at end of file