kin: KIN-UI-002 Исправить падающие тесты миграции (регрессия KIN-ARCH-003) в core/db.py
This commit is contained in:
parent
389b266bee
commit
ff69d24acc
7 changed files with 254 additions and 10 deletions
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, type ProjectDetail, type AuditResult, type Phase } from '../api'
|
||||
import { api, type ProjectDetail, type AuditResult, type Phase, type Task } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ const router = useRouter()
|
|||
const project = ref<ProjectDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules'>('tasks')
|
||||
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban'>('tasks')
|
||||
|
||||
// Phases
|
||||
const phases = ref<Phase[]>([])
|
||||
|
|
@ -247,7 +247,7 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||
FIX: 'rose', OBS: 'teal',
|
||||
}
|
||||
const showAddTask = ref(false)
|
||||
const taskForm = ref({ title: '', priority: 5, route_type: '', category: '' })
|
||||
const taskForm = ref({ title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' })
|
||||
const taskFormError = ref('')
|
||||
|
||||
// Add decision modal
|
||||
|
|
@ -280,6 +280,7 @@ onMounted(async () => {
|
|||
|
||||
onUnmounted(() => {
|
||||
if (phasePollTimer) { clearInterval(phasePollTimer); phasePollTimer = null }
|
||||
if (kanbanPollTimer) { clearInterval(kanbanPollTimer); kanbanPollTimer = null }
|
||||
})
|
||||
|
||||
const taskCategories = computed(() => {
|
||||
|
|
@ -352,9 +353,10 @@ async function addTask() {
|
|||
priority: taskForm.value.priority,
|
||||
route_type: taskForm.value.route_type || undefined,
|
||||
category: taskForm.value.category || undefined,
|
||||
acceptance_criteria: taskForm.value.acceptance_criteria || undefined,
|
||||
})
|
||||
showAddTask.value = false
|
||||
taskForm.value = { title: '', priority: 5, route_type: '', category: '' }
|
||||
taskForm.value = { title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' }
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
taskFormError.value = e.message
|
||||
|
|
@ -385,6 +387,87 @@ async function patchTaskField(taskId: string, data: { priority?: number; route_t
|
|||
}
|
||||
}
|
||||
|
||||
// Kanban
|
||||
const KANBAN_COLUMNS = [
|
||||
{ status: 'pending', label: 'Pending', headerClass: 'text-gray-400', bgClass: 'bg-gray-900/20' },
|
||||
{ status: 'in_progress', label: 'In Progress', headerClass: 'text-blue-400', bgClass: 'bg-blue-950/20' },
|
||||
{ status: 'review', label: 'Review', headerClass: 'text-purple-400', bgClass: 'bg-purple-950/20' },
|
||||
{ status: 'blocked', label: 'Blocked', headerClass: 'text-red-400', bgClass: 'bg-red-950/20' },
|
||||
{ status: 'done', label: 'Done', headerClass: 'text-green-400', bgClass: 'bg-green-950/20' },
|
||||
]
|
||||
|
||||
const draggingTaskId = ref<string | null>(null)
|
||||
const dragOverStatus = ref<string | null>(null)
|
||||
let kanbanPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const kanbanTasksByStatus = computed(() => {
|
||||
if (!project.value) return {} as Record<string, Task[]>
|
||||
const result: Record<string, Task[]> = {}
|
||||
for (const col of KANBAN_COLUMNS) result[col.status] = []
|
||||
for (const t of project.value.tasks) {
|
||||
if (result[t.status]) result[t.status].push(t)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function checkAndPollKanban() {
|
||||
const hasRunning = project.value?.tasks.some(t => t.status === 'in_progress') ?? false
|
||||
if (hasRunning && !kanbanPollTimer) {
|
||||
kanbanPollTimer = setInterval(async () => {
|
||||
project.value = await api.project(props.id).catch(() => project.value)
|
||||
if (!project.value?.tasks.some(t => t.status === 'in_progress')) {
|
||||
clearInterval(kanbanPollTimer!)
|
||||
kanbanPollTimer = null
|
||||
}
|
||||
}, 5000)
|
||||
} else if (!hasRunning && kanbanPollTimer) {
|
||||
clearInterval(kanbanPollTimer)
|
||||
kanbanPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'kanban') {
|
||||
checkAndPollKanban()
|
||||
} else if (kanbanPollTimer) {
|
||||
clearInterval(kanbanPollTimer)
|
||||
kanbanPollTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
function onDragStart(event: DragEvent, taskId: string) {
|
||||
draggingTaskId.value = taskId
|
||||
event.dataTransfer?.setData('text/plain', taskId)
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingTaskId.value = null
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
function onDragLeave(event: DragEvent) {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const related = event.relatedTarget as HTMLElement | null
|
||||
if (!related || !target.contains(related)) dragOverStatus.value = null
|
||||
}
|
||||
|
||||
async function onKanbanDrop(event: DragEvent, newStatus: string) {
|
||||
dragOverStatus.value = null
|
||||
const taskId = event.dataTransfer?.getData('text/plain') || draggingTaskId.value
|
||||
draggingTaskId.value = null
|
||||
if (!taskId || !project.value) return
|
||||
const task = project.value.tasks.find(t => t.id === taskId)
|
||||
if (!task || task.status === newStatus) return
|
||||
try {
|
||||
const updated = await api.patchTask(taskId, { status: newStatus })
|
||||
const idx = project.value.tasks.findIndex(t => t.id === taskId)
|
||||
if (idx >= 0) project.value.tasks[idx] = updated
|
||||
checkAndPollKanban()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function addDecision() {
|
||||
decFormError.value = ''
|
||||
try {
|
||||
|
|
@ -441,18 +524,19 @@ async function addDecision() {
|
|||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 mb-4 border-b border-gray-800">
|
||||
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules'] as const)" :key="tab"
|
||||
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban'] as const)" :key="tab"
|
||||
@click="activeTab = tab"
|
||||
class="px-4 py-2 text-sm border-b-2 transition-colors"
|
||||
:class="activeTab === tab
|
||||
? 'text-gray-200 border-blue-500'
|
||||
: 'text-gray-500 border-transparent hover:text-gray-300'">
|
||||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||
{{ tab === 'kanban' ? 'Kanban' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||
<span class="text-xs text-gray-600 ml-1">
|
||||
{{ tab === 'tasks' ? project.tasks.length
|
||||
: tab === 'phases' ? phases.length
|
||||
: tab === 'decisions' ? project.decisions.length
|
||||
: project.modules.length }}
|
||||
: tab === 'modules' ? project.modules.length
|
||||
: project.tasks.length }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -739,6 +823,45 @@ async function addDecision() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban Tab -->
|
||||
<div v-if="activeTab === 'kanban'" class="overflow-x-auto pb-4">
|
||||
<div class="flex gap-3" style="min-width: max-content">
|
||||
<div v-for="col in KANBAN_COLUMNS" :key="col.status" class="w-64 flex flex-col gap-2">
|
||||
<!-- Column header -->
|
||||
<div class="flex items-center gap-2 px-2 py-1.5">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide" :class="col.headerClass">{{ col.label }}</span>
|
||||
<span class="text-xs text-gray-600">({{ kanbanTasksByStatus[col.status]?.length || 0 }})</span>
|
||||
</div>
|
||||
<!-- Drop zone -->
|
||||
<div
|
||||
class="flex flex-col gap-1.5 min-h-24 rounded p-1.5 border transition-colors"
|
||||
:class="[col.bgClass, dragOverStatus === col.status ? 'border-blue-600' : 'border-transparent']"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="dragOverStatus = col.status"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onKanbanDrop($event, col.status)">
|
||||
<router-link
|
||||
v-for="t in kanbanTasksByStatus[col.status]" :key="t.id"
|
||||
:to="`/task/${t.id}`"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, t.id)"
|
||||
@dragend="onDragEnd"
|
||||
class="block px-2.5 py-2 bg-gray-900 border border-gray-800 rounded text-xs hover:border-gray-600 no-underline cursor-grab active:cursor-grabbing transition-colors select-none"
|
||||
:class="draggingTaskId === t.id ? 'opacity-40' : ''">
|
||||
<div class="text-gray-500 mb-1 text-[10px]">{{ t.id }}</div>
|
||||
<div class="text-gray-300 leading-snug mb-1.5">{{ t.title }}</div>
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<Badge v-if="t.category" :text="t.category" :color="CATEGORY_COLORS[t.category] || 'gray'" />
|
||||
<span class="text-gray-600 text-[10px] ml-auto">p{{ t.priority }}</span>
|
||||
<span v-if="t.status === 'in_progress'" class="inline-block w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>
|
||||
</div>
|
||||
</router-link>
|
||||
<div v-if="!kanbanTasksByStatus[col.status]?.length" class="text-xs text-gray-700 text-center py-6">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Task Modal -->
|
||||
<Modal v-if="showAddTask" title="Add Task" @close="showAddTask = false">
|
||||
<form @submit.prevent="addTask" class="space-y-3">
|
||||
|
|
@ -759,6 +882,12 @@ async function addDecision() {
|
|||
</select>
|
||||
<input v-model.number="taskForm.priority" type="number" min="1" max="10" placeholder="Priority"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Критерии приёмки</label>
|
||||
<textarea v-model="taskForm.acceptance_criteria" rows="3"
|
||||
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
|
||||
</div>
|
||||
<p v-if="taskFormError" class="text-red-400 text-xs">{{ taskFormError }}</p>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900">
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ async function runDeploy() {
|
|||
|
||||
// Edit modal (pending tasks only)
|
||||
const showEdit = ref(false)
|
||||
const editForm = ref({ title: '', briefText: '', priority: 5 })
|
||||
const editForm = ref({ title: '', briefText: '', priority: 5, acceptanceCriteria: '' })
|
||||
const editLoading = ref(false)
|
||||
const editError = ref('')
|
||||
|
||||
|
|
@ -298,6 +298,7 @@ function openEdit() {
|
|||
title: task.value.title,
|
||||
briefText: getBriefText(task.value.brief),
|
||||
priority: task.value.priority,
|
||||
acceptanceCriteria: task.value.acceptance_criteria ?? '',
|
||||
}
|
||||
editError.value = ''
|
||||
showEdit.value = true
|
||||
|
|
@ -313,6 +314,8 @@ async function saveEdit() {
|
|||
if (editForm.value.priority !== task.value.priority) data.priority = editForm.value.priority
|
||||
const origBriefText = getBriefText(task.value.brief)
|
||||
if (editForm.value.briefText !== origBriefText) data.brief_text = editForm.value.briefText
|
||||
const origAC = task.value.acceptance_criteria ?? ''
|
||||
if (editForm.value.acceptanceCriteria !== origAC) data.acceptance_criteria = editForm.value.acceptanceCriteria
|
||||
if (Object.keys(data).length === 0) { showEdit.value = false; return }
|
||||
const updated = await api.patchTask(props.id, data)
|
||||
task.value = { ...task.value, ...updated }
|
||||
|
|
@ -387,6 +390,10 @@ async function saveEdit() {
|
|||
<div v-if="task.brief && !isManualEscalation" class="text-xs text-gray-500 mb-1">
|
||||
Brief: {{ JSON.stringify(task.brief) }}
|
||||
</div>
|
||||
<div v-if="task.acceptance_criteria" class="mb-2 px-3 py-2 border border-gray-700 bg-gray-900/40 rounded">
|
||||
<div class="text-xs font-semibold text-gray-400 mb-1">Критерии приёмки</div>
|
||||
<p class="text-xs text-gray-300 whitespace-pre-wrap">{{ task.acceptance_criteria }}</p>
|
||||
</div>
|
||||
<div v-if="task.status === 'blocked' && task.blocked_reason" class="text-xs text-red-400 mb-1 bg-red-950/30 border border-red-800/40 rounded px-2 py-1">
|
||||
Blocked: {{ task.blocked_reason }}
|
||||
</div>
|
||||
|
|
@ -628,6 +635,12 @@ async function saveEdit() {
|
|||
<input v-model.number="editForm.priority" type="number" min="1" max="10" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Критерии приёмки</label>
|
||||
<textarea v-model="editForm.acceptanceCriteria" rows="3"
|
||||
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
|
||||
</div>
|
||||
<p v-if="editError" class="text-red-400 text-xs">{{ editError }}</p>
|
||||
<button type="submit" :disabled="editLoading"
|
||||
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900 disabled:opacity-50">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue