kin: KIN-108-frontend_dev
This commit is contained in:
parent
8b409fd7db
commit
353416ead1
16 changed files with 799 additions and 212 deletions
|
|
@ -1,5 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -10,8 +19,12 @@ import EscalationBanner from './components/EscalationBanner.vue'
|
|||
</router-link>
|
||||
<nav class="flex items-center gap-4">
|
||||
<EscalationBanner />
|
||||
<router-link to="/settings" class="text-xs text-gray-400 hover:text-gray-200 no-underline">Settings</router-link>
|
||||
<span class="text-xs text-gray-600">multi-agent orchestrator</span>
|
||||
<button
|
||||
@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>
|
||||
</header>
|
||||
<main class="px-6 py-6">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api, type Attachment } from '../api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{ attachments: Attachment[]; taskId: string }>()
|
||||
const emit = defineEmits<{ deleted: [] }>()
|
||||
|
||||
|
|
@ -48,7 +51,7 @@ function formatSize(bytes: number): string {
|
|||
@click="remove(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"
|
||||
title="Удалить"
|
||||
:title="t('attachments.delete_title')"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api } from '../api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{ taskId: string }>()
|
||||
const emit = defineEmits<{ uploaded: [] }>()
|
||||
|
||||
|
|
@ -12,7 +15,7 @@ const fileInput = ref<HTMLInputElement | null>(null)
|
|||
|
||||
async function upload(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error.value = 'Поддерживаются только изображения'
|
||||
error.value = t('attachments.images_only')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
|
|
@ -52,10 +55,10 @@ function onDrop(event: DragEvent) {
|
|||
<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">
|
||||
<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 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>
|
||||
<p v-if="error" class="text-red-400 text-xs mt-1">{{ error }}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api, type EscalationNotification } from '../api'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const STORAGE_KEY = 'kin_dismissed_escalations'
|
||||
const WATCHDOG_TOAST_KEY = 'kin_dismissed_watchdog_toasts'
|
||||
|
||||
|
|
@ -106,7 +109,7 @@ function dismissAll() {
|
|||
|
||||
function formatTime(iso: string): string {
|
||||
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 {
|
||||
return iso
|
||||
}
|
||||
|
|
@ -136,7 +139,7 @@ onUnmounted(() => {
|
|||
>
|
||||
<span class="shrink-0 text-sm">⚠</span>
|
||||
<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>
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<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">
|
||||
<button
|
||||
@click="dismissAll"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -193,7 +196,7 @@ onUnmounted(() => {
|
|||
<button
|
||||
@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"
|
||||
>Принято</button>
|
||||
>{{ t('escalation.dismiss') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api, type PipelineLog } from '../api'
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -7,6 +8,8 @@ const props = defineProps<{
|
|||
pipelineStatus: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const visible = ref(false)
|
||||
const logs = ref<PipelineLog[]>([])
|
||||
const error = ref('')
|
||||
|
|
@ -102,12 +105,12 @@ onUnmounted(() => {
|
|||
@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"
|
||||
>
|
||||
{{ visible ? '▲ Скрыть лог' : '▼ Показать лог' }}
|
||||
{{ visible ? t('liveConsole.hide_log') : t('liveConsole.show_log') }}
|
||||
</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-if="!logs.length && !error" class="text-gray-600">Нет записей...</div>
|
||||
<div v-if="error" class="text-red-400">Ошибка: {{ error }}</div>
|
||||
<div v-if="!logs.length && !error" class="text-gray-600">{{ t('liveConsole.no_records') }}</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">
|
||||
<span class="text-gray-600">{{ log.ts }}</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 SettingsView from './views/SettingsView.vue'
|
||||
import ChatView from './views/ChatView.vue'
|
||||
import { i18n } from './i18n'
|
||||
|
||||
const router = createRouter({
|
||||
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">
|
||||
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api, ApiError, type ChatMessage } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
|
||||
const props = defineProps<{ projectId: string }>()
|
||||
const router = useRouter()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const input = ref('')
|
||||
|
|
@ -43,9 +45,9 @@ function checkAndPoll() {
|
|||
if (!hasRunningTasks(updated)) stopPoll()
|
||||
} catch (e: any) {
|
||||
consecutiveErrors.value++
|
||||
console.warn(`[polling] ошибка #${consecutiveErrors.value}:`, e)
|
||||
console.warn('[polling] error #' + consecutiveErrors.value + ':', e)
|
||||
if (consecutiveErrors.value >= 3) {
|
||||
error.value = 'Сервер недоступен. Проверьте подключение.'
|
||||
error.value = t('chat.server_unavailable')
|
||||
stopPoll()
|
||||
}
|
||||
}
|
||||
|
|
@ -134,7 +136,7 @@ function taskStatusColor(status: string): string {
|
|||
}
|
||||
|
||||
function formatTime(dt: string) {
|
||||
return new Date(dt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
return new Date(dt).toLocaleTimeString(locale.value === 'ru' ? 'ru-RU' : 'en-US', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -145,12 +147,12 @@ function formatTime(dt: string) {
|
|||
<router-link
|
||||
:to="`/project/${projectId}`"
|
||||
class="text-gray-400 hover:text-gray-200 text-sm no-underline"
|
||||
>← Проект</router-link>
|
||||
>{{ t('chat.back_to_project') }}</router-link>
|
||||
<span class="text-gray-600">|</span>
|
||||
<h1 class="text-base font-semibold text-gray-100">
|
||||
{{ projectName || projectId }}
|
||||
</h1>
|
||||
<span class="text-xs text-gray-500 ml-1">— чат</span>
|
||||
<span class="text-xs text-gray-500 ml-1">{{ t('chat.chat_label') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
|
|
@ -160,7 +162,7 @@ function formatTime(dt: string) {
|
|||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
||||
<span class="text-gray-500 text-sm">Загрузка...</span>
|
||||
<span class="text-gray-500 text-sm">{{ t('chat.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
|
|
@ -170,7 +172,7 @@ function formatTime(dt: string) {
|
|||
class="flex-1 overflow-y-auto py-4 flex flex-col gap-3 min-h-0"
|
||||
>
|
||||
<div v-if="messages.length === 0" class="text-center text-gray-500 text-sm mt-8">
|
||||
Опишите задачу или спросите о статусе проекта
|
||||
{{ t('chat.empty_hint') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -209,7 +211,7 @@ function formatTime(dt: string) {
|
|||
<textarea
|
||||
v-model="input"
|
||||
:disabled="sending || loading"
|
||||
placeholder="Опишите задачу или вопрос... (Enter — отправить, Shift+Enter — перенос)"
|
||||
:placeholder="t('chat.input_placeholder')"
|
||||
rows="2"
|
||||
class="flex-1 bg-gray-800/60 border border-gray-700 rounded-xl px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 resize-none focus:outline-none focus:border-indigo-600 disabled:opacity-50"
|
||||
@keydown="onKeydown"
|
||||
|
|
@ -219,7 +221,7 @@ function formatTime(dt: string) {
|
|||
class="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm rounded-xl font-medium transition-colors"
|
||||
@click="send"
|
||||
>
|
||||
{{ sending ? '...' : 'Отправить' }}
|
||||
{{ sending ? t('chat.sending') : t('chat.send') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api, type Project, type CostEntry } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const costs = ref<CostEntry[]>([])
|
||||
const loading = ref(true)
|
||||
|
|
@ -25,12 +28,12 @@ const bsResult = ref('')
|
|||
|
||||
// New Project with Research modal
|
||||
const RESEARCH_ROLES = [
|
||||
{ key: 'business_analyst', label: 'Business Analyst', hint: 'бизнес-модель, аудитория, монетизация' },
|
||||
{ key: 'market_researcher', label: 'Market Researcher', hint: 'конкуренты, ниша, сильные/слабые стороны' },
|
||||
{ key: 'legal_researcher', label: 'Legal Researcher', hint: 'юрисдикция, лицензии, KYC/AML, GDPR' },
|
||||
{ key: 'tech_researcher', label: 'Tech Researcher', hint: 'API, ограничения, стоимость, альтернативы' },
|
||||
{ key: 'ux_designer', label: 'UX Designer', hint: 'анализ UX конкурентов, user journey, wireframes' },
|
||||
{ key: 'marketer', label: 'Marketer', hint: 'стратегия продвижения, SEO, conversion-паттерны' },
|
||||
{ key: 'business_analyst' },
|
||||
{ key: 'market_researcher' },
|
||||
{ key: 'legal_researcher' },
|
||||
{ key: 'tech_researcher' },
|
||||
{ key: 'ux_designer' },
|
||||
{ key: 'marketer' },
|
||||
]
|
||||
const showNewProject = ref(false)
|
||||
const npForm = ref({
|
||||
|
|
@ -55,7 +58,6 @@ let dashPollTimer: ReturnType<typeof setInterval> | null = null
|
|||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
// Poll if there are running tasks
|
||||
checkAndPoll()
|
||||
})
|
||||
|
||||
|
|
@ -87,11 +89,11 @@ function statusColor(s: string) {
|
|||
async function addProject() {
|
||||
formError.value = ''
|
||||
if (form.value.project_type === 'operations' && !form.value.ssh_host) {
|
||||
formError.value = 'SSH host is required for operations projects'
|
||||
formError.value = t('dashboard.ssh_host_required')
|
||||
return
|
||||
}
|
||||
if (form.value.project_type !== 'operations' && !form.value.path) {
|
||||
formError.value = 'Path is required'
|
||||
formError.value = t('dashboard.path_required')
|
||||
return
|
||||
}
|
||||
try {
|
||||
|
|
@ -157,7 +159,7 @@ async function deleteProject(id: string) {
|
|||
async function createNewProject() {
|
||||
npError.value = ''
|
||||
if (!npRoles.value.length) {
|
||||
npError.value = 'Выберите хотя бы одну роль'
|
||||
npError.value = t('dashboard.role_error')
|
||||
return
|
||||
}
|
||||
npSaving.value = true
|
||||
|
|
@ -189,26 +191,26 @@ async function createNewProject() {
|
|||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-100">Dashboard</h1>
|
||||
<p class="text-sm text-gray-500" v-if="totalCost > 0">Cost this week: ${{ totalCost.toFixed(2) }}</p>
|
||||
<h1 class="text-xl font-bold text-gray-100">{{ t('dashboard.title') }}</h1>
|
||||
<p class="text-sm text-gray-500" v-if="totalCost > 0">{{ t('dashboard.cost_this_week') }}: ${{ totalCost.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="showBootstrap = true"
|
||||
class="px-3 py-1.5 text-xs bg-purple-900/50 text-purple-400 border border-purple-800 rounded hover:bg-purple-900">
|
||||
Bootstrap
|
||||
{{ t('dashboard.bootstrap') }}
|
||||
</button>
|
||||
<button @click="showNewProject = true"
|
||||
class="px-3 py-1.5 text-xs bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900">
|
||||
+ New Project
|
||||
{{ t('dashboard.new_project') }}
|
||||
</button>
|
||||
<button @click="showAdd = true"
|
||||
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
||||
+ Blank
|
||||
{{ t('dashboard.blank') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="text-gray-500 text-sm">Loading...</p>
|
||||
<p v-if="loading" class="text-gray-500 text-sm">{{ t('dashboard.loading') }}</p>
|
||||
<p v-else-if="error" class="text-red-400 text-sm">{{ error }}</p>
|
||||
|
||||
<div v-else class="grid gap-3">
|
||||
|
|
@ -216,17 +218,17 @@ async function createNewProject() {
|
|||
<!-- Inline delete confirmation -->
|
||||
<div v-if="confirmDeleteId === p.id"
|
||||
class="border border-red-800 rounded-lg p-4 bg-red-950/20">
|
||||
<p class="text-sm text-gray-200 mb-3">Удалить проект «{{ p.name }}»? Это действие необратимо.</p>
|
||||
<p class="text-sm text-gray-200 mb-3">{{ t('dashboard.delete_confirm', { name: p.name }) }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="deleteProject(p.id)"
|
||||
title="Подтвердить удаление"
|
||||
:title="t('dashboard.delete_project_title')"
|
||||
class="px-3 py-1.5 text-xs bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
||||
Да, удалить
|
||||
{{ t('common.yes_delete') }}
|
||||
</button>
|
||||
<button @click="confirmDeleteId = null"
|
||||
title="Отмена удаления"
|
||||
:title="t('dashboard.cancel_delete_title')"
|
||||
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700">
|
||||
Отмена
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="deleteError" class="text-red-400 text-xs mt-2">{{ deleteError }}</p>
|
||||
|
|
@ -249,7 +251,7 @@ async function createNewProject() {
|
|||
<span v-if="costMap[p.id]">${{ costMap[p.id]?.toFixed(2) }}/wk</span>
|
||||
<span>pri {{ p.priority }}</span>
|
||||
<button @click.prevent.stop="confirmDeleteId = p.id"
|
||||
title="Удалить проект"
|
||||
:title="t('common.delete')"
|
||||
class="text-gray-600 hover:text-red-400 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
|
|
@ -275,82 +277,82 @@ async function createNewProject() {
|
|||
</div>
|
||||
|
||||
<!-- Add Project Modal -->
|
||||
<Modal v-if="showAdd" title="Add Project" @close="showAdd = false">
|
||||
<Modal v-if="showAdd" :title="t('dashboard.add_project_title')" @close="showAdd = false">
|
||||
<form @submit.prevent="addProject" class="space-y-3">
|
||||
<input v-model="form.id" placeholder="ID (e.g. vdol)" required
|
||||
<input v-model="form.id" :placeholder="t('dashboard.id_placeholder')" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="form.name" placeholder="Name" required
|
||||
<input v-model="form.name" :placeholder="t('dashboard.name_placeholder')" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<!-- Project type selector -->
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1.5">Тип проекта:</p>
|
||||
<p class="text-xs text-gray-500 mb-1.5">{{ t('dashboard.project_type_label') }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button v-for="t in ['development', 'operations', 'research']" :key="t"
|
||||
<button v-for="t_type in ['development', 'operations', 'research']" :key="t_type"
|
||||
type="button"
|
||||
@click="form.project_type = t"
|
||||
@click="form.project_type = t_type"
|
||||
class="flex-1 py-1.5 text-xs border rounded transition-colors"
|
||||
:class="form.project_type === t
|
||||
? t === 'development' ? 'bg-blue-900/40 text-blue-300 border-blue-700'
|
||||
: t === 'operations' ? 'bg-orange-900/40 text-orange-300 border-orange-700'
|
||||
:class="form.project_type === t_type
|
||||
? t_type === 'development' ? 'bg-blue-900/40 text-blue-300 border-blue-700'
|
||||
: t_type === 'operations' ? 'bg-orange-900/40 text-orange-300 border-orange-700'
|
||||
: 'bg-green-900/40 text-green-300 border-green-700'
|
||||
: 'bg-gray-900 text-gray-500 border-gray-800 hover:text-gray-300 hover:border-gray-600'"
|
||||
>{{ t }}</button>
|
||||
>{{ t_type }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Path (development / research) -->
|
||||
<input v-if="form.project_type !== 'operations'"
|
||||
v-model="form.path" placeholder="Path (e.g. ~/projects/myproj)"
|
||||
v-model="form.path" :placeholder="t('dashboard.path_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<!-- SSH fields (operations) -->
|
||||
<template v-if="form.project_type === 'operations'">
|
||||
<input v-model="form.ssh_host" placeholder="SSH host (e.g. 192.168.1.1)" required
|
||||
<input v-model="form.ssh_host" :placeholder="t('dashboard.ssh_host_placeholder')" required
|
||||
class="w-full bg-gray-800 border border-orange-800/60 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input v-model="form.ssh_user" placeholder="SSH user (e.g. root)"
|
||||
<input v-model="form.ssh_user" :placeholder="t('dashboard.ssh_user_placeholder')"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="form.ssh_key_path" placeholder="Key path (e.g. ~/.ssh/id_rsa)"
|
||||
<input v-model="form.ssh_key_path" :placeholder="t('dashboard.ssh_key_placeholder')"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<input v-model="form.ssh_proxy_jump" placeholder="ProxyJump (optional, e.g. jumpt)"
|
||||
<input v-model="form.ssh_proxy_jump" :placeholder="t('dashboard.proxy_jump_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<p class="mt-1 flex items-center gap-1 text-xs text-gray-500">
|
||||
<svg class="w-3 h-3 flex-shrink-0 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Алиас из ~/.ssh/config на сервере Kin
|
||||
{{ t('dashboard.ssh_alias_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<input v-model="form.tech_stack" placeholder="Tech stack (comma-separated)"
|
||||
<input v-model="form.tech_stack" :placeholder="t('dashboard.tech_stack_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model.number="form.priority" type="number" min="1" max="10" placeholder="Priority (1-10)"
|
||||
<input v-model.number="form.priority" type="number" min="1" max="10" :placeholder="t('dashboard.priority_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<p v-if="formError" class="text-red-400 text-xs">{{ formError }}</p>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900">
|
||||
Create
|
||||
{{ t('dashboard.create_btn') }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- New Project with Research Modal -->
|
||||
<Modal v-if="showNewProject" title="New Project — Start Research" @close="showNewProject = false">
|
||||
<Modal v-if="showNewProject" :title="t('dashboard.new_project_title')" @close="showNewProject = false">
|
||||
<form @submit.prevent="createNewProject" class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input v-model="npForm.id" placeholder="ID (e.g. myapp)" required
|
||||
<input v-model="npForm.id" :placeholder="t('dashboard.id_placeholder')" required
|
||||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="npForm.name" placeholder="Name" required
|
||||
<input v-model="npForm.name" :placeholder="t('dashboard.name_placeholder')" required
|
||||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
</div>
|
||||
<input v-model="npForm.path" placeholder="Path (e.g. ~/projects/myapp)"
|
||||
<input v-model="npForm.path" :placeholder="t('dashboard.path_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<textarea v-model="npForm.description" placeholder="Описание проекта (свободный текст для агентов)" required rows="4"
|
||||
<textarea v-model="npForm.description" :placeholder="t('dashboard.project_description_placeholder')" required rows="4"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-none"></textarea>
|
||||
<input v-model="npForm.tech_stack" placeholder="Tech stack (comma-separated, optional)"
|
||||
<input v-model="npForm.tech_stack" :placeholder="t('dashboard.tech_stack_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-2">Этапы research (Architect добавляется автоматически последним):</p>
|
||||
<p class="text-xs text-gray-500 mb-2">{{ t('dashboard.research_stages') }}</p>
|
||||
<div class="space-y-1.5">
|
||||
<label v-for="r in RESEARCH_ROLES" :key="r.key"
|
||||
class="flex items-start gap-2 cursor-pointer group">
|
||||
|
|
@ -359,15 +361,15 @@ async function createNewProject() {
|
|||
@change="toggleNpRole(r.key)"
|
||||
class="mt-0.5 accent-green-500 cursor-pointer" />
|
||||
<div>
|
||||
<span class="text-sm text-gray-300 group-hover:text-gray-100">{{ r.label }}</span>
|
||||
<span class="text-xs text-gray-600 ml-1">— {{ r.hint }}</span>
|
||||
<span class="text-sm text-gray-300 group-hover:text-gray-100">{{ t(`dashboard.roles.${r.key}.label`) }}</span>
|
||||
<span class="text-xs text-gray-600 ml-1">— {{ t(`dashboard.roles.${r.key}.hint`) }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start gap-2 opacity-50">
|
||||
<input type="checkbox" checked disabled class="mt-0.5" />
|
||||
<div>
|
||||
<span class="text-sm text-gray-400">Architect</span>
|
||||
<span class="text-xs text-gray-600 ml-1">— blueprint на основе одобренных исследований</span>
|
||||
<span class="text-sm text-gray-400">{{ t('dashboard.roles.architect.label') }}</span>
|
||||
<span class="text-xs text-gray-600 ml-1">— {{ t('dashboard.architect_hint') }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -375,25 +377,25 @@ async function createNewProject() {
|
|||
<p v-if="npError" class="text-red-400 text-xs">{{ npError }}</p>
|
||||
<button type="submit" :disabled="npSaving"
|
||||
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900 disabled:opacity-50">
|
||||
{{ npSaving ? 'Starting...' : 'Start Research' }}
|
||||
{{ npSaving ? t('dashboard.starting') : t('dashboard.start_research') }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Bootstrap Modal -->
|
||||
<Modal v-if="showBootstrap" title="Bootstrap Project" @close="showBootstrap = false">
|
||||
<Modal v-if="showBootstrap" :title="t('dashboard.bootstrap_title')" @close="showBootstrap = false">
|
||||
<form @submit.prevent="runBootstrap" class="space-y-3">
|
||||
<input v-model="bsForm.path" placeholder="Project path (e.g. ~/projects/vdolipoperek)" required
|
||||
<input v-model="bsForm.path" :placeholder="t('dashboard.bootstrap_path_placeholder')" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="bsForm.id" placeholder="ID (e.g. vdol)" required
|
||||
<input v-model="bsForm.id" :placeholder="t('dashboard.id_placeholder')" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="bsForm.name" placeholder="Name" required
|
||||
<input v-model="bsForm.name" :placeholder="t('dashboard.name_placeholder')" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<p v-if="bsError" class="text-red-400 text-xs">{{ bsError }}</p>
|
||||
<p v-if="bsResult" class="text-green-400 text-xs">{{ bsResult }}</p>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-purple-900/50 text-purple-400 border border-purple-800 rounded text-sm hover:bg-purple-900">
|
||||
Bootstrap
|
||||
{{ t('dashboard.bootstrap_btn') }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment, type DeployResult, type ProjectLink } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
|
@ -8,6 +9,7 @@ import Modal from '../components/Modal.vue'
|
|||
const props = defineProps<{ id: string }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const project = ref<ProjectDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
|
|
@ -48,7 +50,7 @@ function openTaskRevise(taskId: string) {
|
|||
}
|
||||
|
||||
async function submitTaskRevise() {
|
||||
if (!taskReviseComment.value.trim()) { taskReviseError.value = 'Комментарий обязателен'; return }
|
||||
if (!taskReviseComment.value.trim()) { taskReviseError.value = t('projectView.comment_required'); return }
|
||||
taskReviseSaving.value = true
|
||||
try {
|
||||
await api.reviseTask(taskReviseTaskId.value!, taskReviseComment.value)
|
||||
|
|
@ -378,7 +380,7 @@ async function submitEnv() {
|
|||
}
|
||||
|
||||
async function deleteEnv(envId: number) {
|
||||
if (!confirm('Удалить среду?')) return
|
||||
if (!confirm(t('projectView.delete_env_confirm'))) return
|
||||
try {
|
||||
await api.deleteEnvironment(props.id, envId)
|
||||
await loadEnvironments()
|
||||
|
|
@ -431,7 +433,7 @@ async function loadLinks() {
|
|||
|
||||
async function addLink() {
|
||||
linkFormError.value = ''
|
||||
if (!linkForm.value.to_project) { linkFormError.value = 'Выберите проект'; return }
|
||||
if (!linkForm.value.to_project) { linkFormError.value = t('projectView.select_project'); return }
|
||||
linkSaving.value = true
|
||||
try {
|
||||
await api.createProjectLink({
|
||||
|
|
@ -451,7 +453,7 @@ async function addLink() {
|
|||
}
|
||||
|
||||
async function deleteLink(id: number) {
|
||||
if (!confirm('Удалить связь?')) return
|
||||
if (!confirm(t('projectView.delete_link_confirm'))) return
|
||||
try {
|
||||
await api.deleteProjectLink(id)
|
||||
await loadLinks()
|
||||
|
|
@ -649,7 +651,7 @@ const runningTaskId = ref<string | null>(null)
|
|||
async function runTask(taskId: string, event: Event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!confirm(`Run pipeline for ${taskId}?`)) return
|
||||
if (!confirm(t('projectView.run_pipeline_confirm', { n: taskId }))) return
|
||||
runningTaskId.value = taskId
|
||||
try {
|
||||
// Sync task execution_mode with current project toggle state before running
|
||||
|
|
@ -659,7 +661,7 @@ async function runTask(taskId: string, event: Event) {
|
|||
if (activeTab.value === 'kanban') checkAndPollKanban()
|
||||
} catch (e: any) {
|
||||
if (e instanceof ApiError && e.code === 'task_already_running') {
|
||||
error.value = 'Pipeline уже запущен'
|
||||
error.value = t('projectView.pipeline_already_running')
|
||||
} else {
|
||||
error.value = e.message
|
||||
}
|
||||
|
|
@ -681,13 +683,13 @@ async function patchTaskField(taskId: string, data: { priority?: number; route_t
|
|||
}
|
||||
|
||||
// Kanban
|
||||
const KANBAN_COLUMNS = [
|
||||
{ status: 'pending', label: 'Pending', headerClass: 'text-gray-400', bgClass: 'bg-gray-900/20' },
|
||||
{ status: 'in_progress', label: 'In Progress', headerClass: 'text-blue-400', bgClass: 'bg-blue-950/20' },
|
||||
{ status: 'review', label: 'Review', headerClass: 'text-purple-400', bgClass: 'bg-purple-950/20' },
|
||||
{ status: 'blocked', label: 'Blocked', headerClass: 'text-red-400', bgClass: 'bg-red-950/20' },
|
||||
{ status: 'done', label: 'Done', headerClass: 'text-green-400', bgClass: 'bg-green-950/20' },
|
||||
]
|
||||
const KANBAN_COLUMNS = computed(() => [
|
||||
{ status: 'pending', label: t('projectView.kanban_pending'), headerClass: 'text-gray-400', bgClass: 'bg-gray-900/20' },
|
||||
{ status: 'in_progress', label: t('projectView.kanban_in_progress'), headerClass: 'text-blue-400', bgClass: 'bg-blue-950/20' },
|
||||
{ status: 'review', label: t('projectView.kanban_review'), headerClass: 'text-purple-400', bgClass: 'bg-purple-950/20' },
|
||||
{ status: 'blocked', label: t('projectView.kanban_blocked'), headerClass: 'text-red-400', bgClass: 'bg-red-950/20' },
|
||||
{ status: 'done', label: t('projectView.kanban_done'), headerClass: 'text-green-400', bgClass: 'bg-green-950/20' },
|
||||
])
|
||||
|
||||
const draggingTaskId = ref<string | null>(null)
|
||||
const dragOverStatus = ref<string | null>(null)
|
||||
|
|
@ -695,9 +697,9 @@ let kanbanPollTimer: ReturnType<typeof setInterval> | null = null
|
|||
|
||||
const kanbanTasksByStatus = computed(() => {
|
||||
const result: Record<string, Task[]> = {}
|
||||
for (const col of KANBAN_COLUMNS) result[col.status] = []
|
||||
for (const t of searchFilteredTasks.value) {
|
||||
if (result[t.status]) result[t.status].push(t)
|
||||
for (const col of KANBAN_COLUMNS.value) result[col.status] = []
|
||||
for (const task of searchFilteredTasks.value) {
|
||||
if (result[task.status]) result[task.status].push(task)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
|
@ -782,15 +784,15 @@ async function addDecision() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="text-gray-500 text-sm">Loading...</div>
|
||||
<div v-if="loading" class="text-gray-500 text-sm">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="error" class="text-red-400 text-sm">{{ error }}</div>
|
||||
<div v-else-if="project">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<router-link to="/" class="text-gray-600 hover:text-gray-400 text-sm no-underline">← back</router-link>
|
||||
<router-link to="/" class="text-gray-600 hover:text-gray-400 text-sm no-underline">{{ t('projectView.back') }}</router-link>
|
||||
<span class="text-gray-700">|</span>
|
||||
<router-link :to="`/chat/${project.id}`" class="text-indigo-500 hover:text-indigo-400 text-sm no-underline">Чат</router-link>
|
||||
<router-link :to="`/chat/${project.id}`" class="text-indigo-500 hover:text-indigo-400 text-sm no-underline">{{ t('projectView.chat') }}</router-link>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<h1 class="text-xl font-bold text-gray-100">{{ project.id }}</h1>
|
||||
|
|
@ -806,7 +808,7 @@ async function addDecision() {
|
|||
class="px-3 py-1 text-xs bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50 ml-auto"
|
||||
>
|
||||
<span v-if="deploying" class="inline-block w-3 h-3 border-2 border-teal-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||
{{ deploying ? 'Deploying...' : 'Deploy' }}
|
||||
{{ deploying ? t('taskDetail.deploying') : t('projectView.deploy') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -815,7 +817,7 @@ async function addDecision() {
|
|||
:class="deployResult.overall_success !== false && deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span :class="deployResult.overall_success !== false && deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
|
||||
{{ deployResult.overall_success !== false && deployResult.success ? 'Deploy succeeded' : 'Deploy failed' }}
|
||||
{{ deployResult.overall_success !== false && deployResult.success ? t('taskDetail.deploy_succeeded') : t('taskDetail.deploy_failed') }}
|
||||
</span>
|
||||
<span class="text-gray-500">{{ deployResult.duration_seconds }}s</span>
|
||||
<button @click.stop="deployResult = null" class="ml-auto text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs">x</button>
|
||||
|
|
@ -841,7 +843,7 @@ async function addDecision() {
|
|||
</template>
|
||||
<!-- Dependents -->
|
||||
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
|
||||
<p class="text-xs text-gray-400 font-semibold mb-1">Зависимые проекты:</p>
|
||||
<p class="text-xs text-gray-400 font-semibold mb-1">{{ t('taskDetail.dependent_projects') }}</p>
|
||||
<div v-for="dep in deployResult.dependents_deployed" :key="dep" class="flex items-center gap-2 px-2 py-0.5">
|
||||
<span class="text-teal-400 font-semibold text-[10px]">ok</span>
|
||||
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
|
||||
|
|
@ -878,7 +880,7 @@ async function addDecision() {
|
|||
:class="activeTab === tab
|
||||
? 'text-gray-200 border-blue-500'
|
||||
: 'text-gray-500 border-transparent hover:text-gray-300'">
|
||||
{{ tab === 'kanban' ? 'Kanban' : tab === 'environments' ? 'Среды' : tab === 'links' ? 'Links' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||
{{ tab === 'tasks' ? t('projectView.tasks_tab') : tab === 'phases' ? t('projectView.phases_tab') : tab === 'decisions' ? t('projectView.decisions_tab') : tab === 'modules' ? t('projectView.modules_tab') : tab === 'kanban' ? t('projectView.kanban_tab') : tab === 'environments' ? t('projectView.environments') : t('projectView.links_tab') }}
|
||||
<span class="text-xs text-gray-600 ml-1">
|
||||
{{ tab === 'tasks' ? project.tasks.length
|
||||
: tab === 'phases' ? phases.length
|
||||
|
|
@ -930,25 +932,25 @@ async function addDecision() {
|
|||
? 'bg-blue-900/30 text-blue-400 border-blue-800 hover:bg-blue-900/50'
|
||||
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
||||
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
|
||||
{{ autoTest ? '✓ Автотест' : 'Автотест' }}
|
||||
{{ autoTest ? '✓ ' + t('projectView.auto_test_label') : t('projectView.auto_test_label') }}
|
||||
</button>
|
||||
<button @click="toggleWorktrees"
|
||||
class="px-2 py-1 text-xs border rounded transition-colors"
|
||||
:class="worktrees
|
||||
? 'bg-teal-900/30 text-teal-400 border-teal-800 hover:bg-teal-900/50'
|
||||
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
||||
:title="worktrees ? 'Worktrees: on — агенты в изолированных git worktrees' : 'Worktrees: off'">
|
||||
{{ worktrees ? '✓ Worktrees' : 'Worktrees' }}
|
||||
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
|
||||
{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}
|
||||
</button>
|
||||
<button @click="runAudit" :disabled="auditLoading"
|
||||
class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50"
|
||||
title="Check which pending tasks are already done">
|
||||
<span v-if="auditLoading" class="inline-block w-3 h-3 border-2 border-purple-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||
{{ auditLoading ? 'Auditing...' : 'Audit backlog' }}
|
||||
{{ auditLoading ? 'Auditing...' : t('projectView.audit_backlog') }}
|
||||
</button>
|
||||
<button @click="showAddTask = true"
|
||||
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
||||
+ Task
|
||||
{{ t('projectView.add_task') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -959,7 +961,7 @@ async function addDecision() {
|
|||
:class="!selectedCategory
|
||||
? 'bg-gray-700/60 text-gray-300 border-gray-600'
|
||||
: 'bg-gray-900 text-gray-600 border-gray-800 hover:text-gray-400 hover:border-gray-700'"
|
||||
>Все</button>
|
||||
>{{ t('projectView.all_statuses') }}</button>
|
||||
<button v-for="cat in taskCategories" :key="cat"
|
||||
@click="selectedCategory = cat"
|
||||
class="px-2 py-0.5 text-xs rounded border transition-colors"
|
||||
|
|
@ -972,7 +974,7 @@ async function addDecision() {
|
|||
</div>
|
||||
<!-- Search -->
|
||||
<div class="flex items-center gap-1">
|
||||
<input v-model="taskSearch" placeholder="Поиск по задачам..."
|
||||
<input v-model="taskSearch" :placeholder="t('projectView.search_placeholder')"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 placeholder-gray-600 w-56 focus:border-gray-500 outline-none" />
|
||||
<button v-if="taskSearch" @click="taskSearch = ''"
|
||||
class="text-gray-600 hover:text-red-400 text-xs px-1">✕</button>
|
||||
|
|
@ -981,7 +983,7 @@ async function addDecision() {
|
|||
<!-- Manual escalation tasks -->
|
||||
<div v-if="manualEscalationTasks.length" class="mb-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-orange-400 uppercase tracking-wide">⚠ Требуют ручного решения</span>
|
||||
<span class="text-xs font-semibold text-orange-400 uppercase tracking-wide">{{ t('projectView.manual_escalations_warn') }}</span>
|
||||
<span class="text-xs text-orange-600">({{ manualEscalationTasks.length }})</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
|
|
@ -1003,7 +1005,7 @@ async function addDecision() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
|
||||
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">{{ t('projectView.no_tasks') }}</div>
|
||||
<div v-else class="space-y-1">
|
||||
<router-link v-for="t in filteredTasks" :key="t.id"
|
||||
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
|
||||
|
|
@ -1077,7 +1079,7 @@ async function addDecision() {
|
|||
<button @click="claudeLoginError = false" class="text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs shrink-0">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="phasesLoading" class="text-gray-500 text-sm">Loading phases...</p>
|
||||
<p v-if="phasesLoading" class="text-gray-500 text-sm">{{ t('projectView.loading_phases') }}</p>
|
||||
<p v-else-if="phaseError" class="text-red-400 text-sm">{{ phaseError }}</p>
|
||||
<div v-else-if="phases.length === 0" class="text-gray-600 text-sm">
|
||||
No research phases. Use "New Project" to start a research workflow.
|
||||
|
|
@ -1224,7 +1226,7 @@ async function addDecision() {
|
|||
<div v-if="activeTab === 'kanban'" class="pb-4">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<input v-model="taskSearch" placeholder="Поиск..."
|
||||
<input v-model="taskSearch" :placeholder="t('projectView.search_placeholder')"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 placeholder-gray-600 w-48 focus:border-gray-500 outline-none" />
|
||||
<button v-if="taskSearch" @click="taskSearch = ''"
|
||||
class="text-gray-600 hover:text-red-400 text-xs px-1">✕</button>
|
||||
|
|
@ -1252,25 +1254,25 @@ async function addDecision() {
|
|||
? 'bg-blue-900/30 text-blue-400 border-blue-800 hover:bg-blue-900/50'
|
||||
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
||||
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
|
||||
{{ autoTest ? '✓ Автотест' : 'Автотест' }}
|
||||
{{ autoTest ? '✓ ' + t('projectView.auto_test_label') : t('projectView.auto_test_label') }}
|
||||
</button>
|
||||
<button @click="toggleWorktrees"
|
||||
class="px-2 py-1 text-xs border rounded transition-colors"
|
||||
:class="worktrees
|
||||
? 'bg-teal-900/30 text-teal-400 border-teal-800 hover:bg-teal-900/50'
|
||||
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
|
||||
:title="worktrees ? 'Worktrees: on — агенты в изолированных git worktrees' : 'Worktrees: off'">
|
||||
{{ worktrees ? '✓ Worktrees' : 'Worktrees' }}
|
||||
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
|
||||
{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}
|
||||
</button>
|
||||
<button @click="runAudit" :disabled="auditLoading"
|
||||
class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50"
|
||||
title="Check which pending tasks are already done">
|
||||
<span v-if="auditLoading" class="inline-block w-3 h-3 border-2 border-purple-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||
{{ auditLoading ? 'Auditing...' : 'Аудит' }}
|
||||
{{ auditLoading ? 'Auditing...' : t('projectView.audit_backlog') }}
|
||||
</button>
|
||||
<button @click="showAddTask = true"
|
||||
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
||||
+ Тас
|
||||
{{ t('projectView.add_task') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1432,7 +1434,7 @@ async function addDecision() {
|
|||
<p v-if="linkFormError" class="text-red-400 text-xs">{{ linkFormError }}</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" @click="showAddLink = false; linkFormError = ''"
|
||||
class="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200">Отмена</button>
|
||||
class="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200">{{ t('common.cancel') }}</button>
|
||||
<button type="submit" :disabled="linkSaving"
|
||||
class="px-4 py-1.5 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
|
||||
{{ linkSaving ? 'Saving...' : 'Add Link' }}
|
||||
|
|
@ -1597,7 +1599,7 @@ async function addDecision() {
|
|||
<p v-if="taskReviseError" class="text-red-400 text-xs">{{ taskReviseError }}</p>
|
||||
<button @click="submitTaskRevise" :disabled="taskReviseSaving"
|
||||
class="w-full py-2 bg-orange-900/50 text-orange-400 border border-orange-800 rounded text-sm hover:bg-orange-900 disabled:opacity-50">
|
||||
{{ taskReviseSaving ? 'Отправляем...' : 'Отправить на доработку' }}
|
||||
{{ taskReviseSaving ? t('common.saving') : t('taskDetail.send_to_revision') }}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api, type Project, type ObsidianSyncResult, type ProjectLink } from '../api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const vaultPaths = ref<Record<string, string>>({})
|
||||
const deployCommands = ref<Record<string, string>>({})
|
||||
|
|
@ -71,9 +74,9 @@ async function saveDeployConfig(projectId: string) {
|
|||
deploy_restart_cmd: deployRestartCmds.value[projectId],
|
||||
deploy_command: deployCommands.value[projectId],
|
||||
})
|
||||
saveDeployConfigStatus.value[projectId] = 'Saved'
|
||||
saveDeployConfigStatus.value[projectId] = t('common.saved')
|
||||
} catch (e: unknown) {
|
||||
saveDeployConfigStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
saveDeployConfigStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
savingDeployConfig.value[projectId] = false
|
||||
}
|
||||
|
|
@ -84,9 +87,9 @@ async function saveVaultPath(projectId: string) {
|
|||
saveStatus.value[projectId] = ''
|
||||
try {
|
||||
await api.patchProject(projectId, { obsidian_vault_path: vaultPaths.value[projectId] })
|
||||
saveStatus.value[projectId] = 'Saved'
|
||||
saveStatus.value[projectId] = t('common.saved')
|
||||
} catch (e: unknown) {
|
||||
saveStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
saveStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
saving.value[projectId] = false
|
||||
}
|
||||
|
|
@ -97,9 +100,9 @@ async function saveTestCommand(projectId: string) {
|
|||
saveTestStatus.value[projectId] = ''
|
||||
try {
|
||||
await api.patchProject(projectId, { test_command: testCommands.value[projectId] })
|
||||
saveTestStatus.value[projectId] = 'Saved'
|
||||
saveTestStatus.value[projectId] = t('common.saved')
|
||||
} catch (e: unknown) {
|
||||
saveTestStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
saveTestStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
savingTest.value[projectId] = false
|
||||
}
|
||||
|
|
@ -111,10 +114,10 @@ async function toggleAutoTest(projectId: string) {
|
|||
saveAutoTestStatus.value[projectId] = ''
|
||||
try {
|
||||
await api.patchProject(projectId, { auto_test_enabled: autoTestEnabled.value[projectId] })
|
||||
saveAutoTestStatus.value[projectId] = 'Saved'
|
||||
saveAutoTestStatus.value[projectId] = t('common.saved')
|
||||
} catch (e: unknown) {
|
||||
autoTestEnabled.value[projectId] = !autoTestEnabled.value[projectId]
|
||||
saveAutoTestStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
saveAutoTestStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
savingAutoTest.value[projectId] = false
|
||||
}
|
||||
|
|
@ -126,10 +129,10 @@ async function toggleWorktrees(projectId: string) {
|
|||
saveWorktreesStatus.value[projectId] = ''
|
||||
try {
|
||||
await api.patchProject(projectId, { worktrees_enabled: worktreesEnabled.value[projectId] })
|
||||
saveWorktreesStatus.value[projectId] = 'Saved'
|
||||
saveWorktreesStatus.value[projectId] = t('common.saved')
|
||||
} catch (e: unknown) {
|
||||
worktreesEnabled.value[projectId] = !worktreesEnabled.value[projectId]
|
||||
saveWorktreesStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
saveWorktreesStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
savingWorktrees.value[projectId] = false
|
||||
}
|
||||
|
|
@ -162,7 +165,7 @@ async function loadLinks(projectId: string) {
|
|||
|
||||
async function addLink(projectId: string) {
|
||||
const form = linkForms.value[projectId]
|
||||
if (!form.to_project) { linkError.value[projectId] = 'Выберите проект'; return }
|
||||
if (!form.to_project) { linkError.value[projectId] = t('settings.select_project_error'); return }
|
||||
linkSaving.value[projectId] = true
|
||||
linkError.value[projectId] = ''
|
||||
try {
|
||||
|
|
@ -183,7 +186,7 @@ async function addLink(projectId: string) {
|
|||
}
|
||||
|
||||
async function deleteLink(projectId: string, linkId: number) {
|
||||
if (!confirm('Удалить связь?')) return
|
||||
if (!confirm(t('settings.delete_link_confirm'))) return
|
||||
try {
|
||||
await api.deleteProjectLink(linkId)
|
||||
await loadLinks(projectId)
|
||||
|
|
@ -195,7 +198,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-100 mb-6">Settings</h1>
|
||||
<h1 class="text-xl font-semibold text-gray-100 mb-6">{{ t('settings.title') }}</h1>
|
||||
|
||||
<div v-if="error" class="text-red-400 mb-4">{{ error }}</div>
|
||||
|
||||
|
|
@ -206,7 +209,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-gray-400 mb-1">Obsidian Vault Path</label>
|
||||
<label class="block text-xs text-gray-400 mb-1">{{ t('settings.obsidian_vault_path') }}</label>
|
||||
<input
|
||||
v-model="vaultPaths[project.id]"
|
||||
type="text"
|
||||
|
|
@ -216,14 +219,14 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-gray-400 mb-1">Test Command</label>
|
||||
<label class="block text-xs text-gray-400 mb-1">{{ t('settings.test_command') }}</label>
|
||||
<input
|
||||
v-model="testCommands[project.id]"
|
||||
type="text"
|
||||
placeholder="make test"
|
||||
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500"
|
||||
/>
|
||||
<p class="text-xs text-gray-600 mt-1">Команда запуска тестов, выполняется через shell в директории проекта.</p>
|
||||
<p class="text-xs text-gray-600 mt-1">{{ t('settings.test_command_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap mb-3">
|
||||
|
|
@ -232,7 +235,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
:disabled="savingTest[project.id]"
|
||||
class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
{{ savingTest[project.id] ? 'Saving…' : 'Save Test' }}
|
||||
{{ savingTest[project.id] ? t('settings.saving_test') : t('settings.save_test') }}
|
||||
</button>
|
||||
<span v-if="saveTestStatus[project.id]" class="text-xs" :class="saveTestStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||
{{ saveTestStatus[project.id] }}
|
||||
|
|
@ -241,9 +244,9 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
|
||||
<!-- Deploy Config -->
|
||||
<div class="mb-2 pt-2 border-t border-gray-800">
|
||||
<p class="text-xs font-semibold text-gray-400 mb-2">Deploy Config</p>
|
||||
<p class="text-xs font-semibold text-gray-400 mb-2">{{ t('settings.deploy_config') }}</p>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Server host</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.server_host') }}</label>
|
||||
<input
|
||||
v-model="deployHosts[project.id]"
|
||||
type="text"
|
||||
|
|
@ -252,7 +255,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Project path on server</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.project_path_on_server') }}</label>
|
||||
<input
|
||||
v-model="deployPaths[project.id]"
|
||||
type="text"
|
||||
|
|
@ -261,12 +264,12 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Runtime</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.runtime') }}</label>
|
||||
<select
|
||||
v-model="deployRuntimes[project.id]"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-gray-500"
|
||||
>
|
||||
<option value="">— выберите runtime —</option>
|
||||
<option value="">{{ t('settings.select_runtime') }}</option>
|
||||
<option value="docker">docker</option>
|
||||
<option value="node">node</option>
|
||||
<option value="python">python</option>
|
||||
|
|
@ -274,7 +277,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Restart command (optional override)</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.restart_command') }}</label>
|
||||
<input
|
||||
v-model="deployRestartCmds[project.id]"
|
||||
type="text"
|
||||
|
|
@ -283,7 +286,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Fallback command (legacy, used when runtime not set)</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.fallback_command') }}</label>
|
||||
<input
|
||||
v-model="deployCommands[project.id]"
|
||||
type="text"
|
||||
|
|
@ -297,7 +300,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
:disabled="savingDeployConfig[project.id]"
|
||||
class="px-3 py-1.5 text-sm bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50"
|
||||
>
|
||||
{{ savingDeployConfig[project.id] ? 'Saving…' : 'Save Deploy Config' }}
|
||||
{{ savingDeployConfig[project.id] ? t('settings.saving_deploy') : t('settings.save_deploy_config') }}
|
||||
</button>
|
||||
<span v-if="saveDeployConfigStatus[project.id]" class="text-xs" :class="saveDeployConfigStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||
{{ saveDeployConfigStatus[project.id] }}
|
||||
|
|
@ -308,28 +311,28 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
<!-- Project Links -->
|
||||
<div class="mb-2 pt-2 border-t border-gray-800">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs font-semibold text-gray-400">Project Links</p>
|
||||
<p class="text-xs font-semibold text-gray-400">{{ t('settings.project_links') }}</p>
|
||||
<button
|
||||
@click="showAddLinkForm[project.id] = !showAddLinkForm[project.id]"
|
||||
class="px-2 py-0.5 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700"
|
||||
>
|
||||
+ Add Link
|
||||
{{ t('settings.add_link') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="linksLoading[project.id]" class="text-xs text-gray-500">Загрузка...</p>
|
||||
<p v-if="linksLoading[project.id]" class="text-xs text-gray-500">{{ t('settings.links_loading') }}</p>
|
||||
<p v-else-if="linkError[project.id]" class="text-xs text-red-400">{{ linkError[project.id] }}</p>
|
||||
<div v-else-if="!projectLinksMap[project.id]?.length" class="text-xs text-gray-600">Нет связей</div>
|
||||
<div v-else-if="!projectLinksMap[project.id]?.length" class="text-xs text-gray-600">{{ t('settings.no_links') }}</div>
|
||||
<div v-else class="space-y-1 mb-2">
|
||||
<div v-for="link in projectLinksMap[project.id]" :key="link.id"
|
||||
class="flex items-center gap-2 px-2 py-1 bg-gray-900 border border-gray-800 rounded text-xs">
|
||||
<span class="text-gray-500 font-mono">{{ link.from_project }}</span>
|
||||
<span class="text-gray-600">→</span>
|
||||
<span class="text-gray-600">→</span>
|
||||
<span class="text-gray-500 font-mono">{{ link.to_project }}</span>
|
||||
<span class="px-1 bg-indigo-900/30 text-indigo-400 border border-indigo-800 rounded">{{ link.type }}</span>
|
||||
<span v-if="link.description" class="text-gray-600">{{ link.description }}</span>
|
||||
<button @click="deleteLink(project.id, link.id)"
|
||||
class="ml-auto text-red-500 hover:text-red-400 bg-transparent border-none cursor-pointer text-xs shrink-0">
|
||||
✕
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -342,8 +345,8 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
<label class="block text-[10px] text-gray-500 mb-0.5">To project</label>
|
||||
<select v-model="linkForms[project.id].to_project" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300">
|
||||
<option value="">— выберите проект —</option>
|
||||
<option v-for="p in allProjectList.filter(p => p.id !== project.id)" :key="p.id" :value="p.id">{{ p.id }} — {{ p.name }}</option>
|
||||
<option value="">{{ t('settings.select_project') }}</option>
|
||||
<option v-for="p in allProjectList.filter(p => p.id !== project.id)" :key="p.id" :value="p.id">{{ p.id }} — {{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -363,11 +366,11 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
<div class="flex gap-2">
|
||||
<button type="submit" :disabled="linkSaving[project.id]"
|
||||
class="px-3 py-1 text-xs bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
|
||||
{{ linkSaving[project.id] ? 'Saving...' : 'Add' }}
|
||||
{{ linkSaving[project.id] ? t('settings.saving_link') : t('common.add') }}
|
||||
</button>
|
||||
<button type="button" @click="showAddLinkForm[project.id] = false; linkError[project.id] = ''"
|
||||
class="px-3 py-1 text-xs text-gray-500 hover:text-gray-300 bg-transparent border-none cursor-pointer">
|
||||
Отмена
|
||||
{{ t('settings.cancel_link') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -382,8 +385,8 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
:disabled="savingAutoTest[project.id]"
|
||||
class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer disabled:opacity-50"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Auto-test</span>
|
||||
<span class="text-xs text-gray-500">— запускать тесты автоматически после pipeline</span>
|
||||
<span class="text-sm text-gray-300">{{ t('settings.auto_test') }}</span>
|
||||
<span class="text-xs text-gray-500">{{ t('settings.auto_test_hint') }}</span>
|
||||
</label>
|
||||
<span v-if="saveAutoTestStatus[project.id]" class="text-xs" :class="saveAutoTestStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||
{{ saveAutoTestStatus[project.id] }}
|
||||
|
|
@ -399,8 +402,8 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
:disabled="savingWorktrees[project.id]"
|
||||
class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer disabled:opacity-50"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Worktrees</span>
|
||||
<span class="text-xs text-gray-500">— агенты запускаются в изолированных git worktrees</span>
|
||||
<span class="text-sm text-gray-300">{{ t('settings.worktrees') }}</span>
|
||||
<span class="text-xs text-gray-500">{{ t('settings.worktrees_hint') }}</span>
|
||||
</label>
|
||||
<span v-if="saveWorktreesStatus[project.id]" class="text-xs" :class="saveWorktreesStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||
{{ saveWorktreesStatus[project.id] }}
|
||||
|
|
@ -413,7 +416,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
:disabled="saving[project.id]"
|
||||
class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
{{ saving[project.id] ? 'Saving…' : 'Save Vault' }}
|
||||
{{ saving[project.id] ? t('settings.saving_vault') : t('settings.save_vault') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -421,7 +424,7 @@ async function deleteLink(projectId: string, linkId: number) {
|
|||
:disabled="syncing[project.id] || !vaultPaths[project.id]"
|
||||
class="px-3 py-1.5 text-sm bg-indigo-700 hover:bg-indigo-600 text-white rounded disabled:opacity-50"
|
||||
>
|
||||
{{ syncing[project.id] ? 'Syncing…' : 'Sync Obsidian' }}
|
||||
{{ syncing[project.id] ? t('settings.syncing') : t('settings.sync_obsidian') }}
|
||||
</button>
|
||||
|
||||
<span v-if="saveStatus[project.id]" class="text-xs" :class="saveStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { api, ApiError, type TaskFull, type PipelineStep, type PendingAction, type DeployResult, type Attachment } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
|
@ -11,6 +12,7 @@ import LiveConsole from '../components/LiveConsole.vue'
|
|||
const props = defineProps<{ id: string }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const task = ref<TaskFull | null>(null)
|
||||
const loading = ref(true)
|
||||
|
|
@ -45,15 +47,14 @@ const parsedSelectedOutput = computed<ParsedAgentOutput | null>(() => {
|
|||
// Auto/Review mode (per-task, persisted in DB; falls back to localStorage per project)
|
||||
const autoMode = ref(false)
|
||||
|
||||
function loadMode(t: typeof task.value) {
|
||||
if (!t) return
|
||||
if (t.execution_mode) {
|
||||
autoMode.value = t.execution_mode === 'auto_complete'
|
||||
} else if (t.status === 'review') {
|
||||
// Task is in review — always show Approve/Reject regardless of localStorage
|
||||
function loadMode(t_val: typeof task.value) {
|
||||
if (!t_val) return
|
||||
if (t_val.execution_mode) {
|
||||
autoMode.value = t_val.execution_mode === 'auto_complete'
|
||||
} else if (t_val.status === 'review') {
|
||||
autoMode.value = false
|
||||
} else {
|
||||
autoMode.value = localStorage.getItem(`kin-mode-${t.project_id}`) === 'auto_complete'
|
||||
autoMode.value = localStorage.getItem(`kin-mode-${t_val.project_id}`) === 'auto_complete'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,11 +75,9 @@ async function load() {
|
|||
const prev = task.value
|
||||
task.value = await api.taskFull(props.id)
|
||||
loadMode(task.value)
|
||||
// Auto-start polling if task is in_progress
|
||||
if (task.value.status === 'in_progress' && !polling.value) {
|
||||
startPolling()
|
||||
}
|
||||
// Stop polling when pipeline done
|
||||
if (prev?.status === 'in_progress' && task.value.status !== 'in_progress') {
|
||||
stopPolling()
|
||||
}
|
||||
|
|
@ -241,7 +240,6 @@ async function runPipeline() {
|
|||
claudeLoginError.value = false
|
||||
pipelineStarting.value = true
|
||||
try {
|
||||
// Sync task execution_mode with current toggle state before running
|
||||
const targetMode = autoMode.value ? 'auto_complete' : 'review'
|
||||
if (task.value && task.value.execution_mode !== targetMode) {
|
||||
const updated = await api.patchTask(props.id, { execution_mode: targetMode })
|
||||
|
|
@ -254,7 +252,7 @@ async function runPipeline() {
|
|||
if (e instanceof ApiError && e.code === 'claude_auth_required') {
|
||||
claudeLoginError.value = true
|
||||
} else if (e instanceof ApiError && e.code === 'task_already_running') {
|
||||
error.value = 'Pipeline уже запущен'
|
||||
error.value = t('taskDetail.pipeline_already_running')
|
||||
} else {
|
||||
error.value = e.message
|
||||
}
|
||||
|
|
@ -271,7 +269,7 @@ const resolvingManually = ref(false)
|
|||
|
||||
async function resolveManually() {
|
||||
if (!task.value) return
|
||||
if (!confirm('Пометить задачу как решённую вручную?')) return
|
||||
if (!confirm(t('taskDetail.mark_resolved_confirm'))) return
|
||||
resolvingManually.value = true
|
||||
try {
|
||||
const updated = await api.patchTask(props.id, { status: 'done' })
|
||||
|
|
@ -386,7 +384,7 @@ async function saveEdit() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading && !task" class="text-gray-500 text-sm">Loading...</div>
|
||||
<div v-if="loading && !task" class="text-gray-500 text-sm">{{ t('taskDetail.loading') }}</div>
|
||||
<div v-else-if="error && !task" class="text-red-400 text-sm">{{ error }}</div>
|
||||
<div v-else-if="task">
|
||||
<!-- Header -->
|
||||
|
|
@ -422,7 +420,7 @@ async function saveEdit() {
|
|||
<!-- Manual escalation context banner -->
|
||||
<div v-if="isManualEscalation" class="mb-3 px-3 py-2 border border-orange-800/60 bg-orange-950/20 rounded">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-semibold text-orange-400">⚠ Требует ручного решения</span>
|
||||
<span class="text-xs font-semibold text-orange-400">{{ t('taskDetail.requires_manual') }}</span>
|
||||
<span v-if="task.parent_task_id" class="text-xs text-gray-600">
|
||||
— эскалация из
|
||||
<router-link :to="`/task/${task.parent_task_id}`" class="text-orange-600 hover:text-orange-400">
|
||||
|
|
@ -432,15 +430,15 @@ async function saveEdit() {
|
|||
</div>
|
||||
<p class="text-xs text-orange-300">{{ task.title }}</p>
|
||||
<p v-if="task.brief?.description" class="text-xs text-gray-400 mt-1">{{ task.brief.description }}</p>
|
||||
<p class="text-xs text-gray-600 mt-1">Автопилот не смог выполнить это автоматически. Примите меры вручную и нажмите «Решить вручную».</p>
|
||||
<p class="text-xs text-gray-600 mt-1">{{ t('taskDetail.autopilot_failed') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Dangerous skip warning banner -->
|
||||
<div v-if="task.dangerously_skipped" class="mb-3 px-3 py-2 border border-red-700 bg-red-950/40 rounded flex items-start gap-2">
|
||||
<span class="text-red-400 text-base shrink-0">⚠</span>
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-red-400">--dangerously-skip-permissions использовался в этой задаче</span>
|
||||
<p class="text-xs text-red-300/70 mt-0.5">Агент выполнял команды с обходом проверок разрешений. Проверьте pipeline-шаги и сделанные изменения.</p>
|
||||
<span class="text-xs font-semibold text-red-400">{{ t('taskDetail.dangerously_skipped') }}</span>
|
||||
<p class="text-xs text-red-300/70 mt-0.5">{{ t('taskDetail.dangerously_skipped_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -448,7 +446,7 @@ async function saveEdit() {
|
|||
Brief: {{ JSON.stringify(task.brief) }}
|
||||
</div>
|
||||
<div v-if="task.acceptance_criteria" class="mb-2 px-3 py-2 border border-gray-700 bg-gray-900/40 rounded">
|
||||
<div class="text-xs font-semibold text-gray-400 mb-1">Критерии приёмки</div>
|
||||
<div class="text-xs font-semibold text-gray-400 mb-1">{{ t('taskDetail.acceptance_criteria') }}</div>
|
||||
<p class="text-xs text-gray-300 whitespace-pre-wrap">{{ task.acceptance_criteria }}</p>
|
||||
</div>
|
||||
<div v-if="task.status === 'blocked' && task.blocked_reason" class="text-xs text-red-400 mb-1 bg-red-950/30 border border-red-800/40 rounded px-2 py-1">
|
||||
|
|
@ -462,8 +460,8 @@ async function saveEdit() {
|
|||
<!-- Pipeline Graph -->
|
||||
<div v-if="hasSteps || isRunning" class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-3">
|
||||
Pipeline
|
||||
<span v-if="isRunning" class="text-blue-400 text-xs font-normal ml-2 animate-pulse">running...</span>
|
||||
{{ t('taskDetail.pipeline') }}
|
||||
<span v-if="isRunning" class="text-blue-400 text-xs font-normal ml-2 animate-pulse">{{ t('taskDetail.running') }}</span>
|
||||
</h2>
|
||||
<div class="flex items-center gap-1 overflow-x-auto pb-2">
|
||||
<template v-for="(step, i) in task.pipeline_steps" :key="step.id">
|
||||
|
|
@ -493,7 +491,7 @@ async function saveEdit() {
|
|||
|
||||
<!-- No pipeline -->
|
||||
<div v-if="!hasSteps && !isRunning" class="mb-6 text-sm text-gray-600">
|
||||
No pipeline steps yet.
|
||||
{{ t('taskDetail.no_pipeline') }}
|
||||
</div>
|
||||
|
||||
<!-- Live Console -->
|
||||
|
|
@ -516,7 +514,7 @@ async function saveEdit() {
|
|||
<div class="p-4">
|
||||
<p class="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap">{{ parsedSelectedOutput.verdict }}</p>
|
||||
<details v-if="parsedSelectedOutput.details !== null" class="mt-3">
|
||||
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-400 select-none">↓ подробнее</summary>
|
||||
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-400 select-none">{{ t('taskDetail.more_details') }}</summary>
|
||||
<pre class="mt-2 text-xs text-gray-500 overflow-x-auto whitespace-pre-wrap max-h-[400px] overflow-y-auto">{{ parsedSelectedOutput.details }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
|
@ -542,7 +540,7 @@ async function saveEdit() {
|
|||
|
||||
<!-- Attachments -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-2">Вложения</h2>
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-2">{{ t('taskDetail.attachments') }}</h2>
|
||||
<AttachmentList :attachments="attachments" :task-id="props.id" @deleted="loadAttachments" />
|
||||
<AttachmentUploader :task-id="props.id" @uploaded="loadAttachments" />
|
||||
</div>
|
||||
|
|
@ -552,22 +550,22 @@ async function saveEdit() {
|
|||
<div v-if="autoMode && (isRunning || task.status === 'review')"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 bg-yellow-900/20 border border-yellow-800/50 rounded text-xs text-yellow-400">
|
||||
<span class="inline-block w-2 h-2 bg-yellow-400 rounded-full animate-pulse"></span>
|
||||
Автопилот активен
|
||||
{{ t('taskDetail.autopilot_active') }}
|
||||
</div>
|
||||
<button v-if="task.status === 'review' && !autoMode"
|
||||
@click="showApprove = true"
|
||||
class="px-4 py-2 text-sm bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900">
|
||||
✓ Approve
|
||||
{{ t('taskDetail.approve_task') }}
|
||||
</button>
|
||||
<button v-if="task.status === 'review' && !autoMode"
|
||||
@click="showRevise = true"
|
||||
class="px-4 py-2 text-sm bg-orange-900/50 text-orange-400 border border-orange-800 rounded hover:bg-orange-900">
|
||||
🔄 Revise
|
||||
{{ t('taskDetail.revise_task') }}
|
||||
</button>
|
||||
<button v-if="(task.status === 'review' || task.status === 'in_progress') && !autoMode"
|
||||
@click="showReject = true"
|
||||
class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
||||
✗ Reject
|
||||
{{ t('taskDetail.reject_task') }}
|
||||
</button>
|
||||
<button v-if="task.status === 'pending' || task.status === 'blocked' || task.status === 'review'"
|
||||
@click="toggleMode"
|
||||
|
|
@ -581,28 +579,28 @@ async function saveEdit() {
|
|||
<button v-if="task.status === 'pending'"
|
||||
@click="openEdit"
|
||||
class="px-3 py-2 text-sm bg-gray-800/50 text-gray-400 border border-gray-700 rounded hover:bg-gray-800">
|
||||
✎ Edit
|
||||
{{ t('taskDetail.edit') }}
|
||||
</button>
|
||||
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
||||
@click="runPipeline"
|
||||
:disabled="polling || pipelineStarting"
|
||||
class="px-4 py-2 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
|
||||
<span v-if="polling || pipelineStarting" class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||
{{ (polling || pipelineStarting) ? 'Pipeline running...' : '▶ Run Pipeline' }}
|
||||
{{ (polling || pipelineStarting) ? t('taskDetail.pipeline_running') : t('taskDetail.run_pipeline') }}
|
||||
</button>
|
||||
<button v-if="isManualEscalation && task.status !== 'done' && task.status !== 'cancelled'"
|
||||
@click="resolveManually"
|
||||
:disabled="resolvingManually"
|
||||
class="px-4 py-2 text-sm bg-orange-900/50 text-orange-400 border border-orange-800 rounded hover:bg-orange-900 disabled:opacity-50">
|
||||
<span v-if="resolvingManually" class="inline-block w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||
{{ resolvingManually ? 'Сохраняем...' : '✓ Решить вручную' }}
|
||||
{{ resolvingManually ? t('taskDetail.resolving') : t('taskDetail.resolve_manually') }}
|
||||
</button>
|
||||
<button v-if="task.status === 'done' && (task.project_deploy_command || task.project_deploy_runtime)"
|
||||
@click.stop="runDeploy"
|
||||
:disabled="deploying"
|
||||
class="px-4 py-2 text-sm bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50">
|
||||
<span v-if="deploying" class="inline-block w-3 h-3 border-2 border-teal-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||
{{ deploying ? 'Deploying...' : '🚀 Deploy' }}
|
||||
{{ deploying ? t('taskDetail.deploying') : t('taskDetail.deploy') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -611,9 +609,9 @@ async function saveEdit() {
|
|||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-yellow-300">⚠ Claude CLI requires login</p>
|
||||
<p class="text-xs text-yellow-200/80 mt-1">Откройте терминал и выполните:</p>
|
||||
<p class="text-xs text-yellow-200/80 mt-1">{{ t('taskDetail.terminal_login_hint') }}</p>
|
||||
<code class="text-xs text-yellow-400 font-mono bg-black/30 px-2 py-0.5 rounded mt-1 inline-block">claude login</code>
|
||||
<p class="text-xs text-gray-500 mt-1">После входа повторите запуск pipeline.</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ t('taskDetail.login_after_hint') }}</p>
|
||||
</div>
|
||||
<button @click="claudeLoginError = false" class="text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs shrink-0">✕</button>
|
||||
</div>
|
||||
|
|
@ -624,7 +622,7 @@ async function saveEdit() {
|
|||
:class="deployResult.overall_success !== false && deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span :class="deployResult.overall_success !== false && deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
|
||||
{{ deployResult.overall_success !== false && deployResult.success ? 'Deploy succeeded' : 'Deploy failed' }}
|
||||
{{ deployResult.overall_success !== false && deployResult.success ? t('taskDetail.deploy_succeeded') : t('taskDetail.deploy_failed') }}
|
||||
</span>
|
||||
<span class="text-gray-500">{{ deployResult.duration_seconds }}s</span>
|
||||
<button @click.stop="deployResult = null" class="ml-auto text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs">x</button>
|
||||
|
|
@ -650,7 +648,7 @@ async function saveEdit() {
|
|||
</template>
|
||||
<!-- Dependents -->
|
||||
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
|
||||
<p class="text-xs text-gray-400 font-semibold mb-1">Зависимые проекты:</p>
|
||||
<p class="text-xs text-gray-400 font-semibold mb-1">{{ t('taskDetail.dependent_projects') }}</p>
|
||||
<div v-for="dep in deployResult.dependents_deployed" :key="dep" class="flex items-center gap-2 px-2 py-0.5">
|
||||
<span class="text-teal-400 font-semibold text-[10px]">ok</span>
|
||||
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
|
||||
|
|
@ -704,9 +702,9 @@ async function saveEdit() {
|
|||
Create follow-up tasks from pipeline results
|
||||
</label>
|
||||
<p class="text-xs text-gray-500">Optionally record a decision:</p>
|
||||
<input v-model="approveForm.title" placeholder="Decision title (optional)"
|
||||
<input v-model="approveForm.title" :placeholder="t('taskDetail.decision_title_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<textarea v-if="approveForm.title" v-model="approveForm.description" placeholder="Description"
|
||||
<textarea v-if="approveForm.title" v-model="approveForm.description" :placeholder="t('taskDetail.description_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y" rows="2"></textarea>
|
||||
<button type="submit" :disabled="approveLoading"
|
||||
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900 disabled:opacity-50">
|
||||
|
|
@ -728,14 +726,14 @@ async function saveEdit() {
|
|||
</Modal>
|
||||
|
||||
<!-- Revise Modal -->
|
||||
<Modal v-if="showRevise" title="🔄 Revise Task" @close="showRevise = false">
|
||||
<Modal v-if="showRevise" :title="t('taskDetail.send_to_revision')" @close="showRevise = false">
|
||||
<form @submit.prevent="revise" class="space-y-3">
|
||||
<p class="text-xs text-gray-500">Опишите, что доработать или уточнить агенту. Задача вернётся в работу с вашим комментарием.</p>
|
||||
<textarea v-model="reviseComment" placeholder="Что доработать / уточнить..." rows="4" required
|
||||
<textarea v-model="reviseComment" :placeholder="t('taskDetail.revise_placeholder')" rows="4" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-orange-900/50 text-orange-400 border border-orange-800 rounded text-sm hover:bg-orange-900">
|
||||
🔄 Отправить на доработку
|
||||
{{ t('taskDetail.send_to_revision') }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
@ -744,30 +742,30 @@ async function saveEdit() {
|
|||
<Modal v-if="showEdit" title="Edit Task" @close="showEdit = false">
|
||||
<form @submit.prevent="saveEdit" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Title</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.title_label') }}</label>
|
||||
<input v-model="editForm.title" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Brief</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.brief_label') }}</label>
|
||||
<textarea v-model="editForm.briefText" rows="4" placeholder="Task description..."
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Priority (1–10)</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.priority_label') }}</label>
|
||||
<input v-model.number="editForm.priority" type="number" min="1" max="10" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Критерии приёмки</label>
|
||||
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.acceptance_criteria_label') }}</label>
|
||||
<textarea v-model="editForm.acceptanceCriteria" rows="3"
|
||||
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
|
||||
:placeholder="t('taskDetail.acceptance_criteria_placeholder')"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
|
||||
</div>
|
||||
<p v-if="editError" class="text-red-400 text-xs">{{ editError }}</p>
|
||||
<button type="submit" :disabled="editLoading"
|
||||
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900 disabled:opacity-50">
|
||||
{{ editLoading ? 'Saving...' : 'Save' }}
|
||||
{{ editLoading ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue