Merge branch 'KIN-108-frontend_dev'
This commit is contained in:
commit
03d49f42e6
16 changed files with 799 additions and 212 deletions
83
web/frontend/package-lock.json
generated
83
web/frontend/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
|
"vue-i18n": "^11.3.0",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -331,6 +332,67 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@intlify/core-base": {
|
||||||
|
"version": "11.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.3.0.tgz",
|
||||||
|
"integrity": "sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/devtools-types": "11.3.0",
|
||||||
|
"@intlify/message-compiler": "11.3.0",
|
||||||
|
"@intlify/shared": "11.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/devtools-types": {
|
||||||
|
"version": "11.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.3.0.tgz",
|
||||||
|
"integrity": "sha512-G9CNL4WpANWVdUjubOIIS7/D2j/0j+1KJmhBJxHilWNKr9mmt3IjFV3Hq4JoBP23uOoC5ynxz/FHZ42M+YxfGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "11.3.0",
|
||||||
|
"@intlify/shared": "11.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/message-compiler": {
|
||||||
|
"version": "11.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.3.0.tgz",
|
||||||
|
"integrity": "sha512-RAJp3TMsqohg/Wa7bVF3cChRhecSYBLrTCQSj7j0UtWVFLP+6iEJoE2zb7GU5fp+fmG5kCbUdzhmlAUCWXiUJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/shared": "11.3.0",
|
||||||
|
"source-map-js": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/shared": {
|
||||||
|
"version": "11.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.0.tgz",
|
||||||
|
"integrity": "sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
|
@ -3666,6 +3728,27 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-i18n": {
|
||||||
|
"version": "11.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
|
||||||
|
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "11.3.0",
|
||||||
|
"@intlify/devtools-types": "11.3.0",
|
||||||
|
"@intlify/shared": "11.3.0",
|
||||||
|
"@vue/devtools-api": "^6.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.6.4",
|
"version": "4.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
|
"vue-i18n": "^11.3.0",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -28,4 +29,4 @@
|
||||||
"vitest": "^4.1.0",
|
"vitest": "^4.1.0",
|
||||||
"vue-tsc": "^3.2.5"
|
"vue-tsc": "^3.2.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import EscalationBanner from './components/EscalationBanner.vue'
|
import EscalationBanner from './components/EscalationBanner.vue'
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
|
function toggleLocale() {
|
||||||
|
const next = locale.value === 'ru' ? 'en' : 'ru'
|
||||||
|
locale.value = next
|
||||||
|
localStorage.setItem('kin-locale', next)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -10,8 +19,12 @@ import EscalationBanner from './components/EscalationBanner.vue'
|
||||||
</router-link>
|
</router-link>
|
||||||
<nav class="flex items-center gap-4">
|
<nav class="flex items-center gap-4">
|
||||||
<EscalationBanner />
|
<EscalationBanner />
|
||||||
<router-link to="/settings" class="text-xs text-gray-400 hover:text-gray-200 no-underline">Settings</router-link>
|
<button
|
||||||
<span class="text-xs text-gray-600">multi-agent orchestrator</span>
|
@click="toggleLocale"
|
||||||
|
class="text-xs text-gray-400 hover:text-gray-200 px-2 py-0.5 border border-gray-700 rounded hover:border-gray-500 transition-colors"
|
||||||
|
>{{ locale === 'ru' ? 'EN' : 'RU' }}</button>
|
||||||
|
<router-link to="/settings" class="text-xs text-gray-400 hover:text-gray-200 no-underline">{{ t('common.settings') }}</router-link>
|
||||||
|
<span class="text-xs text-gray-600">{{ t('common.subtitle') }}</span>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="px-6 py-6">
|
<main class="px-6 py-6">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { api, type Attachment } from '../api'
|
import { api, type Attachment } from '../api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps<{ attachments: Attachment[]; taskId: string }>()
|
const props = defineProps<{ attachments: Attachment[]; taskId: string }>()
|
||||||
const emit = defineEmits<{ deleted: [] }>()
|
const emit = defineEmits<{ deleted: [] }>()
|
||||||
|
|
||||||
|
|
@ -48,7 +51,7 @@ function formatSize(bytes: number): string {
|
||||||
@click="remove(att.id)"
|
@click="remove(att.id)"
|
||||||
:disabled="deletingId === att.id"
|
:disabled="deletingId === att.id"
|
||||||
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-red-900/80 text-red-400 text-xs leading-none opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 flex items-center justify-center"
|
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-red-900/80 text-red-400 text-xs leading-none opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 flex items-center justify-center"
|
||||||
title="Удалить"
|
:title="t('attachments.delete_title')"
|
||||||
>✕</button>
|
>✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps<{ taskId: string }>()
|
const props = defineProps<{ taskId: string }>()
|
||||||
const emit = defineEmits<{ uploaded: [] }>()
|
const emit = defineEmits<{ uploaded: [] }>()
|
||||||
|
|
||||||
|
|
@ -12,7 +15,7 @@ const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
async function upload(file: File) {
|
async function upload(file: File) {
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
error.value = 'Поддерживаются только изображения'
|
error.value = t('attachments.images_only')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
|
|
@ -52,10 +55,10 @@ function onDrop(event: DragEvent) {
|
||||||
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileChange" />
|
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileChange" />
|
||||||
<div v-if="uploading" class="flex items-center justify-center gap-2 text-xs text-blue-400">
|
<div v-if="uploading" class="flex items-center justify-center gap-2 text-xs text-blue-400">
|
||||||
<span class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></span>
|
<span class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></span>
|
||||||
Загрузка...
|
{{ t('attachments.uploading') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-gray-500">
|
<div v-else class="text-xs text-gray-500">
|
||||||
Перетащите изображение или <span class="text-blue-400">нажмите для выбора</span>
|
{{ t('attachments.drop_hint') }} <span class="text-blue-400">{{ t('attachments.click_to_select') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="text-red-400 text-xs mt-1">{{ error }}</p>
|
<p v-if="error" class="text-red-400 text-xs mt-1">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { api, type EscalationNotification } from '../api'
|
import { api, type EscalationNotification } from '../api'
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
const STORAGE_KEY = 'kin_dismissed_escalations'
|
const STORAGE_KEY = 'kin_dismissed_escalations'
|
||||||
const WATCHDOG_TOAST_KEY = 'kin_dismissed_watchdog_toasts'
|
const WATCHDOG_TOAST_KEY = 'kin_dismissed_watchdog_toasts'
|
||||||
|
|
||||||
|
|
@ -106,7 +109,7 @@ function dismissAll() {
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
return new Date(iso).toLocaleString(locale.value === 'ru' ? 'ru-RU' : 'en-US', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
} catch {
|
} catch {
|
||||||
return iso
|
return iso
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +139,7 @@ onUnmounted(() => {
|
||||||
>
|
>
|
||||||
<span class="shrink-0 text-sm">⚠</span>
|
<span class="shrink-0 text-sm">⚠</span>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-xs leading-snug">Watchdog: задача <span class="font-mono font-semibold">{{ toast.task_id }}</span> заблокирована — {{ toast.reason }}</p>
|
<p class="text-xs leading-snug">{{ t('escalation.watchdog_blocked', { task_id: toast.task_id, reason: toast.reason }) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="dismissWatchdogToast(toast.task_id)"
|
@click="dismissWatchdogToast(toast.task_id)"
|
||||||
|
|
@ -153,7 +156,7 @@ onUnmounted(() => {
|
||||||
class="relative flex items-center gap-1.5 px-2.5 py-1 text-xs bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900 transition-colors"
|
class="relative flex items-center gap-1.5 px-2.5 py-1 text-xs bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900 transition-colors"
|
||||||
>
|
>
|
||||||
<span class="inline-block w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>
|
<span class="inline-block w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>
|
||||||
Эскалации
|
{{ t('escalation.escalations') }}
|
||||||
<span class="ml-0.5 font-bold">{{ visible.length }}</span>
|
<span class="ml-0.5 font-bold">{{ visible.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -163,12 +166,12 @@ onUnmounted(() => {
|
||||||
class="absolute right-0 top-full mt-2 w-96 bg-gray-900 border border-red-900/60 rounded-lg shadow-2xl z-50"
|
class="absolute right-0 top-full mt-2 w-96 bg-gray-900 border border-red-900/60 rounded-lg shadow-2xl z-50"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800">
|
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800">
|
||||||
<span class="text-xs font-semibold text-red-400">Эскалации — требуется решение</span>
|
<span class="text-xs font-semibold text-red-400">{{ t('escalation.escalations_panel_title') }}</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@click="dismissAll"
|
@click="dismissAll"
|
||||||
class="text-xs text-gray-500 hover:text-gray-300"
|
class="text-xs text-gray-500 hover:text-gray-300"
|
||||||
>Принять все</button>
|
>{{ t('escalation.dismiss_all') }}</button>
|
||||||
<button @click="showPanel = false" class="text-gray-500 hover:text-gray-300 text-lg leading-none">×</button>
|
<button @click="showPanel = false" class="text-gray-500 hover:text-gray-300 text-lg leading-none">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -193,7 +196,7 @@ onUnmounted(() => {
|
||||||
<button
|
<button
|
||||||
@click="dismiss(n.task_id)"
|
@click="dismiss(n.task_id)"
|
||||||
class="shrink-0 px-2 py-1 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 hover:text-gray-200"
|
class="shrink-0 px-2 py-1 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 hover:text-gray-200"
|
||||||
>Принято</button>
|
>{{ t('escalation.dismiss') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onUnmounted } from 'vue'
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { api, type PipelineLog } from '../api'
|
import { api, type PipelineLog } from '../api'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
@ -7,6 +8,8 @@ const props = defineProps<{
|
||||||
pipelineStatus: string
|
pipelineStatus: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const logs = ref<PipelineLog[]>([])
|
const logs = ref<PipelineLog[]>([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
@ -102,12 +105,12 @@ onUnmounted(() => {
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
class="text-xs text-gray-500 hover:text-gray-300 border border-gray-800 rounded px-3 py-1.5 bg-gray-900/50 hover:bg-gray-900 transition-colors"
|
class="text-xs text-gray-500 hover:text-gray-300 border border-gray-800 rounded px-3 py-1.5 bg-gray-900/50 hover:bg-gray-900 transition-colors"
|
||||||
>
|
>
|
||||||
{{ visible ? '▲ Скрыть лог' : '▼ Показать лог' }}
|
{{ visible ? t('liveConsole.hide_log') : t('liveConsole.show_log') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-show="visible" class="mt-2 bg-gray-950 border border-gray-800 rounded-lg p-4 font-mono text-xs max-h-[400px] overflow-y-auto" ref="consoleEl" @scroll="onScroll">
|
<div v-show="visible" class="mt-2 bg-gray-950 border border-gray-800 rounded-lg p-4 font-mono text-xs max-h-[400px] overflow-y-auto" ref="consoleEl" @scroll="onScroll">
|
||||||
<div v-if="!logs.length && !error" class="text-gray-600">Нет записей...</div>
|
<div v-if="!logs.length && !error" class="text-gray-600">{{ t('liveConsole.no_records') }}</div>
|
||||||
<div v-if="error" class="text-red-400">Ошибка: {{ error }}</div>
|
<div v-if="error" class="text-red-400">{{ t('liveConsole.error_prefix') }} {{ error }}</div>
|
||||||
<div v-for="log in logs" :key="log.id" class="mb-1">
|
<div v-for="log in logs" :key="log.id" class="mb-1">
|
||||||
<span class="text-gray-600">{{ log.ts }}</span>
|
<span class="text-gray-600">{{ log.ts }}</span>
|
||||||
<span :class="[levelClass(log.level), 'ml-2 font-semibold']">[{{ log.level }}]</span>
|
<span :class="[levelClass(log.level), 'ml-2 font-semibold']">[{{ log.level }}]</span>
|
||||||
|
|
|
||||||
12
web/frontend/src/i18n.ts
Normal file
12
web/frontend/src/i18n.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import ru from './locales/ru.json'
|
||||||
|
import en from './locales/en.json'
|
||||||
|
|
||||||
|
const savedLocale = localStorage.getItem('kin-locale') || 'ru'
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: savedLocale,
|
||||||
|
fallbackLocale: 'en',
|
||||||
|
messages: { ru, en },
|
||||||
|
})
|
||||||
229
web/frontend/src/locales/en.json
Normal file
229
web/frontend/src/locales/en.json
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"settings": "Settings",
|
||||||
|
"subtitle": "multi-agent orchestrator",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"saved": "Saved",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"close": "Close",
|
||||||
|
"error": "Error",
|
||||||
|
"yes_delete": "Yes, delete",
|
||||||
|
"add": "Add",
|
||||||
|
"create": "Create"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"cost_this_week": "Cost this week",
|
||||||
|
"bootstrap": "Bootstrap",
|
||||||
|
"new_project": "+ New Project",
|
||||||
|
"blank": "+ Blank",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"delete_confirm": "Delete project \"{name}\"? This action is irreversible.",
|
||||||
|
"delete_project_title": "Confirm delete",
|
||||||
|
"cancel_delete_title": "Cancel delete",
|
||||||
|
"task_count": "{n} tasks",
|
||||||
|
"active_tasks": "{n} active",
|
||||||
|
"awaiting_review": "{n} awaiting review",
|
||||||
|
"blocked_tasks": "{n} blocked",
|
||||||
|
"done_tasks": "{n} done",
|
||||||
|
"pending_tasks": "{n} pending",
|
||||||
|
"add_project_title": "Add Project",
|
||||||
|
"project_type_label": "Project type:",
|
||||||
|
"create_btn": "Create",
|
||||||
|
"new_project_title": "New Project — Start Research",
|
||||||
|
"project_description_placeholder": "Project description (free text for agents)",
|
||||||
|
"research_stages": "Research stages (Architect is added automatically last):",
|
||||||
|
"architect_hint": "blueprint based on approved research",
|
||||||
|
"role_error": "Select at least one role",
|
||||||
|
"start_research": "Start Research",
|
||||||
|
"starting": "Starting...",
|
||||||
|
"bootstrap_title": "Bootstrap Project",
|
||||||
|
"bootstrap_btn": "Bootstrap",
|
||||||
|
"ssh_alias_hint": "Alias from ~/.ssh/config on the Kin server",
|
||||||
|
"path_placeholder": "Path (e.g. ~/projects/myproj)",
|
||||||
|
"name_placeholder": "Name",
|
||||||
|
"id_placeholder": "ID (e.g. vdol)",
|
||||||
|
"tech_stack_placeholder": "Tech stack (comma-separated)",
|
||||||
|
"priority_placeholder": "Priority (1-10)",
|
||||||
|
"ssh_host_placeholder": "SSH host (e.g. 192.168.1.1)",
|
||||||
|
"ssh_user_placeholder": "SSH user (e.g. root)",
|
||||||
|
"ssh_key_placeholder": "Key path (e.g. ~/.ssh/id_rsa)",
|
||||||
|
"proxy_jump_placeholder": "ProxyJump (optional, e.g. jumpt)",
|
||||||
|
"path_required": "Path is required",
|
||||||
|
"ssh_host_required": "SSH host is required for operations projects",
|
||||||
|
"bootstrap_path_placeholder": "Project path (e.g. ~/projects/vdolipoperek)",
|
||||||
|
"roles": {
|
||||||
|
"business_analyst": {
|
||||||
|
"label": "Business Analyst",
|
||||||
|
"hint": "business model, audience, monetization"
|
||||||
|
},
|
||||||
|
"market_researcher": {
|
||||||
|
"label": "Market Researcher",
|
||||||
|
"hint": "competitors, niche, strengths/weaknesses"
|
||||||
|
},
|
||||||
|
"legal_researcher": {
|
||||||
|
"label": "Legal Researcher",
|
||||||
|
"hint": "jurisdiction, licenses, KYC/AML, GDPR"
|
||||||
|
},
|
||||||
|
"tech_researcher": {
|
||||||
|
"label": "Tech Researcher",
|
||||||
|
"hint": "APIs, limitations, costs, alternatives"
|
||||||
|
},
|
||||||
|
"ux_designer": {
|
||||||
|
"label": "UX Designer",
|
||||||
|
"hint": "UX analysis, user journey, wireframes"
|
||||||
|
},
|
||||||
|
"marketer": {
|
||||||
|
"label": "Marketer",
|
||||||
|
"hint": "promotion strategy, SEO, conversion patterns"
|
||||||
|
},
|
||||||
|
"architect": {
|
||||||
|
"label": "Architect"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"back_to_project": "← Project",
|
||||||
|
"chat_label": "— chat",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"server_unavailable": "Server unavailable. Check your connection.",
|
||||||
|
"empty_hint": "Describe a task or ask about the project status",
|
||||||
|
"input_placeholder": "Describe a task or question... (Enter — send, Shift+Enter — newline)",
|
||||||
|
"send": "Send",
|
||||||
|
"sending": "..."
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"obsidian_vault_path": "Obsidian Vault Path",
|
||||||
|
"test_command": "Test Command",
|
||||||
|
"test_command_hint": "Test run command, executed via shell in the project directory.",
|
||||||
|
"save_test": "Save Test",
|
||||||
|
"saving_test": "Saving…",
|
||||||
|
"deploy_config": "Deploy Config",
|
||||||
|
"server_host": "Server host",
|
||||||
|
"project_path_on_server": "Project path on server",
|
||||||
|
"runtime": "Runtime",
|
||||||
|
"select_runtime": "— select runtime —",
|
||||||
|
"restart_command": "Restart command (optional override)",
|
||||||
|
"fallback_command": "Fallback command (legacy, used when runtime not set)",
|
||||||
|
"save_deploy_config": "Save Deploy Config",
|
||||||
|
"saving_deploy": "Saving…",
|
||||||
|
"project_links": "Project Links",
|
||||||
|
"add_link": "+ Add Link",
|
||||||
|
"links_loading": "Loading...",
|
||||||
|
"no_links": "No links",
|
||||||
|
"select_project": "— select project —",
|
||||||
|
"auto_test": "Auto-test",
|
||||||
|
"auto_test_hint": "— run tests automatically after pipeline",
|
||||||
|
"worktrees": "Worktrees",
|
||||||
|
"worktrees_hint": "— agents run in isolated git worktrees",
|
||||||
|
"save_vault": "Save Vault",
|
||||||
|
"saving_vault": "Saving…",
|
||||||
|
"sync_obsidian": "Sync Obsidian",
|
||||||
|
"syncing": "Syncing…",
|
||||||
|
"saving_link": "Saving...",
|
||||||
|
"cancel_link": "Cancel",
|
||||||
|
"delete_link_confirm": "Delete link?",
|
||||||
|
"select_project_error": "Select a project"
|
||||||
|
},
|
||||||
|
"taskDetail": {
|
||||||
|
"pipeline_already_running": "Pipeline already running",
|
||||||
|
"mark_resolved_confirm": "Mark task as manually resolved?",
|
||||||
|
"requires_manual": "⚠ Requires manual resolution",
|
||||||
|
"acceptance_criteria": "Acceptance criteria",
|
||||||
|
"autopilot_failed": "Autopilot could not complete this automatically. Take action manually and click \"Resolve manually\".",
|
||||||
|
"dangerously_skipped": "--dangerously-skip-permissions was used in this task",
|
||||||
|
"dangerously_skipped_hint": "The agent executed commands bypassing permission checks. Review pipeline steps and changes made.",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"pipeline": "Pipeline",
|
||||||
|
"running": "running...",
|
||||||
|
"no_pipeline": "No pipeline steps yet.",
|
||||||
|
"approve_task": "✓ Approve",
|
||||||
|
"revise_task": "🔄 Revise",
|
||||||
|
"reject_task": "✗ Reject",
|
||||||
|
"edit": "✒ Edit",
|
||||||
|
"run_pipeline": "▶ Run Pipeline",
|
||||||
|
"pipeline_running": "Pipeline running...",
|
||||||
|
"deploying": "Deploying...",
|
||||||
|
"deploy": "🚀 Deploy",
|
||||||
|
"deploy_succeeded": "Deploy succeeded",
|
||||||
|
"deploy_failed": "Deploy failed",
|
||||||
|
"resolve_manually": "✓ Resolve manually",
|
||||||
|
"resolving": "Saving...",
|
||||||
|
"send_to_revision": "🔄 Send for revision",
|
||||||
|
"revise_placeholder": "What to revise or clarify...",
|
||||||
|
"autopilot_active": "Autopilot active",
|
||||||
|
"attachments": "Attachments",
|
||||||
|
"more_details": "↓ more details",
|
||||||
|
"terminal_login_hint": "Open a terminal and run:",
|
||||||
|
"login_after_hint": "After login, retry the pipeline.",
|
||||||
|
"dependent_projects": "Dependent projects:",
|
||||||
|
"decision_title_placeholder": "Decision title (optional)",
|
||||||
|
"description_placeholder": "Description",
|
||||||
|
"brief_label": "Brief",
|
||||||
|
"priority_label": "Priority (1–10)",
|
||||||
|
"title_label": "Title",
|
||||||
|
"acceptance_criteria_label": "Acceptance criteria",
|
||||||
|
"acceptance_criteria_placeholder": "What should the output be? What result counts as success?"
|
||||||
|
},
|
||||||
|
"projectView": {
|
||||||
|
"tasks_tab": "Tasks",
|
||||||
|
"phases_tab": "Phases",
|
||||||
|
"decisions_tab": "Decisions",
|
||||||
|
"modules_tab": "Modules",
|
||||||
|
"kanban_tab": "Kanban",
|
||||||
|
"links_tab": "Links",
|
||||||
|
"add_task": "+ Task",
|
||||||
|
"audit_backlog": "Audit backlog",
|
||||||
|
"back": "← back",
|
||||||
|
"deploy": "Deploy",
|
||||||
|
"kanban_pending": "Pending",
|
||||||
|
"kanban_in_progress": "In Progress",
|
||||||
|
"kanban_review": "Review",
|
||||||
|
"kanban_blocked": "Blocked",
|
||||||
|
"kanban_done": "Done",
|
||||||
|
"chat": "Chat",
|
||||||
|
"dependent_projects": "Dependent projects:",
|
||||||
|
"environments": "Environments",
|
||||||
|
"auto_test_label": "Auto-test",
|
||||||
|
"worktrees_on": "Worktrees: on",
|
||||||
|
"worktrees_off": "Worktrees: off",
|
||||||
|
"all_statuses": "All",
|
||||||
|
"search_placeholder": "Search tasks...",
|
||||||
|
"manual_escalations_warn": "⚠ Require manual resolution",
|
||||||
|
"comment_required": "Comment required",
|
||||||
|
"select_project": "Select project",
|
||||||
|
"delete_env_confirm": "Delete environment?",
|
||||||
|
"delete_link_confirm": "Delete link?",
|
||||||
|
"run_pipeline_confirm": "Run pipeline for {n} tasks?",
|
||||||
|
"pipeline_already_running": "Pipeline already running",
|
||||||
|
"no_tasks": "No tasks.",
|
||||||
|
"loading_phases": "Loading phases...",
|
||||||
|
"revise_modal_title": "Revise phase",
|
||||||
|
"reject_modal_title": "Reject phase",
|
||||||
|
"add_link_title": "Add link"
|
||||||
|
},
|
||||||
|
"escalation": {
|
||||||
|
"watchdog_blocked": "Watchdog: task {task_id} blocked — {reason}",
|
||||||
|
"escalations": "Escalations",
|
||||||
|
"escalations_panel_title": "Escalations — action required",
|
||||||
|
"dismiss_all": "Dismiss all",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
},
|
||||||
|
"liveConsole": {
|
||||||
|
"hide_log": "▲ Hide log",
|
||||||
|
"show_log": "▼ Show log",
|
||||||
|
"no_records": "No records...",
|
||||||
|
"error_prefix": "Error:"
|
||||||
|
},
|
||||||
|
"attachments": {
|
||||||
|
"images_only": "Only images are supported",
|
||||||
|
"uploading": "Uploading...",
|
||||||
|
"drop_hint": "Drop an image or",
|
||||||
|
"click_to_select": "click to select",
|
||||||
|
"delete_title": "Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
229
web/frontend/src/locales/ru.json
Normal file
229
web/frontend/src/locales/ru.json
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"settings": "Настройки",
|
||||||
|
"subtitle": "мультиагентный оркестратор",
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"saving": "Сохраняем...",
|
||||||
|
"saved": "Сохранено",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"close": "Закрыть",
|
||||||
|
"error": "Ошибка",
|
||||||
|
"yes_delete": "Да, удалить",
|
||||||
|
"add": "Добавить",
|
||||||
|
"create": "Создать"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"cost_this_week": "Расходы за неделю",
|
||||||
|
"bootstrap": "Bootstrap",
|
||||||
|
"new_project": "+ Новый проект",
|
||||||
|
"blank": "+ Пустой",
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"delete_confirm": "Удалить проект «{name}»? Это действие необратимо.",
|
||||||
|
"delete_project_title": "Подтвердить удаление",
|
||||||
|
"cancel_delete_title": "Отмена удаления",
|
||||||
|
"task_count": "{n} задач",
|
||||||
|
"active_tasks": "{n} активных",
|
||||||
|
"awaiting_review": "{n} ожидают проверки",
|
||||||
|
"blocked_tasks": "{n} заблокированы",
|
||||||
|
"done_tasks": "{n} выполнены",
|
||||||
|
"pending_tasks": "{n} ожидают",
|
||||||
|
"add_project_title": "Добавить проект",
|
||||||
|
"project_type_label": "Тип проекта:",
|
||||||
|
"create_btn": "Создать",
|
||||||
|
"new_project_title": "Новый проект — Запустить исследование",
|
||||||
|
"project_description_placeholder": "Описание проекта (свободный текст для агентов)",
|
||||||
|
"research_stages": "Этапы research (Architect добавляется автоматически последним):",
|
||||||
|
"architect_hint": "blueprint на основе одобренных исследований",
|
||||||
|
"role_error": "Выберите хотя бы одну роль",
|
||||||
|
"start_research": "Запустить исследование",
|
||||||
|
"starting": "Запускаем...",
|
||||||
|
"bootstrap_title": "Bootstrap проекта",
|
||||||
|
"bootstrap_btn": "Bootstrap",
|
||||||
|
"ssh_alias_hint": "Алиас из ~/.ssh/config на сервере Kin",
|
||||||
|
"path_placeholder": "Путь (например ~/projects/myproj)",
|
||||||
|
"name_placeholder": "Название",
|
||||||
|
"id_placeholder": "ID (например vdol)",
|
||||||
|
"tech_stack_placeholder": "Стек (через запятую)",
|
||||||
|
"priority_placeholder": "Приоритет (1-10)",
|
||||||
|
"ssh_host_placeholder": "SSH хост (например 192.168.1.1)",
|
||||||
|
"ssh_user_placeholder": "SSH пользователь (например root)",
|
||||||
|
"ssh_key_placeholder": "Путь к ключу (например ~/.ssh/id_rsa)",
|
||||||
|
"proxy_jump_placeholder": "ProxyJump (опционально, например jumpt)",
|
||||||
|
"path_required": "Путь обязателен",
|
||||||
|
"ssh_host_required": "SSH хост обязателен для операционных проектов",
|
||||||
|
"bootstrap_path_placeholder": "Путь к проекту (например ~/projects/vdolipoperek)",
|
||||||
|
"roles": {
|
||||||
|
"business_analyst": {
|
||||||
|
"label": "Бизнес-аналитик",
|
||||||
|
"hint": "бизнес-модель, аудитория, монетизация"
|
||||||
|
},
|
||||||
|
"market_researcher": {
|
||||||
|
"label": "Маркет-ресёрчер",
|
||||||
|
"hint": "конкуренты, ниша, сильные/слабые стороны"
|
||||||
|
},
|
||||||
|
"legal_researcher": {
|
||||||
|
"label": "Правовой аналитик",
|
||||||
|
"hint": "юрисдикция, лицензии, KYC/AML, GDPR"
|
||||||
|
},
|
||||||
|
"tech_researcher": {
|
||||||
|
"label": "Тех-ресёрчер",
|
||||||
|
"hint": "API, ограничения, стоимость, альтернативы"
|
||||||
|
},
|
||||||
|
"ux_designer": {
|
||||||
|
"label": "UX-дизайнер",
|
||||||
|
"hint": "анализ UX конкурентов, user journey, wireframes"
|
||||||
|
},
|
||||||
|
"marketer": {
|
||||||
|
"label": "Маркетолог",
|
||||||
|
"hint": "стратегия продвижения, SEO, conversion-паттерны"
|
||||||
|
},
|
||||||
|
"architect": {
|
||||||
|
"label": "Архитектор"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"back_to_project": "← Проект",
|
||||||
|
"chat_label": "— чат",
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"server_unavailable": "Сервер недоступен. Проверьте подключение.",
|
||||||
|
"empty_hint": "Опишите задачу или спросите о статусе проекта",
|
||||||
|
"input_placeholder": "Опишите задачу или вопрос... (Enter — отправить, Shift+Enter — перенос)",
|
||||||
|
"send": "Отправить",
|
||||||
|
"sending": "..."
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Настройки",
|
||||||
|
"obsidian_vault_path": "Путь к Obsidian Vault",
|
||||||
|
"test_command": "Команда тестирования",
|
||||||
|
"test_command_hint": "Команда запуска тестов, выполняется через shell в директории проекта.",
|
||||||
|
"save_test": "Сохранить тест",
|
||||||
|
"saving_test": "Сохраняем…",
|
||||||
|
"deploy_config": "Конфигурация деплоя",
|
||||||
|
"server_host": "Хост сервера",
|
||||||
|
"project_path_on_server": "Путь к проекту на сервере",
|
||||||
|
"runtime": "Runtime",
|
||||||
|
"select_runtime": "— выберите runtime —",
|
||||||
|
"restart_command": "Команда перезапуска (опциональный override)",
|
||||||
|
"fallback_command": "Fallback команда (legacy, используется если runtime не задан)",
|
||||||
|
"save_deploy_config": "Сохранить конфиг деплоя",
|
||||||
|
"saving_deploy": "Сохраняем…",
|
||||||
|
"project_links": "Связи проекта",
|
||||||
|
"add_link": "+ Добавить связь",
|
||||||
|
"links_loading": "Загрузка...",
|
||||||
|
"no_links": "Нет связей",
|
||||||
|
"select_project": "— выберите проект —",
|
||||||
|
"auto_test": "Автотест",
|
||||||
|
"auto_test_hint": "— запускать тесты автоматически после pipeline",
|
||||||
|
"worktrees": "Worktrees",
|
||||||
|
"worktrees_hint": "— агенты запускаются в изолированных git worktrees",
|
||||||
|
"save_vault": "Сохранить Vault",
|
||||||
|
"saving_vault": "Сохраняем…",
|
||||||
|
"sync_obsidian": "Синхронизировать Obsidian",
|
||||||
|
"syncing": "Синхронизируем…",
|
||||||
|
"saving_link": "Сохраняем...",
|
||||||
|
"cancel_link": "Отмена",
|
||||||
|
"delete_link_confirm": "Удалить связь?",
|
||||||
|
"select_project_error": "Выберите проект"
|
||||||
|
},
|
||||||
|
"taskDetail": {
|
||||||
|
"pipeline_already_running": "Pipeline уже запущен",
|
||||||
|
"mark_resolved_confirm": "Пометить задачу как решённую вручную?",
|
||||||
|
"requires_manual": "⚠ Требует ручного решения",
|
||||||
|
"acceptance_criteria": "Критерии приёмки",
|
||||||
|
"autopilot_failed": "Автопилот не смог выполнить это автоматически. Примите меры вручную и нажмите «Решить вручную».",
|
||||||
|
"dangerously_skipped": "--dangerously-skip-permissions использовался в этой задаче",
|
||||||
|
"dangerously_skipped_hint": "Агент выполнял команды с обходом проверок разрешений. Проверьте pipeline-шаги и сделанные изменения.",
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"pipeline": "Pipeline",
|
||||||
|
"running": "выполняется...",
|
||||||
|
"no_pipeline": "Нет шагов pipeline.",
|
||||||
|
"approve_task": "✓ Подтвердить",
|
||||||
|
"revise_task": "🔄 Доработать",
|
||||||
|
"reject_task": "✗ Отклонить",
|
||||||
|
"edit": "✒ Редактировать",
|
||||||
|
"run_pipeline": "▶ Запустить Pipeline",
|
||||||
|
"pipeline_running": "Pipeline выполняется...",
|
||||||
|
"deploying": "Деплоим...",
|
||||||
|
"deploy": "🚀 Деплой",
|
||||||
|
"deploy_succeeded": "Деплой успешен",
|
||||||
|
"deploy_failed": "Деплой не удался",
|
||||||
|
"resolve_manually": "✓ Решить вручную",
|
||||||
|
"resolving": "Сохраняем...",
|
||||||
|
"send_to_revision": "🔄 Отправить на доработку",
|
||||||
|
"revise_placeholder": "Что доработать / уточнить...",
|
||||||
|
"autopilot_active": "Автопилот активен",
|
||||||
|
"attachments": "Вложения",
|
||||||
|
"more_details": "↓ подробнее",
|
||||||
|
"terminal_login_hint": "Откройте терминал и выполните:",
|
||||||
|
"login_after_hint": "После входа повторите запуск pipeline.",
|
||||||
|
"dependent_projects": "Зависимые проекты:",
|
||||||
|
"decision_title_placeholder": "Заголовок решения (опционально)",
|
||||||
|
"description_placeholder": "Описание",
|
||||||
|
"brief_label": "Описание",
|
||||||
|
"priority_label": "Приоритет (1–10)",
|
||||||
|
"title_label": "Заголовок",
|
||||||
|
"acceptance_criteria_label": "Критерии приёмки",
|
||||||
|
"acceptance_criteria_placeholder": "Что должно быть на выходе? Какой результат считается успешным?"
|
||||||
|
},
|
||||||
|
"projectView": {
|
||||||
|
"tasks_tab": "Задачи",
|
||||||
|
"phases_tab": "Фазы",
|
||||||
|
"decisions_tab": "Решения",
|
||||||
|
"modules_tab": "Модули",
|
||||||
|
"kanban_tab": "Kanban",
|
||||||
|
"links_tab": "Связи",
|
||||||
|
"add_task": "+ Задача",
|
||||||
|
"audit_backlog": "Аудит бэклога",
|
||||||
|
"back": "← назад",
|
||||||
|
"deploy": "Деплой",
|
||||||
|
"kanban_pending": "Ожидает",
|
||||||
|
"kanban_in_progress": "В работе",
|
||||||
|
"kanban_review": "Проверка",
|
||||||
|
"kanban_blocked": "Заблокирован",
|
||||||
|
"kanban_done": "Выполнено",
|
||||||
|
"chat": "Чат",
|
||||||
|
"dependent_projects": "Зависимые проекты:",
|
||||||
|
"environments": "Среды",
|
||||||
|
"auto_test_label": "Автотест",
|
||||||
|
"worktrees_on": "Worktrees: вкл",
|
||||||
|
"worktrees_off": "Worktrees: выкл",
|
||||||
|
"all_statuses": "Все",
|
||||||
|
"search_placeholder": "Поиск по задачам...",
|
||||||
|
"manual_escalations_warn": "⚠ Требуют ручного решения",
|
||||||
|
"comment_required": "Комментарий обязателен",
|
||||||
|
"select_project": "Выберите проект",
|
||||||
|
"delete_env_confirm": "Удалить среду?",
|
||||||
|
"delete_link_confirm": "Удалить связь?",
|
||||||
|
"run_pipeline_confirm": "Запустить pipeline для {n} задач?",
|
||||||
|
"pipeline_already_running": "Pipeline уже запущен",
|
||||||
|
"no_tasks": "Нет задач.",
|
||||||
|
"loading_phases": "Загрузка фаз...",
|
||||||
|
"revise_modal_title": "Доработать фазу",
|
||||||
|
"reject_modal_title": "Отклонить фазу",
|
||||||
|
"add_link_title": "Добавить связь"
|
||||||
|
},
|
||||||
|
"escalation": {
|
||||||
|
"watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}",
|
||||||
|
"escalations": "Эскалации",
|
||||||
|
"escalations_panel_title": "Эскалации — требуется решение",
|
||||||
|
"dismiss_all": "Принять все",
|
||||||
|
"dismiss": "Принято"
|
||||||
|
},
|
||||||
|
"liveConsole": {
|
||||||
|
"hide_log": "▲ Скрыть лог",
|
||||||
|
"show_log": "▼ Показать лог",
|
||||||
|
"no_records": "Нет записей...",
|
||||||
|
"error_prefix": "Ошибка:"
|
||||||
|
},
|
||||||
|
"attachments": {
|
||||||
|
"images_only": "Поддерживаются только изображения",
|
||||||
|
"uploading": "Загрузка...",
|
||||||
|
"drop_hint": "Перетащите изображение или",
|
||||||
|
"click_to_select": "нажмите для выбора",
|
||||||
|
"delete_title": "Удалить"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import ProjectView from './views/ProjectView.vue'
|
||||||
import TaskDetail from './views/TaskDetail.vue'
|
import TaskDetail from './views/TaskDetail.vue'
|
||||||
import SettingsView from './views/SettingsView.vue'
|
import SettingsView from './views/SettingsView.vue'
|
||||||
import ChatView from './views/ChatView.vue'
|
import ChatView from './views/ChatView.vue'
|
||||||
|
import { i18n } from './i18n'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
|
|
@ -19,4 +20,4 @@ const router = createRouter({
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
createApp(App).use(router).mount('#app')
|
createApp(App).use(router).use(i18n).mount('#app')
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { api, ApiError, type ChatMessage } from '../api'
|
import { api, ApiError, type ChatMessage } from '../api'
|
||||||
import Badge from '../components/Badge.vue'
|
import Badge from '../components/Badge.vue'
|
||||||
|
|
||||||
const props = defineProps<{ projectId: string }>()
|
const props = defineProps<{ projectId: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
const messages = ref<ChatMessage[]>([])
|
const messages = ref<ChatMessage[]>([])
|
||||||
const input = ref('')
|
const input = ref('')
|
||||||
|
|
@ -43,9 +45,9 @@ function checkAndPoll() {
|
||||||
if (!hasRunningTasks(updated)) stopPoll()
|
if (!hasRunningTasks(updated)) stopPoll()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
consecutiveErrors.value++
|
consecutiveErrors.value++
|
||||||
console.warn(`[polling] ошибка #${consecutiveErrors.value}:`, e)
|
console.warn('[polling] error #' + consecutiveErrors.value + ':', e)
|
||||||
if (consecutiveErrors.value >= 3) {
|
if (consecutiveErrors.value >= 3) {
|
||||||
error.value = 'Сервер недоступен. Проверьте подключение.'
|
error.value = t('chat.server_unavailable')
|
||||||
stopPoll()
|
stopPoll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +136,7 @@ function taskStatusColor(status: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(dt: 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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -145,12 +147,12 @@ function formatTime(dt: string) {
|
||||||
<router-link
|
<router-link
|
||||||
:to="`/project/${projectId}`"
|
:to="`/project/${projectId}`"
|
||||||
class="text-gray-400 hover:text-gray-200 text-sm no-underline"
|
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>
|
<span class="text-gray-600">|</span>
|
||||||
<h1 class="text-base font-semibold text-gray-100">
|
<h1 class="text-base font-semibold text-gray-100">
|
||||||
{{ projectName || projectId }}
|
{{ projectName || projectId }}
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
|
|
@ -160,7 +162,7 @@ function formatTime(dt: string) {
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- 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"
|
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">
|
<div v-if="messages.length === 0" class="text-center text-gray-500 text-sm mt-8">
|
||||||
Опишите задачу или спросите о статусе проекта
|
{{ t('chat.empty_hint') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -209,7 +211,7 @@ function formatTime(dt: string) {
|
||||||
<textarea
|
<textarea
|
||||||
v-model="input"
|
v-model="input"
|
||||||
:disabled="sending || loading"
|
:disabled="sending || loading"
|
||||||
placeholder="Опишите задачу или вопрос... (Enter — отправить, Shift+Enter — перенос)"
|
:placeholder="t('chat.input_placeholder')"
|
||||||
rows="2"
|
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"
|
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"
|
@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"
|
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"
|
@click="send"
|
||||||
>
|
>
|
||||||
{{ sending ? '...' : 'Отправить' }}
|
{{ sending ? t('chat.sending') : t('chat.send') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { api, type Project, type CostEntry } from '../api'
|
import { api, type Project, type CostEntry } from '../api'
|
||||||
import Badge from '../components/Badge.vue'
|
import Badge from '../components/Badge.vue'
|
||||||
import Modal from '../components/Modal.vue'
|
import Modal from '../components/Modal.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const projects = ref<Project[]>([])
|
const projects = ref<Project[]>([])
|
||||||
const costs = ref<CostEntry[]>([])
|
const costs = ref<CostEntry[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
@ -25,12 +28,12 @@ const bsResult = ref('')
|
||||||
|
|
||||||
// New Project with Research modal
|
// New Project with Research modal
|
||||||
const RESEARCH_ROLES = [
|
const RESEARCH_ROLES = [
|
||||||
{ key: 'business_analyst', label: 'Business Analyst', hint: 'бизнес-модель, аудитория, монетизация' },
|
{ key: 'business_analyst' },
|
||||||
{ key: 'market_researcher', label: 'Market Researcher', hint: 'конкуренты, ниша, сильные/слабые стороны' },
|
{ key: 'market_researcher' },
|
||||||
{ key: 'legal_researcher', label: 'Legal Researcher', hint: 'юрисдикция, лицензии, KYC/AML, GDPR' },
|
{ key: 'legal_researcher' },
|
||||||
{ key: 'tech_researcher', label: 'Tech Researcher', hint: 'API, ограничения, стоимость, альтернативы' },
|
{ key: 'tech_researcher' },
|
||||||
{ key: 'ux_designer', label: 'UX Designer', hint: 'анализ UX конкурентов, user journey, wireframes' },
|
{ key: 'ux_designer' },
|
||||||
{ key: 'marketer', label: 'Marketer', hint: 'стратегия продвижения, SEO, conversion-паттерны' },
|
{ key: 'marketer' },
|
||||||
]
|
]
|
||||||
const showNewProject = ref(false)
|
const showNewProject = ref(false)
|
||||||
const npForm = ref({
|
const npForm = ref({
|
||||||
|
|
@ -55,7 +58,6 @@ let dashPollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await load()
|
await load()
|
||||||
// Poll if there are running tasks
|
|
||||||
checkAndPoll()
|
checkAndPoll()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -87,11 +89,11 @@ function statusColor(s: string) {
|
||||||
async function addProject() {
|
async function addProject() {
|
||||||
formError.value = ''
|
formError.value = ''
|
||||||
if (form.value.project_type === 'operations' && !form.value.ssh_host) {
|
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
|
return
|
||||||
}
|
}
|
||||||
if (form.value.project_type !== 'operations' && !form.value.path) {
|
if (form.value.project_type !== 'operations' && !form.value.path) {
|
||||||
formError.value = 'Path is required'
|
formError.value = t('dashboard.path_required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -157,7 +159,7 @@ async function deleteProject(id: string) {
|
||||||
async function createNewProject() {
|
async function createNewProject() {
|
||||||
npError.value = ''
|
npError.value = ''
|
||||||
if (!npRoles.value.length) {
|
if (!npRoles.value.length) {
|
||||||
npError.value = 'Выберите хотя бы одну роль'
|
npError.value = t('dashboard.role_error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
npSaving.value = true
|
npSaving.value = true
|
||||||
|
|
@ -189,26 +191,26 @@ async function createNewProject() {
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-bold text-gray-100">Dashboard</h1>
|
<h1 class="text-xl font-bold text-gray-100">{{ t('dashboard.title') }}</h1>
|
||||||
<p class="text-sm text-gray-500" v-if="totalCost > 0">Cost this week: ${{ totalCost.toFixed(2) }}</p>
|
<p class="text-sm text-gray-500" v-if="totalCost > 0">{{ t('dashboard.cost_this_week') }}: ${{ totalCost.toFixed(2) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button @click="showBootstrap = true"
|
<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">
|
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>
|
||||||
<button @click="showNewProject = true"
|
<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">
|
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>
|
||||||
<button @click="showAdd = true"
|
<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">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<p v-else-if="error" class="text-red-400 text-sm">{{ error }}</p>
|
||||||
|
|
||||||
<div v-else class="grid gap-3">
|
<div v-else class="grid gap-3">
|
||||||
|
|
@ -216,17 +218,17 @@ async function createNewProject() {
|
||||||
<!-- Inline delete confirmation -->
|
<!-- Inline delete confirmation -->
|
||||||
<div v-if="confirmDeleteId === p.id"
|
<div v-if="confirmDeleteId === p.id"
|
||||||
class="border border-red-800 rounded-lg p-4 bg-red-950/20">
|
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">
|
<div class="flex gap-2">
|
||||||
<button @click="deleteProject(p.id)"
|
<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">
|
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>
|
||||||
<button @click="confirmDeleteId = null"
|
<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">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="deleteError" class="text-red-400 text-xs mt-2">{{ deleteError }}</p>
|
<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 v-if="costMap[p.id]">${{ costMap[p.id]?.toFixed(2) }}/wk</span>
|
||||||
<span>pri {{ p.priority }}</span>
|
<span>pri {{ p.priority }}</span>
|
||||||
<button @click.prevent.stop="confirmDeleteId = p.id"
|
<button @click.prevent.stop="confirmDeleteId = p.id"
|
||||||
title="Удалить проект"
|
:title="t('common.delete')"
|
||||||
class="text-gray-600 hover:text-red-400 transition-colors">
|
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">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Add Project Modal -->
|
<!-- 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">
|
<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" />
|
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" />
|
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 -->
|
<!-- Project type selector -->
|
||||||
<div>
|
<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">
|
<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"
|
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="flex-1 py-1.5 text-xs border rounded transition-colors"
|
||||||
:class="form.project_type === t
|
:class="form.project_type === t_type
|
||||||
? t === 'development' ? 'bg-blue-900/40 text-blue-300 border-blue-700'
|
? t_type === 'development' ? 'bg-blue-900/40 text-blue-300 border-blue-700'
|
||||||
: t === 'operations' ? 'bg-orange-900/40 text-orange-300 border-orange-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-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'"
|
: 'bg-gray-900 text-gray-500 border-gray-800 hover:text-gray-300 hover:border-gray-600'"
|
||||||
>{{ t }}</button>
|
>{{ t_type }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Path (development / research) -->
|
<!-- Path (development / research) -->
|
||||||
<input v-if="form.project_type !== 'operations'"
|
<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" />
|
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) -->
|
<!-- SSH fields (operations) -->
|
||||||
<template v-if="form.project_type === '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" />
|
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">
|
<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" />
|
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" />
|
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
Алиас из ~/.ssh/config на сервере Kin
|
{{ t('dashboard.ssh_alias_hint') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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" />
|
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" />
|
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>
|
<p v-if="formError" class="text-red-400 text-xs">{{ formError }}</p>
|
||||||
<button type="submit"
|
<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">
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- New Project with Research 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">
|
<form @submit.prevent="createNewProject" class="space-y-3">
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<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" />
|
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" />
|
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="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" />
|
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>
|
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" />
|
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>
|
||||||
<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">
|
<div class="space-y-1.5">
|
||||||
<label v-for="r in RESEARCH_ROLES" :key="r.key"
|
<label v-for="r in RESEARCH_ROLES" :key="r.key"
|
||||||
class="flex items-start gap-2 cursor-pointer group">
|
class="flex items-start gap-2 cursor-pointer group">
|
||||||
|
|
@ -359,15 +361,15 @@ async function createNewProject() {
|
||||||
@change="toggleNpRole(r.key)"
|
@change="toggleNpRole(r.key)"
|
||||||
class="mt-0.5 accent-green-500 cursor-pointer" />
|
class="mt-0.5 accent-green-500 cursor-pointer" />
|
||||||
<div>
|
<div>
|
||||||
<span class="text-sm text-gray-300 group-hover:text-gray-100">{{ r.label }}</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">— {{ r.hint }}</span>
|
<span class="text-xs text-gray-600 ml-1">— {{ t(`dashboard.roles.${r.key}.hint`) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-start gap-2 opacity-50">
|
<label class="flex items-start gap-2 opacity-50">
|
||||||
<input type="checkbox" checked disabled class="mt-0.5" />
|
<input type="checkbox" checked disabled class="mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<span class="text-sm text-gray-400">Architect</span>
|
<span class="text-sm text-gray-400">{{ t('dashboard.roles.architect.label') }}</span>
|
||||||
<span class="text-xs text-gray-600 ml-1">— blueprint на основе одобренных исследований</span>
|
<span class="text-xs text-gray-600 ml-1">— {{ t('dashboard.architect_hint') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -375,25 +377,25 @@ async function createNewProject() {
|
||||||
<p v-if="npError" class="text-red-400 text-xs">{{ npError }}</p>
|
<p v-if="npError" class="text-red-400 text-xs">{{ npError }}</p>
|
||||||
<button type="submit" :disabled="npSaving"
|
<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">
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- Bootstrap 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">
|
<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" />
|
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" />
|
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" />
|
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="bsError" class="text-red-400 text-xs">{{ bsError }}</p>
|
||||||
<p v-if="bsResult" class="text-green-400 text-xs">{{ bsResult }}</p>
|
<p v-if="bsResult" class="text-green-400 text-xs">{{ bsResult }}</p>
|
||||||
<button type="submit"
|
<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">
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 { 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 Badge from '../components/Badge.vue'
|
||||||
import Modal from '../components/Modal.vue'
|
import Modal from '../components/Modal.vue'
|
||||||
|
|
@ -8,6 +9,7 @@ import Modal from '../components/Modal.vue'
|
||||||
const props = defineProps<{ id: string }>()
|
const props = defineProps<{ id: string }>()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const project = ref<ProjectDetail | null>(null)
|
const project = ref<ProjectDetail | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
@ -48,7 +50,7 @@ function openTaskRevise(taskId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitTaskRevise() {
|
async function submitTaskRevise() {
|
||||||
if (!taskReviseComment.value.trim()) { taskReviseError.value = 'Комментарий обязателен'; return }
|
if (!taskReviseComment.value.trim()) { taskReviseError.value = t('projectView.comment_required'); return }
|
||||||
taskReviseSaving.value = true
|
taskReviseSaving.value = true
|
||||||
try {
|
try {
|
||||||
await api.reviseTask(taskReviseTaskId.value!, taskReviseComment.value)
|
await api.reviseTask(taskReviseTaskId.value!, taskReviseComment.value)
|
||||||
|
|
@ -378,7 +380,7 @@ async function submitEnv() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEnv(envId: number) {
|
async function deleteEnv(envId: number) {
|
||||||
if (!confirm('Удалить среду?')) return
|
if (!confirm(t('projectView.delete_env_confirm'))) return
|
||||||
try {
|
try {
|
||||||
await api.deleteEnvironment(props.id, envId)
|
await api.deleteEnvironment(props.id, envId)
|
||||||
await loadEnvironments()
|
await loadEnvironments()
|
||||||
|
|
@ -431,7 +433,7 @@ async function loadLinks() {
|
||||||
|
|
||||||
async function addLink() {
|
async function addLink() {
|
||||||
linkFormError.value = ''
|
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
|
linkSaving.value = true
|
||||||
try {
|
try {
|
||||||
await api.createProjectLink({
|
await api.createProjectLink({
|
||||||
|
|
@ -451,7 +453,7 @@ async function addLink() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteLink(id: number) {
|
async function deleteLink(id: number) {
|
||||||
if (!confirm('Удалить связь?')) return
|
if (!confirm(t('projectView.delete_link_confirm'))) return
|
||||||
try {
|
try {
|
||||||
await api.deleteProjectLink(id)
|
await api.deleteProjectLink(id)
|
||||||
await loadLinks()
|
await loadLinks()
|
||||||
|
|
@ -649,7 +651,7 @@ const runningTaskId = ref<string | null>(null)
|
||||||
async function runTask(taskId: string, event: Event) {
|
async function runTask(taskId: string, event: Event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!confirm(`Run pipeline for ${taskId}?`)) return
|
if (!confirm(t('projectView.run_pipeline_confirm', { n: taskId }))) return
|
||||||
runningTaskId.value = taskId
|
runningTaskId.value = taskId
|
||||||
try {
|
try {
|
||||||
// Sync task execution_mode with current project toggle state before running
|
// 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()
|
if (activeTab.value === 'kanban') checkAndPollKanban()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e instanceof ApiError && e.code === 'task_already_running') {
|
if (e instanceof ApiError && e.code === 'task_already_running') {
|
||||||
error.value = 'Pipeline уже запущен'
|
error.value = t('projectView.pipeline_already_running')
|
||||||
} else {
|
} else {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
}
|
}
|
||||||
|
|
@ -681,13 +683,13 @@ async function patchTaskField(taskId: string, data: { priority?: number; route_t
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kanban
|
// Kanban
|
||||||
const KANBAN_COLUMNS = [
|
const KANBAN_COLUMNS = computed(() => [
|
||||||
{ status: 'pending', label: 'Pending', headerClass: 'text-gray-400', bgClass: 'bg-gray-900/20' },
|
{ status: 'pending', label: t('projectView.kanban_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: 'in_progress', label: t('projectView.kanban_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: 'review', label: t('projectView.kanban_review'), headerClass: 'text-purple-400', bgClass: 'bg-purple-950/20' },
|
||||||
{ status: 'blocked', label: 'Blocked', headerClass: 'text-red-400', bgClass: 'bg-red-950/20' },
|
{ status: 'blocked', label: t('projectView.kanban_blocked'), headerClass: 'text-red-400', bgClass: 'bg-red-950/20' },
|
||||||
{ status: 'done', label: 'Done', headerClass: 'text-green-400', bgClass: 'bg-green-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 draggingTaskId = ref<string | null>(null)
|
||||||
const dragOverStatus = 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 kanbanTasksByStatus = computed(() => {
|
||||||
const result: Record<string, Task[]> = {}
|
const result: Record<string, Task[]> = {}
|
||||||
for (const col of KANBAN_COLUMNS) result[col.status] = []
|
for (const col of KANBAN_COLUMNS.value) result[col.status] = []
|
||||||
for (const t of searchFilteredTasks.value) {
|
for (const task of searchFilteredTasks.value) {
|
||||||
if (result[t.status]) result[t.status].push(t)
|
if (result[task.status]) result[task.status].push(task)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
@ -782,15 +784,15 @@ async function addDecision() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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="error" class="text-red-400 text-sm">{{ error }}</div>
|
||||||
<div v-else-if="project">
|
<div v-else-if="project">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<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>
|
<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>
|
||||||
<div class="flex items-center gap-3 mb-2 flex-wrap">
|
<div class="flex items-center gap-3 mb-2 flex-wrap">
|
||||||
<h1 class="text-xl font-bold text-gray-100">{{ project.id }}</h1>
|
<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"
|
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>
|
<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>
|
</button>
|
||||||
</div>
|
</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'">
|
: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">
|
<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">
|
<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>
|
||||||
<span class="text-gray-500">{{ deployResult.duration_seconds }}s</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>
|
<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>
|
</template>
|
||||||
<!-- Dependents -->
|
<!-- Dependents -->
|
||||||
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
|
<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">
|
<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-teal-400 font-semibold text-[10px]">ok</span>
|
||||||
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
|
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
|
||||||
|
|
@ -878,7 +880,7 @@ async function addDecision() {
|
||||||
:class="activeTab === tab
|
:class="activeTab === tab
|
||||||
? 'text-gray-200 border-blue-500'
|
? 'text-gray-200 border-blue-500'
|
||||||
: 'text-gray-500 border-transparent hover:text-gray-300'">
|
: '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">
|
<span class="text-xs text-gray-600 ml-1">
|
||||||
{{ tab === 'tasks' ? project.tasks.length
|
{{ tab === 'tasks' ? project.tasks.length
|
||||||
: tab === 'phases' ? phases.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-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'"
|
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
||||||
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
|
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
|
||||||
{{ autoTest ? '✓ Автотест' : 'Автотест' }}
|
{{ autoTest ? '✓ ' + t('projectView.auto_test_label') : t('projectView.auto_test_label') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="toggleWorktrees"
|
<button @click="toggleWorktrees"
|
||||||
class="px-2 py-1 text-xs border rounded transition-colors"
|
class="px-2 py-1 text-xs border rounded transition-colors"
|
||||||
:class="worktrees
|
:class="worktrees
|
||||||
? 'bg-teal-900/30 text-teal-400 border-teal-800 hover:bg-teal-900/50'
|
? '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'"
|
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
||||||
:title="worktrees ? 'Worktrees: on — агенты в изолированных git worktrees' : 'Worktrees: off'">
|
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
|
||||||
{{ worktrees ? '✓ Worktrees' : 'Worktrees' }}
|
{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="runAudit" :disabled="auditLoading"
|
<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"
|
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">
|
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>
|
<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>
|
||||||
<button @click="showAddTask = true"
|
<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">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -959,7 +961,7 @@ async function addDecision() {
|
||||||
:class="!selectedCategory
|
:class="!selectedCategory
|
||||||
? 'bg-gray-700/60 text-gray-300 border-gray-600'
|
? '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'"
|
: '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"
|
<button v-for="cat in taskCategories" :key="cat"
|
||||||
@click="selectedCategory = cat"
|
@click="selectedCategory = cat"
|
||||||
class="px-2 py-0.5 text-xs rounded border transition-colors"
|
class="px-2 py-0.5 text-xs rounded border transition-colors"
|
||||||
|
|
@ -972,7 +974,7 @@ async function addDecision() {
|
||||||
</div>
|
</div>
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="flex items-center gap-1">
|
<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" />
|
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 = ''"
|
<button v-if="taskSearch" @click="taskSearch = ''"
|
||||||
class="text-gray-600 hover:text-red-400 text-xs px-1">✕</button>
|
class="text-gray-600 hover:text-red-400 text-xs px-1">✕</button>
|
||||||
|
|
@ -981,7 +983,7 @@ async function addDecision() {
|
||||||
<!-- Manual escalation tasks -->
|
<!-- Manual escalation tasks -->
|
||||||
<div v-if="manualEscalationTasks.length" class="mb-4">
|
<div v-if="manualEscalationTasks.length" class="mb-4">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<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>
|
<span class="text-xs text-orange-600">({{ manualEscalationTasks.length }})</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
|
|
@ -1003,7 +1005,7 @@ async function addDecision() {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div v-else class="space-y-1">
|
||||||
<router-link v-for="t in filteredTasks" :key="t.id"
|
<router-link v-for="t in filteredTasks" :key="t.id"
|
||||||
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
|
: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>
|
<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>
|
||||||
</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>
|
<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">
|
<div v-else-if="phases.length === 0" class="text-gray-600 text-sm">
|
||||||
No research phases. Use "New Project" to start a research workflow.
|
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 v-if="activeTab === 'kanban'" class="pb-4">
|
||||||
<div class="flex items-center justify-between gap-2 mb-3">
|
<div class="flex items-center justify-between gap-2 mb-3">
|
||||||
<div class="flex items-center gap-1">
|
<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" />
|
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 = ''"
|
<button v-if="taskSearch" @click="taskSearch = ''"
|
||||||
class="text-gray-600 hover:text-red-400 text-xs px-1">✕</button>
|
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-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'"
|
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
||||||
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
|
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
|
||||||
{{ autoTest ? '✓ Автотест' : 'Автотест' }}
|
{{ autoTest ? '✓ ' + t('projectView.auto_test_label') : t('projectView.auto_test_label') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="toggleWorktrees"
|
<button @click="toggleWorktrees"
|
||||||
class="px-2 py-1 text-xs border rounded transition-colors"
|
class="px-2 py-1 text-xs border rounded transition-colors"
|
||||||
:class="worktrees
|
:class="worktrees
|
||||||
? 'bg-teal-900/30 text-teal-400 border-teal-800 hover:bg-teal-900/50'
|
? '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'"
|
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
||||||
:title="worktrees ? 'Worktrees: on — агенты в изолированных git worktrees' : 'Worktrees: off'">
|
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
|
||||||
{{ worktrees ? '✓ Worktrees' : 'Worktrees' }}
|
{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="runAudit" :disabled="auditLoading"
|
<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"
|
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">
|
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>
|
<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>
|
||||||
<button @click="showAddTask = true"
|
<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">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1432,7 +1434,7 @@ async function addDecision() {
|
||||||
<p v-if="linkFormError" class="text-red-400 text-xs">{{ linkFormError }}</p>
|
<p v-if="linkFormError" class="text-red-400 text-xs">{{ linkFormError }}</p>
|
||||||
<div class="flex gap-2 justify-end">
|
<div class="flex gap-2 justify-end">
|
||||||
<button type="button" @click="showAddLink = false; linkFormError = ''"
|
<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"
|
<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">
|
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' }}
|
{{ linkSaving ? 'Saving...' : 'Add Link' }}
|
||||||
|
|
@ -1597,7 +1599,7 @@ async function addDecision() {
|
||||||
<p v-if="taskReviseError" class="text-red-400 text-xs">{{ taskReviseError }}</p>
|
<p v-if="taskReviseError" class="text-red-400 text-xs">{{ taskReviseError }}</p>
|
||||||
<button @click="submitTaskRevise" :disabled="taskReviseSaving"
|
<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">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { api, type Project, type ObsidianSyncResult, type ProjectLink } from '../api'
|
import { api, type Project, type ObsidianSyncResult, type ProjectLink } from '../api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const projects = ref<Project[]>([])
|
const projects = ref<Project[]>([])
|
||||||
const vaultPaths = ref<Record<string, string>>({})
|
const vaultPaths = ref<Record<string, string>>({})
|
||||||
const deployCommands = 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_restart_cmd: deployRestartCmds.value[projectId],
|
||||||
deploy_command: deployCommands.value[projectId],
|
deploy_command: deployCommands.value[projectId],
|
||||||
})
|
})
|
||||||
saveDeployConfigStatus.value[projectId] = 'Saved'
|
saveDeployConfigStatus.value[projectId] = t('common.saved')
|
||||||
} catch (e: unknown) {
|
} 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 {
|
} finally {
|
||||||
savingDeployConfig.value[projectId] = false
|
savingDeployConfig.value[projectId] = false
|
||||||
}
|
}
|
||||||
|
|
@ -84,9 +87,9 @@ async function saveVaultPath(projectId: string) {
|
||||||
saveStatus.value[projectId] = ''
|
saveStatus.value[projectId] = ''
|
||||||
try {
|
try {
|
||||||
await api.patchProject(projectId, { obsidian_vault_path: vaultPaths.value[projectId] })
|
await api.patchProject(projectId, { obsidian_vault_path: vaultPaths.value[projectId] })
|
||||||
saveStatus.value[projectId] = 'Saved'
|
saveStatus.value[projectId] = t('common.saved')
|
||||||
} catch (e: unknown) {
|
} 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 {
|
} finally {
|
||||||
saving.value[projectId] = false
|
saving.value[projectId] = false
|
||||||
}
|
}
|
||||||
|
|
@ -97,9 +100,9 @@ async function saveTestCommand(projectId: string) {
|
||||||
saveTestStatus.value[projectId] = ''
|
saveTestStatus.value[projectId] = ''
|
||||||
try {
|
try {
|
||||||
await api.patchProject(projectId, { test_command: testCommands.value[projectId] })
|
await api.patchProject(projectId, { test_command: testCommands.value[projectId] })
|
||||||
saveTestStatus.value[projectId] = 'Saved'
|
saveTestStatus.value[projectId] = t('common.saved')
|
||||||
} catch (e: unknown) {
|
} 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 {
|
} finally {
|
||||||
savingTest.value[projectId] = false
|
savingTest.value[projectId] = false
|
||||||
}
|
}
|
||||||
|
|
@ -111,10 +114,10 @@ async function toggleAutoTest(projectId: string) {
|
||||||
saveAutoTestStatus.value[projectId] = ''
|
saveAutoTestStatus.value[projectId] = ''
|
||||||
try {
|
try {
|
||||||
await api.patchProject(projectId, { auto_test_enabled: autoTestEnabled.value[projectId] })
|
await api.patchProject(projectId, { auto_test_enabled: autoTestEnabled.value[projectId] })
|
||||||
saveAutoTestStatus.value[projectId] = 'Saved'
|
saveAutoTestStatus.value[projectId] = t('common.saved')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
autoTestEnabled.value[projectId] = !autoTestEnabled.value[projectId]
|
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 {
|
} finally {
|
||||||
savingAutoTest.value[projectId] = false
|
savingAutoTest.value[projectId] = false
|
||||||
}
|
}
|
||||||
|
|
@ -126,10 +129,10 @@ async function toggleWorktrees(projectId: string) {
|
||||||
saveWorktreesStatus.value[projectId] = ''
|
saveWorktreesStatus.value[projectId] = ''
|
||||||
try {
|
try {
|
||||||
await api.patchProject(projectId, { worktrees_enabled: worktreesEnabled.value[projectId] })
|
await api.patchProject(projectId, { worktrees_enabled: worktreesEnabled.value[projectId] })
|
||||||
saveWorktreesStatus.value[projectId] = 'Saved'
|
saveWorktreesStatus.value[projectId] = t('common.saved')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
worktreesEnabled.value[projectId] = !worktreesEnabled.value[projectId]
|
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 {
|
} finally {
|
||||||
savingWorktrees.value[projectId] = false
|
savingWorktrees.value[projectId] = false
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +165,7 @@ async function loadLinks(projectId: string) {
|
||||||
|
|
||||||
async function addLink(projectId: string) {
|
async function addLink(projectId: string) {
|
||||||
const form = linkForms.value[projectId]
|
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
|
linkSaving.value[projectId] = true
|
||||||
linkError.value[projectId] = ''
|
linkError.value[projectId] = ''
|
||||||
try {
|
try {
|
||||||
|
|
@ -183,7 +186,7 @@ async function addLink(projectId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteLink(projectId: string, linkId: number) {
|
async function deleteLink(projectId: string, linkId: number) {
|
||||||
if (!confirm('Удалить связь?')) return
|
if (!confirm(t('settings.delete_link_confirm'))) return
|
||||||
try {
|
try {
|
||||||
await api.deleteProjectLink(linkId)
|
await api.deleteProjectLink(linkId)
|
||||||
await loadLinks(projectId)
|
await loadLinks(projectId)
|
||||||
|
|
@ -195,7 +198,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div class="mb-3">
|
<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
|
<input
|
||||||
v-model="vaultPaths[project.id]"
|
v-model="vaultPaths[project.id]"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -216,14 +219,14 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<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
|
<input
|
||||||
v-model="testCommands[project.id]"
|
v-model="testCommands[project.id]"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="make test"
|
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"
|
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>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 flex-wrap mb-3">
|
<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]"
|
: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"
|
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>
|
</button>
|
||||||
<span v-if="saveTestStatus[project.id]" class="text-xs" :class="saveTestStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
<span v-if="saveTestStatus[project.id]" class="text-xs" :class="saveTestStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||||
{{ saveTestStatus[project.id] }}
|
{{ saveTestStatus[project.id] }}
|
||||||
|
|
@ -241,9 +244,9 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
|
|
||||||
<!-- Deploy Config -->
|
<!-- Deploy Config -->
|
||||||
<div class="mb-2 pt-2 border-t border-gray-800">
|
<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">
|
<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
|
<input
|
||||||
v-model="deployHosts[project.id]"
|
v-model="deployHosts[project.id]"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -252,7 +255,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<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
|
<input
|
||||||
v-model="deployPaths[project.id]"
|
v-model="deployPaths[project.id]"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -261,12 +264,12 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<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
|
<select
|
||||||
v-model="deployRuntimes[project.id]"
|
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"
|
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="docker">docker</option>
|
||||||
<option value="node">node</option>
|
<option value="node">node</option>
|
||||||
<option value="python">python</option>
|
<option value="python">python</option>
|
||||||
|
|
@ -274,7 +277,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<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
|
<input
|
||||||
v-model="deployRestartCmds[project.id]"
|
v-model="deployRestartCmds[project.id]"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -283,7 +286,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<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
|
<input
|
||||||
v-model="deployCommands[project.id]"
|
v-model="deployCommands[project.id]"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -297,7 +300,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
:disabled="savingDeployConfig[project.id]"
|
: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"
|
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>
|
</button>
|
||||||
<span v-if="saveDeployConfigStatus[project.id]" class="text-xs" :class="saveDeployConfigStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
<span v-if="saveDeployConfigStatus[project.id]" class="text-xs" :class="saveDeployConfigStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||||
{{ saveDeployConfigStatus[project.id] }}
|
{{ saveDeployConfigStatus[project.id] }}
|
||||||
|
|
@ -308,28 +311,28 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
<!-- Project Links -->
|
<!-- Project Links -->
|
||||||
<div class="mb-2 pt-2 border-t border-gray-800">
|
<div class="mb-2 pt-2 border-t border-gray-800">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<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
|
<button
|
||||||
@click="showAddLinkForm[project.id] = !showAddLinkForm[project.id]"
|
@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"
|
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>
|
</button>
|
||||||
</div>
|
</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>
|
<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-else class="space-y-1 mb-2">
|
||||||
<div v-for="link in projectLinksMap[project.id]" :key="link.id"
|
<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">
|
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-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="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 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>
|
<span v-if="link.description" class="text-gray-600">{{ link.description }}</span>
|
||||||
<button @click="deleteLink(project.id, link.id)"
|
<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">
|
class="ml-auto text-red-500 hover:text-red-400 bg-transparent border-none cursor-pointer text-xs shrink-0">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<label class="block text-[10px] text-gray-500 mb-0.5">To project</label>
|
||||||
<select v-model="linkForms[project.id].to_project" required
|
<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">
|
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300">
|
||||||
<option value="">— выберите проект —</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>
|
<option v-for="p in allProjectList.filter(p => p.id !== project.id)" :key="p.id" :value="p.id">{{ p.id }} — {{ p.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -363,11 +366,11 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button type="submit" :disabled="linkSaving[project.id]"
|
<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">
|
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>
|
||||||
<button type="button" @click="showAddLinkForm[project.id] = false; linkError[project.id] = ''"
|
<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">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -382,8 +385,8 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
:disabled="savingAutoTest[project.id]"
|
:disabled="savingAutoTest[project.id]"
|
||||||
class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer disabled:opacity-50"
|
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-sm text-gray-300">{{ t('settings.auto_test') }}</span>
|
||||||
<span class="text-xs text-gray-500">— запускать тесты автоматически после pipeline</span>
|
<span class="text-xs text-gray-500">{{ t('settings.auto_test_hint') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span v-if="saveAutoTestStatus[project.id]" class="text-xs" :class="saveAutoTestStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
<span v-if="saveAutoTestStatus[project.id]" class="text-xs" :class="saveAutoTestStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||||
{{ saveAutoTestStatus[project.id] }}
|
{{ saveAutoTestStatus[project.id] }}
|
||||||
|
|
@ -399,8 +402,8 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
:disabled="savingWorktrees[project.id]"
|
:disabled="savingWorktrees[project.id]"
|
||||||
class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer disabled:opacity-50"
|
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-sm text-gray-300">{{ t('settings.worktrees') }}</span>
|
||||||
<span class="text-xs text-gray-500">— агенты запускаются в изолированных git worktrees</span>
|
<span class="text-xs text-gray-500">{{ t('settings.worktrees_hint') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<span v-if="saveWorktreesStatus[project.id]" class="text-xs" :class="saveWorktreesStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
<span v-if="saveWorktreesStatus[project.id]" class="text-xs" :class="saveWorktreesStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||||
{{ saveWorktreesStatus[project.id] }}
|
{{ saveWorktreesStatus[project.id] }}
|
||||||
|
|
@ -413,7 +416,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
:disabled="saving[project.id]"
|
: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"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -421,7 +424,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
||||||
:disabled="syncing[project.id] || !vaultPaths[project.id]"
|
: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"
|
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>
|
</button>
|
||||||
|
|
||||||
<span v-if="saveStatus[project.id]" class="text-xs" :class="saveStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
<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">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 { api, ApiError, type TaskFull, type PipelineStep, type PendingAction, type DeployResult, type Attachment } from '../api'
|
||||||
import Badge from '../components/Badge.vue'
|
import Badge from '../components/Badge.vue'
|
||||||
import Modal from '../components/Modal.vue'
|
import Modal from '../components/Modal.vue'
|
||||||
|
|
@ -11,6 +12,7 @@ import LiveConsole from '../components/LiveConsole.vue'
|
||||||
const props = defineProps<{ id: string }>()
|
const props = defineProps<{ id: string }>()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const task = ref<TaskFull | null>(null)
|
const task = ref<TaskFull | null>(null)
|
||||||
const loading = ref(true)
|
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)
|
// Auto/Review mode (per-task, persisted in DB; falls back to localStorage per project)
|
||||||
const autoMode = ref(false)
|
const autoMode = ref(false)
|
||||||
|
|
||||||
function loadMode(t: typeof task.value) {
|
function loadMode(t_val: typeof task.value) {
|
||||||
if (!t) return
|
if (!t_val) return
|
||||||
if (t.execution_mode) {
|
if (t_val.execution_mode) {
|
||||||
autoMode.value = t.execution_mode === 'auto_complete'
|
autoMode.value = t_val.execution_mode === 'auto_complete'
|
||||||
} else if (t.status === 'review') {
|
} else if (t_val.status === 'review') {
|
||||||
// Task is in review — always show Approve/Reject regardless of localStorage
|
|
||||||
autoMode.value = false
|
autoMode.value = false
|
||||||
} else {
|
} 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
|
const prev = task.value
|
||||||
task.value = await api.taskFull(props.id)
|
task.value = await api.taskFull(props.id)
|
||||||
loadMode(task.value)
|
loadMode(task.value)
|
||||||
// Auto-start polling if task is in_progress
|
|
||||||
if (task.value.status === 'in_progress' && !polling.value) {
|
if (task.value.status === 'in_progress' && !polling.value) {
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
// Stop polling when pipeline done
|
|
||||||
if (prev?.status === 'in_progress' && task.value.status !== 'in_progress') {
|
if (prev?.status === 'in_progress' && task.value.status !== 'in_progress') {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +240,6 @@ async function runPipeline() {
|
||||||
claudeLoginError.value = false
|
claudeLoginError.value = false
|
||||||
pipelineStarting.value = true
|
pipelineStarting.value = true
|
||||||
try {
|
try {
|
||||||
// Sync task execution_mode with current toggle state before running
|
|
||||||
const targetMode = autoMode.value ? 'auto_complete' : 'review'
|
const targetMode = autoMode.value ? 'auto_complete' : 'review'
|
||||||
if (task.value && task.value.execution_mode !== targetMode) {
|
if (task.value && task.value.execution_mode !== targetMode) {
|
||||||
const updated = await api.patchTask(props.id, { 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') {
|
if (e instanceof ApiError && e.code === 'claude_auth_required') {
|
||||||
claudeLoginError.value = true
|
claudeLoginError.value = true
|
||||||
} else if (e instanceof ApiError && e.code === 'task_already_running') {
|
} else if (e instanceof ApiError && e.code === 'task_already_running') {
|
||||||
error.value = 'Pipeline уже запущен'
|
error.value = t('taskDetail.pipeline_already_running')
|
||||||
} else {
|
} else {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
}
|
}
|
||||||
|
|
@ -271,7 +269,7 @@ const resolvingManually = ref(false)
|
||||||
|
|
||||||
async function resolveManually() {
|
async function resolveManually() {
|
||||||
if (!task.value) return
|
if (!task.value) return
|
||||||
if (!confirm('Пометить задачу как решённую вручную?')) return
|
if (!confirm(t('taskDetail.mark_resolved_confirm'))) return
|
||||||
resolvingManually.value = true
|
resolvingManually.value = true
|
||||||
try {
|
try {
|
||||||
const updated = await api.patchTask(props.id, { status: 'done' })
|
const updated = await api.patchTask(props.id, { status: 'done' })
|
||||||
|
|
@ -386,7 +384,7 @@ async function saveEdit() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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="error && !task" class="text-red-400 text-sm">{{ error }}</div>
|
||||||
<div v-else-if="task">
|
<div v-else-if="task">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|
@ -422,7 +420,7 @@ async function saveEdit() {
|
||||||
<!-- Manual escalation context banner -->
|
<!-- 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 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">
|
<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">
|
<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">
|
<router-link :to="`/task/${task.parent_task_id}`" class="text-orange-600 hover:text-orange-400">
|
||||||
|
|
@ -432,15 +430,15 @@ async function saveEdit() {
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-orange-300">{{ task.title }}</p>
|
<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 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>
|
</div>
|
||||||
|
|
||||||
<!-- Dangerous skip warning banner -->
|
<!-- 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">
|
<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>
|
<span class="text-red-400 text-base shrink-0">⚠</span>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs font-semibold text-red-400">--dangerously-skip-permissions использовался в этой задаче</span>
|
<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">Агент выполнял команды с обходом проверок разрешений. Проверьте pipeline-шаги и сделанные изменения.</p>
|
<p class="text-xs text-red-300/70 mt-0.5">{{ t('taskDetail.dangerously_skipped_hint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -448,7 +446,7 @@ async function saveEdit() {
|
||||||
Brief: {{ JSON.stringify(task.brief) }}
|
Brief: {{ JSON.stringify(task.brief) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="task.acceptance_criteria" class="mb-2 px-3 py-2 border border-gray-700 bg-gray-900/40 rounded">
|
<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>
|
<p class="text-xs text-gray-300 whitespace-pre-wrap">{{ task.acceptance_criteria }}</p>
|
||||||
</div>
|
</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">
|
<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 -->
|
<!-- Pipeline Graph -->
|
||||||
<div v-if="hasSteps || isRunning" class="mb-6">
|
<div v-if="hasSteps || isRunning" class="mb-6">
|
||||||
<h2 class="text-sm font-semibold text-gray-300 mb-3">
|
<h2 class="text-sm font-semibold text-gray-300 mb-3">
|
||||||
Pipeline
|
{{ t('taskDetail.pipeline') }}
|
||||||
<span v-if="isRunning" class="text-blue-400 text-xs font-normal ml-2 animate-pulse">running...</span>
|
<span v-if="isRunning" class="text-blue-400 text-xs font-normal ml-2 animate-pulse">{{ t('taskDetail.running') }}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex items-center gap-1 overflow-x-auto pb-2">
|
<div class="flex items-center gap-1 overflow-x-auto pb-2">
|
||||||
<template v-for="(step, i) in task.pipeline_steps" :key="step.id">
|
<template v-for="(step, i) in task.pipeline_steps" :key="step.id">
|
||||||
|
|
@ -493,7 +491,7 @@ async function saveEdit() {
|
||||||
|
|
||||||
<!-- No pipeline -->
|
<!-- No pipeline -->
|
||||||
<div v-if="!hasSteps && !isRunning" class="mb-6 text-sm text-gray-600">
|
<div v-if="!hasSteps && !isRunning" class="mb-6 text-sm text-gray-600">
|
||||||
No pipeline steps yet.
|
{{ t('taskDetail.no_pipeline') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live Console -->
|
<!-- Live Console -->
|
||||||
|
|
@ -516,7 +514,7 @@ async function saveEdit() {
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<p class="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap">{{ parsedSelectedOutput.verdict }}</p>
|
<p class="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap">{{ parsedSelectedOutput.verdict }}</p>
|
||||||
<details v-if="parsedSelectedOutput.details !== null" class="mt-3">
|
<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>
|
<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>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -542,7 +540,7 @@ async function saveEdit() {
|
||||||
|
|
||||||
<!-- Attachments -->
|
<!-- Attachments -->
|
||||||
<div class="mb-6">
|
<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" />
|
<AttachmentList :attachments="attachments" :task-id="props.id" @deleted="loadAttachments" />
|
||||||
<AttachmentUploader :task-id="props.id" @uploaded="loadAttachments" />
|
<AttachmentUploader :task-id="props.id" @uploaded="loadAttachments" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -552,22 +550,22 @@ async function saveEdit() {
|
||||||
<div v-if="autoMode && (isRunning || task.status === 'review')"
|
<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">
|
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>
|
<span class="inline-block w-2 h-2 bg-yellow-400 rounded-full animate-pulse"></span>
|
||||||
Автопилот активен
|
{{ t('taskDetail.autopilot_active') }}
|
||||||
</div>
|
</div>
|
||||||
<button v-if="task.status === 'review' && !autoMode"
|
<button v-if="task.status === 'review' && !autoMode"
|
||||||
@click="showApprove = true"
|
@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">
|
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>
|
||||||
<button v-if="task.status === 'review' && !autoMode"
|
<button v-if="task.status === 'review' && !autoMode"
|
||||||
@click="showRevise = true"
|
@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">
|
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>
|
||||||
<button v-if="(task.status === 'review' || task.status === 'in_progress') && !autoMode"
|
<button v-if="(task.status === 'review' || task.status === 'in_progress') && !autoMode"
|
||||||
@click="showReject = true"
|
@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">
|
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>
|
||||||
<button v-if="task.status === 'pending' || task.status === 'blocked' || task.status === 'review'"
|
<button v-if="task.status === 'pending' || task.status === 'blocked' || task.status === 'review'"
|
||||||
@click="toggleMode"
|
@click="toggleMode"
|
||||||
|
|
@ -581,28 +579,28 @@ async function saveEdit() {
|
||||||
<button v-if="task.status === 'pending'"
|
<button v-if="task.status === 'pending'"
|
||||||
@click="openEdit"
|
@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">
|
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>
|
||||||
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
||||||
@click="runPipeline"
|
@click="runPipeline"
|
||||||
:disabled="polling || pipelineStarting"
|
: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">
|
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>
|
<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>
|
||||||
<button v-if="isManualEscalation && task.status !== 'done' && task.status !== 'cancelled'"
|
<button v-if="isManualEscalation && task.status !== 'done' && task.status !== 'cancelled'"
|
||||||
@click="resolveManually"
|
@click="resolveManually"
|
||||||
:disabled="resolvingManually"
|
: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">
|
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>
|
<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>
|
||||||
<button v-if="task.status === 'done' && (task.project_deploy_command || task.project_deploy_runtime)"
|
<button v-if="task.status === 'done' && (task.project_deploy_command || task.project_deploy_runtime)"
|
||||||
@click.stop="runDeploy"
|
@click.stop="runDeploy"
|
||||||
:disabled="deploying"
|
: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">
|
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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -611,9 +609,9 @@ async function saveEdit() {
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-yellow-300">⚠ Claude CLI requires login</p>
|
<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>
|
<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>
|
</div>
|
||||||
<button @click="claudeLoginError = false" class="text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs shrink-0">✕</button>
|
<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>
|
||||||
|
|
@ -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'">
|
: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">
|
<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">
|
<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>
|
||||||
<span class="text-gray-500">{{ deployResult.duration_seconds }}s</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>
|
<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>
|
</template>
|
||||||
<!-- Dependents -->
|
<!-- Dependents -->
|
||||||
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
|
<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">
|
<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-teal-400 font-semibold text-[10px]">ok</span>
|
||||||
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
|
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
|
||||||
|
|
@ -704,9 +702,9 @@ async function saveEdit() {
|
||||||
Create follow-up tasks from pipeline results
|
Create follow-up tasks from pipeline results
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs text-gray-500">Optionally record a decision:</p>
|
<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" />
|
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>
|
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"
|
<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">
|
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>
|
</Modal>
|
||||||
|
|
||||||
<!-- Revise 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">
|
<form @submit.prevent="revise" class="space-y-3">
|
||||||
<p class="text-xs text-gray-500">Опишите, что доработать или уточнить агенту. Задача вернётся в работу с вашим комментарием.</p>
|
<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>
|
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"
|
<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">
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
@ -744,30 +742,30 @@ async function saveEdit() {
|
||||||
<Modal v-if="showEdit" title="Edit Task" @close="showEdit = false">
|
<Modal v-if="showEdit" title="Edit Task" @close="showEdit = false">
|
||||||
<form @submit.prevent="saveEdit" class="space-y-3">
|
<form @submit.prevent="saveEdit" class="space-y-3">
|
||||||
<div>
|
<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
|
<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" />
|
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>
|
||||||
<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..."
|
<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>
|
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>
|
||||||
<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
|
<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" />
|
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200" />
|
||||||
</div>
|
</div>
|
||||||
<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"
|
<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>
|
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>
|
||||||
<p v-if="editError" class="text-red-400 text-xs">{{ editError }}</p>
|
<p v-if="editError" class="text-red-400 text-xs">{{ editError }}</p>
|
||||||
<button type="submit" :disabled="editLoading"
|
<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">
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue