kin: KIN-108-frontend_dev
This commit is contained in:
parent
8b409fd7db
commit
353416ead1
16 changed files with 799 additions and 212 deletions
|
|
@ -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">← 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 ? '✓ Автотест' : 'Автотест' }}
|
||||
{{ 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 ? '✓ 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">⚠ Требуют ручного решения</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 ? '✓ Автотест' : 'Автотест' }}
|
||||
{{ 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 ? '✓ 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue