KanbanView.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. <script setup lang="ts">
  2. import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  3. import { useRoute, useRouter } from 'vue-router'
  4. import type { CreateKanbanBoardInput, CreateKanbanTaskInput, KanbanTask, UpdateKanbanTaskInput } from '@/api/kanban'
  5. import AppIcon from '@/components/common/AppIcon.vue'
  6. import EmptyState from '@/components/common/EmptyState.vue'
  7. import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
  8. import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
  9. import KanbanBoardDialog from '@/components/kanban/KanbanBoardDialog.vue'
  10. import KanbanColumn from '@/components/kanban/KanbanColumn.vue'
  11. import KanbanTaskDialog from '@/components/kanban/KanbanTaskDialog.vue'
  12. import KanbanTaskDrawer from '@/components/kanban/KanbanTaskDrawer.vue'
  13. import { formatKanbanTime, statusMeta } from '@/components/kanban/presentation'
  14. import { useKanbanStore } from '@/stores/kanban'
  15. import { useAuthStore } from '@/stores/auth'
  16. const kanban = useKanbanStore()
  17. const auth = useAuthStore()
  18. const route = useRoute()
  19. const router = useRouter()
  20. const boardDialogOpen = ref(false)
  21. const taskDialogOpen = ref(false)
  22. const taskTargetStatus = ref('ready')
  23. const dialogError = ref('')
  24. const columnNames = computed(() => kanban.snapshot?.columns.map(column => column.name) ?? [])
  25. const visibleTaskCount = computed(() => kanban.filteredColumns.reduce((total, column) => total + column.tasks.length, 0))
  26. const lastUpdated = computed(() => kanban.lastUpdatedAt ? new Intl.DateTimeFormat('zh-CN', {
  27. hour: '2-digit', minute: '2-digit', second: '2-digit',
  28. }).format(kanban.lastUpdatedAt) : '')
  29. async function chooseBoard(slug: string): Promise<void> {
  30. await kanban.selectBoard(slug)
  31. await router.push(`/kanban/${encodeURIComponent(slug)}`)
  32. }
  33. async function reconcileBoardRoute(value: unknown = route.params.boardSlug): Promise<void> {
  34. if (kanban.availability !== 'available') return
  35. const requested = typeof value === 'string' ? value : ''
  36. if (requested && kanban.boards.some(board => board.slug === requested)) {
  37. if (requested !== kanban.selectedBoardSlug) await kanban.selectBoard(requested)
  38. return
  39. }
  40. if (kanban.selectedBoardSlug) {
  41. await router.replace(`/kanban/${encodeURIComponent(kanban.selectedBoardSlug)}`)
  42. }
  43. }
  44. function openTaskCreate(status = 'ready'): void {
  45. taskTargetStatus.value = status
  46. dialogError.value = ''
  47. taskDialogOpen.value = true
  48. }
  49. async function createTask(input: CreateKanbanTaskInput, status: string): Promise<void> {
  50. dialogError.value = ''
  51. try {
  52. const task = await kanban.createTask(input, status)
  53. taskDialogOpen.value = false
  54. if (task) await kanban.selectTask(task.id)
  55. } catch (cause) {
  56. dialogError.value = cause instanceof Error ? cause.message : '创建任务失败'
  57. }
  58. }
  59. async function createBoard(input: CreateKanbanBoardInput): Promise<void> {
  60. dialogError.value = ''
  61. try {
  62. const board = await kanban.createBoard(input)
  63. boardDialogOpen.value = false
  64. await router.replace(`/kanban/${encodeURIComponent(board.slug)}`)
  65. } catch (cause) {
  66. dialogError.value = cause instanceof Error ? cause.message : '创建看板失败'
  67. }
  68. }
  69. async function openTask(task: KanbanTask): Promise<void> {
  70. await kanban.selectTask(task.id)
  71. }
  72. async function moveTask(task: KanbanTask, status: string): Promise<void> {
  73. try { await kanban.moveTask(task, status) } catch { /* store exposes the error */ }
  74. }
  75. async function dropTask(id: string, status: string): Promise<void> {
  76. const task = kanban.allTasks.find(item => item.id === id)
  77. if (task) await moveTask(task, status)
  78. }
  79. async function saveTask(input: UpdateKanbanTaskInput): Promise<void> {
  80. try { await kanban.updateTask(input) } catch { /* task drawer keeps the authoritative detail */ }
  81. }
  82. async function addComment(body: string): Promise<void> {
  83. try { await kanban.comment(body) } catch { /* store exposes the error */ }
  84. }
  85. async function deleteTask(): Promise<void> {
  86. if (!window.confirm('确定删除这个任务吗?此操作无法恢复。')) return
  87. try { await kanban.deleteTask() } catch { /* store exposes the error */ }
  88. }
  89. async function dispatchNow(): Promise<void> {
  90. if (!window.confirm('立即运行一次看板调度?就绪任务可能马上启动机器人。')) return
  91. try { await kanban.dispatch() } catch { /* store exposes the error */ }
  92. }
  93. onMounted(async () => {
  94. await kanban.initialize()
  95. await reconcileBoardRoute()
  96. })
  97. watch(() => route.params.boardSlug, reconcileBoardRoute)
  98. watch(
  99. () => `${auth.isAuthenticated ? 'authenticated' : 'anonymous'}:${auth.user?.id || ''}`,
  100. async (identity, previous) => {
  101. if (identity === previous) return
  102. kanban.reset()
  103. if (auth.isAuthenticated) {
  104. await kanban.initialize()
  105. await reconcileBoardRoute()
  106. }
  107. },
  108. )
  109. onBeforeUnmount(() => kanban.reset())
  110. </script>
  111. <template>
  112. <WorkspaceView
  113. sidebar-title="看板"
  114. :sidebar-subtitle="`${kanban.boards.length} 个看板`"
  115. :inspector-open="Boolean(kanban.selectedTaskId)"
  116. inspector-close-label="关闭任务详情"
  117. @close-inspector="kanban.closeTask"
  118. >
  119. <template #sidebar-action>
  120. <button v-if="kanban.canEdit" type="button" @click="dialogError = ''; boardDialogOpen = true">
  121. <YaoYaoSidebarIcon name="add" /><span>新建看板</span>
  122. </button>
  123. </template>
  124. <template #sidebar>
  125. <div class="kanban-board-list" role="listbox" aria-label="选择看板">
  126. <button
  127. v-for="board in kanban.boards"
  128. :key="board.slug"
  129. type="button"
  130. role="option"
  131. :aria-selected="board.slug === kanban.selectedBoardSlug"
  132. :class="{ active: board.slug === kanban.selectedBoardSlug }"
  133. @click="chooseBoard(board.slug)"
  134. >
  135. <span class="kanban-board-list__icon"><AppIcon name="board" :size="16" /></span>
  136. <span><strong>{{ board.name || board.slug }}</strong><small>{{ board.description || board.slug }}</small></span>
  137. <em>{{ board.total ?? 0 }}</em>
  138. </button>
  139. <p v-if="!kanban.boards.length && kanban.availability === 'available'">还没有看板。</p>
  140. </div>
  141. </template>
  142. <section class="kanban-workspace">
  143. <div v-if="kanban.availability === 'checking'" class="kanban-page-state">正在连接 9119 看板…</div>
  144. <EmptyState
  145. v-else-if="kanban.availability !== 'available'"
  146. icon="alert"
  147. :title="kanban.availability === 'unsupported' ? 'Kanban 插件未启用' : '看板服务暂不可用'"
  148. :description="kanban.error || '请确认 9119 已安装并启用 Kanban 插件。'"
  149. action-label="重新检查"
  150. @action="kanban.initialize"
  151. />
  152. <template v-else>
  153. <header class="kanban-header">
  154. <div class="kanban-header__title">
  155. <span><AppIcon name="board" :size="16" /></span>
  156. <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>
  157. </div>
  158. <div class="kanban-header__actions">
  159. <span v-if="lastUpdated">{{ lastUpdated }} 已同步</span>
  160. <button type="button" title="刷新看板" aria-label="刷新看板" :disabled="kanban.isLoading" @click="kanban.refreshBoard()"><AppIcon name="refresh" :size="15" /></button>
  161. <button v-if="kanban.canEdit" type="button" class="quiet-action" :disabled="kanban.isMutating" @click="dispatchNow"><AppIcon name="bolt" :size="14" />立即调度</button>
  162. <button v-if="kanban.canEdit" type="button" class="primary-action" @click="openTaskCreate('ready')"><AppIcon name="plus" :size="14" />新建任务</button>
  163. </div>
  164. </header>
  165. <div v-if="!kanban.canEdit" class="kanban-readonly-banner"><AppIcon name="alert" :size="14" /><span>当前账号可查看完整看板;移动、创建、编辑、评论和调度仅限管理员。</span></div>
  166. <div v-if="kanban.error" class="kanban-error-banner" role="alert"><AppIcon name="alert" :size="14" /><span>{{ kanban.error }}</span></div>
  167. <div class="kanban-filters">
  168. <label class="kanban-search"><AppIcon name="search" :size="14" /><input v-model="kanban.search" type="search" placeholder="搜索标题、描述或任务 ID" /></label>
  169. <label><span>机器人</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>
  170. <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>
  171. <label class="kanban-archived-toggle"><input :checked="kanban.includeArchived" type="checkbox" @change="kanban.setIncludeArchived(($event.target as HTMLInputElement).checked)" /><span>显示归档</span></label>
  172. <span class="kanban-filter-count">{{ visibleTaskCount }} 项</span>
  173. </div>
  174. <div v-if="kanban.isLoading && !kanban.snapshot" class="kanban-page-state">正在读取看板…</div>
  175. <div v-else-if="kanban.snapshot" class="kanban-columns" aria-label="任务看板">
  176. <KanbanColumn
  177. v-for="column in kanban.filteredColumns"
  178. :key="column.name"
  179. :column="column"
  180. :columns="columnNames"
  181. :can-edit="kanban.canEdit"
  182. @add="openTaskCreate"
  183. @open="openTask"
  184. @move="moveTask"
  185. @drop-task="dropTask"
  186. />
  187. </div>
  188. </template>
  189. </section>
  190. <template #inspector>
  191. <KanbanTaskDrawer
  192. :detail="kanban.selectedTask"
  193. :profiles="kanban.profiles"
  194. :columns="columnNames"
  195. :can-edit="kanban.canEdit"
  196. :busy="kanban.isMutating"
  197. :loading="kanban.isTaskLoading"
  198. :error="kanban.taskError || kanban.error"
  199. @save="saveTask"
  200. @comment="addComment"
  201. @delete="deleteTask"
  202. />
  203. </template>
  204. </WorkspaceView>
  205. <KanbanTaskDialog
  206. :open="taskDialogOpen"
  207. :target-status="taskTargetStatus"
  208. :columns="columnNames"
  209. :profiles="kanban.profiles"
  210. :busy="kanban.isMutating"
  211. :error="dialogError"
  212. @close="taskDialogOpen = false"
  213. @create="createTask"
  214. />
  215. <KanbanBoardDialog
  216. :open="boardDialogOpen"
  217. :busy="kanban.isMutating"
  218. :error="dialogError"
  219. @close="boardDialogOpen = false"
  220. @create="createBoard"
  221. />
  222. </template>
  223. <style scoped>
  224. .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; }
  225. .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; }
  226. .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; }
  227. .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); }
  228. .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; }
  229. .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; }
  230. @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; } }
  231. @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; } }
  232. </style>