kin: KIN-108-frontend_dev

This commit is contained in:
Gros Frumos 2026-03-18 07:57:15 +02:00
parent 8b409fd7db
commit 353416ead1
16 changed files with 799 additions and 212 deletions

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment, type DeployResult, type ProjectLink } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
@ -8,6 +9,7 @@ import Modal from '../components/Modal.vue'
const props = defineProps<{ id: string }>()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const project = ref<ProjectDetail | null>(null)
const loading = ref(true)
@ -48,7 +50,7 @@ function openTaskRevise(taskId: string) {
}
async function submitTaskRevise() {
if (!taskReviseComment.value.trim()) { taskReviseError.value = 'Комментарий обязателен'; return }
if (!taskReviseComment.value.trim()) { taskReviseError.value = t('projectView.comment_required'); return }
taskReviseSaving.value = true
try {
await api.reviseTask(taskReviseTaskId.value!, taskReviseComment.value)
@ -378,7 +380,7 @@ async function submitEnv() {
}
async function deleteEnv(envId: number) {
if (!confirm('Удалить среду?')) return
if (!confirm(t('projectView.delete_env_confirm'))) return
try {
await api.deleteEnvironment(props.id, envId)
await loadEnvironments()
@ -431,7 +433,7 @@ async function loadLinks() {
async function addLink() {
linkFormError.value = ''
if (!linkForm.value.to_project) { linkFormError.value = 'Выберите проект'; return }
if (!linkForm.value.to_project) { linkFormError.value = t('projectView.select_project'); return }
linkSaving.value = true
try {
await api.createProjectLink({
@ -451,7 +453,7 @@ async function addLink() {
}
async function deleteLink(id: number) {
if (!confirm('Удалить связь?')) return
if (!confirm(t('projectView.delete_link_confirm'))) return
try {
await api.deleteProjectLink(id)
await loadLinks()
@ -649,7 +651,7 @@ const runningTaskId = ref<string | null>(null)
async function runTask(taskId: string, event: Event) {
event.preventDefault()
event.stopPropagation()
if (!confirm(`Run pipeline for ${taskId}?`)) return
if (!confirm(t('projectView.run_pipeline_confirm', { n: taskId }))) return
runningTaskId.value = taskId
try {
// Sync task execution_mode with current project toggle state before running
@ -659,7 +661,7 @@ async function runTask(taskId: string, event: Event) {
if (activeTab.value === 'kanban') checkAndPollKanban()
} catch (e: any) {
if (e instanceof ApiError && e.code === 'task_already_running') {
error.value = 'Pipeline уже запущен'
error.value = t('projectView.pipeline_already_running')
} else {
error.value = e.message
}
@ -681,13 +683,13 @@ 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 KANBAN_COLUMNS = computed(() => [
{ status: 'pending', label: t('projectView.kanban_pending'), headerClass: 'text-gray-400', bgClass: 'bg-gray-900/20' },
{ status: 'in_progress', label: t('projectView.kanban_in_progress'), headerClass: 'text-blue-400', bgClass: 'bg-blue-950/20' },
{ status: 'review', label: t('projectView.kanban_review'), headerClass: 'text-purple-400', bgClass: 'bg-purple-950/20' },
{ status: 'blocked', label: t('projectView.kanban_blocked'), headerClass: 'text-red-400', bgClass: 'bg-red-950/20' },
{ status: 'done', label: t('projectView.kanban_done'), headerClass: 'text-green-400', bgClass: 'bg-green-950/20' },
])
const draggingTaskId = ref<string | null>(null)
const dragOverStatus = ref<string | null>(null)
@ -695,9 +697,9 @@ let kanbanPollTimer: ReturnType<typeof setInterval> | null = null
const kanbanTasksByStatus = computed(() => {
const result: Record<string, Task[]> = {}
for (const col of KANBAN_COLUMNS) result[col.status] = []
for (const t of searchFilteredTasks.value) {
if (result[t.status]) result[t.status].push(t)
for (const col of KANBAN_COLUMNS.value) result[col.status] = []
for (const task of searchFilteredTasks.value) {
if (result[task.status]) result[task.status].push(task)
}
return result
})
@ -782,15 +784,15 @@ async function addDecision() {
</script>
<template>
<div v-if="loading" class="text-gray-500 text-sm">Loading...</div>
<div v-if="loading" class="text-gray-500 text-sm">{{ t('common.loading') }}</div>
<div v-else-if="error" class="text-red-400 text-sm">{{ error }}</div>
<div v-else-if="project">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-1">
<router-link to="/" class="text-gray-600 hover:text-gray-400 text-sm no-underline">&larr; back</router-link>
<router-link to="/" class="text-gray-600 hover:text-gray-400 text-sm no-underline">{{ t('projectView.back') }}</router-link>
<span class="text-gray-700">|</span>
<router-link :to="`/chat/${project.id}`" class="text-indigo-500 hover:text-indigo-400 text-sm no-underline">Чат</router-link>
<router-link :to="`/chat/${project.id}`" class="text-indigo-500 hover:text-indigo-400 text-sm no-underline">{{ t('projectView.chat') }}</router-link>
</div>
<div class="flex items-center gap-3 mb-2 flex-wrap">
<h1 class="text-xl font-bold text-gray-100">{{ project.id }}</h1>
@ -806,7 +808,7 @@ async function addDecision() {
class="px-3 py-1 text-xs bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50 ml-auto"
>
<span v-if="deploying" class="inline-block w-3 h-3 border-2 border-teal-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ deploying ? 'Deploying...' : 'Deploy' }}
{{ deploying ? t('taskDetail.deploying') : t('projectView.deploy') }}
</button>
</div>
@ -815,7 +817,7 @@ async function addDecision() {
:class="deployResult.overall_success !== false && deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
<div class="flex items-center gap-2 mb-1">
<span :class="deployResult.overall_success !== false && deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
{{ deployResult.overall_success !== false && deployResult.success ? 'Deploy succeeded' : 'Deploy failed' }}
{{ deployResult.overall_success !== false && deployResult.success ? t('taskDetail.deploy_succeeded') : t('taskDetail.deploy_failed') }}
</span>
<span class="text-gray-500">{{ deployResult.duration_seconds }}s</span>
<button @click.stop="deployResult = null" class="ml-auto text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs">x</button>
@ -841,7 +843,7 @@ async function addDecision() {
</template>
<!-- Dependents -->
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
<p class="text-xs text-gray-400 font-semibold mb-1">Зависимые проекты:</p>
<p class="text-xs text-gray-400 font-semibold mb-1">{{ t('taskDetail.dependent_projects') }}</p>
<div v-for="dep in deployResult.dependents_deployed" :key="dep" class="flex items-center gap-2 px-2 py-0.5">
<span class="text-teal-400 font-semibold text-[10px]">ok</span>
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
@ -878,7 +880,7 @@ async function addDecision() {
:class="activeTab === tab
? 'text-gray-200 border-blue-500'
: 'text-gray-500 border-transparent hover:text-gray-300'">
{{ tab === 'kanban' ? 'Kanban' : tab === 'environments' ? 'Среды' : tab === 'links' ? 'Links' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
{{ tab === 'tasks' ? t('projectView.tasks_tab') : tab === 'phases' ? t('projectView.phases_tab') : tab === 'decisions' ? t('projectView.decisions_tab') : tab === 'modules' ? t('projectView.modules_tab') : tab === 'kanban' ? t('projectView.kanban_tab') : tab === 'environments' ? t('projectView.environments') : t('projectView.links_tab') }}
<span class="text-xs text-gray-600 ml-1">
{{ tab === 'tasks' ? project.tasks.length
: tab === 'phases' ? phases.length
@ -930,25 +932,25 @@ async function addDecision() {
? 'bg-blue-900/30 text-blue-400 border-blue-800 hover:bg-blue-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
{{ autoTest ? '&#x2713; Автотест' : 'Автотест' }}
{{ autoTest ? '✓ ' + t('projectView.auto_test_label') : t('projectView.auto_test_label') }}
</button>
<button @click="toggleWorktrees"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="worktrees
? 'bg-teal-900/30 text-teal-400 border-teal-800 hover:bg-teal-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="worktrees ? 'Worktrees: on — агенты в изолированных git worktrees' : 'Worktrees: off'">
{{ worktrees ? '&#x2713; Worktrees' : 'Worktrees' }}
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}
</button>
<button @click="runAudit" :disabled="auditLoading"
class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50"
title="Check which pending tasks are already done">
<span v-if="auditLoading" class="inline-block w-3 h-3 border-2 border-purple-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ auditLoading ? 'Auditing...' : 'Audit backlog' }}
{{ auditLoading ? 'Auditing...' : t('projectView.audit_backlog') }}
</button>
<button @click="showAddTask = true"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
+ Task
{{ t('projectView.add_task') }}
</button>
</div>
</div>
@ -959,7 +961,7 @@ async function addDecision() {
:class="!selectedCategory
? 'bg-gray-700/60 text-gray-300 border-gray-600'
: 'bg-gray-900 text-gray-600 border-gray-800 hover:text-gray-400 hover:border-gray-700'"
>Все</button>
>{{ t('projectView.all_statuses') }}</button>
<button v-for="cat in taskCategories" :key="cat"
@click="selectedCategory = cat"
class="px-2 py-0.5 text-xs rounded border transition-colors"
@ -972,7 +974,7 @@ async function addDecision() {
</div>
<!-- Search -->
<div class="flex items-center gap-1">
<input v-model="taskSearch" placeholder="Поиск по задачам..."
<input v-model="taskSearch" :placeholder="t('projectView.search_placeholder')"
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 placeholder-gray-600 w-56 focus:border-gray-500 outline-none" />
<button v-if="taskSearch" @click="taskSearch = ''"
class="text-gray-600 hover:text-red-400 text-xs px-1"></button>
@ -981,7 +983,7 @@ async function addDecision() {
<!-- Manual escalation tasks -->
<div v-if="manualEscalationTasks.length" class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-orange-400 uppercase tracking-wide">&#9888; Требуют ручного решения</span>
<span class="text-xs font-semibold text-orange-400 uppercase tracking-wide">{{ t('projectView.manual_escalations_warn') }}</span>
<span class="text-xs text-orange-600">({{ manualEscalationTasks.length }})</span>
</div>
<div class="space-y-1">
@ -1003,7 +1005,7 @@ async function addDecision() {
</div>
</div>
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">{{ t('projectView.no_tasks') }}</div>
<div v-else class="space-y-1">
<router-link v-for="t in filteredTasks" :key="t.id"
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
@ -1077,7 +1079,7 @@ async function addDecision() {
<button @click="claudeLoginError = false" class="text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs shrink-0"></button>
</div>
</div>
<p v-if="phasesLoading" class="text-gray-500 text-sm">Loading phases...</p>
<p v-if="phasesLoading" class="text-gray-500 text-sm">{{ t('projectView.loading_phases') }}</p>
<p v-else-if="phaseError" class="text-red-400 text-sm">{{ phaseError }}</p>
<div v-else-if="phases.length === 0" class="text-gray-600 text-sm">
No research phases. Use "New Project" to start a research workflow.
@ -1224,7 +1226,7 @@ async function addDecision() {
<div v-if="activeTab === 'kanban'" class="pb-4">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-1">
<input v-model="taskSearch" placeholder="Поиск..."
<input v-model="taskSearch" :placeholder="t('projectView.search_placeholder')"
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 placeholder-gray-600 w-48 focus:border-gray-500 outline-none" />
<button v-if="taskSearch" @click="taskSearch = ''"
class="text-gray-600 hover:text-red-400 text-xs px-1"></button>
@ -1252,25 +1254,25 @@ async function addDecision() {
? 'bg-blue-900/30 text-blue-400 border-blue-800 hover:bg-blue-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
{{ autoTest ? '&#x2713; Автотест' : 'Автотест' }}
{{ autoTest ? '✓ ' + t('projectView.auto_test_label') : t('projectView.auto_test_label') }}
</button>
<button @click="toggleWorktrees"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="worktrees
? 'bg-teal-900/30 text-teal-400 border-teal-800 hover:bg-teal-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="worktrees ? 'Worktrees: on — агенты в изолированных git worktrees' : 'Worktrees: off'">
{{ worktrees ? '&#x2713; Worktrees' : 'Worktrees' }}
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}
</button>
<button @click="runAudit" :disabled="auditLoading"
class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50"
title="Check which pending tasks are already done">
<span v-if="auditLoading" class="inline-block w-3 h-3 border-2 border-purple-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ auditLoading ? 'Auditing...' : 'Аудит' }}
{{ auditLoading ? 'Auditing...' : t('projectView.audit_backlog') }}
</button>
<button @click="showAddTask = true"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
+ Тас
{{ t('projectView.add_task') }}
</button>
</div>
</div>
@ -1432,7 +1434,7 @@ async function addDecision() {
<p v-if="linkFormError" class="text-red-400 text-xs">{{ linkFormError }}</p>
<div class="flex gap-2 justify-end">
<button type="button" @click="showAddLink = false; linkFormError = ''"
class="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200">Отмена</button>
class="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200">{{ t('common.cancel') }}</button>
<button type="submit" :disabled="linkSaving"
class="px-4 py-1.5 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
{{ linkSaving ? 'Saving...' : 'Add Link' }}
@ -1597,7 +1599,7 @@ async function addDecision() {
<p v-if="taskReviseError" class="text-red-400 text-xs">{{ taskReviseError }}</p>
<button @click="submitTaskRevise" :disabled="taskReviseSaving"
class="w-full py-2 bg-orange-900/50 text-orange-400 border border-orange-800 rounded text-sm hover:bg-orange-900 disabled:opacity-50">
{{ taskReviseSaving ? 'Отправляем...' : 'Отправить на доработку' }}
{{ taskReviseSaving ? t('common.saving') : t('taskDetail.send_to_revision') }}
</button>
</div>
</Modal>