fix: improve kanban board filtering (#919)
- Render only the selected status column when status chips are active - Add status color treatments and default assignee normalization - Reuse profile avatars for Kanban card assignee tags - Cover status filtering, default assignee labels, and avatar rendering
This commit is contained in:
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
|
|||||||
import { NModal, NForm, NFormItem, NInput, NSelect, NButton, useMessage } from 'naive-ui'
|
import { NModal, NForm, NFormItem, NInput, NSelect, NButton, useMessage } from 'naive-ui'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useKanbanStore } from '@/stores/hermes/kanban'
|
import { useKanbanStore } from '@/stores/hermes/kanban'
|
||||||
|
import { withDefaultAssignee } from '@/utils/hermes/kanban-assignees'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
@@ -26,10 +27,8 @@ const priorityOptions = computed(() => [
|
|||||||
])
|
])
|
||||||
|
|
||||||
const assigneeOptions = computed(() => {
|
const assigneeOptions = computed(() => {
|
||||||
return kanbanStore.assignees.map(a => {
|
return withDefaultAssignee(kanbanStore.assignees, kanbanStore.stats?.by_assignee || {})
|
||||||
const total = Object.values(a.counts || {}).reduce((s, c) => s + c, 0)
|
.map(a => ({ label: a.name, value: a.name }))
|
||||||
return { label: `${a.name} · ${t('kanban.stats.tasks')}: ${total}`, value: a.name }
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { NTooltip } from 'naive-ui'
|
import { NTooltip } from 'naive-ui'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
|
||||||
import type { KanbanTask } from '@/api/hermes/kanban'
|
import type { KanbanTask } from '@/api/hermes/kanban'
|
||||||
|
import type { ProfileAvatar as ProfileAvatarData } from '@/api/hermes/profiles'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
task: KanbanTask
|
task: KanbanTask
|
||||||
|
assigneeAvatar?: ProfileAvatarData | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -34,12 +37,21 @@ const priorityText = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="kanban-task-card" @click="emit('click', task.id)">
|
<div class="kanban-task-card" :class="`status-${task.status}`" @click="emit('click', task.id)">
|
||||||
<div class="card-title">{{ task.title }}</div>
|
<div class="card-title">{{ task.title }}</div>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
<NTooltip v-if="task.assignee" trigger="hover">
|
<NTooltip v-if="task.assignee" trigger="hover">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<span class="meta-tag assignee-tag">{{ task.assignee }}</span>
|
<span class="meta-tag assignee-tag">
|
||||||
|
<ProfileAvatar
|
||||||
|
class="assignee-profile-avatar"
|
||||||
|
:name="task.assignee"
|
||||||
|
:avatar="assigneeAvatar"
|
||||||
|
:size="18"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{{ task.assignee }}</span>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
{{ t('kanban.card.assigneeTooltip') }}
|
{{ t('kanban.card.assigneeTooltip') }}
|
||||||
</NTooltip>
|
</NTooltip>
|
||||||
@@ -54,15 +66,25 @@ const priorityText = computed(() => {
|
|||||||
@use '@/styles/variables' as *;
|
@use '@/styles/variables' as *;
|
||||||
|
|
||||||
.kanban-task-card {
|
.kanban-task-card {
|
||||||
|
--kanban-card-status-color: #64748b;
|
||||||
background-color: $bg-card;
|
background-color: $bg-card;
|
||||||
border: 1px solid $border-color;
|
border: 1px solid $border-color;
|
||||||
|
border-left: 3px solid var(--kanban-card-status-color);
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color $transition-fast, box-shadow $transition-fast;
|
transition: border-color $transition-fast, box-shadow $transition-fast;
|
||||||
|
|
||||||
|
&.status-triage { --kanban-card-status-color: #94a3b8; }
|
||||||
|
&.status-todo { --kanban-card-status-color: #38bdf8; }
|
||||||
|
&.status-ready { --kanban-card-status-color: #f59e0b; }
|
||||||
|
&.status-running { --kanban-card-status-color: #8b5cf6; }
|
||||||
|
&.status-blocked { --kanban-card-status-color: #ef4444; }
|
||||||
|
&.status-done { --kanban-card-status-color: #22c55e; }
|
||||||
|
&.status-archived { --kanban-card-status-color: #64748b; }
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: rgba(var(--accent-primary-rgb), 0.3);
|
border-color: var(--kanban-card-status-color);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,8 +113,16 @@ const priorityText = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assignee-tag {
|
.assignee-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||||
color: $accent-primary;
|
color: $accent-primary;
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignee-profile-avatar {
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--accent-primary-rgb), 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.priority-tag {
|
.priority-tag {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { request } from '@/api/client'
|
import { request } from '@/api/client'
|
||||||
import { getTask } from '@/api/hermes/kanban'
|
import { getTask } from '@/api/hermes/kanban'
|
||||||
import { useKanbanStore } from '@/stores/hermes/kanban'
|
import { useKanbanStore } from '@/stores/hermes/kanban'
|
||||||
|
import { withDefaultAssignee } from '@/utils/hermes/kanban-assignees'
|
||||||
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
|
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
|
||||||
import type { Session, Message } from '@/stores/hermes/chat'
|
import type { Session, Message } from '@/stores/hermes/chat'
|
||||||
import type { KanbanTaskDetail } from '@/api/hermes/kanban'
|
import type { KanbanTaskDetail } from '@/api/hermes/kanban'
|
||||||
@@ -106,10 +107,8 @@ const historySession = computed<Session | null>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const assigneeOptions = computed(() => {
|
const assigneeOptions = computed(() => {
|
||||||
return kanbanStore.assignees.map(a => {
|
return withDefaultAssignee(kanbanStore.assignees, kanbanStore.stats?.by_assignee || {})
|
||||||
const total = Object.values(a.counts || {}).reduce((s, c) => s + c, 0)
|
.map(a => ({ label: a.name, value: a.name }))
|
||||||
return { label: `${a.name} · ${t('kanban.stats.tasks')}: ${total}`, value: a.name }
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => [props.taskId, kanbanStore.selectedBoard] as const, async ([id, board]) => {
|
watch(() => [props.taskId, kanbanStore.selectedBoard] as const, async ([id, board]) => {
|
||||||
@@ -482,19 +481,14 @@ async function handleAssign() {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&.running {
|
&.triage {
|
||||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
background: rgba(148, 163, 184, 0.14);
|
||||||
color: $accent-primary;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.done {
|
&.todo {
|
||||||
background: rgba(var(--success-rgb), 0.12);
|
background: rgba(56, 189, 248, 0.14);
|
||||||
color: $success;
|
color: #38bdf8;
|
||||||
}
|
|
||||||
|
|
||||||
&.blocked {
|
|
||||||
background: rgba(var(--error-rgb), 0.12);
|
|
||||||
color: $error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.ready {
|
&.ready {
|
||||||
@@ -502,9 +496,24 @@ async function handleAssign() {
|
|||||||
color: $warning;
|
color: $warning;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.triage, &.archived {
|
&.running {
|
||||||
background: rgba(128, 128, 128, 0.12);
|
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||||
color: $text-muted;
|
color: $accent-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.blocked {
|
||||||
|
background: rgba(var(--error-rgb), 0.12);
|
||||||
|
color: $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.done {
|
||||||
|
background: rgba(var(--success-rgb), 0.12);
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.archived {
|
||||||
|
background: rgba(100, 116, 139, 0.14);
|
||||||
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const DEFAULT_KANBAN_ASSIGNEE = 'default'
|
||||||
|
|
||||||
|
export interface KanbanAssigneeSummary {
|
||||||
|
name: string
|
||||||
|
counts?: Record<string, number> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assigneeTaskTotal(assignee: KanbanAssigneeSummary): number {
|
||||||
|
return Object.values(assignee.counts || {}).reduce((sum, count) => sum + count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withDefaultAssignee<T extends KanbanAssigneeSummary>(
|
||||||
|
assignees: T[],
|
||||||
|
byAssignee: Record<string, number> = {},
|
||||||
|
): KanbanAssigneeSummary[] {
|
||||||
|
const defaultCount = byAssignee[DEFAULT_KANBAN_ASSIGNEE] || 0
|
||||||
|
const hasDefault = assignees.some(assignee => assignee.name === DEFAULT_KANBAN_ASSIGNEE)
|
||||||
|
const normalized = assignees.map(assignee => {
|
||||||
|
if (assignee.name !== DEFAULT_KANBAN_ASSIGNEE || assignee.counts) return assignee
|
||||||
|
return { ...assignee, counts: { total: defaultCount } }
|
||||||
|
})
|
||||||
|
if (hasDefault) return normalized
|
||||||
|
return [
|
||||||
|
{ name: DEFAULT_KANBAN_ASSIGNEE, counts: { total: defaultCount } },
|
||||||
|
...normalized,
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,13 +7,17 @@ import KanbanTaskCard from '@/components/hermes/kanban/KanbanTaskCard.vue'
|
|||||||
import KanbanTaskDrawer from '@/components/hermes/kanban/KanbanTaskDrawer.vue'
|
import KanbanTaskDrawer from '@/components/hermes/kanban/KanbanTaskDrawer.vue'
|
||||||
import KanbanCreateForm from '@/components/hermes/kanban/KanbanCreateForm.vue'
|
import KanbanCreateForm from '@/components/hermes/kanban/KanbanCreateForm.vue'
|
||||||
import { DEFAULT_KANBAN_BOARD, useKanbanStore } from '@/stores/hermes/kanban'
|
import { DEFAULT_KANBAN_BOARD, useKanbanStore } from '@/stores/hermes/kanban'
|
||||||
|
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||||
|
import { withDefaultAssignee } from '@/utils/hermes/kanban-assignees'
|
||||||
import type { KanbanTaskStatus } from '@/api/hermes/kanban'
|
import type { KanbanTaskStatus } from '@/api/hermes/kanban'
|
||||||
|
import type { ProfileAvatar } from '@/api/hermes/profiles'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const kanbanStore = useKanbanStore()
|
const kanbanStore = useKanbanStore()
|
||||||
|
const profilesStore = useProfilesStore()
|
||||||
|
|
||||||
const showCreateForm = ref(false)
|
const showCreateForm = ref(false)
|
||||||
const showCreateBoardForm = ref(false)
|
const showCreateBoardForm = ref(false)
|
||||||
@@ -25,6 +29,7 @@ const refreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
|||||||
const routeReady = ref(false)
|
const routeReady = ref(false)
|
||||||
|
|
||||||
const boardStatuses: KanbanTaskStatus[] = ['triage', 'todo', 'ready', 'running', 'blocked', 'done', 'archived']
|
const boardStatuses: KanbanTaskStatus[] = ['triage', 'todo', 'ready', 'running', 'blocked', 'done', 'archived']
|
||||||
|
const expandedStatusNames = ref<string[]>([...boardStatuses])
|
||||||
|
|
||||||
function firstQueryString(value: unknown): string | null {
|
function firstQueryString(value: unknown): string | null {
|
||||||
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : null
|
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : null
|
||||||
@@ -82,6 +87,17 @@ const tasksByStatus = computed(() => {
|
|||||||
return grouped
|
return grouped
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const visibleBoardStatuses = computed(() => {
|
||||||
|
const status = kanbanStore.filterStatus as KanbanTaskStatus | null
|
||||||
|
return status && boardStatuses.includes(status) ? [status] : boardStatuses
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleAssignees = computed(() => withDefaultAssignee(kanbanStore.assignees, kanbanStore.stats?.by_assignee || {}))
|
||||||
|
|
||||||
|
const profileAvatarByName = computed<Record<string, ProfileAvatar | null>>(() => {
|
||||||
|
return Object.fromEntries(profilesStore.profiles.map(profile => [profile.name, profile.avatar || null]))
|
||||||
|
})
|
||||||
|
|
||||||
const statusFilterOptions = computed(() => [
|
const statusFilterOptions = computed(() => [
|
||||||
{ label: t('kanban.allStatuses'), value: '' },
|
{ label: t('kanban.allStatuses'), value: '' },
|
||||||
...boardStatuses.map(s => ({ label: t(`kanban.columns.${s}`, s), value: s })),
|
...boardStatuses.map(s => ({ label: t(`kanban.columns.${s}`, s), value: s })),
|
||||||
@@ -89,10 +105,7 @@ const statusFilterOptions = computed(() => [
|
|||||||
|
|
||||||
const assigneeFilterOptions = computed(() => [
|
const assigneeFilterOptions = computed(() => [
|
||||||
{ label: t('kanban.allAssignees'), value: '' },
|
{ label: t('kanban.allAssignees'), value: '' },
|
||||||
...kanbanStore.assignees.map(a => {
|
...visibleAssignees.value.map(a => ({ label: a.name, value: a.name })),
|
||||||
const total = Object.values(a.counts || {}).reduce((s, c) => s + c, 0)
|
|
||||||
return { label: `${t('kanban.detail.assignee')}: ${a.name} · ${taskCountLabel(total)}`, value: a.name }
|
|
||||||
}),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const filterStatusValue = computed({
|
const filterStatusValue = computed({
|
||||||
@@ -110,8 +123,16 @@ watch(() => route.query.board, async () => {
|
|||||||
await applyBoardSelection(routeBoard(), false)
|
await applyBoardSelection(routeBoard(), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(visibleBoardStatuses, statuses => {
|
||||||
|
expandedStatusNames.value = [...statuses]
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([kanbanStore.fetchBoards(), kanbanStore.fetchCapabilities()])
|
await Promise.all([
|
||||||
|
kanbanStore.fetchBoards(),
|
||||||
|
kanbanStore.fetchCapabilities(),
|
||||||
|
profilesStore.profiles.length === 0 ? profilesStore.fetchProfiles() : Promise.resolve(),
|
||||||
|
])
|
||||||
await applyBoardSelection(routeBoard(), true, true)
|
await applyBoardSelection(routeBoard(), true, true)
|
||||||
kanbanStore.startEventStream()
|
kanbanStore.startEventStream()
|
||||||
routeReady.value = true
|
routeReady.value = true
|
||||||
@@ -143,6 +164,12 @@ async function handleApplyFilter() {
|
|||||||
await kanbanStore.fetchTasks()
|
await kanbanStore.fetchTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleStatusChipClick(status: KanbanTaskStatus | null) {
|
||||||
|
kanbanStore.setFilter('status', status)
|
||||||
|
expandedStatusNames.value = status ? [status] : [...boardStatuses]
|
||||||
|
await kanbanStore.fetchTasks()
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTaskCreated() {
|
async function handleTaskCreated() {
|
||||||
await Promise.all([kanbanStore.fetchTasks(), kanbanStore.fetchStats(), kanbanStore.fetchBoards()])
|
await Promise.all([kanbanStore.fetchTasks(), kanbanStore.fetchStats(), kanbanStore.fetchBoards()])
|
||||||
}
|
}
|
||||||
@@ -236,31 +263,53 @@ async function handleArchiveSelectedBoard() {
|
|||||||
|
|
||||||
<!-- Stats bar -->
|
<!-- Stats bar -->
|
||||||
<div v-if="kanbanStore.stats" class="stats-bar">
|
<div v-if="kanbanStore.stats" class="stats-bar">
|
||||||
<div v-for="status in boardStatuses" :key="status" class="stat-chip" :class="status">
|
<button
|
||||||
|
v-for="status in boardStatuses"
|
||||||
|
:key="status"
|
||||||
|
type="button"
|
||||||
|
class="stat-chip"
|
||||||
|
:class="[status, { active: kanbanStore.filterStatus === status }]"
|
||||||
|
:aria-pressed="kanbanStore.filterStatus === status"
|
||||||
|
@click="handleStatusChipClick(status)"
|
||||||
|
>
|
||||||
<span class="stat-count">{{ kanbanStore.stats.by_status[status] || 0 }}</span>
|
<span class="stat-count">{{ kanbanStore.stats.by_status[status] || 0 }}</span>
|
||||||
<span class="stat-label">{{ t(`kanban.columns.${status}`, status) }}</span>
|
<span class="stat-label">{{ t(`kanban.columns.${status}`, status) }}</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="stat-chip total">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stat-chip total"
|
||||||
|
:class="{ active: !kanbanStore.filterStatus }"
|
||||||
|
:aria-pressed="!kanbanStore.filterStatus"
|
||||||
|
@click="handleStatusChipClick(null)"
|
||||||
|
>
|
||||||
<span class="stat-count">{{ kanbanStore.stats.total }}</span>
|
<span class="stat-count">{{ kanbanStore.stats.total }}</span>
|
||||||
<span class="stat-label">{{ t('kanban.stats.total') }}</span>
|
<span class="stat-label">{{ t('kanban.stats.total') }}</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Board -->
|
<!-- Board -->
|
||||||
<NSpin :show="kanbanStore.loading && kanbanStore.tasks.length === 0">
|
<NSpin :show="kanbanStore.loading && kanbanStore.tasks.length === 0">
|
||||||
<div class="kanban-board">
|
<div class="kanban-board">
|
||||||
<NCollapse>
|
<NCollapse v-model:expanded-names="expandedStatusNames">
|
||||||
<NCollapseItem
|
<NCollapseItem
|
||||||
v-for="status in boardStatuses"
|
v-for="status in visibleBoardStatuses"
|
||||||
:key="status"
|
:key="status"
|
||||||
:title="`${t(`kanban.columns.${status}`, status)} (${tasksByStatus[status].length})`"
|
:title="`${t(`kanban.columns.${status}`, status)} (${tasksByStatus[status].length})`"
|
||||||
:name="status"
|
:name="status"
|
||||||
|
:class="['kanban-column', `status-${status}`]"
|
||||||
>
|
>
|
||||||
<div class="task-list">
|
<template #header>
|
||||||
|
<span class="column-header" :class="`status-${status}`">
|
||||||
|
<span class="status-dot" aria-hidden="true" />
|
||||||
|
<span>{{ t(`kanban.columns.${status}`, status) }} ({{ tasksByStatus[status].length }})</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<div class="task-list" :class="`status-${status}`">
|
||||||
<KanbanTaskCard
|
<KanbanTaskCard
|
||||||
v-for="task in tasksByStatus[status]"
|
v-for="task in tasksByStatus[status]"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
:task="task"
|
:task="task"
|
||||||
|
:assignee-avatar="task.assignee ? profileAvatarByName[task.assignee] || null : null"
|
||||||
@click="handleTaskClick(task.id)"
|
@click="handleTaskClick(task.id)"
|
||||||
/>
|
/>
|
||||||
<div v-if="tasksByStatus[status].length === 0" class="column-empty">
|
<div v-if="tasksByStatus[status].length === 0" class="column-empty">
|
||||||
@@ -332,6 +381,28 @@ async function handleArchiveSelectedBoard() {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-chip,
|
||||||
|
.column-header,
|
||||||
|
.task-list {
|
||||||
|
--kanban-status-color: #64748b;
|
||||||
|
|
||||||
|
&.triage,
|
||||||
|
&.status-triage { --kanban-status-color: #94a3b8; }
|
||||||
|
&.todo,
|
||||||
|
&.status-todo { --kanban-status-color: #38bdf8; }
|
||||||
|
&.ready,
|
||||||
|
&.status-ready { --kanban-status-color: #f59e0b; }
|
||||||
|
&.running,
|
||||||
|
&.status-running { --kanban-status-color: #8b5cf6; }
|
||||||
|
&.blocked,
|
||||||
|
&.status-blocked { --kanban-status-color: #ef4444; }
|
||||||
|
&.done,
|
||||||
|
&.status-done { --kanban-status-color: #22c55e; }
|
||||||
|
&.archived,
|
||||||
|
&.status-archived { --kanban-status-color: #64748b; }
|
||||||
|
&.total { --kanban-status-color: #e2e8f0; }
|
||||||
|
}
|
||||||
|
|
||||||
.stat-chip {
|
.stat-chip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -340,13 +411,28 @@ async function handleArchiveSelectedBoard() {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border: 1px solid $border-light;
|
border: 1px solid $border-light;
|
||||||
|
border-left: 3px solid var(--kanban-status-color);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
|
||||||
&.triage, &.todo, &.ready { border-left: 3px solid $text-muted; }
|
&:hover,
|
||||||
&.running { border-left: 3px solid $accent-primary; }
|
&.active {
|
||||||
&.blocked { border-left: 3px solid $error; }
|
border-color: var(--kanban-status-color);
|
||||||
&.done { border-left: 3px solid $success; }
|
background-color: rgba(var(--accent-primary-rgb), 0.08);
|
||||||
&.archived { border-left: 3px solid $border-color; }
|
}
|
||||||
&.total { border-left: 3px solid $text-primary; }
|
|
||||||
|
&.active .stat-label,
|
||||||
|
&.active .stat-count {
|
||||||
|
color: var(--kanban-status-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--kanban-status-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-count {
|
.stat-count {
|
||||||
@@ -365,10 +451,29 @@ async function handleArchiveSelectedBoard() {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.column-header {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--kanban-status-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--kanban-status-color);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--kanban-status-color) 18%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.task-list {
|
.task-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
border-left: 2px solid var(--kanban-status-color);
|
||||||
|
padding-left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-empty {
|
.column-empty {
|
||||||
|
|||||||
@@ -86,4 +86,12 @@ describe('KanbanCreateForm', () => {
|
|||||||
expect(wrapper.emitted('created')).toBeTruthy()
|
expect(wrapper.emitted('created')).toBeTruthy()
|
||||||
expect(wrapper.emitted('close')).toBeTruthy()
|
expect(wrapper.emitted('close')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses compact profile names for assignee options', () => {
|
||||||
|
const wrapper = mount(KanbanCreateForm)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('default')
|
||||||
|
expect(wrapper.text()).toContain('alice')
|
||||||
|
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ vi.mock('naive-ui', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/hermes/profiles/ProfileAvatar.vue', () => ({
|
||||||
|
default: defineComponent({
|
||||||
|
name: 'ProfileAvatar',
|
||||||
|
props: { name: { type: String, required: true }, avatar: { type: Object, required: false }, size: { type: Number, required: false } },
|
||||||
|
template: '<span class="assignee-profile-avatar-stub" :data-name="name" :data-avatar-type="avatar?.type || null" :data-avatar-seed="avatar?.seed || null"></span>',
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
import KanbanTaskCard from '@/components/hermes/kanban/KanbanTaskCard.vue'
|
import KanbanTaskCard from '@/components/hermes/kanban/KanbanTaskCard.vue'
|
||||||
|
|
||||||
describe('KanbanTaskCard i18n', () => {
|
describe('KanbanTaskCard i18n', () => {
|
||||||
@@ -39,6 +47,7 @@ describe('KanbanTaskCard i18n', () => {
|
|||||||
|
|
||||||
const wrapper = mount(KanbanTaskCard, {
|
const wrapper = mount(KanbanTaskCard, {
|
||||||
props: {
|
props: {
|
||||||
|
assigneeAvatar: { type: 'generated', seed: 'alice-seed' },
|
||||||
task: {
|
task: {
|
||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
title: 'Ship kanban i18n',
|
title: 'Ship kanban i18n',
|
||||||
@@ -62,5 +71,10 @@ describe('KanbanTaskCard i18n', () => {
|
|||||||
expect(wrapper.text()).toContain('高')
|
expect(wrapper.text()).toContain('高')
|
||||||
expect(wrapper.text()).toContain('2分钟前')
|
expect(wrapper.text()).toContain('2分钟前')
|
||||||
expect(wrapper.text()).toContain('负责人')
|
expect(wrapper.text()).toContain('负责人')
|
||||||
|
expect(wrapper.classes()).toContain('status-todo')
|
||||||
|
const avatar = wrapper.find('.assignee-profile-avatar-stub')
|
||||||
|
expect(avatar.attributes('data-name')).toBe('alice')
|
||||||
|
expect(avatar.attributes('data-avatar-type')).toBe('generated')
|
||||||
|
expect(avatar.attributes('data-avatar-seed')).toBe('alice-seed')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const routeState = vi.hoisted(() => ({
|
|||||||
const routerReplace = vi.hoisted(() => vi.fn())
|
const routerReplace = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
const storeState = vi.hoisted(() => ({
|
const storeState = vi.hoisted(() => ({
|
||||||
tasks: [] as Array<{ id: string; title: string; status: string; created_at: number }>,
|
tasks: [] as Array<{ id: string; title: string; status: string; created_at: number; assignee?: string | null }>,
|
||||||
stats: { by_status: { todo: 1, done: 0 }, by_assignee: {}, total: 1 } as Record<string, any>,
|
stats: { by_status: { todo: 1, done: 0 }, by_assignee: {}, total: 1 } as Record<string, any>,
|
||||||
assignees: [] as Array<{ name: string; counts: Record<string, number> | null }>,
|
assignees: [] as Array<{ name: string; counts: Record<string, number> | null }>,
|
||||||
activeBoards: [] as Array<{ slug: string; name: string; icon?: string; total?: number }>,
|
activeBoards: [] as Array<{ slug: string; name: string; icon?: string; total?: number }>,
|
||||||
@@ -34,6 +34,10 @@ const mockCreateBoard = vi.hoisted(() => vi.fn())
|
|||||||
const mockArchiveSelectedBoard = vi.hoisted(() => vi.fn())
|
const mockArchiveSelectedBoard = vi.hoisted(() => vi.fn())
|
||||||
const mockStartEventStream = vi.hoisted(() => vi.fn())
|
const mockStartEventStream = vi.hoisted(() => vi.fn())
|
||||||
const mockStopEventStream = vi.hoisted(() => vi.fn())
|
const mockStopEventStream = vi.hoisted(() => vi.fn())
|
||||||
|
const mockFetchProfiles = vi.hoisted(() => vi.fn())
|
||||||
|
const profilesState = vi.hoisted(() => ({
|
||||||
|
profiles: [] as Array<{ name: string; avatar?: Record<string, any> | null }>,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: () => routeState,
|
useRoute: () => routeState,
|
||||||
@@ -64,11 +68,18 @@ vi.mock('@/stores/hermes/kanban', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/hermes/profiles', () => ({
|
||||||
|
useProfilesStore: () => ({
|
||||||
|
profiles: profilesState.profiles,
|
||||||
|
fetchProfiles: mockFetchProfiles,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/hermes/kanban/KanbanTaskCard.vue', () => ({
|
vi.mock('@/components/hermes/kanban/KanbanTaskCard.vue', () => ({
|
||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
name: 'KanbanTaskCard',
|
name: 'KanbanTaskCard',
|
||||||
props: { task: { type: Object, required: true } },
|
props: { task: { type: Object, required: true }, assigneeAvatar: { type: Object, required: false } },
|
||||||
template: '<div class="kanban-task-card-stub">{{ task.title }}</div>',
|
template: '<div class="kanban-task-card-stub" :data-avatar-seed="assigneeAvatar?.seed || null">{{ task.title }}</div>',
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -119,13 +130,14 @@ vi.mock('naive-ui', () => ({
|
|||||||
}),
|
}),
|
||||||
NCollapse: defineComponent({
|
NCollapse: defineComponent({
|
||||||
name: 'NCollapse',
|
name: 'NCollapse',
|
||||||
props: { defaultExpandedNames: { type: Array, required: false } },
|
props: { expandedNames: { type: Array, required: false }, defaultExpandedNames: { type: Array, required: false } },
|
||||||
template: '<div class="n-collapse-stub" :data-default-expanded="JSON.stringify(defaultExpandedNames ?? null)"><slot /></div>',
|
emits: ['update:expandedNames'],
|
||||||
|
template: '<div class="n-collapse-stub" :data-expanded="JSON.stringify(expandedNames ?? null)" :data-default-expanded="JSON.stringify(defaultExpandedNames ?? null)"><slot /></div>',
|
||||||
}),
|
}),
|
||||||
NCollapseItem: defineComponent({
|
NCollapseItem: defineComponent({
|
||||||
name: 'NCollapseItem',
|
name: 'NCollapseItem',
|
||||||
props: { title: { type: String, required: false }, name: { type: String, required: false } },
|
props: { title: { type: String, required: false }, name: { type: String, required: false } },
|
||||||
template: '<section class="n-collapse-item-stub"><slot /></section>',
|
template: '<section class="n-collapse-item-stub" :data-name="name"><slot /></section>',
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -158,11 +170,13 @@ describe('KanbanView', () => {
|
|||||||
storeState.capabilities = null
|
storeState.capabilities = null
|
||||||
storeState.filterStatus = null
|
storeState.filterStatus = null
|
||||||
storeState.filterAssignee = null
|
storeState.filterAssignee = null
|
||||||
|
profilesState.profiles = []
|
||||||
mockFetchBoards.mockResolvedValue(undefined)
|
mockFetchBoards.mockResolvedValue(undefined)
|
||||||
mockFetchCapabilities.mockResolvedValue(undefined)
|
mockFetchCapabilities.mockResolvedValue(undefined)
|
||||||
mockRefreshAll.mockResolvedValue(undefined)
|
mockRefreshAll.mockResolvedValue(undefined)
|
||||||
mockFetchTasks.mockResolvedValue(undefined)
|
mockFetchTasks.mockResolvedValue(undefined)
|
||||||
mockFetchStats.mockResolvedValue(undefined)
|
mockFetchStats.mockResolvedValue(undefined)
|
||||||
|
mockFetchProfiles.mockResolvedValue(undefined)
|
||||||
mockCreateBoard.mockResolvedValue({ slug: 'new-board' })
|
mockCreateBoard.mockResolvedValue({ slug: 'new-board' })
|
||||||
mockArchiveSelectedBoard.mockResolvedValue(undefined)
|
mockArchiveSelectedBoard.mockResolvedValue(undefined)
|
||||||
mockRecoverSelectedBoard.mockImplementation((candidate: string) => {
|
mockRecoverSelectedBoard.mockImplementation((candidate: string) => {
|
||||||
@@ -185,10 +199,11 @@ describe('KanbanView', () => {
|
|||||||
|
|
||||||
expect(mockFetchBoards).toHaveBeenCalledOnce()
|
expect(mockFetchBoards).toHaveBeenCalledOnce()
|
||||||
expect(mockFetchCapabilities).toHaveBeenCalledOnce()
|
expect(mockFetchCapabilities).toHaveBeenCalledOnce()
|
||||||
|
expect(mockFetchProfiles).toHaveBeenCalledOnce()
|
||||||
expect(mockRecoverSelectedBoard).toHaveBeenCalledWith('project-a')
|
expect(mockRecoverSelectedBoard).toHaveBeenCalledWith('project-a')
|
||||||
expect(mockRefreshAll).toHaveBeenCalledOnce()
|
expect(mockRefreshAll).toHaveBeenCalledOnce()
|
||||||
expect(routerReplace).not.toHaveBeenCalled()
|
expect(routerReplace).not.toHaveBeenCalled()
|
||||||
expect(wrapper.find('.n-collapse-stub').attributes('data-default-expanded')).toBe('null')
|
expect(wrapper.find('.n-collapse-stub').attributes('data-expanded')).toBe('["triage","todo","ready","running","blocked","done","archived"]')
|
||||||
|
|
||||||
await wrapper.find('.drawer-updated').trigger('click')
|
await wrapper.find('.drawer-updated').trigger('click')
|
||||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1)
|
expect(mockFetchTasks).toHaveBeenCalledTimes(1)
|
||||||
@@ -202,14 +217,53 @@ describe('KanbanView', () => {
|
|||||||
expect(mockFetchStats).toHaveBeenCalledTimes(2)
|
expect(mockFetchStats).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders board and assignee count labels with explicit context', async () => {
|
it('renders board count labels and compact assignee profile labels', async () => {
|
||||||
storeState.assignees = [{ name: 'alice', counts: { todo: 2, done: 1 } }]
|
storeState.assignees = [{ name: 'alice', counts: { todo: 2, done: 1 } }]
|
||||||
const wrapper = mount(KanbanView)
|
const wrapper = mount(KanbanView)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('kanban.title: Default · kanban.stats.tasks: 0')
|
expect(wrapper.text()).toContain('kanban.title: Default · kanban.stats.tasks: 0')
|
||||||
expect(wrapper.text()).toContain('kanban.title: Project A · kanban.stats.tasks: 2')
|
expect(wrapper.text()).toContain('kanban.title: Project A · kanban.stats.tasks: 2')
|
||||||
expect(wrapper.text()).toContain('kanban.detail.assignee: alice · kanban.stats.tasks: 3')
|
expect(wrapper.text()).toContain('default')
|
||||||
|
expect(wrapper.text()).toContain('alice')
|
||||||
|
expect(wrapper.text()).not.toContain('kanban.detail.assignee: alice')
|
||||||
|
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes matching profile avatars to task cards', async () => {
|
||||||
|
storeState.tasks = [{ id: 'task-1', title: 'Task one', status: 'todo', created_at: 10, assignee: 'alice' }]
|
||||||
|
profilesState.profiles = [{ name: 'alice', avatar: { type: 'generated', seed: 'alice-seed' } }]
|
||||||
|
|
||||||
|
const wrapper = mount(KanbanView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.kanban-task-card-stub').attributes('data-avatar-seed')).toBe('alice-seed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters the visible board columns from stats chips', async () => {
|
||||||
|
storeState.filterStatus = 'done'
|
||||||
|
|
||||||
|
const wrapper = mount(KanbanView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const columns = wrapper.findAll('.n-collapse-item-stub')
|
||||||
|
expect(wrapper.find('.n-collapse-stub').attributes('data-expanded')).toBe('["done"]')
|
||||||
|
expect(columns).toHaveLength(1)
|
||||||
|
expect(columns[0].attributes('data-name')).toBe('done')
|
||||||
|
expect(wrapper.text()).toContain('Task two')
|
||||||
|
expect(wrapper.text()).not.toContain('Task one')
|
||||||
|
|
||||||
|
await wrapper.find('.stat-chip.todo').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mockSetFilter).toHaveBeenCalledWith('status', 'todo')
|
||||||
|
expect(mockFetchTasks).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await wrapper.find('.stat-chip.total').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mockSetFilter).toHaveBeenCalledWith('status', null)
|
||||||
|
expect(mockFetchTasks).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates and archives boards from the board toolbar', async () => {
|
it('creates and archives boards from the board toolbar', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user