Просмотр исходного кода

实现 Web 看板与双路由代理

LSamien 2 месяцев назад
Родитель
Сommit
d10a44d078

+ 4 - 0
src/client/App.vue

@@ -7,18 +7,22 @@ import PasswordChangeView from '@/views/PasswordChangeView.vue'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
 import { useGroupsStore } from '@/stores/groups'
+import { useKanbanStore } from '@/stores/kanban'
 import { useThemeStore } from '@/stores/theme'
 import { reconcileYaoyaoPlugin } from '@/api/systemUpdate'
 
 const auth = useAuthStore()
 const chat = useChatStore()
 const groups = useGroupsStore()
+const kanban = useKanbanStore()
 const theme = useThemeStore()
 const route = useRoute()
 
 const pageTitle = computed(() => {
   const name = route.path.startsWith('/groups')
     ? groups.selectedRoom?.name || '团队'
+    : route.path.startsWith('/kanban')
+      ? kanban.selectedBoard?.name || kanban.selectedBoardSlug || '看板'
     : route.path.startsWith('/files')
       ? '文件库'
       : chat.activeSession?.title || '对话'

+ 1 - 0
src/client/api/index.ts

@@ -2,6 +2,7 @@ export * from './auth'
 export * from './client'
 export * from './files'
 export * from './groups'
+export * from './kanban'
 export * from './profiles'
 export * from './realtime'
 export * from './sessions'

+ 216 - 0
src/client/api/kanban.ts

@@ -0,0 +1,216 @@
+import type { JsonValue } from '@shared/types'
+import { apiRequest, apiUrl, unwrapData } from './client'
+
+const BASE = '/api/app/kanban'
+
+export interface KanbanStatus {
+  available: boolean
+  version?: string
+  reason?: string
+}
+
+export interface KanbanBoardMeta {
+  slug: string
+  name?: string | null
+  description?: string | null
+  icon?: string | null
+  color?: string | null
+  total?: number
+  counts?: Record<string, number>
+  is_current?: boolean
+  default_workdir?: string | null
+  default_workspace_kind?: string | null
+  project_id?: string | null
+  project_name?: string | null
+}
+
+export interface KanbanBoardsResponse {
+  boards: KanbanBoardMeta[]
+  current: string
+}
+
+export interface KanbanTask {
+  id: string
+  title: string
+  body?: string | null
+  result?: string | null
+  status: string
+  assignee?: string | null
+  tenant?: string | null
+  priority?: number
+  created_by?: string | null
+  created_at?: number
+  started_at?: number | null
+  completed_at?: number | null
+  latest_summary?: string | null
+  comment_count?: number
+  model_override?: string | null
+  provider_override?: string | null
+  reasoning_effort?: string | null
+  workspace_kind?: string | null
+  workspace_path?: string | null
+  branch_name?: string | null
+  link_counts?: { parents: number; children: number }
+  progress?: { done: number; total: number } | null
+  warnings?: { count: number; highest_severity?: string | null } | null
+  diagnostics?: KanbanDiagnostic[]
+}
+
+export interface KanbanColumn {
+  name: string
+  tasks: KanbanTask[]
+}
+
+export interface KanbanBoardSnapshot {
+  columns: KanbanColumn[]
+  tenants: string[]
+  assignees: string[]
+  latest_event_id: number
+  now: number
+}
+
+export interface KanbanProfile {
+  name: string
+  is_default: boolean
+  description?: string
+  description_auto?: boolean
+  model?: string
+  provider?: string
+}
+
+export interface KanbanComment {
+  id: string | number
+  task_id?: string
+  author: string
+  body: string
+  created_at: number
+}
+
+export interface KanbanEvent {
+  id: number
+  task_id?: string
+  run_id?: string | number | null
+  kind: string
+  payload?: JsonValue | string | null
+  created_at: number
+}
+
+export interface KanbanRun {
+  id: string | number
+  task_id?: string
+  profile?: string | null
+  status: string
+  outcome?: string | null
+  summary?: string | null
+  error?: string | null
+  metadata?: JsonValue | string | null
+  worker_pid?: number | null
+  started_at?: number | null
+  ended_at?: number | null
+  last_heartbeat_at?: number | null
+}
+
+export interface KanbanDiagnostic {
+  kind: string
+  severity: 'warning' | 'error' | 'critical' | string
+  title: string
+  detail: string
+  count?: number
+  last_seen_at?: number
+}
+
+export interface KanbanTaskDetail {
+  task: KanbanTask
+  comments: KanbanComment[]
+  events: KanbanEvent[]
+  links: { parents: string[]; children: string[] }
+  child_results?: Array<{ id: string; title: string; status: string; latest_summary?: string | null; result?: string | null }>
+  runs: KanbanRun[]
+}
+
+export interface CreateKanbanBoardInput {
+  slug: string
+  name?: string
+  description?: string
+}
+
+export interface CreateKanbanTaskInput {
+  title: string
+  body?: string
+  assignee?: string
+  tenant?: string
+  priority?: number
+  workspace_kind?: 'scratch' | 'worktree' | 'dir'
+  workspace_path?: string
+  parents?: string[]
+  triage?: boolean
+}
+
+export type UpdateKanbanTaskInput = Partial<Pick<KanbanTask,
+  'title' | 'body' | 'assignee' | 'priority' | 'result' | 'model_override' | 'provider_override' | 'reasoning_effort'>> & {
+  status?: string
+  block_reason?: string
+  summary?: string
+  clear_model_override?: boolean
+  clear_reasoning_effort?: boolean
+}
+
+function boardPath(path: string, board: string, query: Record<string, string | number | boolean | undefined> = {}): string {
+  return apiUrl(`${BASE}${path}`, { ...query, board })
+}
+
+export async function getKanbanStatus(): Promise<KanbanStatus> {
+  return unwrapData(await apiRequest<KanbanStatus>(`${BASE}/status`))
+}
+
+export async function listKanbanBoards(): Promise<KanbanBoardsResponse> {
+  return unwrapData(await apiRequest<KanbanBoardsResponse>(`${BASE}/boards`))
+}
+
+export async function createKanbanBoard(input: CreateKanbanBoardInput): Promise<{ board: KanbanBoardMeta; current: string }> {
+  return unwrapData(await apiRequest(`${BASE}/boards`, {
+    method: 'POST',
+    body: { ...input, switch: false },
+  }))
+}
+
+export async function getKanbanBoard(board: string, includeArchived = false): Promise<KanbanBoardSnapshot> {
+  return unwrapData(await apiRequest<KanbanBoardSnapshot>(boardPath('/board', board, {
+    include_archived: includeArchived,
+  })))
+}
+
+export async function listKanbanProfiles(): Promise<KanbanProfile[]> {
+  const payload = unwrapData(await apiRequest<{ profiles: KanbanProfile[] }>(`${BASE}/profiles`))
+  return Array.isArray(payload.profiles) ? payload.profiles : []
+}
+
+export async function getKanbanTask(board: string, id: string): Promise<KanbanTaskDetail> {
+  return unwrapData(await apiRequest<KanbanTaskDetail>(boardPath(`/tasks/${encodeURIComponent(id)}`, board)))
+}
+
+export async function createKanbanTask(board: string, input: CreateKanbanTaskInput): Promise<{ task: KanbanTask | null; warning?: string }> {
+  return unwrapData(await apiRequest(boardPath('/tasks', board), {
+    method: 'POST', body: input as unknown as JsonValue,
+  }))
+}
+
+export async function updateKanbanTask(board: string, id: string, input: UpdateKanbanTaskInput): Promise<{ task: KanbanTask | null }> {
+  return unwrapData(await apiRequest(boardPath(`/tasks/${encodeURIComponent(id)}`, board), {
+    method: 'PATCH', body: input as unknown as JsonValue,
+  }))
+}
+
+export async function deleteKanbanTask(board: string, id: string): Promise<void> {
+  await apiRequest(boardPath(`/tasks/${encodeURIComponent(id)}`, board), { method: 'DELETE', body: {} })
+}
+
+export async function addKanbanComment(board: string, id: string, comment: { body: string }): Promise<void> {
+  await apiRequest(boardPath(`/tasks/${encodeURIComponent(id)}/comments`, board), {
+    method: 'POST', body: comment,
+  })
+}
+
+export async function dispatchKanban(board: string): Promise<unknown> {
+  return unwrapData(await apiRequest(boardPath('/dispatch', board), { method: 'POST', body: {} }))
+}

+ 9 - 7
src/client/components/app/WorkspaceShell.vue

@@ -9,10 +9,10 @@ import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 
 type NavItem = {
-  key: 'chat' | 'groups' | 'files'
+  key: 'chat' | 'groups' | 'kanban' | 'files'
   label: string
   path: string
-  icon: 'chat' | 'groups' | 'files'
+  icon: 'chat' | 'groups' | 'board' | 'files'
 }
 
 type SettingsPage =
@@ -106,6 +106,7 @@ function profileTitle(profile?: Profile): string {
 const navItems: NavItem[] = [
   { key: 'chat', label: '对话', path: '/chat', icon: 'chat' },
   { key: 'groups', label: '团队', path: '/groups', icon: 'groups' },
+  { key: 'kanban', label: '看板', path: '/kanban', icon: 'board' },
   { key: 'files', label: '文件库', path: '/files', icon: 'files' },
 ]
 
@@ -122,10 +123,11 @@ const activeNav = computed(() => {
 const contextHeading = computed(() => ({
   chat: '历史记录',
   groups: '话题列表',
+  kanban: '看板列表',
   files: '历史记录',
 })[activeNav.value.key])
 
-const hasPrimaryAction = computed(() => ['chat', 'groups', 'files'].includes(activeNav.value.key))
+const hasPrimaryAction = computed(() => ['chat', 'groups', 'kanban', 'files'].includes(activeNav.value.key))
 
 async function navigate(path: string) {
   mobileDrawerOpen.value = false
@@ -207,8 +209,8 @@ function toggleSidebar() {
   try { localStorage.setItem(SIDEBAR_COLLAPSED_KEY, sidebarCollapsed.value ? '1' : '0') } catch { /* optional persistence */ }
 }
 
-function navIcon(key: NavItem['key']): 'chat' | 'folder' | 'people' {
-  return key === 'groups' ? 'people' : key === 'files' ? 'folder' : 'chat'
+function navIcon(key: NavItem['key']): 'chat' | 'folder' | 'people' | 'board' {
+  return key === 'groups' ? 'people' : key === 'kanban' ? 'board' : key === 'files' ? 'folder' : 'chat'
 }
 
 async function openSidebarSearch(host: HTMLElement | null) {
@@ -304,7 +306,7 @@ onBeforeUnmount(() => {
       </button>
 
       <button
-        v-if="activeNav.key !== 'files'"
+        v-if="!['files', 'kanban'].includes(activeNav.key)"
         class="sidebar-search-trigger"
         :class="{ 'sidebar-search-trigger--searching': sidebarSearchOpen }"
         type="button"
@@ -411,7 +413,7 @@ onBeforeUnmount(() => {
       </div>
 
       <button
-        v-if="activeNav.key !== 'files'"
+        v-if="!['files', 'kanban'].includes(activeNav.key)"
         class="sidebar-search-trigger"
         :class="{ 'sidebar-search-trigger--searching': sidebarSearchOpen }"
         type="button"

+ 2 - 0
src/client/components/common/AppIcon.vue

@@ -4,6 +4,7 @@ type IconName =
   | 'groups'
   | 'files'
   | 'artifacts'
+  | 'board'
   | 'plus'
   | 'search'
   | 'dots'
@@ -78,6 +79,7 @@ withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
     <template v-else-if="name === 'artifacts'">
       <path d="M12 3 3 8l9 5 9-5zM3 12l9 5 9-5M3 16l9 5 9-5" />
     </template>
+    <template v-else-if="name === 'board'"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M9 4v16M15 4v16M5.5 8h1M11.5 8h1M17.5 8h1" /></template>
     <template v-else-if="name === 'plus'"><path d="M12 5v14M5 12h14" /></template>
     <template v-else-if="name === 'search'"><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></template>
     <template v-else-if="name === 'dots'"><circle cx="5" cy="12" r="1" fill="currentColor" stroke="none" /><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" /><circle cx="19" cy="12" r="1" fill="currentColor" stroke="none" /></template>

+ 3 - 1
src/client/components/common/YaoYaoSidebarIcon.vue

@@ -5,15 +5,17 @@ import {
   AddOutline,
   ChatbubbleOutline,
   FolderOutline,
+  GridOutline,
   PeopleOutline,
   SearchOutline,
 } from '@vicons/ionicons5'
 
-type SidebarIconName = 'add' | 'chat' | 'folder' | 'people' | 'search'
+type SidebarIconName = 'add' | 'board' | 'chat' | 'folder' | 'people' | 'search'
 
 const props = withDefaults(defineProps<{ name: SidebarIconName; size?: number }>(), { size: 20 })
 const component = computed(() => ({
   add: AddOutline,
+  board: GridOutline,
   chat: ChatbubbleOutline,
   folder: FolderOutline,
   people: PeopleOutline,

Разница между файлами не показана из-за своего большого размера
+ 50 - 0
src/client/components/kanban/KanbanBoardDialog.vue


+ 83 - 0
src/client/components/kanban/KanbanCard.vue

@@ -0,0 +1,83 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { KanbanTask } from '@/api/kanban'
+import AppIcon from '@/components/common/AppIcon.vue'
+import { formatKanbanTime, movableStatuses, statusMeta } from './presentation'
+
+const props = defineProps<{
+  task: KanbanTask
+  columns: string[]
+  canEdit: boolean
+}>()
+
+const emit = defineEmits<{
+  open: [task: KanbanTask]
+  move: [task: KanbanTask, status: string]
+}>()
+
+const meta = computed(() => statusMeta(props.task.status))
+const targets = computed(() => movableStatuses(props.columns, props.task.status))
+const summary = computed(() => props.task.latest_summary || props.task.body || '')
+
+function startDrag(event: DragEvent): void {
+  if (!props.canEdit || !event.dataTransfer) return
+  event.dataTransfer.effectAllowed = 'move'
+  event.dataTransfer.setData('text/plain', props.task.id)
+  event.dataTransfer.setData('application/x-hermes-kanban-status', props.task.status)
+}
+
+function changeStatus(event: Event): void {
+  const status = (event.target as HTMLSelectElement).value
+  if (status && status !== props.task.status) emit('move', props.task, status)
+}
+</script>
+
+<template>
+  <article
+    class="kanban-card"
+    :class="{ 'kanban-card--running': task.status === 'running' }"
+    :style="{ '--kanban-card-tone': meta.color }"
+    :draggable="canEdit"
+    :aria-label="`${task.title},${meta.label}`"
+    tabindex="0"
+    @click="emit('open', task)"
+    @keydown.enter="emit('open', task)"
+    @dragstart="startDrag"
+  >
+    <header>
+      <strong>{{ task.title || task.id }}</strong>
+      <span v-if="task.priority" class="kanban-card__priority" :title="`优先级 ${task.priority}`">P{{ task.priority }}</span>
+    </header>
+    <p v-if="summary">{{ summary }}</p>
+    <div v-if="task.progress" class="kanban-card__progress" :aria-label="`子任务进度 ${task.progress.done}/${task.progress.total}`">
+      <span :style="{ width: `${task.progress.total ? task.progress.done / task.progress.total * 100 : 0}%` }" />
+    </div>
+    <footer>
+      <span v-if="task.assignee" class="kanban-card__assignee"><i>{{ task.assignee.slice(0, 1).toUpperCase() }}</i>{{ task.assignee }}</span>
+      <span v-if="task.tenant" class="kanban-card__tenant">{{ task.tenant }}</span>
+      <span v-if="task.comment_count" class="kanban-card__comments"><AppIcon name="chat" :size="11" />{{ task.comment_count }}</span>
+      <time v-if="task.created_at">{{ formatKanbanTime(task.created_at) }}</time>
+    </footer>
+    <label class="kanban-card__mobile-status" @click.stop>
+      <span class="sr-only">移动任务状态</span>
+      <select :value="task.status" :disabled="!canEdit" @change="changeStatus">
+        <option v-for="status in targets" :key="status" :value="status">{{ statusMeta(status).label }}</option>
+      </select>
+    </label>
+  </article>
+</template>
+
+<style scoped>
+.kanban-card { position: relative; display: flex; flex-direction: column; gap: 8px; min-height: 106px; padding: 11px 11px 10px; border: 1px solid var(--line); border-left: 3px solid var(--kanban-card-tone); border-radius: 10px; background: var(--surface-raised); box-shadow: 0 1px 1px rgb(0 0 0 / .025); cursor: pointer; transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease; }
+.kanban-card:hover { border-color: var(--line-strong); background: var(--surface); transform: translateY(-1px); }.kanban-card:focus-visible { outline: 0; box-shadow: 0 0 0 3px var(--focus-ring); }.kanban-card[draggable="true"] { cursor: grab; }.kanban-card[draggable="true"]:active { cursor: grabbing; }
+.kanban-card--running::after { position: absolute; top: 9px; right: 9px; width: 7px; height: 7px; border-radius: 50%; background: var(--kanban-card-tone); content: ''; animation: kanban-pulse 1.8s ease-in-out infinite; }
+.kanban-card header { display: flex; min-width: 0; align-items: flex-start; gap: 8px; padding-right: 7px; }.kanban-card header strong { min-width: 0; flex: 1; color: var(--text-primary); font-size: 13px; font-weight: 650; line-height: 1.4; overflow-wrap: anywhere; }.kanban-card__priority { flex: 0 0 auto; padding: 2px 5px; border-radius: 5px; background: var(--surface-soft); color: var(--text-muted); font-size: 8px; font-weight: 750; }
+.kanban-card > p { display: -webkit-box; margin: 0; overflow: hidden; color: var(--text-secondary); font-size: 10.5px; line-height: 1.5; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
+.kanban-card__progress { height: 3px; overflow: hidden; border-radius: 999px; background: var(--surface-soft); }.kanban-card__progress span { display: block; height: 100%; border-radius: inherit; background: var(--kanban-card-tone); }
+.kanban-card footer { display: flex; min-width: 0; align-items: center; gap: 7px; margin-top: auto; color: var(--text-muted); font-size: 8.5px; }.kanban-card__assignee { display: flex; min-width: 0; align-items: center; gap: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.kanban-card__assignee i { display: grid; width: 17px; height: 17px; flex: 0 0 17px; place-items: center; border-radius: 50%; background: color-mix(in srgb, var(--kanban-card-tone) 14%, var(--surface)); color: var(--kanban-card-tone); font-size: 8px; font-style: normal; font-weight: 750; }.kanban-card__tenant { max-width: 75px; overflow: hidden; padding: 2px 5px; border-radius: 5px; background: var(--surface-soft); text-overflow: ellipsis; white-space: nowrap; }.kanban-card__comments { display: flex; align-items: center; gap: 2px; }.kanban-card time { margin-left: auto; white-space: nowrap; }
+.kanban-card__mobile-status { display: none; }
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
+@keyframes kanban-pulse { 0%, 100% { opacity: .35; transform: scale(.85); } 50% { opacity: 1; transform: scale(1.12); } }
+@media (max-width: 600px) { .kanban-card { min-height: 118px; padding: 13px; }.kanban-card header strong { font-size: 14px; }.kanban-card > p { font-size: 11.5px; -webkit-line-clamp: 3; }.kanban-card footer { font-size: 9.5px; }.kanban-card__mobile-status { display: block; }.kanban-card__mobile-status select { width: 100%; min-height: 34px; padding: 0 28px 0 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--text-secondary); font: inherit; font-size: 11px; } }
+@media (prefers-reduced-motion: reduce) { .kanban-card, .kanban-card--running::after { animation: none; transition: none; } }
+</style>

+ 84 - 0
src/client/components/kanban/KanbanColumn.vue

@@ -0,0 +1,84 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import type { KanbanColumn, KanbanTask } from '@/api/kanban'
+import AppIcon from '@/components/common/AppIcon.vue'
+import KanbanCard from './KanbanCard.vue'
+import { LOCKED_KANBAN_TARGETS, statusMeta } from './presentation'
+
+const props = defineProps<{
+  column: KanbanColumn
+  columns: string[]
+  canEdit: boolean
+}>()
+
+const emit = defineEmits<{
+  add: [status: string]
+  open: [task: KanbanTask]
+  move: [task: KanbanTask, status: string]
+  dropTask: [taskId: string, status: string]
+}>()
+
+const over = ref(false)
+const meta = computed(() => statusMeta(props.column.name))
+const locked = computed(() => LOCKED_KANBAN_TARGETS.has(props.column.name))
+const canCreate = computed(() => props.canEdit && !locked.value && props.column.name !== 'archived')
+
+function dragOver(event: DragEvent): void {
+  if (!props.canEdit || locked.value) return
+  event.preventDefault()
+  if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
+  over.value = true
+}
+
+function drop(event: DragEvent): void {
+  over.value = false
+  if (!props.canEdit || locked.value) return
+  event.preventDefault()
+  const id = event.dataTransfer?.getData('text/plain') || ''
+  if (id) emit('dropTask', id, props.column.name)
+}
+</script>
+
+<template>
+  <section
+    class="kanban-column"
+    :class="{ 'kanban-column--over': over, 'kanban-column--locked': locked }"
+    :style="{ '--kanban-column-tone': meta.color }"
+    :aria-label="`${meta.label},${column.tasks.length} 项任务`"
+    @dragover="dragOver"
+    @dragleave.self="over = false"
+    @drop="drop"
+  >
+    <header>
+      <i />
+      <strong :title="meta.description">{{ meta.label }}</strong>
+      <span>{{ column.tasks.length }}</span>
+      <button v-if="canCreate" type="button" :aria-label="`在${meta.label}中新建任务`" @click="emit('add', column.name)">
+        <AppIcon name="plus" :size="14" />
+      </button>
+    </header>
+    <div class="kanban-column__cards">
+      <KanbanCard
+        v-for="task in column.tasks"
+        :key="task.id"
+        :task="task"
+        :columns="columns"
+        :can-edit="canEdit"
+        @open="emit('open', $event)"
+        @move="(item, status) => emit('move', item, status)"
+      />
+      <button v-if="canCreate" class="kanban-column__add" type="button" @click="emit('add', column.name)">
+        <AppIcon name="plus" :size="13" />新建任务
+      </button>
+      <p v-if="column.tasks.length === 0" class="kanban-column__empty">暂无任务</p>
+    </div>
+  </section>
+</template>
+
+<style scoped>
+.kanban-column { display: flex; width: 278px; min-width: 278px; min-height: 0; flex-direction: column; border: 1px solid transparent; border-radius: 12px; background: color-mix(in srgb, var(--surface-soft) 68%, transparent); transition: background-color 120ms ease, border-color 120ms ease; scroll-snap-align: start; }.kanban-column--over { border-color: color-mix(in srgb, var(--kanban-column-tone) 54%, var(--line)); background: color-mix(in srgb, var(--kanban-column-tone) 7%, var(--surface-soft)); }.kanban-column--locked { background: color-mix(in srgb, var(--surface-soft) 46%, transparent); }
+.kanban-column > header { display: flex; min-height: 42px; flex: 0 0 auto; align-items: center; gap: 7px; padding: 7px 9px; }.kanban-column > header > i { width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--kanban-column-tone); }.kanban-column > header strong { color: var(--text-secondary); font-size: 10px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }.kanban-column > header span { color: var(--text-muted); font-size: 9px; font-variant-numeric: tabular-nums; }.kanban-column > header button { display: grid; width: 28px; height: 28px; margin-left: auto; place-items: center; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.kanban-column > header button:hover { background: var(--surface-hover); color: var(--text-primary); }
+.kanban-column__cards { position: relative; display: flex; min-height: 120px; flex: 1; flex-direction: column; gap: 8px; padding: 0 7px 8px; overflow-y: auto; }.kanban-column__add { display: flex; min-height: 34px; flex: 0 0 auto; align-items: center; justify-content: center; gap: 5px; border: 1px dashed var(--line-strong); border-radius: 9px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 10px; opacity: .45; }.kanban-column:hover .kanban-column__add, .kanban-column__add:focus-visible { opacity: 1; }.kanban-column__add:hover { background: var(--surface-hover); color: var(--text-secondary); }.kanban-column__empty { position: absolute; inset: 42px 0 auto; margin: 0; color: var(--text-muted); font-size: 10px; text-align: center; pointer-events: none; }
+@media (max-width: 600px) { .kanban-column { width: calc(100vw - 30px); min-width: calc(100vw - 30px); max-height: 100%; }.kanban-column > header { min-height: 46px; padding-inline: 12px; }.kanban-column > header strong { font-size: 11px; }.kanban-column__cards { padding: 0 9px 10px; }.kanban-column__add { min-height: 40px; opacity: 1; } }
+@media (prefers-reduced-motion: reduce) { .kanban-column { transition: none; } }
+</style>

+ 92 - 0
src/client/components/kanban/KanbanTaskDialog.vue

@@ -0,0 +1,92 @@
+<script setup lang="ts">
+import { computed, nextTick, ref, watch } from 'vue'
+import type { CreateKanbanTaskInput, KanbanProfile } from '@/api/kanban'
+import AppIcon from '@/components/common/AppIcon.vue'
+import { LOCKED_KANBAN_TARGETS, statusMeta } from './presentation'
+
+const props = defineProps<{
+  open: boolean
+  targetStatus: string
+  columns: string[]
+  profiles: KanbanProfile[]
+  busy?: boolean
+  error?: string
+}>()
+
+const emit = defineEmits<{
+  close: []
+  create: [input: CreateKanbanTaskInput, targetStatus: string]
+}>()
+
+const titleInput = ref<HTMLInputElement | null>(null)
+const title = ref('')
+const body = ref('')
+const status = ref('ready')
+const assignee = ref('')
+const tenant = ref('')
+const priority = ref(0)
+const workspaceKind = ref<'scratch' | 'worktree' | 'dir'>('scratch')
+const workspacePath = ref('')
+
+const statuses = computed(() => props.columns.filter(value => !LOCKED_KANBAN_TARGETS.has(value) && value !== 'archived'))
+
+watch(() => props.open, open => {
+  if (!open) return
+  title.value = ''
+  body.value = ''
+  status.value = statuses.value.includes(props.targetStatus) ? props.targetStatus : 'ready'
+  assignee.value = ''
+  tenant.value = ''
+  priority.value = 0
+  workspaceKind.value = 'scratch'
+  workspacePath.value = ''
+  void nextTick(() => titleInput.value?.focus())
+})
+
+function submit(): void {
+  if (!title.value.trim() || props.busy) return
+  emit('create', {
+    title: title.value.trim(),
+    ...(body.value.trim() ? { body: body.value.trim() } : {}),
+    ...(assignee.value ? { assignee: assignee.value } : {}),
+    ...(tenant.value.trim() ? { tenant: tenant.value.trim() } : {}),
+    priority: Number(priority.value) || 0,
+    workspace_kind: workspaceKind.value,
+    ...(workspaceKind.value !== 'scratch' && workspacePath.value.trim() ? { workspace_path: workspacePath.value.trim() } : {}),
+  }, status.value)
+}
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="kanban-dialog-fade">
+      <div v-if="open" class="kanban-dialog-layer" role="presentation" @mousedown.self="emit('close')" @keydown.esc="emit('close')">
+        <form class="kanban-task-dialog" role="dialog" aria-modal="true" aria-labelledby="kanban-task-create-title" @submit.prevent="submit">
+          <header>
+            <div><small>{{ statusMeta(status).label }}</small><h2 id="kanban-task-create-title">新建任务</h2></div>
+            <button type="button" aria-label="关闭新建任务" @click="emit('close')"><AppIcon name="close" /></button>
+          </header>
+          <div class="kanban-task-dialog__body">
+            <label class="kanban-field kanban-field--wide"><span>标题</span><input ref="titleInput" v-model="title" maxlength="500" required placeholder="要完成什么?" /></label>
+            <label class="kanban-field kanban-field--wide"><span>描述</span><textarea v-model="body" rows="4" placeholder="补充目标、边界和验收要求" /></label>
+            <label class="kanban-field"><span>状态</span><select v-model="status"><option v-for="value in statuses" :key="value" :value="value">{{ statusMeta(value).label }}</option></select></label>
+            <label class="kanban-field"><span>分配给</span><select v-model="assignee"><option value="">暂不分配</option><option v-for="profile in profiles" :key="profile.name" :value="profile.name">{{ profile.name }}</option></select></label>
+            <label class="kanban-field"><span>租户</span><input v-model="tenant" maxlength="120" placeholder="可选命名空间" /></label>
+            <label class="kanban-field"><span>优先级</span><input v-model.number="priority" type="number" min="-1000" max="1000" /></label>
+            <label class="kanban-field"><span>工作区</span><select v-model="workspaceKind"><option value="scratch">临时目录</option><option value="worktree">Git worktree</option><option value="dir">已有目录</option></select></label>
+            <label v-if="workspaceKind !== 'scratch'" class="kanban-field"><span>工作区路径</span><input v-model="workspacePath" placeholder="留空则继承看板设置" /></label>
+          </div>
+          <p v-if="error" class="kanban-dialog-error" role="alert">{{ error }}</p>
+          <footer><button type="button" class="quiet-button" @click="emit('close')">取消</button><button type="submit" class="primary-button" :disabled="!title.trim() || busy">{{ busy ? '正在创建…' : '创建任务' }}</button></footer>
+        </form>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.kanban-dialog-layer { position: fixed; z-index: 100; display: grid; inset: 0; place-items: center; padding: 20px; background: var(--scrim); backdrop-filter: blur(3px); }.kanban-task-dialog { display: flex; width: min(660px, 96vw); max-height: min(760px, 92vh); flex-direction: column; overflow: hidden; border: 1px solid var(--line); border-radius: 17px; background: var(--surface-raised); box-shadow: var(--shadow-float); }.kanban-task-dialog > header { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; padding: 17px 19px 13px; border-bottom: 1px solid var(--line); }.kanban-task-dialog > header small { color: var(--text-muted); font-size: 9px; font-weight: 700; letter-spacing: .08em; }.kanban-task-dialog h2 { margin: 2px 0 0; font-size: 17px; }.kanban-task-dialog > header button { display: grid; width: 34px; height: 34px; place-items: center; padding: 0; border: 0; border-radius: 9px; background: transparent; color: var(--text-muted); cursor: pointer; }.kanban-task-dialog > header button:hover { background: var(--surface-soft); color: var(--text-primary); }
+.kanban-task-dialog__body { display: grid; min-height: 0; grid-template-columns: 1fr 1fr; gap: 13px; padding: 17px 19px; overflow-y: auto; }.kanban-field { display: flex; min-width: 0; flex-direction: column; gap: 6px; }.kanban-field--wide { grid-column: 1 / -1; }.kanban-field > span { color: var(--text-secondary); font-size: 10px; font-weight: 700; }.kanban-field input, .kanban-field textarea, .kanban-field select { width: 100%; min-height: 38px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface); color: var(--text-primary); font: inherit; font-size: 12px; }.kanban-field textarea { min-height: 96px; resize: vertical; line-height: 1.5; }.kanban-field input:focus, .kanban-field textarea:focus, .kanban-field select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.kanban-dialog-error { margin: 0 19px; padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, var(--danger) 10%, transparent); color: var(--danger); font-size: 11px; }.kanban-task-dialog > footer { display: flex; flex: 0 0 auto; justify-content: flex-end; gap: 8px; padding: 13px 19px 17px; }.kanban-task-dialog > footer button { min-height: 38px; padding: 0 16px; border-radius: 9px; cursor: pointer; font: inherit; font-size: 11px; font-weight: 700; }.quiet-button { border: 1px solid var(--line); background: transparent; color: var(--text-secondary); }.primary-button { border: 1px solid var(--accent); background: var(--accent); color: white; }.primary-button:disabled { cursor: not-allowed; opacity: .45; }
+.kanban-dialog-fade-enter-active, .kanban-dialog-fade-leave-active { transition: opacity 150ms ease; }.kanban-dialog-fade-enter-from, .kanban-dialog-fade-leave-to { opacity: 0; }
+@media (max-width: 600px) { .kanban-dialog-layer { align-items: end; padding: 0; }.kanban-task-dialog { width: 100%; max-height: calc(100dvh - 52px); border-width: 1px 0 0; border-radius: 17px 17px 0 0; }.kanban-task-dialog__body { grid-template-columns: 1fr; padding: 15px; }.kanban-field--wide { grid-column: auto; }.kanban-field input, .kanban-field textarea, .kanban-field select { min-height: 44px; font-size: 16px; }.kanban-task-dialog > footer { padding: 12px 15px max(14px, env(safe-area-inset-bottom)); }.kanban-task-dialog > footer button { min-height: 44px; flex: 1; } }
+</style>

Разница между файлами не показана из-за своего большого размера
+ 189 - 0
src/client/components/kanban/KanbanTaskDrawer.vue


+ 62 - 0
src/client/components/kanban/presentation.ts

@@ -0,0 +1,62 @@
+import type { KanbanEvent } from '@/api/kanban'
+
+export const KANBAN_STATUS_META: Record<string, { label: string; color: string; description: string }> = {
+  triage: { label: '待分诊', color: '#8b8b86', description: '需要补充或拆解的任务' },
+  todo: { label: '待办', color: '#68727e', description: '已明确但尚未进入执行队列' },
+  scheduled: { label: '已计划', color: '#8b5cf6', description: '由系统在指定时间唤醒' },
+  ready: { label: '就绪', color: '#1677ff', description: '等待调度器分配 Agent' },
+  running: { label: '进行中', color: '#1a9a68', description: 'Agent 正在处理' },
+  blocked: { label: '已阻塞', color: '#d44848', description: '等待外部输入或恢复' },
+  review: { label: '评审中', color: '#c58a15', description: '等待人工或 Agent 评审' },
+  done: { label: '已完成', color: '#66756d', description: '已经完成的任务' },
+  archived: { label: '已归档', color: '#999994', description: '已从活动看板移除' },
+}
+
+export const LOCKED_KANBAN_TARGETS = new Set(['scheduled', 'running', 'review', 'archived'])
+
+export function statusMeta(status: string) {
+  return KANBAN_STATUS_META[status] ?? { label: status, color: '#7c7c78', description: status }
+}
+
+export function movableStatuses(columns: string[], current: string): string[] {
+  const values = columns.includes(current) ? columns : [current, ...columns]
+  return values.filter((status, index) => values.indexOf(status) === index
+    && (status === current || !LOCKED_KANBAN_TARGETS.has(status)))
+}
+
+export function formatKanbanTime(seconds?: number | null): string {
+  if (!seconds) return ''
+  const milliseconds = seconds < 10_000_000_000 ? seconds * 1_000 : seconds
+  const difference = Date.now() - milliseconds
+  if (difference < 60_000) return '刚刚'
+  if (difference < 3_600_000) return `${Math.max(1, Math.floor(difference / 60_000))} 分钟前`
+  if (difference < 86_400_000) return `${Math.max(1, Math.floor(difference / 3_600_000))} 小时前`
+  return new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
+    .format(new Date(milliseconds))
+}
+
+function eventPayload(event: KanbanEvent): Record<string, unknown> {
+  if (event.payload && typeof event.payload === 'object' && !Array.isArray(event.payload)) {
+    return event.payload as Record<string, unknown>
+  }
+  if (typeof event.payload !== 'string') return {}
+  try {
+    const parsed = JSON.parse(event.payload) as unknown
+    return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {}
+  } catch { return {} }
+}
+
+export function eventDescription(event: KanbanEvent): string {
+  const payload = eventPayload(event)
+  const text = (key: string) => typeof payload[key] === 'string' ? payload[key] as string : ''
+  if (event.kind === 'created') return `创建任务${text('assignee') ? `,分配给 ${text('assignee')}` : ''}`
+  if (event.kind === 'status') return `移动到 ${statusMeta(text('status')).label}`
+  if (event.kind === 'assigned') return text('assignee') ? `分配给 ${text('assignee')}` : '取消分配'
+  if (event.kind === 'commented') return `添加评论${text('author') ? ` · ${text('author')}` : ''}`
+  if (event.kind === 'claimed' || event.kind === 'spawned') return 'Agent 开始处理'
+  if (event.kind === 'completed') return '任务完成'
+  if (event.kind === 'blocked') return `任务阻塞${text('reason') ? ` · ${text('reason')}` : ''}`
+  if (event.kind === 'unblocked') return '任务已恢复'
+  if (event.kind === 'reclaimed') return '运行已回收'
+  return event.kind.replaceAll('_', ' ')
+}

+ 1 - 0
src/client/router/index.ts

@@ -6,6 +6,7 @@ const router = createRouter({
     { path: '/', redirect: '/chat' },
     { path: '/chat/:sessionId?', name: 'chat', component: () => import('@/views/ChatView.vue') },
     { path: '/groups/:roomId?/:topicId?', name: 'groups', component: () => import('@/views/GroupsView.vue') },
+    { path: '/kanban/:boardSlug?', name: 'kanban', component: () => import('@/views/KanbanView.vue') },
     { path: '/files', name: 'files', component: () => import('@/views/FilesView.vue') },
     { path: '/artifacts', redirect: '/chat' },
     { path: '/:pathMatch(.*)*', redirect: '/chat' },

+ 1 - 0
src/client/stores/index.ts

@@ -4,4 +4,5 @@ export * from './auth'
 export * from './chat'
 export * from './files'
 export * from './groups'
+export * from './kanban'
 export * from './theme'

+ 412 - 0
src/client/stores/kanban.ts

@@ -0,0 +1,412 @@
+import { computed, ref, shallowRef, watch } from 'vue'
+import { defineStore } from 'pinia'
+import {
+  addKanbanComment,
+  createKanbanBoard as createBoardApi,
+  createKanbanTask as createTaskApi,
+  deleteKanbanTask as deleteTaskApi,
+  dispatchKanban,
+  getKanbanBoard,
+  getKanbanStatus,
+  getKanbanTask,
+  listKanbanBoards,
+  listKanbanProfiles,
+  updateKanbanTask,
+  type CreateKanbanBoardInput,
+  type CreateKanbanTaskInput,
+  type KanbanBoardMeta,
+  type KanbanBoardSnapshot,
+  type KanbanProfile,
+  type KanbanTask,
+  type KanbanTaskDetail,
+  type UpdateKanbanTaskInput,
+} from '@/api/kanban'
+import { ApiError } from '@/api/client'
+import { useAuthStore } from './auth'
+
+export type KanbanAvailability = 'checking' | 'available' | 'unsupported' | 'unavailable'
+
+const BOARD_SELECTION_PREFIX = 'hermes-yaoyao:kanban-board:'
+const POLL_INTERVAL_MS = 5_000
+const MUTABLE_STATUSES = new Set(['triage', 'todo', 'ready', 'blocked', 'done'])
+
+function errorMessage(cause: unknown): string {
+  return cause instanceof Error ? cause.message : '看板请求失败'
+}
+
+export const useKanbanStore = defineStore('kanban', () => {
+  const auth = useAuthStore()
+  const availability = ref<KanbanAvailability>('checking')
+  const pluginVersion = ref('')
+  const boards = ref<KanbanBoardMeta[]>([])
+  const currentBoardSlug = ref('')
+  const selectedBoardSlug = ref('')
+  const snapshot = shallowRef<KanbanBoardSnapshot>()
+  const profiles = ref<KanbanProfile[]>([])
+  const selectedTaskId = ref('')
+  const selectedTask = shallowRef<KanbanTaskDetail>()
+  const includeArchived = ref(false)
+  const search = ref('')
+  const assignee = ref('')
+  const tenant = ref('')
+  const isLoading = ref(false)
+  const isTaskLoading = ref(false)
+  const isMutating = ref(false)
+  const error = ref('')
+  const taskError = ref('')
+  const lastUpdatedAt = ref(0)
+
+  let pollTimer: number | undefined
+  let generation = 0
+  let boardRefresh: Promise<void> | undefined
+  let boardRefreshGeneration = -1
+  let boardRequestRevision = 0
+  let taskRequestRevision = 0
+
+  const canEdit = computed(() => auth.user?.role === 'admin')
+  const selectedBoard = computed(() => boards.value.find(board => board.slug === selectedBoardSlug.value))
+  const allTasks = computed(() => snapshot.value?.columns.flatMap(column => column.tasks) ?? [])
+  const filteredColumns = computed(() => {
+    const needle = search.value.trim().toLocaleLowerCase()
+    return (snapshot.value?.columns ?? []).map(column => ({
+      ...column,
+      tasks: column.tasks.filter(task => (
+        (!needle || `${task.title} ${task.body ?? ''} ${task.latest_summary ?? ''} ${task.id}`.toLocaleLowerCase().includes(needle))
+        && (!assignee.value || task.assignee === assignee.value)
+        && (!tenant.value || task.tenant === tenant.value)
+      )),
+    }))
+  })
+
+  function selectionKey(): string {
+    return `${BOARD_SELECTION_PREFIX}${auth.user?.id || 'local'}`
+  }
+
+  function persistedSelection(): string {
+    try { return localStorage.getItem(selectionKey())?.trim() ?? '' } catch { return '' }
+  }
+
+  function persistSelection(value: string): void {
+    try { localStorage.setItem(selectionKey(), value) } catch { /* optional persistence */ }
+  }
+
+  function requireEditor(): void {
+    if (!canEdit.value) throw new ApiError('看板编辑仅限管理员', 403, 'admin_required')
+  }
+
+  function requireMutableStatus(status: string): void {
+    if (!MUTABLE_STATUSES.has(status)) {
+      throw new ApiError('该状态由 Kanban 系统维护,客户端不能直接写入', 400, 'invalid_kanban_status')
+    }
+  }
+
+  function cancelledMutation(): ApiError {
+    return new ApiError('账号或看板上下文已变化,请重试', 0, 'REQUEST_ABORTED')
+  }
+
+  async function initialize(): Promise<void> {
+    const expected = ++generation
+    stopPolling()
+    availability.value = 'checking'
+    isLoading.value = true
+    error.value = ''
+    try {
+      const status = await getKanbanStatus()
+      if (expected !== generation) return
+      pluginVersion.value = status.version || ''
+      if (!status.available) {
+        availability.value = 'unsupported'
+        error.value = status.reason || '9119 尚未安装或启用 Kanban 插件'
+        boards.value = []
+        snapshot.value = undefined
+        return
+      }
+      const [boardResult, profileResult] = await Promise.all([
+        listKanbanBoards(),
+        listKanbanProfiles(),
+      ])
+      if (expected !== generation) return
+      availability.value = 'available'
+      boards.value = boardResult.boards
+      profiles.value = profileResult
+      currentBoardSlug.value = boardResult.current
+      const remembered = persistedSelection()
+      selectedBoardSlug.value = boards.value.some(board => board.slug === remembered)
+        ? remembered
+        : boards.value.some(board => board.slug === boardResult.current)
+          ? boardResult.current
+          : boards.value[0]?.slug || ''
+      if (selectedBoardSlug.value) await refreshBoard(false, true)
+      if (expected !== generation) return
+      startPolling()
+    } catch (cause) {
+      if (expected !== generation) return
+      availability.value = 'unavailable'
+      error.value = errorMessage(cause)
+    } finally {
+      if (expected === generation) isLoading.value = false
+    }
+  }
+
+  async function refreshBoards(): Promise<void> {
+    const expected = generation
+    const result = await listKanbanBoards()
+    if (expected !== generation) return
+    boards.value = result.boards
+    currentBoardSlug.value = result.current
+    if (!boards.value.some(board => board.slug === selectedBoardSlug.value)) {
+      selectedBoardSlug.value = boards.value.some(board => board.slug === result.current)
+        ? result.current : boards.value[0]?.slug || ''
+    }
+  }
+
+  async function refreshBoard(silent = false, forceAfterPending = false): Promise<void> {
+    if (boardRefresh && boardRefreshGeneration === generation) {
+      if (!forceAfterPending) return boardRefresh
+      await boardRefresh
+    }
+    const slug = selectedBoardSlug.value
+    if (!slug) return
+    const expected = generation
+    const revision = ++boardRequestRevision
+    if (!silent) isLoading.value = true
+    const operation = (async () => {
+      try {
+        const next = await getKanbanBoard(slug, includeArchived.value)
+        if (expected !== generation || revision !== boardRequestRevision || selectedBoardSlug.value !== slug) return
+        snapshot.value = next
+        lastUpdatedAt.value = Date.now()
+        error.value = ''
+      } catch (cause) {
+        if (expected === generation && revision === boardRequestRevision
+          && selectedBoardSlug.value === slug) error.value = errorMessage(cause)
+      } finally {
+        if (!silent && expected === generation) isLoading.value = false
+      }
+    })()
+    let tracked: Promise<void>
+    tracked = operation.finally(() => {
+      if (boardRefresh === tracked) {
+        boardRefresh = undefined
+        boardRefreshGeneration = -1
+      }
+    })
+    boardRefresh = tracked
+    boardRefreshGeneration = expected
+    return tracked
+  }
+
+  async function selectBoard(slug: string): Promise<void> {
+    if (slug === selectedBoardSlug.value || !boards.value.some(board => board.slug === slug)) return
+    selectedBoardSlug.value = slug
+    selectedTaskId.value = ''
+    selectedTask.value = undefined
+    taskError.value = ''
+    snapshot.value = undefined
+    persistSelection(slug)
+    await refreshBoard(false, true)
+  }
+
+  async function selectTask(id: string): Promise<void> {
+    selectedTaskId.value = id
+    selectedTask.value = undefined
+    taskError.value = ''
+    await refreshTask(false)
+  }
+
+  function closeTask(): void {
+    taskRequestRevision += 1
+    selectedTaskId.value = ''
+    selectedTask.value = undefined
+    taskError.value = ''
+  }
+
+  async function refreshTask(silent = true): Promise<void> {
+    const id = selectedTaskId.value
+    const slug = selectedBoardSlug.value
+    if (!id || !slug) return
+    const expected = generation
+    const revision = ++taskRequestRevision
+    if (!silent) isTaskLoading.value = true
+    try {
+      const detail = await getKanbanTask(slug, id)
+      if (expected !== generation || revision !== taskRequestRevision
+        || selectedTaskId.value !== id || selectedBoardSlug.value !== slug) return
+      selectedTask.value = detail
+      taskError.value = ''
+    } catch (cause) {
+      if (expected === generation && revision === taskRequestRevision
+        && selectedTaskId.value === id) taskError.value = errorMessage(cause)
+    } finally {
+      if (expected === generation && revision === taskRequestRevision) isTaskLoading.value = false
+    }
+  }
+
+  async function authoritativeRefresh(taskId = selectedTaskId.value): Promise<void> {
+    await Promise.all([
+      refreshBoard(true, true),
+      taskId && taskId === selectedTaskId.value ? refreshTask(true) : Promise.resolve(),
+    ])
+  }
+
+  async function withMutation<T>(
+    run: () => Promise<T>,
+    taskId = selectedTaskId.value,
+    allowBoardChange = false,
+  ): Promise<T> {
+    requireEditor()
+    const expected = generation
+    const expectedBoard = selectedBoardSlug.value
+    isMutating.value = true
+    error.value = ''
+    try {
+      const result = await run()
+      if (expected !== generation || (!allowBoardChange && selectedBoardSlug.value !== expectedBoard)) {
+        throw cancelledMutation()
+      }
+      boardRequestRevision += 1
+      taskRequestRevision += 1
+      await authoritativeRefresh(taskId)
+      if (expected !== generation || (!allowBoardChange && selectedBoardSlug.value !== expectedBoard)) {
+        throw cancelledMutation()
+      }
+      return result
+    } catch (cause) {
+      if (expected === generation && !(cause instanceof ApiError && cause.code === 'REQUEST_ABORTED')) {
+        error.value = errorMessage(cause)
+      }
+      throw cause
+    } finally {
+      if (expected === generation) isMutating.value = false
+    }
+  }
+
+  async function createBoard(input: CreateKanbanBoardInput): Promise<KanbanBoardMeta> {
+    const expected = generation
+    return withMutation(async () => {
+      const result = await createBoardApi(input)
+      if (expected !== generation) throw cancelledMutation()
+      await refreshBoards()
+      if (expected !== generation) throw cancelledMutation()
+      selectedBoardSlug.value = result.board.slug
+      persistSelection(result.board.slug)
+      const nextSnapshot = await getKanbanBoard(result.board.slug, includeArchived.value)
+      if (expected !== generation) throw cancelledMutation()
+      snapshot.value = nextSnapshot
+      return result.board
+    }, '', true)
+  }
+
+  async function createTask(input: CreateKanbanTaskInput, targetStatus = 'ready'): Promise<KanbanTask | null> {
+    requireMutableStatus(targetStatus)
+    const slug = selectedBoardSlug.value
+    if (!slug) throw new Error('尚未选择看板')
+    return withMutation(async () => {
+      const created = await createTaskApi(slug, { ...input, triage: targetStatus === 'triage' })
+      if (created.task && created.task.status !== targetStatus) {
+        const moved = await updateKanbanTask(slug, created.task.id, { status: targetStatus })
+        return moved.task
+      }
+      return created.task
+    }, '')
+  }
+
+  async function moveTask(task: KanbanTask, status: string): Promise<void> {
+    if (status === task.status) return
+    requireMutableStatus(status)
+    const slug = selectedBoardSlug.value
+    await withMutation(async () => {
+      await updateKanbanTask(slug, task.id, { status })
+    }, task.id)
+  }
+
+  async function updateTask(input: UpdateKanbanTaskInput): Promise<void> {
+    if (input.status !== undefined) requireMutableStatus(input.status)
+    const id = selectedTaskId.value
+    const slug = selectedBoardSlug.value
+    if (!id || !slug) return
+    await withMutation(async () => { await updateKanbanTask(slug, id, input) }, id)
+  }
+
+  async function deleteTask(id = selectedTaskId.value): Promise<void> {
+    const slug = selectedBoardSlug.value
+    if (!id || !slug) return
+    await withMutation(async () => { await deleteTaskApi(slug, id) }, '')
+    if (selectedTaskId.value === id) closeTask()
+  }
+
+  async function comment(body: string): Promise<void> {
+    const id = selectedTaskId.value
+    const slug = selectedBoardSlug.value
+    if (!id || !slug || !body.trim()) return
+    await withMutation(async () => {
+      await addKanbanComment(slug, id, {
+        body: body.trim(),
+      })
+    }, id)
+  }
+
+  async function dispatch(): Promise<unknown> {
+    const slug = selectedBoardSlug.value
+    if (!slug) return
+    return withMutation(() => dispatchKanban(slug), selectedTaskId.value)
+  }
+
+  async function setIncludeArchived(value: boolean): Promise<void> {
+    includeArchived.value = value
+    await refreshBoard(false, true)
+  }
+
+  function startPolling(): void {
+    stopPolling()
+    pollTimer = window.setInterval(() => {
+      if (availability.value !== 'available' || document.visibilityState === 'hidden') return
+      void refreshBoard(true)
+      if (selectedTaskId.value) void refreshTask(true)
+    }, POLL_INTERVAL_MS)
+  }
+
+  function stopPolling(): void {
+    if (pollTimer !== undefined) window.clearInterval(pollTimer)
+    pollTimer = undefined
+  }
+
+  function reset(): void {
+    generation += 1
+    boardRequestRevision += 1
+    taskRequestRevision += 1
+    stopPolling()
+    availability.value = 'checking'
+    pluginVersion.value = ''
+    boards.value = []
+    currentBoardSlug.value = ''
+    selectedBoardSlug.value = ''
+    snapshot.value = undefined
+    profiles.value = []
+    selectedTaskId.value = ''
+    selectedTask.value = undefined
+    includeArchived.value = false
+    search.value = ''
+    assignee.value = ''
+    tenant.value = ''
+    isLoading.value = false
+    isTaskLoading.value = false
+    isMutating.value = false
+    error.value = ''
+    taskError.value = ''
+    lastUpdatedAt.value = 0
+  }
+
+  watch(
+    () => `${auth.isAuthenticated ? 'authenticated' : 'anonymous'}:${auth.user?.id || ''}`,
+    (identity, previous) => { if (identity !== previous) reset() },
+  )
+
+  return {
+    availability, pluginVersion, boards, currentBoardSlug, selectedBoardSlug, selectedBoard, snapshot, profiles,
+    selectedTaskId, selectedTask, includeArchived, search, assignee, tenant, isLoading, isTaskLoading, isMutating,
+    error, taskError, lastUpdatedAt, canEdit, allTasks, filteredColumns,
+    initialize, refreshBoards, refreshBoard, selectBoard, selectTask, closeTask, refreshTask, createBoard, createTask,
+    moveTask, updateTask, deleteTask, comment, dispatch, setIncludeArchived, startPolling, stopPolling, reset,
+  }
+})

+ 258 - 0
src/client/views/KanbanView.vue

@@ -0,0 +1,258 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import type { CreateKanbanBoardInput, CreateKanbanTaskInput, KanbanTask, UpdateKanbanTaskInput } from '@/api/kanban'
+import AppIcon from '@/components/common/AppIcon.vue'
+import EmptyState from '@/components/common/EmptyState.vue'
+import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
+import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
+import KanbanBoardDialog from '@/components/kanban/KanbanBoardDialog.vue'
+import KanbanColumn from '@/components/kanban/KanbanColumn.vue'
+import KanbanTaskDialog from '@/components/kanban/KanbanTaskDialog.vue'
+import KanbanTaskDrawer from '@/components/kanban/KanbanTaskDrawer.vue'
+import { formatKanbanTime, statusMeta } from '@/components/kanban/presentation'
+import { useKanbanStore } from '@/stores/kanban'
+import { useAuthStore } from '@/stores/auth'
+
+const kanban = useKanbanStore()
+const auth = useAuthStore()
+const route = useRoute()
+const router = useRouter()
+const boardDialogOpen = ref(false)
+const taskDialogOpen = ref(false)
+const taskTargetStatus = ref('ready')
+const dialogError = ref('')
+
+const columnNames = computed(() => kanban.snapshot?.columns.map(column => column.name) ?? [])
+const visibleTaskCount = computed(() => kanban.filteredColumns.reduce((total, column) => total + column.tasks.length, 0))
+const lastUpdated = computed(() => kanban.lastUpdatedAt ? new Intl.DateTimeFormat('zh-CN', {
+  hour: '2-digit', minute: '2-digit', second: '2-digit',
+}).format(kanban.lastUpdatedAt) : '')
+
+async function chooseBoard(slug: string): Promise<void> {
+  await kanban.selectBoard(slug)
+  await router.push(`/kanban/${encodeURIComponent(slug)}`)
+}
+
+async function reconcileBoardRoute(value: unknown = route.params.boardSlug): Promise<void> {
+  if (kanban.availability !== 'available') return
+  const requested = typeof value === 'string' ? value : ''
+  if (requested && kanban.boards.some(board => board.slug === requested)) {
+    if (requested !== kanban.selectedBoardSlug) await kanban.selectBoard(requested)
+    return
+  }
+  if (kanban.selectedBoardSlug) {
+    await router.replace(`/kanban/${encodeURIComponent(kanban.selectedBoardSlug)}`)
+  }
+}
+
+function openTaskCreate(status = 'ready'): void {
+  taskTargetStatus.value = status
+  dialogError.value = ''
+  taskDialogOpen.value = true
+}
+
+async function createTask(input: CreateKanbanTaskInput, status: string): Promise<void> {
+  dialogError.value = ''
+  try {
+    const task = await kanban.createTask(input, status)
+    taskDialogOpen.value = false
+    if (task) await kanban.selectTask(task.id)
+  } catch (cause) {
+    dialogError.value = cause instanceof Error ? cause.message : '创建任务失败'
+  }
+}
+
+async function createBoard(input: CreateKanbanBoardInput): Promise<void> {
+  dialogError.value = ''
+  try {
+    const board = await kanban.createBoard(input)
+    boardDialogOpen.value = false
+    await router.replace(`/kanban/${encodeURIComponent(board.slug)}`)
+  } catch (cause) {
+    dialogError.value = cause instanceof Error ? cause.message : '创建看板失败'
+  }
+}
+
+async function openTask(task: KanbanTask): Promise<void> {
+  await kanban.selectTask(task.id)
+}
+
+async function moveTask(task: KanbanTask, status: string): Promise<void> {
+  try { await kanban.moveTask(task, status) } catch { /* store exposes the error */ }
+}
+
+async function dropTask(id: string, status: string): Promise<void> {
+  const task = kanban.allTasks.find(item => item.id === id)
+  if (task) await moveTask(task, status)
+}
+
+async function saveTask(input: UpdateKanbanTaskInput): Promise<void> {
+  try { await kanban.updateTask(input) } catch { /* task drawer keeps the authoritative detail */ }
+}
+
+async function addComment(body: string): Promise<void> {
+  try { await kanban.comment(body) } catch { /* store exposes the error */ }
+}
+
+async function deleteTask(): Promise<void> {
+  if (!window.confirm('确定删除这个任务吗?此操作无法恢复。')) return
+  try { await kanban.deleteTask() } catch { /* store exposes the error */ }
+}
+
+async function dispatchNow(): Promise<void> {
+  if (!window.confirm('立即运行一次看板调度?就绪任务可能马上启动 Agent。')) return
+  try { await kanban.dispatch() } catch { /* store exposes the error */ }
+}
+
+onMounted(async () => {
+  await kanban.initialize()
+  await reconcileBoardRoute()
+})
+
+watch(() => route.params.boardSlug, reconcileBoardRoute)
+
+watch(
+  () => `${auth.isAuthenticated ? 'authenticated' : 'anonymous'}:${auth.user?.id || ''}`,
+  async (identity, previous) => {
+    if (identity === previous) return
+    kanban.reset()
+    if (auth.isAuthenticated) {
+      await kanban.initialize()
+      await reconcileBoardRoute()
+    }
+  },
+)
+
+onBeforeUnmount(() => kanban.reset())
+</script>
+
+<template>
+  <WorkspaceView
+    sidebar-title="看板"
+    :sidebar-subtitle="`${kanban.boards.length} 个看板`"
+    :inspector-open="Boolean(kanban.selectedTaskId)"
+    inspector-close-label="关闭任务详情"
+    @close-inspector="kanban.closeTask"
+  >
+    <template #sidebar-action>
+      <button v-if="kanban.canEdit" type="button" @click="dialogError = ''; boardDialogOpen = true">
+        <YaoYaoSidebarIcon name="add" /><span>新建看板</span>
+      </button>
+    </template>
+    <template #sidebar>
+      <div class="kanban-board-list" role="listbox" aria-label="选择看板">
+        <button
+          v-for="board in kanban.boards"
+          :key="board.slug"
+          type="button"
+          role="option"
+          :aria-selected="board.slug === kanban.selectedBoardSlug"
+          :class="{ active: board.slug === kanban.selectedBoardSlug }"
+          @click="chooseBoard(board.slug)"
+        >
+          <span class="kanban-board-list__icon"><AppIcon name="board" :size="16" /></span>
+          <span><strong>{{ board.name || board.slug }}</strong><small>{{ board.description || board.slug }}</small></span>
+          <em>{{ board.total ?? 0 }}</em>
+        </button>
+        <p v-if="!kanban.boards.length && kanban.availability === 'available'">还没有看板。</p>
+      </div>
+    </template>
+
+    <section class="kanban-workspace">
+      <div v-if="kanban.availability === 'checking'" class="kanban-page-state">正在连接 9119 看板…</div>
+      <EmptyState
+        v-else-if="kanban.availability !== 'available'"
+        icon="alert"
+        :title="kanban.availability === 'unsupported' ? 'Kanban 插件未启用' : '看板服务暂不可用'"
+        :description="kanban.error || '请确认 9119 已安装并启用 Kanban 插件。'"
+        action-label="重新检查"
+        @action="kanban.initialize"
+      />
+      <template v-else>
+        <header class="kanban-header">
+          <div class="kanban-header__title">
+            <span><AppIcon name="board" :size="16" /></span>
+            <div><h1>{{ kanban.selectedBoard?.name || kanban.selectedBoardSlug || '看板' }}</h1><p>{{ kanban.selectedBoard?.description || `${kanban.snapshot?.columns.length ?? 0} 个状态 · ${kanban.pluginVersion ? `Kanban ${kanban.pluginVersion}` : '9119'}` }}</p></div>
+          </div>
+          <div class="kanban-header__actions">
+            <span v-if="lastUpdated">{{ lastUpdated }} 已同步</span>
+            <button type="button" title="刷新看板" aria-label="刷新看板" :disabled="kanban.isLoading" @click="kanban.refreshBoard()"><AppIcon name="refresh" :size="15" /></button>
+            <button v-if="kanban.canEdit" type="button" class="quiet-action" :disabled="kanban.isMutating" @click="dispatchNow"><AppIcon name="bolt" :size="14" />立即调度</button>
+            <button v-if="kanban.canEdit" type="button" class="primary-action" @click="openTaskCreate('ready')"><AppIcon name="plus" :size="14" />新建任务</button>
+          </div>
+        </header>
+
+        <div v-if="!kanban.canEdit" class="kanban-readonly-banner"><AppIcon name="alert" :size="14" /><span>当前账号可查看完整看板;移动、创建、编辑、评论和调度仅限管理员。</span></div>
+        <div v-if="kanban.error" class="kanban-error-banner" role="alert"><AppIcon name="alert" :size="14" /><span>{{ kanban.error }}</span></div>
+
+        <div class="kanban-filters">
+          <label class="kanban-search"><AppIcon name="search" :size="14" /><input v-model="kanban.search" type="search" placeholder="搜索标题、描述或任务 ID" /></label>
+          <label><span>Agent</span><select v-model="kanban.assignee"><option value="">全部</option><option v-for="value in kanban.snapshot?.assignees || []" :key="value" :value="value">{{ value }}</option></select></label>
+          <label><span>租户</span><select v-model="kanban.tenant"><option value="">全部</option><option v-for="value in kanban.snapshot?.tenants || []" :key="value" :value="value">{{ value }}</option></select></label>
+          <label class="kanban-archived-toggle"><input :checked="kanban.includeArchived" type="checkbox" @change="kanban.setIncludeArchived(($event.target as HTMLInputElement).checked)" /><span>显示归档</span></label>
+          <span class="kanban-filter-count">{{ visibleTaskCount }} 项</span>
+        </div>
+
+        <div v-if="kanban.isLoading && !kanban.snapshot" class="kanban-page-state">正在读取看板…</div>
+        <div v-else-if="kanban.snapshot" class="kanban-columns" aria-label="任务看板">
+          <KanbanColumn
+            v-for="column in kanban.filteredColumns"
+            :key="column.name"
+            :column="column"
+            :columns="columnNames"
+            :can-edit="kanban.canEdit"
+            @add="openTaskCreate"
+            @open="openTask"
+            @move="moveTask"
+            @drop-task="dropTask"
+          />
+        </div>
+      </template>
+    </section>
+
+    <template #inspector>
+      <KanbanTaskDrawer
+        :detail="kanban.selectedTask"
+        :profiles="kanban.profiles"
+        :columns="columnNames"
+        :can-edit="kanban.canEdit"
+        :busy="kanban.isMutating"
+        :loading="kanban.isTaskLoading"
+        :error="kanban.taskError || kanban.error"
+        @save="saveTask"
+        @comment="addComment"
+        @delete="deleteTask"
+      />
+    </template>
+  </WorkspaceView>
+
+  <KanbanTaskDialog
+    :open="taskDialogOpen"
+    :target-status="taskTargetStatus"
+    :columns="columnNames"
+    :profiles="kanban.profiles"
+    :busy="kanban.isMutating"
+    :error="dialogError"
+    @close="taskDialogOpen = false"
+    @create="createTask"
+  />
+  <KanbanBoardDialog
+    :open="boardDialogOpen"
+    :busy="kanban.isMutating"
+    :error="dialogError"
+    @close="boardDialogOpen = false"
+    @create="createBoard"
+  />
+</template>
+
+<style scoped>
+.kanban-workspace { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; overflow: hidden; background: var(--canvas); }.kanban-page-state { display: grid; min-height: 260px; flex: 1; place-items: center; color: var(--text-muted); font-size: 11px; }
+.kanban-board-list { display: flex; min-height: 0; height: 100%; padding: 2px 10px 12px; flex-direction: column; gap: 2px; overflow-y: auto; }.kanban-board-list > button { display: flex; width: 100%; min-height: 48px; align-items: center; gap: 8px; padding: 6px 8px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }.kanban-board-list > button:hover, .kanban-board-list > button.active { background: var(--surface-soft); }.kanban-board-list__icon { display: grid; width: 28px; height: 28px; flex: 0 0 28px; place-items: center; border-radius: 8px; background: var(--surface-raised); color: var(--text-secondary); }.kanban-board-list > button > span:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; }.kanban-board-list strong, .kanban-board-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.kanban-board-list strong { font-size: 12px; font-weight: 620; }.kanban-board-list small { margin-top: 2px; color: var(--text-muted); font-size: 9px; }.kanban-board-list em { color: var(--text-muted); font-size: 9px; font-style: normal; font-variant-numeric: tabular-nums; }.kanban-board-list > p { padding: 20px 10px; color: var(--text-muted); font-size: 10px; text-align: center; }
+.kanban-header { display: flex; min-height: 62px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 14px; padding: 9px 16px 9px 18px; border-bottom: 1px solid var(--line); background: var(--surface); }.kanban-header__title { display: flex; min-width: 0; align-items: center; gap: 9px; }.kanban-header__title > span { display: grid; width: 32px; height: 32px; flex: 0 0 32px; place-items: center; border-radius: 9px; background: var(--surface-soft); color: var(--text-secondary); }.kanban-header__title > div { min-width: 0; }.kanban-header h1 { margin: 0; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }.kanban-header p { margin: 3px 0 0; overflow: hidden; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.kanban-header__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; }.kanban-header__actions > span { margin-right: 3px; color: var(--text-muted); font-size: 8px; }.kanban-header__actions button { display: flex; min-height: 34px; align-items: center; justify-content: center; gap: 5px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: 10px; font-weight: 650; }.kanban-header__actions button[aria-label="刷新看板"] { width: 34px; padding: 0; }.kanban-header__actions button:hover { background: var(--surface-soft); }.kanban-header__actions .primary-action { border-color: var(--accent); background: var(--accent); color: white; }.kanban-header__actions button:disabled { cursor: not-allowed; opacity: .5; }
+.kanban-readonly-banner, .kanban-error-banner { display: flex; min-height: 34px; flex: 0 0 auto; align-items: center; gap: 7px; padding: 6px 18px; border-bottom: 1px solid var(--line); background: var(--surface-soft); color: var(--text-secondary); font-size: 9.5px; }.kanban-error-banner { background: color-mix(in srgb, var(--danger) 8%, var(--surface)); color: var(--danger); }
+.kanban-filters { display: flex; min-height: 48px; flex: 0 0 auto; align-items: center; gap: 7px; padding: 7px 16px 7px 18px; border-bottom: 1px solid var(--line); background: var(--surface); overflow-x: auto; }.kanban-filters > label:not(.kanban-search):not(.kanban-archived-toggle) { display: flex; align-items: center; gap: 5px; }.kanban-filters label > span { color: var(--text-muted); font-size: 8.5px; white-space: nowrap; }.kanban-filters select { min-height: 31px; max-width: 130px; padding: 0 25px 0 8px; border: 1px solid var(--line); border-radius: 8px; outline: 0; background: var(--surface-raised); color: var(--text-secondary); font: inherit; font-size: 9.5px; }.kanban-search { display: flex; width: min(280px, 32vw); min-height: 32px; flex: 0 0 auto; align-items: center; gap: 6px; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: var(--text-muted); }.kanban-search:focus-within { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.kanban-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 10px; }.kanban-archived-toggle { display: flex; align-items: center; gap: 5px; white-space: nowrap; }.kanban-archived-toggle input { accent-color: var(--accent); }.kanban-filter-count { margin-left: auto; color: var(--text-muted); font-size: 9px; white-space: nowrap; }
+.kanban-columns { display: flex; min-width: 0; min-height: 0; flex: 1; gap: 9px; padding: 10px 12px 12px; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x proximity; scrollbar-gutter: stable; }
+@media (max-width: 900px) { .kanban-header { padding-inline: 13px; }.kanban-header__actions > span { display: none; }.kanban-filters { padding-inline: 13px; }.kanban-search { width: min(230px, 52vw); }.kanban-columns { padding-inline: 8px; } }
+@media (max-width: 600px) { .kanban-header { min-height: 56px; padding: 7px 10px; }.kanban-header__title > span, .kanban-header p { display: none; }.kanban-header h1 { max-width: 36vw; font-size: 14px; }.kanban-header__actions button { min-height: 38px; }.kanban-header__actions .quiet-action, .kanban-header__actions .primary-action { width: 38px; padding: 0; font-size: 0; }.kanban-readonly-banner, .kanban-error-banner { padding-inline: 11px; font-size: 9px; }.kanban-filters { min-height: 50px; padding: 7px 8px; }.kanban-search { width: 62vw; min-height: 36px; }.kanban-search input { font-size: 16px; }.kanban-filters select { min-height: 36px; font-size: 12px; }.kanban-filter-count { display: none; }.kanban-columns { gap: 8px; padding: 8px; scroll-snap-type: x mandatory; } }
+</style>

+ 211 - 0
src/server/routes.ts

@@ -695,6 +695,214 @@ async function proxyAdminFeature(
   })
 }
 
+const KANBAN_UPSTREAM_PREFIX = '/api/plugins/kanban'
+
+function sendKanbanUpstreamResponse(
+  ctx: Koa.Context,
+  response: UpstreamResponse,
+  jar: CookieJar,
+): void {
+  if (response.status === 401) {
+    throw new HttpError(502, '9119 Kanban 认证会话不可用', 'upstream_auth_unavailable')
+  }
+  sendUpstreamResponse(ctx, response, jar)
+}
+
+async function kanbanAvailability(
+  ctx: Koa.Context,
+  dependencies: RouteDependencies,
+): Promise<void> {
+  dependencies.auth.require(ctx)
+  const probe = await dependencies.upstreamSession.request(`${KANBAN_UPSTREAM_PREFIX}/boards`)
+  if (probe.status === 404 || probe.status === 405) {
+    json(ctx, 200, {
+      available: false,
+      reason: '9119 未安装、未启用或未挂载 Kanban API',
+    })
+    return
+  }
+  if (probe.status < 200 || probe.status >= 300) {
+    sendKanbanUpstreamResponse(ctx, probe, dependencies.upstreamSession.jar)
+    return
+  }
+  let version: string | undefined
+  try {
+    const metadata = await dependencies.upstreamSession.request('/api/dashboard/plugins')
+    if (metadata.status >= 200 && metadata.status < 300) {
+      const manifests = parseJsonValue(metadata)
+      if (Array.isArray(manifests)) {
+        const manifest = manifests.find(entry => (
+          entry && typeof entry === 'object' && !Array.isArray(entry)
+          && (entry as JsonObject).name === 'kanban'
+        )) as JsonObject | undefined
+        if (typeof manifest?.version === 'string') version = manifest.version
+      }
+    }
+  } catch { /* The mounted API is authoritative; metadata is optional. */ }
+  json(ctx, 200, {
+    available: true,
+    version,
+  })
+}
+
+async function proxyKanban(
+  ctx: Koa.Context,
+  dependencies: RouteDependencies,
+  path: string | (() => string),
+  options: {
+    allowedQuery?: readonly string[]
+    body?: 'required' | 'optional'
+    boardRequired?: boolean
+    requestBody?: () => JsonObject
+    sanitizeTaskDetail?: boolean
+  } = {},
+): Promise<void> {
+  if (ctx.method === 'GET' || ctx.method === 'HEAD') dependencies.auth.require(ctx)
+  else dependencies.auth.requireAdmin(ctx)
+  const resolvedPath = typeof path === 'function' ? path() : path
+  const requestBody = options.requestBody?.() ?? (options.body === 'required' ? body(ctx)
+    : options.body === 'optional' ? optionalBody(ctx) : undefined)
+  const search = searchFrom(ctx, options.allowedQuery ?? [])
+  if (options.boardRequired) {
+    const boardValues = search.getAll('board')
+    const board = boardValues[0]?.trim() ?? ''
+    if (boardValues.length === 0 || !board) {
+      throw new HttpError(400, 'Kanban board is required', 'kanban_board_required')
+    }
+    if (boardValues.length !== 1) {
+      throw new HttpError(400, 'Kanban board must be provided exactly once', 'invalid_kanban_board')
+    }
+    if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(board)) {
+      throw new HttpError(400, 'Kanban board is invalid', 'invalid_kanban_board')
+    }
+    search.set('board', board)
+  }
+  const upstreamPath = `${KANBAN_UPSTREAM_PREFIX}${resolvedPath}`
+  if (options.sanitizeTaskDetail) {
+    await withJar(ctx, async (jar) => {
+      const response = await dependencies.upstream.request(upstreamPath, jar, {
+        method: ctx.method,
+        search,
+        body: requestBody,
+      })
+      if (response.status < 200 || response.status >= 300) {
+        sendKanbanUpstreamResponse(ctx, response, jar)
+        return
+      }
+      const payload = parseJson(response)
+      if (Object.prototype.hasOwnProperty.call(payload, 'attachments')) payload.attachments = []
+      json(ctx, response.status, payload)
+    })
+    return
+  }
+  await withJar(ctx, async (jar) => {
+    const response = await dependencies.upstream.request(upstreamPath, jar, {
+      method: ctx.method,
+      search,
+      body: requestBody,
+    })
+    sendKanbanUpstreamResponse(ctx, response, jar)
+  })
+}
+
+function kanbanBoardCreateBody(ctx: Koa.Context): JsonObject {
+  const input = body(ctx)
+  const output: JsonObject = {}
+  for (const key of ['slug', 'name', 'description']) {
+    if (Object.prototype.hasOwnProperty.call(input, key)) output[key] = input[key]
+  }
+  output.switch = false
+  return output
+}
+
+function kanbanCommentBody(ctx: Koa.Context, dependencies: RouteDependencies): JsonObject {
+  const input = body(ctx)
+  const admin = dependencies.auth.requireAdmin(ctx)
+  return {
+    body: input.body,
+    author: admin.username,
+  }
+}
+
+function registerKanbanRoutes(router: Router, dependencies: RouteDependencies, prefix: string): void {
+  router.get(`${prefix}/status`, async (ctx) => {
+    await kanbanAvailability(ctx, dependencies)
+  })
+  router.get(`${prefix}/boards`, async (ctx) => {
+    await proxyKanban(ctx, dependencies, '/boards', {
+      allowedQuery: ['include_archived'],
+    })
+  })
+  router.post(`${prefix}/boards`, async (ctx) => {
+    await proxyKanban(ctx, dependencies, '/boards', {
+      requestBody: () => kanbanBoardCreateBody(ctx),
+    })
+  })
+  router.get(`${prefix}/board`, async (ctx) => {
+    await proxyKanban(ctx, dependencies, '/board', {
+      allowedQuery: ['board', 'include_archived'],
+      boardRequired: true,
+    })
+  })
+  router.get(`${prefix}/profiles`, async (ctx) => {
+    await proxyKanban(ctx, dependencies, '/profiles')
+  })
+  router.get(`${prefix}/tasks/:taskID`, async (ctx) => {
+    await proxyKanban(ctx, dependencies, () => {
+      const id = safeIdentifier(ctx.params.taskID, 'Kanban task ID')
+      return `/tasks/${encodeURIComponent(id)}`
+    }, {
+      allowedQuery: ['board', 'run_state_name', 'run_state_type'],
+      boardRequired: true,
+      sanitizeTaskDetail: true,
+    })
+  })
+  router.post(`${prefix}/tasks`, async (ctx) => {
+    await proxyKanban(ctx, dependencies, '/tasks', {
+      allowedQuery: ['board'],
+      boardRequired: true,
+      body: 'required',
+    })
+  })
+  router.patch(`${prefix}/tasks/:taskID`, async (ctx) => {
+    await proxyKanban(ctx, dependencies, () => {
+      const id = safeIdentifier(ctx.params.taskID, 'Kanban task ID')
+      return `/tasks/${encodeURIComponent(id)}`
+    }, {
+      allowedQuery: ['board'],
+      boardRequired: true,
+      body: 'required',
+    })
+  })
+  router.delete(`${prefix}/tasks/:taskID`, async (ctx) => {
+    await proxyKanban(ctx, dependencies, () => {
+      const id = safeIdentifier(ctx.params.taskID, 'Kanban task ID')
+      return `/tasks/${encodeURIComponent(id)}`
+    }, {
+      allowedQuery: ['board'],
+      boardRequired: true,
+      body: 'optional',
+    })
+  })
+  router.post(`${prefix}/tasks/:taskID/comments`, async (ctx) => {
+    await proxyKanban(ctx, dependencies, () => {
+      const id = safeIdentifier(ctx.params.taskID, 'Kanban task ID')
+      return `/tasks/${encodeURIComponent(id)}/comments`
+    }, {
+      allowedQuery: ['board'],
+      boardRequired: true,
+      requestBody: () => kanbanCommentBody(ctx, dependencies),
+    })
+  })
+  router.post(`${prefix}/dispatch`, async (ctx) => {
+    await proxyKanban(ctx, dependencies, '/dispatch', {
+      allowedQuery: ['board', 'dry_run', 'max'],
+      boardRequired: true,
+      body: 'optional',
+    })
+  })
+}
+
 async function proxyOptionalUnread(
   ctx: Koa.Context,
   upstream: UpstreamClient,
@@ -1027,6 +1235,9 @@ function pairedLocalMediaPath(config: ServerConfig, rawPath: string): string {
 export function createApiRouter(dependencies: RouteDependencies): Router {
   const router = new Router()
 
+  registerKanbanRoutes(router, dependencies, '/api/app/kanban')
+  registerKanbanRoutes(router, dependencies, '/api/kanban/v1')
+
   router.get('/healthz', (ctx) => {
     ctx.set('Cache-Control', 'no-store')
     json(ctx, 200, { ok: true })

+ 66 - 0
tests/client/kanbanProtocol.test.ts

@@ -0,0 +1,66 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+  addKanbanComment,
+  createKanbanBoard,
+  createKanbanTask,
+  deleteKanbanTask,
+  dispatchKanban,
+  getKanbanBoard,
+  getKanbanStatus,
+  getKanbanTask,
+  listKanbanBoards,
+  listKanbanProfiles,
+  updateKanbanTask,
+} from '@/api/kanban'
+import { setApiCsrfToken } from '@/api/client'
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  setApiCsrfToken('')
+})
+
+describe('Kanban Web client protocol', () => {
+  it('keeps every browser request under the CSRF-protected app alias and scopes board reads', async () => {
+    const calls: Array<{ path: string; init?: RequestInit }> = []
+    vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
+      calls.push({ path, init })
+      if (path.endsWith('/status')) return Response.json({ available: true, version: '1.0.0' })
+      if (path.endsWith('/boards')) return Response.json({ boards: [], current: 'default', board: { slug: 'mobile-release' } })
+      if (path.includes('/board?')) return Response.json({ columns: [], tenants: [], assignees: [], latest_event_id: 0, now: 1 })
+      if (path.endsWith('/profiles')) return Response.json({ profiles: [] })
+      if (path.includes('/tasks/task%20%2F%20one?')) return Response.json({ task: { id: 'task / one', title: 'Task', status: 'todo' }, comments: [], events: [], links: { parents: [], children: [] }, runs: [] })
+      return Response.json({ ok: true, task: { id: 'task / one', title: 'Task', status: 'ready' }, spawned: [] })
+    }))
+    setApiCsrfToken('csrf-kanban')
+
+    await getKanbanStatus()
+    await listKanbanBoards()
+    await getKanbanBoard('mobile / 中文', true)
+    await listKanbanProfiles()
+    await getKanbanTask('mobile / 中文', 'task / one')
+    await createKanbanBoard({ slug: 'mobile-release', name: '移动发布' })
+    await createKanbanTask('mobile / 中文', { title: '实现看板', assignee: 'yaoyao' })
+    await updateKanbanTask('mobile / 中文', 'task / one', { status: 'ready' })
+    await addKanbanComment('mobile / 中文', 'task / one', { body: '请继续' })
+    await dispatchKanban('mobile / 中文')
+    await deleteKanbanTask('mobile / 中文', 'task / one')
+
+    expect(calls.map(call => call.path)).toEqual([
+      '/api/app/kanban/status',
+      '/api/app/kanban/boards',
+      '/api/app/kanban/board?include_archived=true&board=mobile+%2F+%E4%B8%AD%E6%96%87',
+      '/api/app/kanban/profiles',
+      '/api/app/kanban/tasks/task%20%2F%20one?board=mobile+%2F+%E4%B8%AD%E6%96%87',
+      '/api/app/kanban/boards',
+      '/api/app/kanban/tasks?board=mobile+%2F+%E4%B8%AD%E6%96%87',
+      '/api/app/kanban/tasks/task%20%2F%20one?board=mobile+%2F+%E4%B8%AD%E6%96%87',
+      '/api/app/kanban/tasks/task%20%2F%20one/comments?board=mobile+%2F+%E4%B8%AD%E6%96%87',
+      '/api/app/kanban/dispatch?board=mobile+%2F+%E4%B8%AD%E6%96%87',
+      '/api/app/kanban/tasks/task%20%2F%20one?board=mobile+%2F+%E4%B8%AD%E6%96%87',
+    ])
+    const mutations = calls.filter(call => !['GET', 'HEAD'].includes((call.init?.method ?? 'GET').toUpperCase()))
+    expect(mutations.every(call => new Headers(call.init?.headers).get('X-CSRF-Token') === 'csrf-kanban')).toBe(true)
+    expect(JSON.parse(String(calls[5]!.init?.body))).toMatchObject({ slug: 'mobile-release', switch: false })
+    expect(calls.some(call => call.path.includes('/switch'))).toBe(false)
+  })
+})

+ 244 - 0
tests/client/kanbanStore.test.ts

@@ -0,0 +1,244 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+import { setApiCsrfToken } from '@/api/client'
+import { useAuthStore } from '@/stores/auth'
+import { useKanbanStore } from '@/stores/kanban'
+
+const boardPayload = () => ({
+  columns: [
+    { name: 'todo', tasks: [{ id: 'task-a', title: '移动端验收', body: 'iOS 与 Web', status: 'todo', assignee: 'yaoyao', tenant: 'mobile', priority: 2, created_at: 1 }] },
+    { name: 'ready', tasks: [{ id: 'task-b', title: '服务端核对', status: 'ready', assignee: 'ops', tenant: 'backend', created_at: 2 }] },
+  ],
+  tenants: ['backend', 'mobile'],
+  assignees: ['ops', 'yaoyao'],
+  latest_event_id: 2,
+  now: 3,
+})
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  let reject!: (reason?: unknown) => void
+  const promise = new Promise<T>((resolvePromise, rejectPromise) => {
+    resolve = resolvePromise
+    reject = rejectPromise
+  })
+  return { promise, resolve, reject }
+}
+
+beforeEach(() => {
+  setActivePinia(createPinia())
+  localStorage.clear()
+  vi.useFakeTimers()
+  setApiCsrfToken('csrf-kanban-store')
+})
+
+afterEach(() => {
+  useKanbanStore().stopPolling()
+  vi.useRealTimers()
+  vi.unstubAllGlobals()
+  setApiCsrfToken('')
+})
+
+describe('Kanban store', () => {
+  it('loads and switches boards, filters locally, and authoritatively refreshes after a write', async () => {
+    const calls: Array<{ path: string; method: string }> = []
+    vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
+      const method = init?.method ?? 'GET'
+      calls.push({ path, method })
+      if (path.endsWith('/status')) return Response.json({ available: true, version: '1.0.0' })
+      if (path.endsWith('/boards')) return Response.json({ boards: [{ slug: 'default', name: '默认', total: 2 }, { slug: 'mobile', name: '移动端', total: 2 }], current: 'default' })
+      if (path.endsWith('/profiles')) return Response.json({ profiles: [{ name: 'yaoyao', is_default: true }, { name: 'ops', is_default: false }] })
+      if (path.includes('/board?')) return Response.json(boardPayload())
+      if (path.includes('/tasks?') && method === 'POST') return Response.json({ task: { id: 'task-new', title: '新任务', status: 'ready' } })
+      return Response.json({ ok: true })
+    }))
+    const auth = useAuthStore()
+    auth.status = 'authenticated'
+    auth.user = { id: 'admin-a', username: 'admin', role: 'admin' }
+    const store = useKanbanStore()
+
+    await store.initialize()
+    await store.selectBoard('mobile')
+    expect(store.availability).toBe('available')
+    expect(store.selectedBoardSlug).toBe('mobile')
+    expect(store.allTasks.map(task => task.id)).toEqual(['task-a', 'task-b'])
+    expect(calls.filter(call => call.path.includes('/board?')).at(-1)?.path).toContain('board=mobile')
+
+    store.search = 'iOS'
+    store.assignee = 'yaoyao'
+    store.tenant = 'mobile'
+    expect(store.filteredColumns.flatMap(column => column.tasks).map(task => task.id)).toEqual(['task-a'])
+
+    const beforeRejectedTargets = calls.length
+    await expect(store.createTask({ title: '非法状态' }, 'running')).rejects.toMatchObject({ code: 'invalid_kanban_status' })
+    await expect(store.moveTask(store.allTasks[0]!, 'review')).rejects.toMatchObject({ code: 'invalid_kanban_status' })
+    expect(calls).toHaveLength(beforeRejectedTargets)
+
+    await store.createTask({ title: '新任务' })
+    expect(calls.filter(call => call.path.includes('/tasks?') && call.method === 'POST')).toHaveLength(1)
+    expect(calls.filter(call => call.path.includes('/board?')).length).toBeGreaterThanOrEqual(2)
+  })
+
+  it('allows regular users to read but rejects every mutation before making a request', async () => {
+    const calls: Array<{ path: string; method: string }> = []
+    vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
+      calls.push({ path, method: init?.method ?? 'GET' })
+      if (path.endsWith('/status')) return Response.json({ available: true })
+      if (path.endsWith('/boards')) return Response.json({ boards: [{ slug: 'default', total: 2 }], current: 'default' })
+      if (path.endsWith('/profiles')) return Response.json({ profiles: [] })
+      if (path.includes('/board?')) return Response.json(boardPayload())
+      return Response.json({ ok: true })
+    }))
+    const auth = useAuthStore()
+    auth.status = 'authenticated'
+    auth.user = { id: 'reader', username: 'reader', role: 'user' }
+    const store = useKanbanStore()
+    await store.initialize()
+
+    expect(store.canEdit).toBe(false)
+    await expect(store.moveTask(store.allTasks[0]!, 'ready')).rejects.toMatchObject({ status: 403, code: 'admin_required' })
+    expect(calls.some(call => call.method === 'PATCH')).toBe(false)
+  })
+
+  it('forces a post-mutation board read after an older poll and discards stale task detail responses', async () => {
+    const staleBoard = deferred<Response>()
+    const freshBoard = deferred<Response>()
+    const staleTask = deferred<Response>()
+    let boardReads = 0
+    let taskReads = 0
+    let writes = 0
+    vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
+      const method = init?.method ?? 'GET'
+      if (path.endsWith('/status')) return Response.json({ available: true })
+      if (path.endsWith('/boards')) return Response.json({ boards: [{ slug: 'default', total: 1 }], current: 'default' })
+      if (path.endsWith('/profiles')) return Response.json({ profiles: [] })
+      if (path.includes('/board?')) {
+        boardReads += 1
+        if (boardReads === 2) return staleBoard.promise
+        if (boardReads >= 3) return freshBoard.promise
+        return Response.json({
+          columns: [{ name: 'todo', tasks: [{ id: 'task-a', title: '竞态任务', status: 'todo' }] }, { name: 'ready', tasks: [] }],
+          tenants: [], assignees: [], latest_event_id: boardReads, now: boardReads,
+        })
+      }
+      if (path.includes('/tasks/task-a?') && method === 'GET') {
+        taskReads += 1
+        if (taskReads === 1) return staleTask.promise
+        return Response.json({ task: { id: 'task-a', title: '新详情', status: 'ready' }, comments: [], events: [], links: { parents: [], children: [] }, runs: [] })
+      }
+      if (path.includes('/tasks/task-a?') && method === 'PATCH') {
+        writes += 1
+        return Response.json({ task: { id: 'task-a', title: '竞态任务', status: 'ready' } })
+      }
+      return Response.json({ ok: true })
+    }))
+    const auth = useAuthStore()
+    auth.status = 'authenticated'
+    auth.user = { id: 'admin-race', username: 'admin', role: 'admin' }
+    const store = useKanbanStore()
+    await store.initialize()
+
+    const oldPoll = store.refreshBoard(true)
+    await vi.waitFor(() => expect(boardReads).toBe(2))
+    const mutation = store.moveTask(store.allTasks[0]!, 'ready')
+    await vi.waitFor(() => expect(writes).toBe(1))
+    staleBoard.resolve(Response.json({
+      columns: [{ name: 'todo', tasks: [{ id: 'task-a', title: '旧轮询不应回写', status: 'todo' }] }, { name: 'ready', tasks: [] }],
+      tenants: [], assignees: [], latest_event_id: 1, now: 1,
+    }))
+    await vi.waitFor(() => expect(boardReads).toBe(3))
+    expect(store.allTasks[0]?.title).toBe('竞态任务')
+    freshBoard.resolve(Response.json({
+      columns: [{ name: 'todo', tasks: [] }, { name: 'ready', tasks: [{ id: 'task-a', title: '竞态任务', status: 'ready' }] }],
+      tenants: [], assignees: [], latest_event_id: 3, now: 3,
+    }))
+    await Promise.all([oldPoll, mutation])
+    expect(boardReads).toBe(3)
+    expect(store.allTasks[0]?.status).toBe('ready')
+
+    const oldDetail = store.selectTask('task-a')
+    await vi.waitFor(() => expect(taskReads).toBe(1))
+    const newDetail = store.refreshTask(true)
+    await newDetail
+    expect(store.selectedTask?.task.title).toBe('新详情')
+    expect(store.isTaskLoading).toBe(false)
+    staleTask.resolve(Response.json({ task: { id: 'task-a', title: '旧详情', status: 'todo' }, comments: [], events: [], links: { parents: [], children: [] }, runs: [] }))
+    await oldDetail
+    expect(store.selectedTask?.task.title).toBe('新详情')
+  })
+
+  it('invalidates pending work and clears account-scoped state when the authenticated user changes', async () => {
+    const oldTask = deferred<Response>()
+    let taskReads = 0
+    vi.stubGlobal('fetch', vi.fn(async (path: string) => {
+      if (path.endsWith('/status')) return Response.json({ available: true, version: '1.0.0' })
+      if (path.endsWith('/boards')) return Response.json({ boards: [{ slug: 'default', total: 1 }], current: 'default' })
+      if (path.endsWith('/profiles')) return Response.json({ profiles: [{ name: 'yaoyao', is_default: true }] })
+      if (path.includes('/board?')) return Response.json({ columns: [{ name: 'todo', tasks: [{ id: 'task-a', title: '账号 A', status: 'todo' }] }], tenants: ['a'], assignees: ['yaoyao'], latest_event_id: 1, now: 1 })
+      if (path.includes('/tasks/task-a?')) {
+        taskReads += 1
+        return oldTask.promise
+      }
+      return Response.json({ ok: true })
+    }))
+    const auth = useAuthStore()
+    auth.status = 'authenticated'
+    auth.user = { id: 'user-a', username: 'a', role: 'admin' }
+    const store = useKanbanStore()
+    await store.initialize()
+    const pending = store.selectTask('task-a')
+    await vi.waitFor(() => expect(taskReads).toBe(1))
+
+    auth.user = { id: 'user-b', username: 'b', role: 'admin' }
+    await nextTick()
+    oldTask.resolve(Response.json({ task: { id: 'task-a', title: '不应回写', status: 'todo' }, comments: [], events: [], links: { parents: [], children: [] }, runs: [] }))
+    await pending
+
+    expect(store.boards).toEqual([])
+    expect(store.profiles).toEqual([])
+    expect(store.selectedBoardSlug).toBe('')
+    expect(store.selectedTask).toBeUndefined()
+    expect(store.search).toBe('')
+    expect(store.pluginVersion).toBe('')
+  })
+
+  it('does not let a completed create-board request repopulate state after reset', async () => {
+    const createResponse = deferred<Response>()
+    let createCalls = 0
+    let boardReads = 0
+    vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
+      const method = init?.method ?? 'GET'
+      if (path.endsWith('/status')) return Response.json({ available: true })
+      if (path.endsWith('/boards') && method === 'GET') return Response.json({ boards: [{ slug: 'default', total: 0 }], current: 'default' })
+      if (path.endsWith('/boards') && method === 'POST') {
+        createCalls += 1
+        return createResponse.promise
+      }
+      if (path.endsWith('/profiles')) return Response.json({ profiles: [] })
+      if (path.includes('/board?')) {
+        boardReads += 1
+        return Response.json({ columns: [], tenants: [], assignees: [], latest_event_id: 0, now: 1 })
+      }
+      return Response.json({ ok: true })
+    }))
+    const auth = useAuthStore()
+    auth.status = 'authenticated'
+    auth.user = { id: 'admin-create-race', username: 'admin', role: 'admin' }
+    const store = useKanbanStore()
+    await store.initialize()
+    expect(boardReads).toBe(1)
+
+    const pending = store.createBoard({ slug: 'stale-board', name: '旧账号看板' })
+    await vi.waitFor(() => expect(createCalls).toBe(1))
+    store.reset()
+    createResponse.resolve(Response.json({ board: { slug: 'stale-board', name: '旧账号看板' }, current: 'default' }))
+    await expect(pending).rejects.toMatchObject({ code: 'REQUEST_ABORTED' })
+
+    expect(store.boards).toEqual([])
+    expect(store.selectedBoardSlug).toBe('')
+    expect(store.snapshot).toBeUndefined()
+    expect(store.isMutating).toBe(false)
+    expect(boardReads).toBe(1)
+  })
+})

+ 201 - 0
tests/client/kanbanView.test.ts

@@ -0,0 +1,201 @@
+import { createPinia } from 'pinia'
+import { mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createMemoryHistory, createRouter } from 'vue-router'
+import { defineComponent } from 'vue'
+import KanbanCard from '@/components/kanban/KanbanCard.vue'
+import KanbanColumn from '@/components/kanban/KanbanColumn.vue'
+import KanbanTaskDrawer from '@/components/kanban/KanbanTaskDrawer.vue'
+import KanbanView from '@/views/KanbanView.vue'
+import { setApiCsrfToken } from '@/api/client'
+import { useAuthStore } from '@/stores/auth'
+import { useKanbanStore } from '@/stores/kanban'
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  const promise = new Promise<T>(resolvePromise => { resolve = resolvePromise })
+  return { promise, resolve }
+}
+
+afterEach(() => {
+  vi.restoreAllMocks()
+  vi.unstubAllGlobals()
+  setApiCsrfToken('')
+  document.body.innerHTML = ''
+})
+
+describe('Kanban components', () => {
+  it('offers a mobile-safe status menu and emits an allowed move', async () => {
+    const wrapper = mount(KanbanCard, {
+      props: {
+        task: { id: 'task-a', title: '移动端状态', status: 'todo', assignee: 'yaoyao' },
+        columns: ['todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done'],
+        canEdit: true,
+      },
+      global: { stubs: { AppIcon: true } },
+    })
+    const options = wrapper.findAll('option').map(option => option.attributes('value'))
+    expect(options).toEqual(['todo', 'ready', 'blocked', 'done'])
+    await wrapper.get('select').setValue('ready')
+    expect(wrapper.emitted('move')).toEqual([[
+      expect.objectContaining({ id: 'task-a' }),
+      'ready',
+    ]])
+  })
+
+  it('accepts desktop drops only on operator-owned status columns', async () => {
+    const transfer = { dropEffect: '', getData: () => 'task-a' }
+    const wrapper = mount(KanbanColumn, {
+      props: {
+        column: { name: 'ready', tasks: [] },
+        columns: ['todo', 'ready', 'running'],
+        canEdit: true,
+      },
+      global: { stubs: { AppIcon: true, KanbanCard: true } },
+    })
+    await wrapper.get('.kanban-column').trigger('dragover', { dataTransfer: transfer })
+    await wrapper.get('.kanban-column').trigger('drop', { dataTransfer: transfer })
+    expect(wrapper.emitted('dropTask')).toEqual([['task-a', 'ready']])
+
+    await wrapper.setProps({ column: { name: 'running', tasks: [] } })
+    await wrapper.get('.kanban-column').trigger('drop', { dataTransfer: transfer })
+    expect(wrapper.emitted('dropTask')).toHaveLength(1)
+  })
+
+  it('renders task comments, runs, events, and a genuine read-only state', () => {
+    const wrapper = mount(KanbanTaskDrawer, {
+      props: {
+        canEdit: false,
+        columns: ['todo', 'ready', 'running', 'done'],
+        profiles: [{ name: 'yaoyao', is_default: true }],
+        detail: {
+          task: { id: 'task-a', title: '发布核对', body: '检查全部端', status: 'running', assignee: 'yaoyao' },
+          comments: [{ id: 1, author: 'admin', body: '请补充验收', created_at: 1 }],
+          events: [{ id: 1, kind: 'claimed', created_at: 2 }],
+          links: { parents: [], children: [] },
+          runs: [{ id: 1, profile: 'yaoyao', status: 'running', summary: '正在执行' }],
+        },
+      },
+      global: { stubs: { AppIcon: true } },
+    })
+    expect(wrapper.text()).toContain('编辑仅限管理员')
+    expect(wrapper.text()).toContain('请补充验收')
+    expect(wrapper.text()).toContain('正在执行')
+    expect(wrapper.text()).toContain('Agent 开始处理')
+    expect(wrapper.findAll('input:disabled, textarea:disabled, select:disabled').length).toBeGreaterThan(0)
+    expect(wrapper.find('.kanban-comment-form').exists()).toBe(false)
+    expect(wrapper.find('.kanban-task-form__actions').exists()).toBe(false)
+  })
+
+  it('patches only edited fields so a running task can be renamed safely', async () => {
+    const detail = {
+      task: { id: 'task-running', title: '旧标题', body: '描述', status: 'running', assignee: 'yaoyao', priority: 2 },
+      comments: [], events: [], links: { parents: [], children: [] }, runs: [],
+    }
+    const wrapper = mount(KanbanTaskDrawer, {
+      props: { canEdit: true, columns: ['todo', 'ready', 'running', 'done'], profiles: [{ name: 'yaoyao', is_default: true }], detail },
+      global: { stubs: { AppIcon: true } },
+    })
+    await wrapper.get('.kanban-task-form input[required]').setValue('新标题')
+    await wrapper.get('.kanban-task-form').trigger('submit')
+    expect(wrapper.emitted('save')).toEqual([[{ title: '新标题' }]])
+
+    await wrapper.setProps({ detail: { ...detail, task: { ...detail.task, status: 'done', latest_summary: '远端轮询更新' } } })
+    expect((wrapper.get('.kanban-task-form input[required]').element as HTMLInputElement).value).toBe('新标题')
+    expect((wrapper.get('.kanban-task-form select').element as HTMLSelectElement).value).toBe('done')
+    await wrapper.get('.kanban-task-form').trigger('submit')
+    expect(wrapper.emitted('save')?.at(-1)).toEqual([{ title: '新标题' }])
+    await wrapper.setProps({ detail: { ...detail, task: { ...detail.task, title: '新标题', status: 'done' } } })
+    expect(wrapper.get<HTMLButtonElement>('.kanban-task-form .primary-button').element.disabled).toBe(true)
+  })
+})
+
+describe('Kanban view', () => {
+  it('loads the official snapshot and presents the board in the shared workspace shell', async () => {
+    vi.stubGlobal('fetch', vi.fn(async (path: string) => {
+      if (path.endsWith('/status')) return Response.json({ available: true, version: '1.0.0' })
+      if (path.endsWith('/boards')) return Response.json({ boards: [{ slug: 'default', name: '默认看板', total: 1 }, { slug: 'mobile', name: '移动看板', total: 1 }], current: 'default' })
+      if (path.endsWith('/profiles')) return Response.json({ profiles: [{ name: 'yaoyao', is_default: true }] })
+      if (path.includes('/board?')) return Response.json({
+        columns: [{ name: 'todo', tasks: [{ id: 'task-a', title: 'Web 看板验收', status: 'todo', assignee: 'yaoyao' }] }, { name: 'ready', tasks: [] }],
+        tenants: [], assignees: ['yaoyao'], latest_event_id: 1, now: 2,
+      })
+      return Response.json({ ok: true })
+    }))
+    setApiCsrfToken('csrf-view')
+    const pinia = createPinia()
+    const router = createRouter({
+      history: createMemoryHistory(),
+      routes: [{ path: '/kanban/:boardSlug?', component: KanbanView }],
+    })
+    await router.push('/kanban')
+    await router.isReady()
+    const auth = useAuthStore(pinia)
+    auth.status = 'authenticated'
+    auth.user = { id: 'admin', username: 'admin', role: 'admin' }
+    const WorkspaceStub = defineComponent({
+      emits: ['closeInspector'],
+      template: '<div class="workspace-stub"><slot name="sidebar-action"/><slot name="sidebar"/><slot/><slot name="inspector"/></div>',
+    })
+    const wrapper = mount(KanbanView, {
+      attachTo: document.body,
+      global: {
+        plugins: [pinia, router],
+        stubs: {
+          WorkspaceView: WorkspaceStub,
+          KanbanTaskDialog: true,
+          KanbanBoardDialog: true,
+          AppIcon: true,
+          YaoYaoSidebarIcon: true,
+        },
+      },
+    })
+    await vi.waitFor(() => expect(wrapper.text()).toContain('Web 看板验收'))
+    expect(wrapper.text()).toContain('默认看板')
+    expect(wrapper.text()).toContain('待办')
+    expect(wrapper.get('.kanban-columns').attributes('aria-label')).toBe('任务看板')
+
+    await router.push('/kanban/mobile')
+    await vi.waitFor(() => expect(wrapper.text()).toContain('移动看板'))
+    expect(useKanbanStore(pinia).selectedBoardSlug).toBe('mobile')
+    router.back()
+    await vi.waitFor(() => expect(useKanbanStore(pinia).selectedBoardSlug).toBe('default'))
+    wrapper.unmount()
+  })
+
+  it('does not start polling when the view unmounts during initialization', async () => {
+    const boardResponse = deferred<Response>()
+    let boardReads = 0
+    vi.stubGlobal('fetch', vi.fn(async (path: string) => {
+      if (path.endsWith('/status')) return Response.json({ available: true })
+      if (path.endsWith('/boards')) return Response.json({ boards: [{ slug: 'default', total: 0 }], current: 'default' })
+      if (path.endsWith('/profiles')) return Response.json({ profiles: [] })
+      if (path.includes('/board?')) {
+        boardReads += 1
+        return boardResponse.promise
+      }
+      return Response.json({ ok: true })
+    }))
+    const interval = vi.spyOn(window, 'setInterval')
+    const pinia = createPinia()
+    const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/kanban/:boardSlug?', component: KanbanView }] })
+    await router.push('/kanban')
+    await router.isReady()
+    const auth = useAuthStore(pinia)
+    auth.status = 'authenticated'
+    auth.user = { id: 'admin-unmount', username: 'admin', role: 'admin' }
+    const WorkspaceStub = defineComponent({ template: '<div><slot name="sidebar-action"/><slot name="sidebar"/><slot/><slot name="inspector"/></div>' })
+    const wrapper = mount(KanbanView, {
+      global: {
+        plugins: [pinia, router],
+        stubs: { WorkspaceView: WorkspaceStub, KanbanTaskDialog: true, KanbanBoardDialog: true, AppIcon: true, YaoYaoSidebarIcon: true },
+      },
+    })
+    await vi.waitFor(() => expect(boardReads).toBe(1))
+    wrapper.unmount()
+    boardResponse.resolve(Response.json({ columns: [], tenants: [], assignees: [], latest_event_id: 0, now: 1 }))
+    await new Promise(resolve => window.setTimeout(resolve, 15))
+    expect(interval).not.toHaveBeenCalled()
+    interval.mockRestore()
+  })
+})

+ 13 - 1
tests/client/workspaceShell.test.ts

@@ -1,5 +1,5 @@
 import { mount } from '@vue/test-utils'
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { createMemoryHistory, createRouter } from 'vue-router'
 import { defineComponent } from 'vue'
 import type { Profile } from '@shared/types'
@@ -26,6 +26,7 @@ async function mountShell() {
     routes: [
       { path: '/chat', component: { template: '<div></div>' } },
       { path: '/groups', component: { template: '<div></div>' } },
+      { path: '/kanban', component: { template: '<div></div>' } },
       { path: '/files', component: { template: '<div></div>' } },
     ],
   })
@@ -63,6 +64,17 @@ afterEach(() => {
 })
 
 describe('Workspace shell account controls', () => {
+  it('includes the Kanban workspace in desktop and mobile navigation', async () => {
+    const wrapper = await mountShell()
+    expect(wrapper.get('.desktop-sidebar').text()).toContain('看板')
+    expect(wrapper.get('.mobile-drawer').text()).toContain('看板')
+    await wrapper.get('.desktop-sidebar .sidebar-feature-nav button[title="看板"]').trigger('click')
+    await vi.waitFor(() => expect(wrapper.get('.sidebar-context__heading').text()).toContain('看板列表'))
+    expect(wrapper.get('.desktop-sidebar').find('button[aria-label="搜索"]').exists()).toBe(false)
+    expect(wrapper.get('.mobile-drawer').find('button[aria-label="搜索"]').exists()).toBe(false)
+    wrapper.unmount()
+  })
+
   it('keeps Agent switching focused and opens settings from its independent button', async () => {
     const wrapper = await mountShell()
     const desktop = wrapper.get('.desktop-sidebar')

+ 29 - 0
tests/e2e/workspace.spec.ts

@@ -990,3 +990,32 @@ test('uses the mobile composer and keeps the closed drawer inert', async ({ page
   await expect(page.locator('.composer-shell')).toHaveCSS('min-height', '118px')
   await expect(page.locator('.composer-textarea')).toHaveCSS('font-size', '16px')
 })
+
+test('renders the Kanban snapshot and mobile status control without page overflow', async ({ page }) => {
+  await page.getByRole('button', { name: '看板', exact: true }).click()
+  await expect(page).toHaveURL(/\/kanban\/default$/)
+  await expect(page).toHaveTitle('产品研发 · 夭夭')
+  await expect(page.getByRole('heading', { name: '产品研发', exact: true })).toBeVisible()
+  await expect(page.getByRole('article', { name: /完成 Web 看板验收/ })).toBeVisible()
+  await expect(page.locator('.kanban-column')).toHaveCount(8)
+
+  await page.getByRole('article', { name: /完成 Web 看板验收/ }).click()
+  await expect(page.getByRole('heading', { name: '完成 Web 看板验收', exact: true })).toBeVisible()
+  await expect(page.getByText('请保留真实浏览器验收证据。', { exact: true })).toBeVisible()
+  await expect(page.locator('.kanban-task-drawer').getByText('正在执行浏览器验收', { exact: true }).first()).toBeVisible()
+  await page.getByRole('button', { name: '关闭任务详情' }).click()
+
+  await page.setViewportSize({ width: 390, height: 844 })
+  await page.reload()
+  await expect(page.getByRole('button', { name: '立即调度', exact: true })).toBeVisible()
+  const card = page.getByRole('article', { name: /完成 Web 看板验收/ })
+  await expect(card).toBeVisible()
+  await expect(card.locator('.kanban-card__mobile-status select')).toBeVisible()
+  const geometry = await page.evaluate(() => ({
+    viewport: window.innerWidth,
+    page: document.documentElement.scrollWidth,
+    board: document.querySelector('.kanban-columns')?.scrollWidth || 0,
+  }))
+  expect(geometry.page).toBe(geometry.viewport)
+  expect(geometry.board).toBeGreaterThan(geometry.viewport)
+})

+ 168 - 1
tests/fixtures/fake-hermes.mjs

@@ -14,6 +14,57 @@ const groupClients = new Set()
 let groupConnectionCount = 0
 let groupAvailable = true
 
+const kanbanColumns = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done']
+const kanbanBoards = [
+  { slug: 'default', name: '产品研发', description: 'Web 与移动端功能', is_current: true, default_workspace_kind: 'scratch' },
+  { slug: 'mobile-release', name: '移动端发布', description: 'iOS 与 Android 发布任务', is_current: false, default_workspace_kind: 'worktree' },
+]
+let kanbanTaskSequence = 3
+let kanbanEventSequence = 3
+const kanbanTasks = [
+  { id: 't_web001', board: 'default', title: '完成 Web 看板验收', body: '覆盖桌面与 390px 响应式布局', status: 'running', assignee: 'yaoyao', tenant: 'web', priority: 3, created_by: 'dashboard', created_at: now() - 1800, started_at: now() - 420, latest_summary: '正在执行浏览器验收', comment_count: 1, link_counts: { parents: 0, children: 1 }, progress: { done: 0, total: 1 } },
+  { id: 't_ios001', board: 'default', title: '核对 iOS 双端口', body: '9119 与 8800 使用同一契约', status: 'ready', assignee: 'yaoer', tenant: 'mobile', priority: 2, created_by: 'dashboard', created_at: now() - 1200, comment_count: 0, link_counts: { parents: 1, children: 0 }, progress: null },
+  { id: 't_done01', board: 'mobile-release', title: '准备发布说明', body: '整理已完成事项', status: 'done', assignee: 'yaoyao', tenant: 'release', priority: 1, created_by: 'dashboard', created_at: now() - 7200, completed_at: now() - 3600, latest_summary: '发布说明已完成', comment_count: 0, link_counts: { parents: 0, children: 0 }, progress: null },
+]
+const kanbanComments = new Map([
+  ['t_web001', [{ id: 1, task_id: 't_web001', author: 'admin', body: '请保留真实浏览器验收证据。', created_at: now() - 600 }]],
+])
+const kanbanEvents = new Map([
+  ['t_web001', [
+    { id: 1, task_id: 't_web001', run_id: null, kind: 'created', payload: { status: 'ready', assignee: 'yaoyao' }, created_at: now() - 1800 },
+    { id: 2, task_id: 't_web001', run_id: 1, kind: 'claimed', payload: { profile: 'yaoyao' }, created_at: now() - 420 },
+  ]],
+  ['t_ios001', [{ id: 3, task_id: 't_ios001', run_id: null, kind: 'created', payload: { status: 'ready', assignee: 'yaoer' }, created_at: now() - 1200 }]],
+])
+const kanbanRuns = new Map([
+  ['t_web001', [{ id: 1, task_id: 't_web001', profile: 'yaoyao', status: 'running', outcome: null, summary: '正在执行浏览器验收', metadata: { verification: ['playwright'] }, worker_pid: 12345, started_at: now() - 420, ended_at: null }]],
+])
+
+function kanbanBoardSlug(url) {
+  return url.searchParams.get('board') || 'default'
+}
+
+function kanbanBoardSnapshot(url) {
+  const slug = kanbanBoardSlug(url)
+  const includeArchived = url.searchParams.get('include_archived') === 'true'
+  const scoped = kanbanTasks.filter(task => task.board === slug && (includeArchived || task.status !== 'archived'))
+  const columns = [...kanbanColumns, ...(includeArchived ? ['archived'] : [])]
+    .map(name => ({ name, tasks: scoped.filter(task => task.status === name) }))
+  return {
+    columns,
+    tenants: [...new Set(scoped.map(task => task.tenant).filter(Boolean))].sort(),
+    assignees: [...new Set(scoped.map(task => task.assignee).filter(Boolean))].sort(),
+    latest_event_id: kanbanEventSequence,
+    now: now(),
+  }
+}
+
+function recordKanbanEvent(task, kind, payload = null, runId = null) {
+  const event = { id: ++kanbanEventSequence, task_id: task.id, run_id: runId, kind, payload, created_at: now() }
+  kanbanEvents.set(task.id, [...(kanbanEvents.get(task.id) || []), event])
+  return event
+}
+
 function broadcastGroup(event, payload) {
   groupCursor += 1
   const frame = JSON.stringify({ type: 'group.event', epoch, cursor: groupCursor, roomId, event, payload })
@@ -116,11 +167,127 @@ const server = createServer(async (request, response) => {
   if (url.pathname === '/api/auth/me') return json(response, 200, { user_id: 'demo-user', display_name: '验收用户', email: 'demo@example.invalid', provider: 'basic' })
   if (url.pathname === '/api/auth/ws-ticket' && request.method === 'POST') return json(response, 200, { ticket: 'fake-ticket' })
   if (url.pathname === '/api/profiles') return json(response, 200, { profiles })
-  if (url.pathname === '/api/dashboard/plugins') return json(response, 200, [{ name: 'yaoyao', version: '1.7.3' }])
+  if (url.pathname === '/api/dashboard/plugins') return json(response, 200, [{ name: 'yaoyao', version: '1.7.3' }, { name: 'kanban', version: '1.0.0' }])
   if (url.pathname === '/api/plugins/yaoyao/profiles') return json(response, 200, { profiles: [
     { name: 'yaoyao', label: '夭夭', botName: '夭夭', agentName: '旧夭夭名称', isDefault: true },
     { name: 'yaoer', label: '瑶儿', botName: '瑶儿', agentName: '旧瑶儿名称', isDefault: false },
   ] })
+  if (url.pathname === '/api/plugins/kanban/boards' && request.method === 'GET') {
+    return json(response, 200, {
+      boards: kanbanBoards.map(board => {
+        const scoped = kanbanTasks.filter(task => task.board === board.slug)
+        const counts = Object.fromEntries(kanbanColumns.map(status => [status, scoped.filter(task => task.status === status).length]))
+        return { ...board, counts, total: scoped.filter(task => task.status !== 'archived').length }
+      }),
+      current: 'default',
+    })
+  }
+  if (url.pathname === '/api/plugins/kanban/boards' && request.method === 'POST') {
+    const input = await body(request)
+    const slug = String(input.slug || '').trim().toLowerCase()
+    if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug)) return json(response, 400, { detail: 'invalid board slug' })
+    let board = kanbanBoards.find(item => item.slug === slug)
+    if (!board) {
+      board = { slug, name: String(input.name || slug), description: String(input.description || ''), is_current: false, default_workspace_kind: 'scratch' }
+      kanbanBoards.push(board)
+    }
+    return json(response, 200, { board, current: 'default' })
+  }
+  if (url.pathname === '/api/plugins/kanban/board' && request.method === 'GET') {
+    return json(response, 200, kanbanBoardSnapshot(url))
+  }
+  if (url.pathname === '/api/plugins/kanban/profiles' && request.method === 'GET') {
+    return json(response, 200, { profiles: profiles.map(profile => ({
+      name: profile.name,
+      is_default: profile.is_default,
+      description: profile.description,
+      description_auto: false,
+      model: profile.model,
+      provider: profile.provider,
+    })) })
+  }
+  if (url.pathname === '/api/plugins/kanban/tasks' && request.method === 'POST') {
+    const input = await body(request)
+    if (!String(input.title || '').trim()) return json(response, 400, { detail: 'title is required' })
+    const task = {
+      id: `t_e2e${kanbanTaskSequence++}`,
+      board: kanbanBoardSlug(url),
+      title: String(input.title).trim(),
+      body: input.body == null ? null : String(input.body),
+      status: input.triage === true ? 'triage' : 'ready',
+      assignee: input.assignee == null ? null : String(input.assignee),
+      tenant: input.tenant == null ? null : String(input.tenant),
+      priority: Number(input.priority) || 0,
+      created_by: 'dashboard',
+      created_at: now(),
+      comment_count: 0,
+      link_counts: { parents: Array.isArray(input.parents) ? input.parents.length : 0, children: 0 },
+      progress: null,
+    }
+    kanbanTasks.push(task)
+    recordKanbanEvent(task, 'created', { status: task.status, assignee: task.assignee })
+    return json(response, 200, { task })
+  }
+  const kanbanCommentMatch = /^\/api\/plugins\/kanban\/tasks\/([^/]+)\/comments$/.exec(url.pathname)
+  if (kanbanCommentMatch && request.method === 'POST') {
+    const id = decodeURIComponent(kanbanCommentMatch[1])
+    const task = kanbanTasks.find(item => item.id === id && item.board === kanbanBoardSlug(url))
+    if (!task) return json(response, 404, { detail: 'task not found' })
+    const input = await body(request)
+    if (!String(input.body || '').trim()) return json(response, 400, { detail: 'body is required' })
+    const comments = kanbanComments.get(id) || []
+    comments.push({ id: comments.length + 1, task_id: id, author: String(input.author || 'dashboard'), body: String(input.body).trim(), created_at: now() })
+    kanbanComments.set(id, comments)
+    task.comment_count = comments.length
+    recordKanbanEvent(task, 'commented', { author: input.author || 'dashboard' })
+    return json(response, 200, { ok: true })
+  }
+  const kanbanTaskMatch = /^\/api\/plugins\/kanban\/tasks\/([^/]+)$/.exec(url.pathname)
+  if (kanbanTaskMatch) {
+    const id = decodeURIComponent(kanbanTaskMatch[1])
+    const index = kanbanTasks.findIndex(item => item.id === id && item.board === kanbanBoardSlug(url))
+    const task = kanbanTasks[index]
+    if (!task) return json(response, 404, { detail: 'task not found' })
+    if (request.method === 'PATCH') {
+      const input = await body(request)
+      const previousStatus = task.status
+      for (const key of ['title', 'body', 'assignee', 'priority', 'result']) {
+        if (Object.prototype.hasOwnProperty.call(input, key)) task[key] = input[key]
+      }
+      if (typeof input.status === 'string' && input.status) task.status = input.status
+      if (task.status !== previousStatus) recordKanbanEvent(task, 'status', { status: task.status })
+      else recordKanbanEvent(task, 'edited', null)
+      return json(response, 200, { task })
+    }
+    if (request.method === 'DELETE') {
+      kanbanTasks.splice(index, 1)
+      kanbanComments.delete(id)
+      kanbanEvents.delete(id)
+      kanbanRuns.delete(id)
+      return json(response, 200, { ok: true })
+    }
+    if (request.method === 'GET') {
+      return json(response, 200, {
+        task,
+        comments: kanbanComments.get(id) || [],
+        events: kanbanEvents.get(id) || [],
+        attachments: [],
+        links: { parents: id === 't_ios001' ? ['t_web001'] : [], children: id === 't_web001' ? ['t_ios001'] : [] },
+        child_results: id === 't_web001' ? [{ id: 't_ios001', title: '核对 iOS 双端口', status: 'ready' }] : [],
+        runs: kanbanRuns.get(id) || [],
+      })
+    }
+  }
+  if (url.pathname === '/api/plugins/kanban/dispatch' && request.method === 'POST') {
+    const task = kanbanTasks.find(item => item.board === kanbanBoardSlug(url) && item.status === 'ready' && item.assignee)
+    if (!task) return json(response, 200, { spawned: [] })
+    task.status = 'running'
+    task.started_at = now()
+    const run = { id: kanbanRuns.size + 2, task_id: task.id, profile: task.assignee, status: 'running', outcome: null, summary: null, metadata: null, worker_pid: 12346, started_at: task.started_at, ended_at: null }
+    kanbanRuns.set(task.id, [...(kanbanRuns.get(task.id) || []), run])
+    recordKanbanEvent(task, 'claimed', { profile: task.assignee }, run.id)
+    return json(response, 200, { spawned: [{ task_id: task.id, profile: task.assignee }] })
+  }
   if (url.pathname === '/api/model/options') return json(response, 200, { model: 'gpt-5.6', provider: 'openai', providers: [{ slug: 'openai', name: 'OpenAI', models: ['gpt-5.6', 'gpt-5.5'] }] })
   if (url.pathname === '/api/profiles/sessions' || url.pathname === '/api/sessions') {
     const offset = Math.max(0, Number(url.searchParams.get('offset') || 0))

+ 387 - 0
tests/server/kanbanRoutes.test.ts

@@ -0,0 +1,387 @@
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import request from 'supertest'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { ApplicationRuntime } from '../../src/server/app.js'
+import type { ServerConfig } from '../../src/server/config.js'
+import {
+  createAuthenticatedApplication,
+  createUserAuthenticatedApplication,
+} from './authenticatedApplication.js'
+
+const HOST = '127.0.0.1:8800'
+const ORIGIN = `http://${HOST}`
+const roots: string[] = []
+const runtimes: ApplicationRuntime[] = []
+
+afterEach(() => {
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+interface UpstreamCall {
+  path: string
+  method: string
+  search: string
+  body?: unknown
+}
+
+function json(value: unknown, init: ResponseInit = {}): Response {
+  return new Response(JSON.stringify(value), {
+    ...init,
+    headers: { 'content-type': 'application/json', ...init.headers },
+  })
+}
+
+function testConfig(): ServerConfig {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-kanban-routes-'))
+  roots.push(home)
+  return {
+    host: '127.0.0.1',
+    port: 8800,
+    upstream: new URL('http://127.0.0.1:9119'),
+    allowedHosts: new Set(),
+    home,
+    mediaRoot: home,
+    attachmentsRoot: home,
+    imagesRoot: home,
+    mediaOwner: 'tester',
+    allowInsecureLan: false,
+    insecureLan: false,
+    production: false,
+  }
+}
+
+function upstreamFixture(manifests: unknown[] = [{ name: 'kanban', version: '1.2.3' }]) {
+  const calls: UpstreamCall[] = []
+  const state: { manifests: unknown[]; kanbanBoardsStatus: number; taskDetail?: unknown; unauthorizedPaths: Set<string> } = {
+    manifests,
+    kanbanBoardsStatus: 200,
+    unauthorizedPaths: new Set(),
+  }
+  const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
+    const url = new URL(input instanceof Request ? input.url : String(input))
+    if (url.pathname === '/api/status') return json({ auth_required: false })
+    if (url.pathname === '/api/profiles') return json({ profiles: [{ name: 'default', is_default: true }] })
+    if (url.pathname === '/api/plugins/yaoyao/profiles') return json({ profiles: [] })
+    if (url.pathname === '/api/dashboard/plugins') return json(state.manifests)
+
+    let requestBody: unknown
+    if (init?.body !== undefined && init.body !== null) requestBody = JSON.parse(String(init.body))
+    const call: UpstreamCall = {
+      path: url.pathname,
+      method: init?.method ?? 'GET',
+      search: url.search,
+      ...(requestBody === undefined ? {} : { body: requestBody }),
+    }
+    calls.push(call)
+    if (state.unauthorizedPaths.has(url.pathname)) {
+      return json({ detail: 'Unauthorized' }, {
+        status: 401,
+        headers: { 'set-cookie': 'upstream_secret=must-not-leak; Path=/; HttpOnly' },
+      })
+    }
+    if (url.pathname === '/api/plugins/kanban/boards'
+      && call.method === 'GET' && state.kanbanBoardsStatus !== 200) {
+      return json({ detail: 'Kanban API unavailable' }, { status: state.kanbanBoardsStatus })
+    }
+    if (/^\/api\/plugins\/kanban\/tasks\/[^/]+$/.test(url.pathname)
+      && call.method === 'GET' && state.taskDetail !== undefined) {
+      return json(state.taskDetail)
+    }
+    return json(call)
+  }) as typeof fetch
+  return { calls, state, fetchImpl }
+}
+
+type Agent = ReturnType<typeof request.agent>
+type Method = 'GET' | 'POST' | 'PATCH' | 'DELETE'
+
+function apiRequest(agent: Agent, method: Method, path: string) {
+  switch (method) {
+    case 'GET': return agent.get(path)
+    case 'POST': return agent.post(path)
+    case 'PATCH': return agent.patch(path)
+    case 'DELETE': return agent.delete(path)
+  }
+}
+
+async function csrfToken(agent: Agent): Promise<string> {
+  const response = await agent.get('/api/app/bootstrap').set('Host', HOST).expect(200)
+  return response.body.csrfToken as string
+}
+
+describe('Kanban proxy routes', () => {
+  it('maps the same explicit route and query allowlist for Web and native aliases', async () => {
+    const fixture = upstreamFixture()
+    const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const csrf = await csrfToken(agent)
+    fixture.calls.splice(0)
+
+    const cases: Array<{
+      method: Method
+      suffix: string
+      upstreamPath: string
+      search?: string
+      body?: Record<string, unknown>
+      upstreamBody?: Record<string, unknown>
+    }> = [
+      { method: 'GET', suffix: '/boards?include_archived=true&escape=1', upstreamPath: '/api/plugins/kanban/boards', search: '?include_archived=true' },
+      { method: 'POST', suffix: '/boards?escape=1', upstreamPath: '/api/plugins/kanban/boards', body: { slug: 'main', name: 'Main' }, upstreamBody: { slug: 'main', name: 'Main', switch: false } },
+      { method: 'GET', suffix: '/board?board=main&include_archived=true&escape=1', upstreamPath: '/api/plugins/kanban/board', search: '?board=main&include_archived=true' },
+      { method: 'GET', suffix: '/profiles?escape=1', upstreamPath: '/api/plugins/kanban/profiles' },
+      { method: 'GET', suffix: '/tasks/t_01234567?board=main&run_state_name=running&run_state_type=status&escape=1', upstreamPath: '/api/plugins/kanban/tasks/t_01234567', search: '?board=main&run_state_name=running&run_state_type=status' },
+      { method: 'POST', suffix: '/tasks?board=main&escape=1', upstreamPath: '/api/plugins/kanban/tasks', search: '?board=main', body: { title: 'Ship it' } },
+      { method: 'PATCH', suffix: '/tasks/t_01234567?board=main&escape=1', upstreamPath: '/api/plugins/kanban/tasks/t_01234567', search: '?board=main', body: { status: 'ready' } },
+      { method: 'DELETE', suffix: '/tasks/t_01234567?board=main&escape=1', upstreamPath: '/api/plugins/kanban/tasks/t_01234567', search: '?board=main', body: {} },
+      { method: 'POST', suffix: '/tasks/t_01234567/comments?board=main&escape=1', upstreamPath: '/api/plugins/kanban/tasks/t_01234567/comments', search: '?board=main', body: { author: 'forged', body: 'Looks good' }, upstreamBody: { author: 'test-admin', body: 'Looks good' } },
+      { method: 'POST', suffix: '/dispatch?board=main&dry_run=true&max=2&escape=1', upstreamPath: '/api/plugins/kanban/dispatch', search: '?board=main&dry_run=true&max=2', body: {} },
+    ]
+
+    for (const route of cases) {
+      const responses: unknown[] = []
+      for (const prefix of ['/api/app/kanban', '/api/kanban/v1']) {
+        let pending = apiRequest(agent, route.method, `${prefix}${route.suffix}`).set('Host', HOST)
+        if (prefix.startsWith('/api/app/') && route.method !== 'GET') {
+          pending = pending.set('Origin', ORIGIN).set('X-CSRF-Token', csrf)
+        }
+        if (route.body !== undefined) pending = pending.send(route.body)
+        const response = await pending.expect(200)
+        responses.push(response.body)
+        expect(response.body).toEqual({
+          path: route.upstreamPath,
+          method: route.method,
+          search: route.search ?? '',
+          ...(route.body === undefined ? {} : { body: route.upstreamBody ?? route.body }),
+        })
+      }
+      expect(responses[0]).toEqual(responses[1])
+    }
+    expect(fixture.calls).toHaveLength(cases.length * 2)
+  })
+
+  it('uses the authenticated boards API as availability truth and manifest only for metadata', async () => {
+    const fixture = upstreamFixture()
+    const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+
+    const web = await agent.get('/api/app/kanban/status').set('Host', HOST).expect(200)
+    const native = await agent.get('/api/kanban/v1/status').set('Host', HOST).expect(200)
+    expect(web.body).toEqual({ available: true, version: '1.2.3' })
+    expect(native.body).toEqual(web.body)
+
+    fixture.state.manifests = [{ name: 'yaoyao', version: '1.7.3' }]
+    const hidden = await agent.get('/api/app/kanban/status').set('Host', HOST).expect(200)
+    expect(hidden.body).toEqual({ available: true })
+
+    fixture.state.manifests = [{ name: 'kanban', version: '9.9.9', has_api: false }]
+    fixture.state.kanbanBoardsStatus = 404
+    const missing = await agent.get('/api/app/kanban/status').set('Host', HOST).expect(200)
+    expect(missing.body).toEqual({
+      available: false,
+      reason: '9119 未安装、未启用或未挂载 Kanban API',
+    })
+  })
+
+  it('allows authenticated users to read but rejects every user mutation alias', async () => {
+    const fixture = upstreamFixture()
+    const runtime = createUserAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const csrf = await csrfToken(agent)
+    fixture.calls.splice(0)
+
+    await agent.get('/api/app/kanban/board?board=main').set('Host', HOST).expect(200)
+    await agent.get('/api/kanban/v1/board?board=main').set('Host', HOST).expect(200)
+    expect(fixture.calls).toHaveLength(2)
+
+    const web = await agent.post('/api/app/kanban/tasks?board=main')
+      .set('Host', HOST)
+      .set('Origin', ORIGIN)
+      .set('X-CSRF-Token', csrf)
+      .send({ title: 'forbidden' })
+      .expect(403)
+    expect(web.body.code).toBe('admin_required')
+
+    const native = await agent.patch('/api/kanban/v1/tasks/t_01234567?board=main')
+      .set('Host', HOST)
+      .send({ status: 'ready' })
+      .expect(403)
+    expect(native.body.code).toBe('admin_required')
+    expect(fixture.calls).toHaveLength(2)
+  })
+
+  it('keeps CSRF on Web mutations while native admins can mutate without CSRF', async () => {
+    const fixture = upstreamFixture()
+    const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const csrf = await csrfToken(agent)
+    fixture.calls.splice(0)
+
+    const rejected = await agent.post('/api/app/kanban/tasks?board=main')
+      .set('Host', HOST)
+      .set('Origin', ORIGIN)
+      .send({ title: 'missing csrf' })
+      .expect(403)
+    expect(rejected.body.code).toBe('invalid_csrf')
+    expect(fixture.calls).toHaveLength(0)
+
+    await agent.post('/api/app/kanban/tasks?board=main')
+      .set('Host', HOST)
+      .set('Origin', ORIGIN)
+      .set('X-CSRF-Token', csrf)
+      .send({ title: 'web' })
+      .expect(200)
+    await agent.post('/api/kanban/v1/tasks?board=main')
+      .set('Host', HOST)
+      .send({ title: 'native' })
+      .expect(200)
+    expect(fixture.calls.map(call => call.body)).toEqual([{ title: 'web' }, { title: 'native' }])
+  })
+
+  it('forces create-board switch off and drops fields outside the stable alias contract', async () => {
+    const fixture = upstreamFixture()
+    const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const csrf = await csrfToken(agent)
+    fixture.calls.splice(0)
+
+    const input = {
+      slug: 'main', name: 'Main', description: 'Board', project_id: 'project-a',
+      default_workdir: '/Users/forged/repository', icon: 'danger', color: '#f00',
+      switch: true, delete: true, archive: '/tmp/escape.tar.gz',
+    }
+    await agent.post('/api/app/kanban/boards')
+      .set('Host', HOST).set('Origin', ORIGIN).set('X-CSRF-Token', csrf).send(input).expect(200)
+    await agent.post('/api/kanban/v1/boards').set('Host', HOST).send(input).expect(200)
+
+    expect(fixture.calls.map(call => call.body)).toEqual([
+      { slug: 'main', name: 'Main', description: 'Board', switch: false },
+      { slug: 'main', name: 'Main', description: 'Board', switch: false },
+    ])
+  })
+
+  it('derives comment audit authors from the authenticated admin for both aliases', async () => {
+    const fixture = upstreamFixture()
+    const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const csrf = await csrfToken(agent)
+    fixture.calls.splice(0)
+
+    const forged = { body: '审计内容', author: 'forged-user', role: 'owner' }
+    await agent.post('/api/app/kanban/tasks/t_01234567/comments?board=main')
+      .set('Host', HOST).set('Origin', ORIGIN).set('X-CSRF-Token', csrf).send(forged).expect(200)
+    await agent.post('/api/kanban/v1/tasks/t_01234567/comments?board=main')
+      .set('Host', HOST).send(forged).expect(200)
+
+    expect(fixture.calls.map(call => call.body)).toEqual([
+      { body: '审计内容', author: 'test-admin' },
+      { body: '审计内容', author: 'test-admin' },
+    ])
+  })
+
+  it('removes unsupported attachment paths from task detail responses for both aliases', async () => {
+    const fixture = upstreamFixture()
+    fixture.state.taskDetail = {
+      task: { id: 't_01234567', title: 'Private path check', status: 'todo' },
+      comments: [], events: [], links: { parents: [], children: [] }, runs: [],
+      attachments: [{ id: 1, filename: 'secret.txt', stored_path: '/Users/upstream/.hermes/kanban/attachments/secret.txt' }],
+    }
+    const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+
+    for (const prefix of ['/api/app/kanban', '/api/kanban/v1']) {
+      const response = await agent.get(`${prefix}/tasks/t_01234567?board=main`).set('Host', HOST).expect(200)
+      expect(response.body.attachments).toEqual([])
+      expect(JSON.stringify(response.body)).not.toContain('/Users/upstream')
+      expect(response.body.task).toMatchObject({ id: 't_01234567', status: 'todo' })
+    }
+  })
+
+  it('maps server-owned upstream 401 responses without expiring the local 8800 session', async () => {
+    const fixture = upstreamFixture()
+    const config = testConfig()
+    config.production = true
+    config.upstreamUsername = 'service-admin'
+    config.upstreamPassword = 'service-password'
+    const runtime = createAuthenticatedApplication({ config, fetchImpl: fixture.fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+
+    fixture.state.unauthorizedPaths.add('/api/plugins/kanban/boards')
+    for (const prefix of ['/api/app/kanban', '/api/kanban/v1']) {
+      const response = await agent.get(`${prefix}/status`).set('Host', HOST).expect(502)
+      expect(response.body.code).toBe('upstream_auth_unavailable')
+      expect(response.headers['set-cookie']).toBeUndefined()
+      expect(JSON.stringify(response.body)).not.toContain('upstream_secret')
+    }
+    fixture.state.unauthorizedPaths.clear()
+    await agent.get('/api/app/kanban/status').set('Host', HOST).expect(200)
+
+    fixture.state.unauthorizedPaths.add('/api/plugins/kanban/tasks/t_01234567')
+    for (const prefix of ['/api/app/kanban', '/api/kanban/v1']) {
+      const response = await agent.get(`${prefix}/tasks/t_01234567?board=main`).set('Host', HOST).expect(502)
+      expect(response.body.code).toBe('upstream_auth_unavailable')
+      expect(response.headers['set-cookie']).toBeUndefined()
+    }
+  })
+
+  it('requires an explicit canonical board on every board-scoped endpoint', async () => {
+    const fixture = upstreamFixture()
+    const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const csrf = await csrfToken(agent)
+    fixture.calls.splice(0)
+
+    const cases: Array<{ method: Method; suffix: string; body?: Record<string, unknown> }> = [
+      { method: 'GET', suffix: '/board' },
+      { method: 'GET', suffix: '/tasks/t_01234567' },
+      { method: 'POST', suffix: '/tasks', body: { title: 'missing board' } },
+      { method: 'PATCH', suffix: '/tasks/t_01234567', body: { status: 'ready' } },
+      { method: 'DELETE', suffix: '/tasks/t_01234567', body: {} },
+      { method: 'POST', suffix: '/tasks/t_01234567/comments', body: { body: 'missing board' } },
+      { method: 'POST', suffix: '/dispatch', body: {} },
+    ]
+    for (const prefix of ['/api/app/kanban', '/api/kanban/v1']) {
+      for (const item of cases) {
+        let pending = apiRequest(agent, item.method, `${prefix}${item.suffix}`).set('Host', HOST)
+        if (prefix.startsWith('/api/app/') && item.method !== 'GET') {
+          pending = pending.set('Origin', ORIGIN).set('X-CSRF-Token', csrf)
+        }
+        if (item.body) pending = pending.send(item.body)
+        const response = await pending.expect(400)
+        expect(response.body.code).toBe('kanban_board_required')
+      }
+    }
+    const invalid = await agent.get('/api/app/kanban/board?board=..%2Fescape').set('Host', HOST).expect(400)
+    expect(invalid.body.code).toBe('invalid_kanban_board')
+    const duplicate = await agent.get('/api/kanban/v1/board?board=main&board=other').set('Host', HOST).expect(400)
+    expect(duplicate.body.code).toBe('invalid_kanban_board')
+    expect(fixture.calls).toHaveLength(0)
+  })
+
+  it('does not expose switch, bulk, attachments, or arbitrary nested plugin paths', async () => {
+    const fixture = upstreamFixture()
+    const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const csrf = await csrfToken(agent)
+    fixture.calls.splice(0)
+
+    await agent.post('/api/app/kanban/boards/main/switch')
+      .set('Host', HOST).set('Origin', ORIGIN).set('X-CSRF-Token', csrf).send({}).expect(404)
+    await agent.post('/api/kanban/v1/tasks/bulk').set('Host', HOST).send({ ids: [] }).expect(404)
+    await agent.post('/api/kanban/v1/tasks/t_01234567/attachments').set('Host', HOST).send({}).expect(404)
+    await agent.get('/api/kanban/v1/orchestration').set('Host', HOST).expect(404)
+    expect(fixture.calls).toHaveLength(0)
+  })
+})

Некоторые файлы не были показаны из-за большого количества измененных файлов