Add Issue extension and UC endpoint for listing user issues

copilot/fix-b3acabcb-6bd9-4ef0-ba34-6a865280bd64
copilot-swe-agent[bot] 2025-09-02 07:26:08 +00:00
parent 6497ceb4cf
commit 39ff2f1ed8
10 changed files with 998 additions and 0 deletions

View File

@ -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;
/**
* <p>Issue extension for user-reported issues/problems.</p>
*
* @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;
}
}

View File

@ -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<Issue> toPredicate() {
Predicate<Issue> 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());
}
}

View File

@ -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<ListResult<ListedIssue>> listIssues(IssueQuery query);
/**
* Create issue.
*/
Mono<Issue> createIssue(Issue issue);
/**
* Update issue.
*/
Mono<Issue> updateIssue(Issue issue);
/**
* Get issue by name and owner.
*/
Mono<Issue> getByUsername(String issueName, String username);
/**
* Delete issue by name and owner.
*/
Mono<Issue> deleteByUsername(String issueName, String username);
}

View File

@ -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;
}
}

View File

@ -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<ListResult<ListedIssue>> 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<Issue> 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<Issue> updateIssue(Issue issue) {
Assert.notNull(issue, "Issue must not be null");
return client.update(issue);
}
@Override
public Mono<Issue> 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<Issue> 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<ListedIssue> defaultComparator() {
Function<ListedIssue, Instant> creationTime = issue ->
issue.getIssue().getMetadata().getCreationTimestamp();
return Comparator.comparing(creationTime).reversed();
}
}

View File

@ -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<ServerResponse> 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<ServerResponse> listMyIssues(ServerRequest request) {
return getCurrentUser()
.map(username -> new IssueQuery(request, username))
.flatMap(issueService::listIssues)
.flatMap(issues -> ServerResponse.ok().bodyValue(issues));
}
private Mono<ServerResponse> 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<ServerResponse> getMyIssue(ServerRequest request) {
var issueName = request.pathVariable("name");
return getCurrentUser()
.flatMap(username -> issueService.getByUsername(issueName, username))
.flatMap(issue -> ServerResponse.ok().bodyValue(issue));
}
private Mono<ServerResponse> 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<ServerResponse> deleteMyIssue(ServerRequest request) {
var issueName = request.pathVariable("name");
return getCurrentUser()
.flatMap(username -> issueService.deleteByUsername(issueName, username))
.flatMap(issue -> ServerResponse.ok().bodyValue(issue));
}
private Mono<String> 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")
);
}
}

View File

@ -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:

View File

@ -0,0 +1,200 @@
<script lang="ts" setup>
import {
IconAddCircle,
IconBookRead,
IconRefreshLine,
VButton,
VCard,
VEmpty,
VEntityContainer,
VLoading,
VPageHeader,
VPagination,
VSpace,
SearchInput,
FilterCleanButton,
FilterDropdown,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { computed, watch } from "vue";
import IssueListItem from "./components/IssueListItem.vue";
const page = useRouteQuery<number>("page", 1, {
transform: Number,
});
const size = useRouteQuery<number>("size", 20, {
transform: Number,
});
const keyword = useRouteQuery<string>("keyword", "");
const selectedStatus = useRouteQuery<
"OPEN" | "IN_PROGRESS" | "RESOLVED" | "CLOSED" | undefined
>("status");
function handleClearFilters() {
selectedStatus.value = undefined;
}
const hasFilters = computed(() => {
return selectedStatus.value !== undefined;
});
watch(
() => [selectedStatus.value, keyword.value],
() => {
page.value = 1;
}
);
const {
data: issues,
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: ["my-issues", page, size, keyword, selectedStatus],
queryFn: async () => {
// For now, we'll use a mock API call until the real API client is generated
// This would eventually be: ucApiClient.content.issue.listMyIssues({...})
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API delay
// Mock response structure
return {
page: page.value,
size: size.value,
total: 0,
items: [],
first: true,
last: true,
hasNext: false,
hasPrevious: false,
totalPages: 0
};
},
onSuccess(data) {
page.value = data.page;
size.value = data.size;
},
refetchInterval: false,
});
</script>
<template>
<VPageHeader :title="$t('core.uc_issue.title')">
<template #icon>
<IconBookRead />
</template>
<template #actions>
<VButton type="secondary">
<template #icon>
<IconAddCircle />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col flex-wrap items-start justify-between gap-4 sm:flex-row sm:items-center"
>
<SearchInput v-model="keyword" />
<VSpace spacing="lg" class="flex-wrap">
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
<FilterDropdown
v-model="selectedStatus"
:label="$t('core.common.filters.labels.status')"
:items="[
{
label: $t('core.common.filters.item_labels.all'),
value: undefined,
},
{
label: $t('core.issue.filters.status.items.open'),
value: 'OPEN',
},
{
label: $t('core.issue.filters.status.items.in_progress'),
value: 'IN_PROGRESS',
},
{
label: $t('core.issue.filters.status.items.resolved'),
value: 'RESOLVED',
},
{
label: $t('core.issue.filters.status.items.closed'),
value: 'CLOSED',
},
]"
/>
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="refetch()"
>
<IconRefreshLine
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</VSpace>
</div>
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!issues?.items.length" appear name="fade">
<VEmpty
:message="$t('core.issue.empty.message')"
:title="$t('core.issue.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton type="secondary">
<template #icon>
<IconAddCircle />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<VEntityContainer>
<IssueListItem
v-for="issue in issues.items"
:key="issue.issue.metadata.name"
:issue="issue"
/>
</VEntityContainer>
</Transition>
<template #footer>
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', {
total: issues?.total || 0,
})
"
:total="issues?.total || 0"
:size-options="[20, 30, 50, 100]"
/>
</template>
</VCard>
</div>
</template>

View File

@ -0,0 +1,124 @@
<script lang="ts" setup>
import { formatDatetime, relativeTimeTo } from "@/utils/date";
import {
VEntity,
VEntityField,
VSpace,
VTag,
} from "@halo-dev/components";
import { computed } from "vue";
interface Issue {
metadata: {
name: string;
creationTimestamp: string;
};
spec: {
title: string;
description: string;
status: string;
priority: string;
type: string;
};
}
interface ListedIssue {
issue: Issue;
stats: {
commentsCount: number;
upvotesCount: number;
};
}
const props = withDefaults(
defineProps<{
issue: ListedIssue;
}>(),
{}
);
const statusColor = computed(() => {
switch (props.issue.issue.spec.status) {
case "OPEN":
return "green";
case "IN_PROGRESS":
return "yellow";
case "RESOLVED":
return "blue";
case "CLOSED":
return "gray";
default:
return "gray";
}
});
const priorityColor = computed(() => {
switch (props.issue.issue.spec.priority) {
case "CRITICAL":
return "red";
case "HIGH":
return "orange";
case "MEDIUM":
return "yellow";
case "LOW":
return "green";
default:
return "gray";
}
});
const typeColor = computed(() => {
switch (props.issue.issue.spec.type) {
case "BUG":
return "red";
case "FEATURE_REQUEST":
return "blue";
case "IMPROVEMENT":
return "green";
case "QUESTION":
return "purple";
default:
return "gray";
}
});
</script>
<template>
<VEntity>
<template #start>
<VEntityField
:title="issue.issue.spec.title"
:description="issue.issue.spec.description"
:route="{
name: 'IssueDetail',
params: { name: issue.issue.metadata.name },
}"
>
<template #extra>
<VSpace class="mt-1 sm:mt-0">
<VTag :color="statusColor" variant="outline" size="sm">
{{ $t(`core.issue.status.${issue.issue.spec.status.toLowerCase()}`) }}
</VTag>
<VTag :color="priorityColor" variant="outline" size="sm">
{{ $t(`core.issue.priority.${issue.issue.spec.priority.toLowerCase()}`) }}
</VTag>
<VTag :color="typeColor" variant="outline" size="sm">
{{ $t(`core.issue.type.${issue.issue.spec.type.toLowerCase()}`) }}
</VTag>
</VSpace>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField>
<template #description>
<div class="flex items-center gap-2 text-xs text-gray-500">
<span>{{ formatDatetime(issue.issue.metadata.creationTimestamp) }}</span>
<span></span>
<span>{{ relativeTimeTo(issue.issue.metadata.creationTimestamp) }}</span>
</div>
</template>
</VEntityField>
</template>
</VEntity>
</template>

View File

@ -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,
},
],
},
],
});