kin: KIN-108-frontend_dev
This commit is contained in:
parent
8b409fd7db
commit
353416ead1
16 changed files with 799 additions and 212 deletions
|
|
@ -1,11 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api, ApiError, type ChatMessage } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
|
||||
const props = defineProps<{ projectId: string }>()
|
||||
const router = useRouter()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const input = ref('')
|
||||
|
|
@ -43,9 +45,9 @@ function checkAndPoll() {
|
|||
if (!hasRunningTasks(updated)) stopPoll()
|
||||
} catch (e: any) {
|
||||
consecutiveErrors.value++
|
||||
console.warn(`[polling] ошибка #${consecutiveErrors.value}:`, e)
|
||||
console.warn('[polling] error #' + consecutiveErrors.value + ':', e)
|
||||
if (consecutiveErrors.value >= 3) {
|
||||
error.value = 'Сервер недоступен. Проверьте подключение.'
|
||||
error.value = t('chat.server_unavailable')
|
||||
stopPoll()
|
||||
}
|
||||
}
|
||||
|
|
@ -134,7 +136,7 @@ function taskStatusColor(status: string): string {
|
|||
}
|
||||
|
||||
function formatTime(dt: string) {
|
||||
return new Date(dt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
return new Date(dt).toLocaleTimeString(locale.value === 'ru' ? 'ru-RU' : 'en-US', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -145,12 +147,12 @@ function formatTime(dt: string) {
|
|||
<router-link
|
||||
:to="`/project/${projectId}`"
|
||||
class="text-gray-400 hover:text-gray-200 text-sm no-underline"
|
||||
>← Проект</router-link>
|
||||
>{{ t('chat.back_to_project') }}</router-link>
|
||||
<span class="text-gray-600">|</span>
|
||||
<h1 class="text-base font-semibold text-gray-100">
|
||||
{{ projectName || projectId }}
|
||||
</h1>
|
||||
<span class="text-xs text-gray-500 ml-1">— чат</span>
|
||||
<span class="text-xs text-gray-500 ml-1">{{ t('chat.chat_label') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
|
|
@ -160,7 +162,7 @@ function formatTime(dt: string) {
|
|||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
||||
<span class="text-gray-500 text-sm">Загрузка...</span>
|
||||
<span class="text-gray-500 text-sm">{{ t('chat.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
|
|
@ -170,7 +172,7 @@ function formatTime(dt: string) {
|
|||
class="flex-1 overflow-y-auto py-4 flex flex-col gap-3 min-h-0"
|
||||
>
|
||||
<div v-if="messages.length === 0" class="text-center text-gray-500 text-sm mt-8">
|
||||
Опишите задачу или спросите о статусе проекта
|
||||
{{ t('chat.empty_hint') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -209,7 +211,7 @@ function formatTime(dt: string) {
|
|||
<textarea
|
||||
v-model="input"
|
||||
:disabled="sending || loading"
|
||||
placeholder="Опишите задачу или вопрос... (Enter — отправить, Shift+Enter — перенос)"
|
||||
:placeholder="t('chat.input_placeholder')"
|
||||
rows="2"
|
||||
class="flex-1 bg-gray-800/60 border border-gray-700 rounded-xl px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 resize-none focus:outline-none focus:border-indigo-600 disabled:opacity-50"
|
||||
@keydown="onKeydown"
|
||||
|
|
@ -219,7 +221,7 @@ function formatTime(dt: string) {
|
|||
class="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm rounded-xl font-medium transition-colors"
|
||||
@click="send"
|
||||
>
|
||||
{{ sending ? '...' : 'Отправить' }}
|
||||
{{ sending ? t('chat.sending') : t('chat.send') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api, type Project, type CostEntry } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const costs = ref<CostEntry[]>([])
|
||||
const loading = ref(true)
|
||||
|
|
@ -25,12 +28,12 @@ const bsResult = ref('')
|
|||
|
||||
// New Project with Research modal
|
||||
const RESEARCH_ROLES = [
|
||||
{ key: 'business_analyst', label: 'Business Analyst', hint: 'бизнес-модель, аудитория, монетизация' },
|
||||
{ key: 'market_researcher', label: 'Market Researcher', hint: 'конкуренты, ниша, сильные/слабые стороны' },
|
||||
{ key: 'legal_researcher', label: 'Legal Researcher', hint: 'юрисдикция, лицензии, KYC/AML, GDPR' },
|
||||
{ key: 'tech_researcher', label: 'Tech Researcher', hint: 'API, ограничения, стоимость, альтернативы' },
|
||||
{ key: 'ux_designer', label: 'UX Designer', hint: 'анализ UX конкурентов, user journey, wireframes' },
|
||||
{ key: 'marketer', label: 'Marketer', hint: 'стратегия продвижения, SEO, conversion-паттерны' },
|
||||
{ key: 'business_analyst' },
|
||||
{ key: 'market_researcher' },
|
||||
{ key: 'legal_researcher' },
|
||||
{ key: 'tech_researcher' },
|
||||
{ key: 'ux_designer' },
|
||||
{ key: 'marketer' },
|
||||
]
|
||||
const showNewProject = ref(false)
|
||||
const npForm = ref({
|
||||
|
|
@ -55,7 +58,6 @@ let dashPollTimer: ReturnType<typeof setInterval> | null = null
|
|||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
// Poll if there are running tasks
|
||||
checkAndPoll()
|
||||
})
|
||||
|
||||
|
|
@ -87,11 +89,11 @@ function statusColor(s: string) {
|
|||
async function addProject() {
|
||||
formError.value = ''
|
||||
if (form.value.project_type === 'operations' && !form.value.ssh_host) {
|
||||
formError.value = 'SSH host is required for operations projects'
|
||||
formError.value = t('dashboard.ssh_host_required')
|
||||
return
|
||||
}
|
||||
if (form.value.project_type !== 'operations' && !form.value.path) {
|
||||
formError.value = 'Path is required'
|
||||
formError.value = t('dashboard.path_required')
|
||||
return
|
||||
}
|
||||
try {
|
||||
|
|
@ -157,7 +159,7 @@ async function deleteProject(id: string) {
|
|||
async function createNewProject() {
|
||||
npError.value = ''
|
||||
if (!npRoles.value.length) {
|
||||
npError.value = 'Выберите хотя бы одну роль'
|
||||
npError.value = t('dashboard.role_error')
|
||||
return
|
||||
}
|
||||
npSaving.value = true
|
||||
|
|
@ -189,26 +191,26 @@ async function createNewProject() {
|
|||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-100">Dashboard</h1>
|
||||
<p class="text-sm text-gray-500" v-if="totalCost > 0">Cost this week: ${{ totalCost.toFixed(2) }}</p>
|
||||
<h1 class="text-xl font-bold text-gray-100">{{ t('dashboard.title') }}</h1>
|
||||
<p class="text-sm text-gray-500" v-if="totalCost > 0">{{ t('dashboard.cost_this_week') }}: ${{ totalCost.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="showBootstrap = true"
|
||||
class="px-3 py-1.5 text-xs bg-purple-900/50 text-purple-400 border border-purple-800 rounded hover:bg-purple-900">
|
||||
Bootstrap
|
||||
{{ t('dashboard.bootstrap') }}
|
||||
</button>
|
||||
<button @click="showNewProject = true"
|
||||
class="px-3 py-1.5 text-xs bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900">
|
||||
+ New Project
|
||||
{{ t('dashboard.new_project') }}
|
||||
</button>
|
||||
<button @click="showAdd = true"
|
||||
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
||||
+ Blank
|
||||
{{ t('dashboard.blank') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="text-gray-500 text-sm">Loading...</p>
|
||||
<p v-if="loading" class="text-gray-500 text-sm">{{ t('dashboard.loading') }}</p>
|
||||
<p v-else-if="error" class="text-red-400 text-sm">{{ error }}</p>
|
||||
|
||||
<div v-else class="grid gap-3">
|
||||
|
|
@ -216,17 +218,17 @@ async function createNewProject() {
|
|||
<!-- Inline delete confirmation -->
|
||||
<div v-if="confirmDeleteId === p.id"
|
||||
class="border border-red-800 rounded-lg p-4 bg-red-950/20">
|
||||
<p class="text-sm text-gray-200 mb-3">Удалить проект «{{ p.name }}»? Это действие необратимо.</p>
|
||||
<p class="text-sm text-gray-200 mb-3">{{ t('dashboard.delete_confirm', { name: p.name }) }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="deleteProject(p.id)"
|
||||
title="Подтвердить удаление"
|
||||
:title="t('dashboard.delete_project_title')"
|
||||
class="px-3 py-1.5 text-xs bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
||||
Да, удалить
|
||||
{{ t('common.yes_delete') }}
|
||||
</button>
|
||||
<button @click="confirmDeleteId = null"
|
||||
title="Отмена удаления"
|
||||
:title="t('dashboard.cancel_delete_title')"
|
||||
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700">
|
||||
Отмена
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="deleteError" class="text-red-400 text-xs mt-2">{{ deleteError }}</p>
|
||||
|
|
@ -249,7 +251,7 @@ async function createNewProject() {
|
|||
<span v-if="costMap[p.id]">${{ costMap[p.id]?.toFixed(2) }}/wk</span>
|
||||
<span>pri {{ p.priority }}</span>
|
||||
<button @click.prevent.stop="confirmDeleteId = p.id"
|
||||
title="Удалить проект"
|
||||
:title="t('common.delete')"
|
||||
class="text-gray-600 hover:text-red-400 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
|
|
@ -275,82 +277,82 @@ async function createNewProject() {
|
|||
</div>
|
||||
|
||||
<!-- Add Project Modal -->
|
||||
<Modal v-if="showAdd" title="Add Project" @close="showAdd = false">
|
||||
<Modal v-if="showAdd" :title="t('dashboard.add_project_title')" @close="showAdd = false">
|
||||
<form @submit.prevent="addProject" class="space-y-3">
|
||||
<input v-model="form.id" placeholder="ID (e.g. vdol)" required
|
||||
<input v-model="form.id" :placeholder="t('dashboard.id_placeholder')" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="form.name" placeholder="Name" required
|
||||
<input v-model="form.name" :placeholder="t('dashboard.name_placeholder')" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<!-- Project type selector -->
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1.5">Тип проекта:</p>
|
||||
<p class="text-xs text-gray-500 mb-1.5">{{ t('dashboard.project_type_label') }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button v-for="t in ['development', 'operations', 'research']" :key="t"
|
||||
<button v-for="t_type in ['development', 'operations', 'research']" :key="t_type"
|
||||
type="button"
|
||||
@click="form.project_type = t"
|
||||
@click="form.project_type = t_type"
|
||||
class="flex-1 py-1.5 text-xs border rounded transition-colors"
|
||||
:class="form.project_type === t
|
||||
? t === 'development' ? 'bg-blue-900/40 text-blue-300 border-blue-700'
|
||||
: t === 'operations' ? 'bg-orange-900/40 text-orange-300 border-orange-700'
|
||||
:class="form.project_type === t_type
|
||||
? t_type === 'development' ? 'bg-blue-900/40 text-blue-300 border-blue-700'
|
||||
: t_type === 'operations' ? 'bg-orange-900/40 text-orange-300 border-orange-700'
|
||||
: 'bg-green-900/40 text-green-300 border-green-700'
|
||||
: 'bg-gray-900 text-gray-500 border-gray-800 hover:text-gray-300 hover:border-gray-600'"
|
||||
>{{ t }}</button>
|
||||
>{{ t_type }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Path (development / research) -->
|
||||
<input v-if="form.project_type !== 'operations'"
|
||||
v-model="form.path" placeholder="Path (e.g. ~/projects/myproj)"
|
||||
v-model="form.path" :placeholder="t('dashboard.path_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<!-- SSH fields (operations) -->
|
||||
<template v-if="form.project_type === 'operations'">
|
||||
<input v-model="form.ssh_host" placeholder="SSH host (e.g. 192.168.1.1)" required
|
||||
<input v-model="form.ssh_host" :placeholder="t('dashboard.ssh_host_placeholder')" required
|
||||
class="w-full bg-gray-800 border border-orange-800/60 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input v-model="form.ssh_user" placeholder="SSH user (e.g. root)"
|
||||
<input v-model="form.ssh_user" :placeholder="t('dashboard.ssh_user_placeholder')"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="form.ssh_key_path" placeholder="Key path (e.g. ~/.ssh/id_rsa)"
|
||||
<input v-model="form.ssh_key_path" :placeholder="t('dashboard.ssh_key_placeholder')"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<input v-model="form.ssh_proxy_jump" placeholder="ProxyJump (optional, e.g. jumpt)"
|
||||
<input v-model="form.ssh_proxy_jump" :placeholder="t('dashboard.proxy_jump_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<p class="mt-1 flex items-center gap-1 text-xs text-gray-500">
|
||||
<svg class="w-3 h-3 flex-shrink-0 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Алиас из ~/.ssh/config на сервере Kin
|
||||
{{ t('dashboard.ssh_alias_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<input v-model="form.tech_stack" placeholder="Tech stack (comma-separated)"
|
||||
<input v-model="form.tech_stack" :placeholder="t('dashboard.tech_stack_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model.number="form.priority" type="number" min="1" max="10" placeholder="Priority (1-10)"
|
||||
<input v-model.number="form.priority" type="number" min="1" max="10" :placeholder="t('dashboard.priority_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<p v-if="formError" class="text-red-400 text-xs">{{ formError }}</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">
|
||||
Create
|
||||
{{ t('dashboard.create_btn') }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- New Project with Research Modal -->
|
||||
<Modal v-if="showNewProject" title="New Project — Start Research" @close="showNewProject = false">
|
||||
<Modal v-if="showNewProject" :title="t('dashboard.new_project_title')" @close="showNewProject = false">
|
||||
<form @submit.prevent="createNewProject" class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input v-model="npForm.id" placeholder="ID (e.g. myapp)" required
|
||||
<input v-model="npForm.id" :placeholder="t('dashboard.id_placeholder')" required
|
||||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="npForm.name" placeholder="Name" required
|
||||
<input v-model="npForm.name" :placeholder="t('dashboard.name_placeholder')" required
|
||||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
</div>
|
||||
<input v-model="npForm.path" placeholder="Path (e.g. ~/projects/myapp)"
|
||||
<input v-model="npForm.path" :placeholder="t('dashboard.path_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<textarea v-model="npForm.description" placeholder="Описание проекта (свободный текст для агентов)" required rows="4"
|
||||
<textarea v-model="npForm.description" :placeholder="t('dashboard.project_description_placeholder')" required rows="4"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-none"></textarea>
|
||||
<input v-model="npForm.tech_stack" placeholder="Tech stack (comma-separated, optional)"
|
||||
<input v-model="npForm.tech_stack" :placeholder="t('dashboard.tech_stack_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-2">Этапы research (Architect добавляется автоматически последним):</p>
|
||||
<p class="text-xs text-gray-500 mb-2">{{ t('dashboard.research_stages') }}</p>
|
||||
<div class="space-y-1.5">
|
||||
<label v-for="r in RESEARCH_ROLES" :key="r.key"
|
||||
class="flex items-start gap-2 cursor-pointer group">
|
||||
|
|
@ -359,15 +361,15 @@ async function createNewProject() {
|
|||
@change="toggleNpRole(r.key)"
|
||||
class="mt-0.5 accent-green-500 cursor-pointer" />
|
||||
<div>
|
||||
<span class="text-sm text-gray-300 group-hover:text-gray-100">{{ r.label }}</span>
|
||||
<span class="text-xs text-gray-600 ml-1">— {{ r.hint }}</span>
|
||||
<span class="text-sm text-gray-300 group-hover:text-gray-100">{{ t(`dashboard.roles.${r.key}.label`) }}</span>
|
||||
<span class="text-xs text-gray-600 ml-1">— {{ t(`dashboard.roles.${r.key}.hint`) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start gap-2 opacity-50">
|
||||
<input type="checkbox" checked disabled class="mt-0.5" />
|
||||
<div>
|
||||
<span class="text-sm text-gray-400">Architect</span>
|
||||
<span class="text-xs text-gray-600 ml-1">— blueprint на основе одобренных исследований</span>
|
||||
<span class="text-sm text-gray-400">{{ t('dashboard.roles.architect.label') }}</span>
|
||||
<span class="text-xs text-gray-600 ml-1">— {{ t('dashboard.architect_hint') }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -375,25 +377,25 @@ async function createNewProject() {
|
|||
<p v-if="npError" class="text-red-400 text-xs">{{ npError }}</p>
|
||||
<button type="submit" :disabled="npSaving"
|
||||
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900 disabled:opacity-50">
|
||||
{{ npSaving ? 'Starting...' : 'Start Research' }}
|
||||
{{ npSaving ? t('dashboard.starting') : t('dashboard.start_research') }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Bootstrap Modal -->
|
||||
<Modal v-if="showBootstrap" title="Bootstrap Project" @close="showBootstrap = false">
|
||||
<Modal v-if="showBootstrap" :title="t('dashboard.bootstrap_title')" @close="showBootstrap = false">
|
||||
<form @submit.prevent="runBootstrap" class="space-y-3">
|
||||
<input v-model="bsForm.path" placeholder="Project path (e.g. ~/projects/vdolipoperek)" required
|
||||
<input v-model="bsForm.path" :placeholder="t('dashboard.bootstrap_path_placeholder')" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="bsForm.id" placeholder="ID (e.g. vdol)" required
|
||||
<input v-model="bsForm.id" :placeholder="t('dashboard.id_placeholder')" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="bsForm.name" placeholder="Name" required
|
||||
<input v-model="bsForm.name" :placeholder="t('dashboard.name_placeholder')" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<p v-if="bsError" class="text-red-400 text-xs">{{ bsError }}</p>
|
||||
<p v-if="bsResult" class="text-green-400 text-xs">{{ bsResult }}</p>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-purple-900/50 text-purple-400 border border-purple-800 rounded text-sm hover:bg-purple-900">
|
||||
Bootstrap
|
||||
{{ t('dashboard.bootstrap_btn') }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api, type Project, type ObsidianSyncResult, type ProjectLink } from '../api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const vaultPaths = ref<Record<string, string>>({})
|
||||
const deployCommands = ref<Record<string, string>>({})
|
||||
|
|
@ -71,9 +74,9 @@ async function saveDeployConfig(projectId: string) {
|
|||
deploy_restart_cmd: deployRestartCmds.value[projectId],
|
||||
deploy_command: deployCommands.value[projectId],
|
||||
})
|
||||
saveDeployConfigStatus.value[projectId] = 'Saved'
|
||||
saveDeployConfigStatus.value[projectId] = t('common.saved')
|
||||
} catch (e: unknown) {
|
||||
saveDeployConfigStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
saveDeployConfigStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
savingDeployConfig.value[projectId] = false
|
||||
}
|
||||
|
|
@ -84,9 +87,9 @@ async function saveVaultPath(projectId: string) {
|
|||
saveStatus.value[projectId] = ''
|
||||
try {
|
||||
await api.patchProject(projectId, { obsidian_vault_path: vaultPaths.value[projectId] })
|
||||
saveStatus.value[projectId] = 'Saved'
|
||||
saveStatus.value[projectId] = t('common.saved')
|
||||
} catch (e: unknown) {
|
||||
saveStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
saveStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
saving.value[projectId] = false
|
||||
}
|
||||
|
|
@ -97,9 +100,9 @@ async function saveTestCommand(projectId: string) {
|
|||
saveTestStatus.value[projectId] = ''
|
||||
try {
|
||||
await api.patchProject(projectId, { test_command: testCommands.value[projectId] })
|
||||
saveTestStatus.value[projectId] = 'Saved'
|
||||
saveTestStatus.value[projectId] = t('common.saved')
|
||||
} catch (e: unknown) {
|
||||
saveTestStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
saveTestStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
savingTest.value[projectId] = false
|
||||
}
|
||||
|
|
@ -111,10 +114,10 @@ async function toggleAutoTest(projectId: string) {
|
|||
saveAutoTestStatus.value[projectId] = ''
|
||||
try {
|
||||
await api.patchProject(projectId, { auto_test_enabled: autoTestEnabled.value[projectId] })
|
||||
saveAutoTestStatus.value[projectId] = 'Saved'
|
||||
saveAutoTestStatus.value[projectId] = t('common.saved')
|
||||
} catch (e: unknown) {
|
||||
autoTestEnabled.value[projectId] = !autoTestEnabled.value[projectId]
|
||||
saveAutoTestStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
saveAutoTestStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
savingAutoTest.value[projectId] = false
|
||||
}
|
||||
|
|
@ -126,10 +129,10 @@ async function toggleWorktrees(projectId: string) {
|
|||
saveWorktreesStatus.value[projectId] = ''
|
||||
try {
|
||||
await api.patchProject(projectId, { worktrees_enabled: worktreesEnabled.value[projectId] })
|
||||
saveWorktreesStatus.value[projectId] = 'Saved'
|
||||
saveWorktreesStatus.value[projectId] = t('common.saved')
|
||||
} catch (e: unknown) {
|
||||
worktreesEnabled.value[projectId] = !worktreesEnabled.value[projectId]
|
||||
saveWorktreesStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
saveWorktreesStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
savingWorktrees.value[projectId] = false
|
||||
}
|
||||
|
|
@ -162,7 +165,7 @@ async function loadLinks(projectId: string) {
|
|||
|
||||
async function addLink(projectId: string) {
|
||||
const form = linkForms.value[projectId]
|
||||
if (!form.to_project) { linkError.value[projectId] = 'Выберите проект'; return }
|
||||
if (!form.to_project) { linkError.value[projectId] = t('settings.select_project_error'); return }
|
||||
linkSaving.value[projectId] = true
|
||||
linkError.value[projectId] = ''
|
||||
try {
|
||||
|
|
@ -183,7 +186,7 @@ async function addLink(projectId: string) {
|
|||
}
|
||||
|
||||
async function deleteLink(projectId: string, linkId: number) {
|
||||
if (!confirm('Удалить связь?')) return
|
||||
if (!confirm(t('settings.delete_link_confirm'))) return
|
||||
try {
|
||||
await api.deleteProjectLink(linkId)
|
||||
await loadLinks(projectId)
|
||||
|
|
@ -195,7 +198,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-100 mb-6">Settings</h1>
|
||||
<h1 class="text-xl font-semibold text-gray-100 mb-6">{{ t('settings.title') }}</h1>
|
||||
|
||||
<div v-if="error" class="text-red-400 mb-4">{{ error }}</div>
|
||||
|
||||
|
|
@ -206,7 +209,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-gray-400 mb-1">Obsidian Vault Path</label>
|
||||
<label class="block text-xs text-gray-400 mb-1">{{ t('settings.obsidian_vault_path') }}</label>
|
||||
<input
|
||||
v-model="vaultPaths[project.id]"
|
||||
type="text"
|
||||
|
|
@ -216,14 +219,14 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-gray-400 mb-1">Test Command</label>
|
||||
<label class="block text-xs text-gray-400 mb-1">{{ t('settings.test_command') }}</label>
|
||||
<input
|
||||
v-model="testCommands[project.id]"
|
||||
type="text"
|
||||
placeholder="make test"
|
||||
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500"
|
||||
/>
|
||||
<p class="text-xs text-gray-600 mt-1">Команда запуска тестов, выполняется через shell в директории проекта.</p>
|
||||
<p class="text-xs text-gray-600 mt-1">{{ t('settings.test_command_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap mb-3">
|
||||
|
|
@ -232,7 +235,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
:disabled="savingTest[project.id]"
|
||||
class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
{{ savingTest[project.id] ? 'Saving…' : 'Save Test' }}
|
||||
{{ savingTest[project.id] ? t('settings.saving_test') : t('settings.save_test') }}
|
||||
</button>
|
||||
<span v-if="saveTestStatus[project.id]" class="text-xs" :class="saveTestStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||
{{ saveTestStatus[project.id] }}
|
||||
|
|
@ -241,9 +244,9 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
|
||||
<!-- Deploy Config -->
|
||||
<div class="mb-2 pt-2 border-t border-gray-800">
|
||||
<p class="text-xs font-semibold text-gray-400 mb-2">Deploy Config</p>
|
||||
<p class="text-xs font-semibold text-gray-400 mb-2">{{ t('settings.deploy_config') }}</p>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Server host</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.server_host') }}</label>
|
||||
<input
|
||||
v-model="deployHosts[project.id]"
|
||||
type="text"
|
||||
|
|
@ -252,7 +255,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Project path on server</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.project_path_on_server') }}</label>
|
||||
<input
|
||||
v-model="deployPaths[project.id]"
|
||||
type="text"
|
||||
|
|
@ -261,12 +264,12 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Runtime</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.runtime') }}</label>
|
||||
<select
|
||||
v-model="deployRuntimes[project.id]"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-gray-500"
|
||||
>
|
||||
<option value="">— выберите runtime —</option>
|
||||
<option value="">{{ t('settings.select_runtime') }}</option>
|
||||
<option value="docker">docker</option>
|
||||
<option value="node">node</option>
|
||||
<option value="python">python</option>
|
||||
|
|
@ -274,7 +277,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Restart command (optional override)</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.restart_command') }}</label>
|
||||
<input
|
||||
v-model="deployRestartCmds[project.id]"
|
||||
type="text"
|
||||
|
|
@ -283,7 +286,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Fallback command (legacy, used when runtime not set)</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.fallback_command') }}</label>
|
||||
<input
|
||||
v-model="deployCommands[project.id]"
|
||||
type="text"
|
||||
|
|
@ -297,7 +300,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
:disabled="savingDeployConfig[project.id]"
|
||||
class="px-3 py-1.5 text-sm bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50"
|
||||
>
|
||||
{{ savingDeployConfig[project.id] ? 'Saving…' : 'Save Deploy Config' }}
|
||||
{{ savingDeployConfig[project.id] ? t('settings.saving_deploy') : t('settings.save_deploy_config') }}
|
||||
</button>
|
||||
<span v-if="saveDeployConfigStatus[project.id]" class="text-xs" :class="saveDeployConfigStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||
{{ saveDeployConfigStatus[project.id] }}
|
||||
|
|
@ -308,28 +311,28 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
<!-- Project Links -->
|
||||
<div class="mb-2 pt-2 border-t border-gray-800">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs font-semibold text-gray-400">Project Links</p>
|
||||
<p class="text-xs font-semibold text-gray-400">{{ t('settings.project_links') }}</p>
|
||||
<button
|
||||
@click="showAddLinkForm[project.id] = !showAddLinkForm[project.id]"
|
||||
class="px-2 py-0.5 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700"
|
||||
>
|
||||
+ Add Link
|
||||
{{ t('settings.add_link') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="linksLoading[project.id]" class="text-xs text-gray-500">Загрузка...</p>
|
||||
<p v-if="linksLoading[project.id]" class="text-xs text-gray-500">{{ t('settings.links_loading') }}</p>
|
||||
<p v-else-if="linkError[project.id]" class="text-xs text-red-400">{{ linkError[project.id] }}</p>
|
||||
<div v-else-if="!projectLinksMap[project.id]?.length" class="text-xs text-gray-600">Нет связей</div>
|
||||
<div v-else-if="!projectLinksMap[project.id]?.length" class="text-xs text-gray-600">{{ t('settings.no_links') }}</div>
|
||||
<div v-else class="space-y-1 mb-2">
|
||||
<div v-for="link in projectLinksMap[project.id]" :key="link.id"
|
||||
class="flex items-center gap-2 px-2 py-1 bg-gray-900 border border-gray-800 rounded text-xs">
|
||||
<span class="text-gray-500 font-mono">{{ link.from_project }}</span>
|
||||
<span class="text-gray-600">→</span>
|
||||
<span class="text-gray-600">→</span>
|
||||
<span class="text-gray-500 font-mono">{{ link.to_project }}</span>
|
||||
<span class="px-1 bg-indigo-900/30 text-indigo-400 border border-indigo-800 rounded">{{ link.type }}</span>
|
||||
<span v-if="link.description" class="text-gray-600">{{ link.description }}</span>
|
||||
<button @click="deleteLink(project.id, link.id)"
|
||||
class="ml-auto text-red-500 hover:text-red-400 bg-transparent border-none cursor-pointer text-xs shrink-0">
|
||||
✕
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -342,8 +345,8 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
<label class="block text-[10px] text-gray-500 mb-0.5">To project</label>
|
||||
<select v-model="linkForms[project.id].to_project" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300">
|
||||
<option value="">— выберите проект —</option>
|
||||
<option v-for="p in allProjectList.filter(p => p.id !== project.id)" :key="p.id" :value="p.id">{{ p.id }} — {{ p.name }}</option>
|
||||
<option value="">{{ t('settings.select_project') }}</option>
|
||||
<option v-for="p in allProjectList.filter(p => p.id !== project.id)" :key="p.id" :value="p.id">{{ p.id }} — {{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -363,11 +366,11 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
<div class="flex gap-2">
|
||||
<button type="submit" :disabled="linkSaving[project.id]"
|
||||
class="px-3 py-1 text-xs bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
|
||||
{{ linkSaving[project.id] ? 'Saving...' : 'Add' }}
|
||||
{{ linkSaving[project.id] ? t('settings.saving_link') : t('common.add') }}
|
||||
</button>
|
||||
<button type="button" @click="showAddLinkForm[project.id] = false; linkError[project.id] = ''"
|
||||
class="px-3 py-1 text-xs text-gray-500 hover:text-gray-300 bg-transparent border-none cursor-pointer">
|
||||
Отмена
|
||||
{{ t('settings.cancel_link') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -382,8 +385,8 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
:disabled="savingAutoTest[project.id]"
|
||||
class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer disabled:opacity-50"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Auto-test</span>
|
||||
<span class="text-xs text-gray-500">— запускать тесты автоматически после pipeline</span>
|
||||
<span class="text-sm text-gray-300">{{ t('settings.auto_test') }}</span>
|
||||
<span class="text-xs text-gray-500">{{ t('settings.auto_test_hint') }}</span>
|
||||
</label>
|
||||
<span v-if="saveAutoTestStatus[project.id]" class="text-xs" :class="saveAutoTestStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||
{{ saveAutoTestStatus[project.id] }}
|
||||
|
|
@ -399,8 +402,8 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
:disabled="savingWorktrees[project.id]"
|
||||
class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer disabled:opacity-50"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Worktrees</span>
|
||||
<span class="text-xs text-gray-500">— агенты запускаются в изолированных git worktrees</span>
|
||||
<span class="text-sm text-gray-300">{{ t('settings.worktrees') }}</span>
|
||||
<span class="text-xs text-gray-500">{{ t('settings.worktrees_hint') }}</span>
|
||||
</label>
|
||||
<span v-if="saveWorktreesStatus[project.id]" class="text-xs" :class="saveWorktreesStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||
{{ saveWorktreesStatus[project.id] }}
|
||||
|
|
@ -413,7 +416,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
:disabled="saving[project.id]"
|
||||
class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
{{ saving[project.id] ? 'Saving…' : 'Save Vault' }}
|
||||
{{ saving[project.id] ? t('settings.saving_vault') : t('settings.save_vault') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -421,7 +424,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
:disabled="syncing[project.id] || !vaultPaths[project.id]"
|
||||
class="px-3 py-1.5 text-sm bg-indigo-700 hover:bg-indigo-600 text-white rounded disabled:opacity-50"
|
||||
>
|
||||
{{ syncing[project.id] ? 'Syncing…' : 'Sync Obsidian' }}
|
||||
{{ syncing[project.id] ? t('settings.syncing') : t('settings.sync_obsidian') }}
|
||||
</button>
|
||||
|
||||
<span v-if="saveStatus[project.id]" class="text-xs" :class="saveStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api, ApiError, type TaskFull, type PipelineStep, type PendingAction, type DeployResult, type Attachment } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
|
@ -11,6 +12,7 @@ import LiveConsole from '../components/LiveConsole.vue'
|
|||
const props = defineProps<{ id: string }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const task = ref<TaskFull | null>(null)
|
||||
const loading = ref(true)
|
||||
|
|
@ -45,15 +47,14 @@ const parsedSelectedOutput = computed<ParsedAgentOutput | null>(() => {
|
|||
// Auto/Review mode (per-task, persisted in DB; falls back to localStorage per project)
|
||||
const autoMode = ref(false)
|
||||
|
||||
function loadMode(t: typeof task.value) {
|
||||
if (!t) return
|
||||
if (t.execution_mode) {
|
||||
autoMode.value = t.execution_mode === 'auto_complete'
|
||||
} else if (t.status === 'review') {
|
||||
// Task is in review — always show Approve/Reject regardless of localStorage
|
||||
function loadMode(t_val: typeof task.value) {
|
||||
if (!t_val) return
|
||||
if (t_val.execution_mode) {
|
||||
autoMode.value = t_val.execution_mode === 'auto_complete'
|
||||
} else if (t_val.status === 'review') {
|
||||
autoMode.value = false
|
||||
} else {
|
||||
autoMode.value = localStorage.getItem(`kin-mode-${t.project_id}`) === 'auto_complete'
|
||||
autoMode.value = localStorage.getItem(`kin-mode-${t_val.project_id}`) === 'auto_complete'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,11 +75,9 @@ async function load() {
|
|||
const prev = task.value
|
||||
task.value = await api.taskFull(props.id)
|
||||
loadMode(task.value)
|
||||
// Auto-start polling if task is in_progress
|
||||
if (task.value.status === 'in_progress' && !polling.value) {
|
||||
startPolling()
|
||||
}
|
||||
// Stop polling when pipeline done
|
||||
if (prev?.status === 'in_progress' && task.value.status !== 'in_progress') {
|
||||
stopPolling()
|
||||
}
|
||||
|
|
@ -241,7 +240,6 @@ async function runPipeline() {
|
|||
claudeLoginError.value = false
|
||||
pipelineStarting.value = true
|
||||
try {
|
||||
// Sync task execution_mode with current toggle state before running
|
||||
const targetMode = autoMode.value ? 'auto_complete' : 'review'
|
||||
if (task.value && task.value.execution_mode !== targetMode) {
|
||||
const updated = await api.patchTask(props.id, { execution_mode: targetMode })
|
||||
|
|
@ -254,7 +252,7 @@ async function runPipeline() {
|
|||
if (e instanceof ApiError && e.code === 'claude_auth_required') {
|
||||
claudeLoginError.value = true
|
||||
} else if (e instanceof ApiError && e.code === 'task_already_running') {
|
||||
error.value = 'Pipeline уже запущен'
|
||||
error.value = t('taskDetail.pipeline_already_running')
|
||||
} else {
|
||||
error.value = e.message
|
||||
}
|
||||
|
|
@ -271,7 +269,7 @@ const resolvingManually = ref(false)
|
|||
|
||||
async function resolveManually() {
|
||||
if (!task.value) return
|
||||
if (!confirm('Пометить задачу как решённую вручную?')) return
|
||||
if (!confirm(t('taskDetail.mark_resolved_confirm'))) return
|
||||
resolvingManually.value = true
|
||||
try {
|
||||
const updated = await api.patchTask(props.id, { status: 'done' })
|
||||
|
|
@ -386,7 +384,7 @@ async function saveEdit() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading && !task" class="text-gray-500 text-sm">Loading...</div>
|
||||
<div v-if="loading && !task" class="text-gray-500 text-sm">{{ t('taskDetail.loading') }}</div>
|
||||
<div v-else-if="error && !task" class="text-red-400 text-sm">{{ error }}</div>
|
||||
<div v-else-if="task">
|
||||
<!-- Header -->
|
||||
|
|
@ -422,7 +420,7 @@ async function saveEdit() {
|
|||
<!-- Manual escalation context banner -->
|
||||
<div v-if="isManualEscalation" class="mb-3 px-3 py-2 border border-orange-800/60 bg-orange-950/20 rounded">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-semibold text-orange-400">⚠ Требует ручного решения</span>
|
||||
<span class="text-xs font-semibold text-orange-400">{{ t('taskDetail.requires_manual') }}</span>
|
||||
<span v-if="task.parent_task_id" class="text-xs text-gray-600">
|
||||
— эскалация из
|
||||
<router-link :to="`/task/${task.parent_task_id}`" class="text-orange-600 hover:text-orange-400">
|
||||
|
|
@ -432,15 +430,15 @@ async function saveEdit() {
|
|||
</div>
|
||||
<p class="text-xs text-orange-300">{{ task.title }}</p>
|
||||
<p v-if="task.brief?.description" class="text-xs text-gray-400 mt-1">{{ task.brief.description }}</p>
|
||||
<p class="text-xs text-gray-600 mt-1">Автопилот не смог выполнить это автоматически. Примите меры вручную и нажмите «Решить вручную».</p>
|
||||
<p class="text-xs text-gray-600 mt-1">{{ t('taskDetail.autopilot_failed') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Dangerous skip warning banner -->
|
||||
<div v-if="task.dangerously_skipped" class="mb-3 px-3 py-2 border border-red-700 bg-red-950/40 rounded flex items-start gap-2">
|
||||
<span class="text-red-400 text-base shrink-0">⚠</span>
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-red-400">--dangerously-skip-permissions использовался в этой задаче</span>
|
||||
<p class="text-xs text-red-300/70 mt-0.5">Агент выполнял команды с обходом проверок разрешений. Проверьте pipeline-шаги и сделанные изменения.</p>
|
||||
<span class="text-xs font-semibold text-red-400">{{ t('taskDetail.dangerously_skipped') }}</span>
|
||||
<p class="text-xs text-red-300/70 mt-0.5">{{ t('taskDetail.dangerously_skipped_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -448,7 +446,7 @@ async function saveEdit() {
|
|||
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>
|
||||
<div class="text-xs font-semibold text-gray-400 mb-1">{{ t('taskDetail.acceptance_criteria') }}</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">
|
||||
|
|
@ -462,8 +460,8 @@ async function saveEdit() {
|
|||
<!-- Pipeline Graph -->
|
||||
<div v-if="hasSteps || isRunning" class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-3">
|
||||
Pipeline
|
||||
<span v-if="isRunning" class="text-blue-400 text-xs font-normal ml-2 animate-pulse">running...</span>
|
||||
{{ t('taskDetail.pipeline') }}
|
||||
<span v-if="isRunning" class="text-blue-400 text-xs font-normal ml-2 animate-pulse">{{ t('taskDetail.running') }}</span>
|
||||
</h2>
|
||||
<div class="flex items-center gap-1 overflow-x-auto pb-2">
|
||||
<template v-for="(step, i) in task.pipeline_steps" :key="step.id">
|
||||
|
|
@ -493,7 +491,7 @@ async function saveEdit() {
|
|||
|
||||
<!-- No pipeline -->
|
||||
<div v-if="!hasSteps && !isRunning" class="mb-6 text-sm text-gray-600">
|
||||
No pipeline steps yet.
|
||||
{{ t('taskDetail.no_pipeline') }}
|
||||
</div>
|
||||
|
||||
<!-- Live Console -->
|
||||
|
|
@ -516,7 +514,7 @@ async function saveEdit() {
|
|||
<div class="p-4">
|
||||
<p class="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap">{{ parsedSelectedOutput.verdict }}</p>
|
||||
<details v-if="parsedSelectedOutput.details !== null" class="mt-3">
|
||||
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-400 select-none">↓ подробнее</summary>
|
||||
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-400 select-none">{{ t('taskDetail.more_details') }}</summary>
|
||||
<pre class="mt-2 text-xs text-gray-500 overflow-x-auto whitespace-pre-wrap max-h-[400px] overflow-y-auto">{{ parsedSelectedOutput.details }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
|
@ -542,7 +540,7 @@ async function saveEdit() {
|
|||
|
||||
<!-- Attachments -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-2">Вложения</h2>
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-2">{{ t('taskDetail.attachments') }}</h2>
|
||||
<AttachmentList :attachments="attachments" :task-id="props.id" @deleted="loadAttachments" />
|
||||
<AttachmentUploader :task-id="props.id" @uploaded="loadAttachments" />
|
||||
</div>
|
||||
|
|
@ -552,22 +550,22 @@ async function saveEdit() {
|
|||
<div v-if="autoMode && (isRunning || task.status === 'review')"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 bg-yellow-900/20 border border-yellow-800/50 rounded text-xs text-yellow-400">
|
||||
<span class="inline-block w-2 h-2 bg-yellow-400 rounded-full animate-pulse"></span>
|
||||
Автопилот активен
|
||||
{{ t('taskDetail.autopilot_active') }}
|
||||
</div>
|
||||
<button v-if="task.status === 'review' && !autoMode"
|
||||
@click="showApprove = true"
|
||||
class="px-4 py-2 text-sm bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900">
|
||||
✓ Approve
|
||||
{{ t('taskDetail.approve_task') }}
|
||||
</button>
|
||||
<button v-if="task.status === 'review' && !autoMode"
|
||||
@click="showRevise = true"
|
||||
class="px-4 py-2 text-sm bg-orange-900/50 text-orange-400 border border-orange-800 rounded hover:bg-orange-900">
|
||||
🔄 Revise
|
||||
{{ t('taskDetail.revise_task') }}
|
||||
</button>
|
||||
<button v-if="(task.status === 'review' || task.status === 'in_progress') && !autoMode"
|
||||
@click="showReject = true"
|
||||
class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
||||
✗ Reject
|
||||
{{ t('taskDetail.reject_task') }}
|
||||
</button>
|
||||
<button v-if="task.status === 'pending' || task.status === 'blocked' || task.status === 'review'"
|
||||
@click="toggleMode"
|
||||
|
|
@ -581,28 +579,28 @@ async function saveEdit() {
|
|||
<button v-if="task.status === 'pending'"
|
||||
@click="openEdit"
|
||||
class="px-3 py-2 text-sm bg-gray-800/50 text-gray-400 border border-gray-700 rounded hover:bg-gray-800">
|
||||
✎ Edit
|
||||
{{ t('taskDetail.edit') }}
|
||||
</button>
|
||||
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
||||
@click="runPipeline"
|
||||
:disabled="polling || pipelineStarting"
|
||||
class="px-4 py-2 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
|
||||
<span v-if="polling || pipelineStarting" class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||
{{ (polling || pipelineStarting) ? 'Pipeline running...' : '▶ Run Pipeline' }}
|
||||
{{ (polling || pipelineStarting) ? t('taskDetail.pipeline_running') : t('taskDetail.run_pipeline') }}
|
||||
</button>
|
||||
<button v-if="isManualEscalation && task.status !== 'done' && task.status !== 'cancelled'"
|
||||
@click="resolveManually"
|
||||
:disabled="resolvingManually"
|
||||
class="px-4 py-2 text-sm bg-orange-900/50 text-orange-400 border border-orange-800 rounded hover:bg-orange-900 disabled:opacity-50">
|
||||
<span v-if="resolvingManually" class="inline-block w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||
{{ resolvingManually ? 'Сохраняем...' : '✓ Решить вручную' }}
|
||||
{{ resolvingManually ? t('taskDetail.resolving') : t('taskDetail.resolve_manually') }}
|
||||
</button>
|
||||
<button v-if="task.status === 'done' && (task.project_deploy_command || task.project_deploy_runtime)"
|
||||
@click.stop="runDeploy"
|
||||
:disabled="deploying"
|
||||
class="px-4 py-2 text-sm bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50">
|
||||
<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('taskDetail.deploy') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -611,9 +609,9 @@ async function saveEdit() {
|
|||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-yellow-300">⚠ Claude CLI requires login</p>
|
||||
<p class="text-xs text-yellow-200/80 mt-1">Откройте терминал и выполните:</p>
|
||||
<p class="text-xs text-yellow-200/80 mt-1">{{ t('taskDetail.terminal_login_hint') }}</p>
|
||||
<code class="text-xs text-yellow-400 font-mono bg-black/30 px-2 py-0.5 rounded mt-1 inline-block">claude login</code>
|
||||
<p class="text-xs text-gray-500 mt-1">После входа повторите запуск pipeline.</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ t('taskDetail.login_after_hint') }}</p>
|
||||
</div>
|
||||
<button @click="claudeLoginError = false" class="text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs shrink-0">✕</button>
|
||||
</div>
|
||||
|
|
@ -624,7 +622,7 @@ async function saveEdit() {
|
|||
: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>
|
||||
|
|
@ -650,7 +648,7 @@ async function saveEdit() {
|
|||
</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>
|
||||
|
|
@ -704,9 +702,9 @@ async function saveEdit() {
|
|||
Create follow-up tasks from pipeline results
|
||||
</label>
|
||||
<p class="text-xs text-gray-500">Optionally record a decision:</p>
|
||||
<input v-model="approveForm.title" placeholder="Decision title (optional)"
|
||||
<input v-model="approveForm.title" :placeholder="t('taskDetail.decision_title_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<textarea v-if="approveForm.title" v-model="approveForm.description" placeholder="Description"
|
||||
<textarea v-if="approveForm.title" v-model="approveForm.description" :placeholder="t('taskDetail.description_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" rows="2"></textarea>
|
||||
<button type="submit" :disabled="approveLoading"
|
||||
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900 disabled:opacity-50">
|
||||
|
|
@ -728,14 +726,14 @@ async function saveEdit() {
|
|||
</Modal>
|
||||
|
||||
<!-- Revise Modal -->
|
||||
<Modal v-if="showRevise" title="🔄 Revise Task" @close="showRevise = false">
|
||||
<Modal v-if="showRevise" :title="t('taskDetail.send_to_revision')" @close="showRevise = false">
|
||||
<form @submit.prevent="revise" class="space-y-3">
|
||||
<p class="text-xs text-gray-500">Опишите, что доработать или уточнить агенту. Задача вернётся в работу с вашим комментарием.</p>
|
||||
<textarea v-model="reviseComment" placeholder="Что доработать / уточнить..." rows="4" required
|
||||
<textarea v-model="reviseComment" :placeholder="t('taskDetail.revise_placeholder')" rows="4" required
|
||||
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>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-orange-900/50 text-orange-400 border border-orange-800 rounded text-sm hover:bg-orange-900">
|
||||
🔄 Отправить на доработку
|
||||
{{ t('taskDetail.send_to_revision') }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
@ -744,30 +742,30 @@ async function saveEdit() {
|
|||
<Modal v-if="showEdit" title="Edit Task" @close="showEdit = false">
|
||||
<form @submit.prevent="saveEdit" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Title</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.title_label') }}</label>
|
||||
<input v-model="editForm.title" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Brief</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.brief_label') }}</label>
|
||||
<textarea v-model="editForm.briefText" rows="4" placeholder="Task description..."
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Priority (1–10)</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.priority_label') }}</label>
|
||||
<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>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.acceptance_criteria_label') }}</label>
|
||||
<textarea v-model="editForm.acceptanceCriteria" rows="3"
|
||||
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
|
||||
:placeholder="t('taskDetail.acceptance_criteria_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">
|
||||
{{ editLoading ? 'Saving...' : 'Save' }}
|
||||
{{ editLoading ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue