diff --git a/packages/client/src/api/hermes/kanban.ts b/packages/client/src/api/hermes/kanban.ts index a5f3ab3f..169121d2 100644 --- a/packages/client/src/api/hermes/kanban.ts +++ b/packages/client/src/api/hermes/kanban.ts @@ -98,6 +98,35 @@ export interface KanbanAssignee { counts: Record | null } +export interface KanbanBoard { + slug: string + name: string + description: string + icon: string + color: string + created_at: number | null + archived: boolean + db_path?: string + is_current?: boolean + counts: Record + total: number +} + +export interface KanbanBoardCreateRequest { + slug: string + name?: string + description?: string + icon?: string + color?: string + switchCurrent?: boolean +} + +export interface KanbanCapabilities { + source: 'hermes-cli' + supports: Record + missing: string[] +} + export interface KanbanCreateRequest { title: string body?: string @@ -106,68 +135,115 @@ export interface KanbanCreateRequest { tenant?: string } -// ─── API functions ─────────────────────────────────────────────── +export interface KanbanBoardOptions { + board?: string +} -export async function listTasks(opts?: { +export interface KanbanListOptions extends KanbanBoardOptions { status?: string assignee?: string tenant?: string -}): Promise { +} + +function normalizedBoard(board?: string): string { + const trimmed = board?.trim() + return trimmed || 'default' +} + +function appendQuery(path: string, params: URLSearchParams): string { + const qs = params.toString() + return qs ? `${path}?${qs}` : path +} + +function boardParams(board?: string): URLSearchParams { const params = new URLSearchParams() + params.set('board', normalizedBoard(board)) + return params +} + +// ─── API functions ─────────────────────────────────────────────── + +export async function listBoards(opts?: { includeArchived?: boolean }): Promise { + const params = new URLSearchParams() + if (opts?.includeArchived) params.set('includeArchived', 'true') + const res = await request<{ boards: KanbanBoard[] }>(appendQuery('/api/hermes/kanban/boards', params)) + return res.boards +} + +export async function createBoard(data: KanbanBoardCreateRequest): Promise { + const res = await request<{ board: KanbanBoard }>('/api/hermes/kanban/boards', { + method: 'POST', + body: JSON.stringify(data), + }) + return res.board +} + +export async function archiveBoard(slug: string): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(`/api/hermes/kanban/boards/${encodeURIComponent(slug)}`, { + method: 'DELETE', + }) +} + +export async function getCapabilities(): Promise { + const res = await request<{ capabilities: KanbanCapabilities }>('/api/hermes/kanban/capabilities') + return res.capabilities +} + +export async function listTasks(opts?: KanbanListOptions): Promise { + const params = boardParams(opts?.board) if (opts?.status) params.set('status', opts.status) if (opts?.assignee) params.set('assignee', opts.assignee) if (opts?.tenant) params.set('tenant', opts.tenant) - const qs = params.toString() - const res = await request<{ tasks: KanbanTask[] }>(`/api/hermes/kanban${qs ? `?${qs}` : ''}`) + const res = await request<{ tasks: KanbanTask[] }>(appendQuery('/api/hermes/kanban', params)) return res.tasks } -export async function getTask(id: string): Promise { - return request(`/api/hermes/kanban/${id}`) +export async function getTask(id: string, opts?: KanbanBoardOptions): Promise { + return request(appendQuery(`/api/hermes/kanban/${encodeURIComponent(id)}`, boardParams(opts?.board))) } -export async function createTask(data: KanbanCreateRequest): Promise { - const res = await request<{ task: KanbanTask }>('/api/hermes/kanban', { +export async function createTask(data: KanbanCreateRequest, opts?: KanbanBoardOptions): Promise { + const res = await request<{ task: KanbanTask }>(appendQuery('/api/hermes/kanban', boardParams(opts?.board)), { method: 'POST', body: JSON.stringify(data), }) return res.task } -export async function completeTasks(taskIds: string[], summary?: string): Promise<{ ok: boolean }> { - return request<{ ok: boolean }>('/api/hermes/kanban/complete', { +export async function completeTasks(taskIds: string[], summary?: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(appendQuery('/api/hermes/kanban/complete', boardParams(opts?.board)), { method: 'POST', body: JSON.stringify({ task_ids: taskIds, summary }), }) } -export async function blockTask(taskId: string, reason: string): Promise<{ ok: boolean }> { - return request<{ ok: boolean }>(`/api/hermes/kanban/${taskId}/block`, { +export async function blockTask(taskId: string, reason: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/block`, boardParams(opts?.board)), { method: 'POST', body: JSON.stringify({ reason }), }) } -export async function unblockTasks(taskIds: string[]): Promise<{ ok: boolean }> { - return request<{ ok: boolean }>('/api/hermes/kanban/unblock', { +export async function unblockTasks(taskIds: string[], opts?: KanbanBoardOptions): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(appendQuery('/api/hermes/kanban/unblock', boardParams(opts?.board)), { method: 'POST', body: JSON.stringify({ task_ids: taskIds }), }) } -export async function assignTask(taskId: string, profile: string): Promise<{ ok: boolean }> { - return request<{ ok: boolean }>(`/api/hermes/kanban/${taskId}/assign`, { +export async function assignTask(taskId: string, profile: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/assign`, boardParams(opts?.board)), { method: 'POST', body: JSON.stringify({ profile }), }) } -export async function getStats(): Promise { - const res = await request<{ stats: KanbanStats }>('/api/hermes/kanban/stats') +export async function getStats(opts?: KanbanBoardOptions): Promise { + const res = await request<{ stats: KanbanStats }>(appendQuery('/api/hermes/kanban/stats', boardParams(opts?.board))) return res.stats } -export async function getAssignees(): Promise { - const res = await request<{ assignees: KanbanAssignee[] }>('/api/hermes/kanban/assignees') +export async function getAssignees(opts?: KanbanBoardOptions): Promise { + const res = await request<{ assignees: KanbanAssignee[] }>(appendQuery('/api/hermes/kanban/assignees', boardParams(opts?.board))) return res.assignees } diff --git a/packages/client/src/components/hermes/kanban/KanbanCreateForm.vue b/packages/client/src/components/hermes/kanban/KanbanCreateForm.vue index c573d6c5..bfbe154d 100644 --- a/packages/client/src/components/hermes/kanban/KanbanCreateForm.vue +++ b/packages/client/src/components/hermes/kanban/KanbanCreateForm.vue @@ -28,7 +28,7 @@ const priorityOptions = computed(() => [ const assigneeOptions = computed(() => { return kanbanStore.assignees.map(a => { const total = Object.values(a.counts || {}).reduce((s, c) => s + c, 0) - return { label: `${a.name} (${total})`, value: a.name } + return { label: `${a.name} · ${t('kanban.stats.tasks')}: ${total}`, value: a.name } }) }) diff --git a/packages/client/src/components/hermes/kanban/KanbanTaskDrawer.vue b/packages/client/src/components/hermes/kanban/KanbanTaskDrawer.vue index 9c93559a..855b34a4 100644 --- a/packages/client/src/components/hermes/kanban/KanbanTaskDrawer.vue +++ b/packages/client/src/components/hermes/kanban/KanbanTaskDrawer.vue @@ -61,7 +61,7 @@ async function searchTaskSessions() { sessionLoading.value = true try { const res = await request<{ results: any[] }>( - `/api/hermes/kanban/search-sessions?task_id=${encodeURIComponent(detail.value.task.id)}&profile=${encodeURIComponent(profile)}` + `/api/hermes/kanban/search-sessions?task_id=${encodeURIComponent(detail.value.task.id)}&profile=${encodeURIComponent(profile)}&board=${encodeURIComponent(kanbanStore.selectedBoard)}` ) sessionResults.value = res.results } catch { @@ -103,22 +103,29 @@ const historySession = computed(() => { const assigneeOptions = computed(() => { return kanbanStore.assignees.map(a => { const total = Object.values(a.counts || {}).reduce((s, c) => s + c, 0) - return { label: `${a.name} (${total})`, value: a.name } + return { label: `${a.name} · ${t('kanban.stats.tasks')}: ${total}`, value: a.name } }) }) -watch(() => props.taskId, async (id) => { +watch(() => [props.taskId, kanbanStore.selectedBoard] as const, async ([id, board]) => { if (!id) { detail.value = null return } loading.value = true try { - detail.value = await getTask(id) + const nextDetail = await getTask(id, { board }) + if (props.taskId === id && kanbanStore.selectedBoard === board) { + detail.value = nextDetail + } } catch (err: any) { - message.error(t('kanban.message.loadFailed')) + if (props.taskId === id && kanbanStore.selectedBoard === board) { + message.error(t('kanban.message.loadFailed')) + } } finally { - loading.value = false + if (props.taskId === id && kanbanStore.selectedBoard === board) { + loading.value = false + } } }, { immediate: true }) @@ -178,7 +185,7 @@ async function handleAssign() { message.success(t('kanban.message.taskAssigned')) assignProfile.value = null if (detail.value) { - detail.value = await getTask(props.taskId) + detail.value = await getTask(props.taskId, { board: kanbanStore.selectedBoard }) } emit('updated') } catch (err: any) { diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index 3ef8f98e..582399b9 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -226,6 +226,16 @@ export default { noTasks: 'No tasks', allStatuses: 'All Statuses', allAssignees: 'All Assignees', + board: { + create: 'New Board', + archive: 'Archive Board', + slugPlaceholder: 'Board slug, e.g. project-a', + namePlaceholder: 'Display name (optional)', + slugRequired: 'Board slug is required', + created: 'Board created', + archived: 'Board archived', + archiveConfirm: 'Archive the current board?', + }, columns: { triage: 'Triage', todo: 'To Do', @@ -299,6 +309,7 @@ export default { }, stats: { total: 'Total', + tasks: 'Tasks', }, }, diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index cd851636..502d1ec4 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -226,6 +226,16 @@ export default { noTasks: '暂无任务', allStatuses: '全部状态', allAssignees: '全部负责人', + board: { + create: '新建看板', + archive: '归档看板', + slugPlaceholder: '看板标识,例如 project-a', + namePlaceholder: '显示名称(可选)', + slugRequired: '看板标识不能为空', + created: '看板已创建', + archived: '看板已归档', + archiveConfirm: '确定归档当前看板?', + }, columns: { triage: '待分拣', todo: '待办', @@ -299,6 +309,7 @@ export default { }, stats: { total: '总计', + tasks: '任务数', }, }, diff --git a/packages/client/src/stores/hermes/kanban.ts b/packages/client/src/stores/hermes/kanban.ts index a449c236..ee731c35 100644 --- a/packages/client/src/stores/hermes/kanban.ts +++ b/packages/client/src/stores/hermes/kanban.ts @@ -1,83 +1,259 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { computed, ref } from 'vue' import * as kanbanApi from '@/api/hermes/kanban' -import type { KanbanTask, KanbanStats, KanbanAssignee } from '@/api/hermes/kanban' +import type { KanbanTask, KanbanStats, KanbanAssignee, KanbanBoard, KanbanCapabilities } from '@/api/hermes/kanban' + +export const KANBAN_SELECTED_BOARD_STORAGE_KEY = 'hermes.kanban.selectedBoard' +export const DEFAULT_KANBAN_BOARD = 'default' + +const BOARD_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/ + +function safeStorageGet(key: string): string | null { + if (typeof window === 'undefined') return null + try { + return window.localStorage.getItem(key) + } catch { + return null + } +} + +function safeStorageSet(key: string, value: string) { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(key, value) + } catch { + // Ignore storage failures; selectedBoard still remains explicit in-memory. + } +} + +export function normalizeBoardSlug(board?: string | null): string { + const trimmed = board?.trim() + if (!trimmed) return DEFAULT_KANBAN_BOARD + return BOARD_SLUG_RE.test(trimmed) ? trimmed : DEFAULT_KANBAN_BOARD +} export const useKanbanStore = defineStore('kanban', () => { const tasks = ref([]) const stats = ref(null) const assignees = ref([]) + const boards = ref([]) + const capabilities = ref(null) const loading = ref(false) + const boardsLoading = ref(false) + const boardWarning = ref(null) + + const selectedBoard = ref(normalizeBoardSlug(safeStorageGet(KANBAN_SELECTED_BOARD_STORAGE_KEY))) const filterStatus = ref(null) const filterAssignee = ref(null) + let boardGeneration = 0 + let boardsRequestSeq = 0 + let tasksRequestSeq = 0 + let statsRequestSeq = 0 + let assigneesRequestSeq = 0 + let loadingRequestSeq = 0 + + const activeBoards = computed(() => { + const visible = boards.value.filter(board => !board.archived) + if (!visible.some(board => board.slug === DEFAULT_KANBAN_BOARD)) { + return [{ + slug: DEFAULT_KANBAN_BOARD, + name: 'Default', + description: '', + icon: '', + color: '', + created_at: null, + archived: false, + counts: {}, + total: 0, + }, ...visible] + } + return visible + }) + + function boardExists(board: string): boolean { + return activeBoards.value.some(item => item.slug === board) + } + + function resolveAvailableBoard(candidate?: string | null): string { + const normalized = normalizeBoardSlug(candidate) + if (boards.value.length > 0 && !boardExists(normalized)) return DEFAULT_KANBAN_BOARD + return normalized + } + + function clearBoardScopedState() { + tasks.value = [] + stats.value = null + assignees.value = [] + } + + function setSelectedBoard(board?: string | null): string { + const resolved = resolveAvailableBoard(board) + const changed = selectedBoard.value !== resolved + selectedBoard.value = resolved + safeStorageSet(KANBAN_SELECTED_BOARD_STORAGE_KEY, resolved) + boardWarning.value = null + if (changed) { + clearBoardScopedState() + boardGeneration++ + } + return resolved + } + + function recoverSelectedBoard(candidate?: string | null): { board: string; recovered: boolean } { + const normalized = normalizeBoardSlug(candidate) + const resolved = resolveAvailableBoard(normalized) + const recovered = resolved !== normalized + setSelectedBoard(resolved) + if (recovered) { + boardWarning.value = `Board "${normalized}" is unavailable; fell back to "${resolved}".` + } + return { board: resolved, recovered } + } + + function nextRequestContext(nextSeq: () => number) { + const seq = nextSeq() + const generation = boardGeneration + const board = selectedBoard.value + return { seq, generation, board } + } + + function isCurrentRequest(seq: number, generation: number, board: string, currentSeq: number): boolean { + return seq === currentSeq && generation === boardGeneration && board === selectedBoard.value + } + + async function fetchBoards(includeArchived = false) { + const seq = ++boardsRequestSeq + boardsLoading.value = true + try { + const nextBoards = await kanbanApi.listBoards({ includeArchived }) + if (seq !== boardsRequestSeq) return + boards.value = nextBoards + const resolved = resolveAvailableBoard(selectedBoard.value) + if (resolved !== selectedBoard.value) recoverSelectedBoard(selectedBoard.value) + } catch (err) { + if (seq === boardsRequestSeq) console.error('Failed to fetch kanban boards:', err) + } finally { + if (seq === boardsRequestSeq) boardsLoading.value = false + } + } + + async function fetchCapabilities() { + try { + capabilities.value = await kanbanApi.getCapabilities() + } catch (err) { + console.error('Failed to fetch kanban capabilities:', err) + } + } + + async function createBoard(data: { slug: string; name?: string; description?: string; icon?: string; color?: string; switchCurrent?: boolean }) { + const board = await kanbanApi.createBoard(data) + await fetchBoards() + setSelectedBoard(board.slug) + await refreshAll() + return board + } + + async function archiveSelectedBoard() { + const board = selectedBoard.value + if (board === DEFAULT_KANBAN_BOARD) throw new Error('Cannot archive the default kanban board') + await kanbanApi.archiveBoard(board) + await fetchBoards() + setSelectedBoard(DEFAULT_KANBAN_BOARD) + await refreshAll() + } + async function fetchTasks(silent = false) { + const { seq, generation, board } = nextRequestContext(() => ++tasksRequestSeq) + const loadingSeq = silent ? 0 : ++loadingRequestSeq if (!silent) loading.value = true try { - tasks.value = await kanbanApi.listTasks({ + const nextTasks = await kanbanApi.listTasks({ + board, status: filterStatus.value || undefined, assignee: filterAssignee.value || undefined, }) + if (isCurrentRequest(seq, generation, board, tasksRequestSeq)) tasks.value = nextTasks } catch (err) { - console.error('Failed to fetch kanban tasks:', err) + if (isCurrentRequest(seq, generation, board, tasksRequestSeq)) console.error('Failed to fetch kanban tasks:', err) } finally { - if (!silent) loading.value = false + if (!silent && loadingSeq === loadingRequestSeq) loading.value = false } } async function fetchStats() { + const { seq, generation, board } = nextRequestContext(() => ++statsRequestSeq) try { - stats.value = await kanbanApi.getStats() + const nextStats = await kanbanApi.getStats({ board }) + if (isCurrentRequest(seq, generation, board, statsRequestSeq)) stats.value = nextStats } catch (err) { - console.error('Failed to fetch kanban stats:', err) + if (isCurrentRequest(seq, generation, board, statsRequestSeq)) console.error('Failed to fetch kanban stats:', err) } } async function fetchAssignees() { + const { seq, generation, board } = nextRequestContext(() => ++assigneesRequestSeq) try { - assignees.value = await kanbanApi.getAssignees() + const nextAssignees = await kanbanApi.getAssignees({ board }) + if (isCurrentRequest(seq, generation, board, assigneesRequestSeq)) assignees.value = nextAssignees } catch (err) { - console.error('Failed to fetch kanban assignees:', err) + if (isCurrentRequest(seq, generation, board, assigneesRequestSeq)) console.error('Failed to fetch kanban assignees:', err) } } async function createTask(data: { title: string; body?: string; assignee?: string; priority?: number; tenant?: string }) { - const task = await kanbanApi.createTask(data) - tasks.value.unshift(task) - await fetchStats() + const board = selectedBoard.value + const task = await kanbanApi.createTask(data, { board }) + if (board === selectedBoard.value) { + tasks.value.unshift(task) + await Promise.all([fetchStats(), fetchBoards()]) + } return task } async function completeTasks(taskIds: string[], summary?: string) { - await kanbanApi.completeTasks(taskIds, summary) - for (const id of taskIds) { - const task = tasks.value.find(t => t.id === id) - if (task) task.status = 'done' + const board = selectedBoard.value + await kanbanApi.completeTasks(taskIds, summary, { board }) + if (board === selectedBoard.value) { + for (const id of taskIds) { + const task = tasks.value.find(t => t.id === id) + if (task) task.status = 'done' + } + await Promise.all([fetchStats(), fetchBoards()]) } - await fetchStats() } async function blockTask(taskId: string, reason: string) { - await kanbanApi.blockTask(taskId, reason) - const task = tasks.value.find(t => t.id === taskId) - if (task) task.status = 'blocked' - await fetchStats() + const board = selectedBoard.value + await kanbanApi.blockTask(taskId, reason, { board }) + if (board === selectedBoard.value) { + const task = tasks.value.find(t => t.id === taskId) + if (task) task.status = 'blocked' + await Promise.all([fetchStats(), fetchBoards()]) + } } async function unblockTasks(taskIds: string[]) { - await kanbanApi.unblockTasks(taskIds) - for (const id of taskIds) { - const task = tasks.value.find(t => t.id === id) - if (task) task.status = 'ready' + const board = selectedBoard.value + await kanbanApi.unblockTasks(taskIds, { board }) + if (board === selectedBoard.value) { + for (const id of taskIds) { + const task = tasks.value.find(t => t.id === id) + if (task) task.status = 'ready' + } + await Promise.all([fetchStats(), fetchBoards()]) } - await fetchStats() } async function assignTask(taskId: string, profile: string) { - await kanbanApi.assignTask(taskId, profile) - const task = tasks.value.find(t => t.id === taskId) - if (task) task.assignee = profile + const board = selectedBoard.value + await kanbanApi.assignTask(taskId, profile, { board }) + if (board === selectedBoard.value) { + const task = tasks.value.find(t => t.id === taskId) + if (task) task.assignee = profile + await Promise.all([fetchStats(), fetchAssignees()]) + } } function setFilter(key: 'status' | 'assignee', value: string | null) { @@ -86,25 +262,39 @@ export const useKanbanStore = defineStore('kanban', () => { } async function refreshAll() { - await Promise.all([fetchTasks(), fetchStats(), fetchAssignees()]) + await Promise.all([fetchBoards(), fetchTasks(), fetchStats(), fetchAssignees()]) } return { tasks, stats, assignees, + boards, + capabilities, + activeBoards, loading, + boardsLoading, + boardWarning, + selectedBoard, filterStatus, filterAssignee, + fetchBoards, + fetchCapabilities, fetchTasks, fetchStats, fetchAssignees, createTask, + createBoard, + archiveSelectedBoard, completeTasks, blockTask, unblockTasks, assignTask, setFilter, + setSelectedBoard, + recoverSelectedBoard, + resolveAvailableBoard, + clearBoardScopedState, refreshAll, } }) diff --git a/packages/client/src/views/hermes/KanbanView.vue b/packages/client/src/views/hermes/KanbanView.vue index e6b2c26e..4cc22e2e 100644 --- a/packages/client/src/views/hermes/KanbanView.vue +++ b/packages/client/src/views/hermes/KanbanView.vue @@ -1,22 +1,77 @@ @@ -89,6 +190,25 @@ async function handleTaskCreated() {