Agent loop state machine refactor, unified LLM interface, and UI improvements
- Rewrite agent loop as Planning→Executing(N)→Completed state machine with per-step context isolation to prevent token explosion - Split tools and prompts by phase (planning vs execution) - Add advance_step/save_memo tools for step transitions and cross-step memory - Unify LLM interface: remove duplicate types, single chat_with_tools path - Add UTF-8 safe truncation (truncate_str) to prevent panics on Chinese text - Extract CreateForm component, add auto-scroll to execution log - Add report generation with app access URL, non-blocking title generation - Add timer system, file serving, app proxy, exec module - Update Dockerfile with uv, deployment config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1125
web/package-lock.json
generated
1125
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^17.0.3",
|
||||
"mermaid": "^11.12.3",
|
||||
"vue": "^3.5.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Project, Workflow, PlanStep, Comment } from './types'
|
||||
import type { Project, Workflow, PlanStep, Comment, Timer } from './types'
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
@@ -54,4 +54,25 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
}),
|
||||
|
||||
getReport: (workflowId: string) =>
|
||||
request<{ report: string }>(`/workflows/${workflowId}/report`),
|
||||
|
||||
listTimers: (projectId: string) =>
|
||||
request<Timer[]>(`/projects/${projectId}/timers`),
|
||||
|
||||
createTimer: (projectId: string, data: { name: string; interval_secs: number; requirement: string }) =>
|
||||
request<Timer>(`/projects/${projectId}/timers`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
updateTimer: (timerId: string, data: { name?: string; interval_secs?: number; requirement?: string; enabled?: boolean }) =>
|
||||
request<Timer>(`/timers/${timerId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
deleteTimer: (timerId: string) =>
|
||||
request<void>(`/timers/${timerId}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
@@ -1,62 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import Sidebar from './Sidebar.vue'
|
||||
import WorkflowView from './WorkflowView.vue'
|
||||
import ReportView from './ReportView.vue'
|
||||
import CreateForm from './CreateForm.vue'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../types'
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const selectedProjectId = ref('')
|
||||
const reportWorkflowId = ref('')
|
||||
const error = ref('')
|
||||
const creating = ref(false)
|
||||
|
||||
const isReportPage = computed(() => !!reportWorkflowId.value)
|
||||
|
||||
function parseUrl(): { projectId: string; reportId: string } {
|
||||
const reportMatch = location.pathname.match(/^\/report\/([^/]+)/)
|
||||
if (reportMatch) return { projectId: '', reportId: reportMatch[1] ?? '' }
|
||||
const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/)
|
||||
return { projectId: projectMatch?.[1] ?? '', reportId: '' }
|
||||
}
|
||||
|
||||
function onPopState() {
|
||||
const { projectId, reportId } = parseUrl()
|
||||
selectedProjectId.value = projectId
|
||||
reportWorkflowId.value = reportId
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
projects.value = await api.listProjects()
|
||||
const first = projects.value[0]
|
||||
if (first) {
|
||||
selectedProjectId.value = first.id
|
||||
const { projectId, reportId } = parseUrl()
|
||||
if (reportId) {
|
||||
reportWorkflowId.value = reportId
|
||||
} else if (projectId && projects.value.some(p => p.id === projectId)) {
|
||||
selectedProjectId.value = projectId
|
||||
} else if (projects.value[0]) {
|
||||
selectedProjectId.value = projects.value[0].id
|
||||
history.replaceState(null, '', `/projects/${projects.value[0].id}`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
window.addEventListener('popstate', onPopState)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('popstate', onPopState)
|
||||
})
|
||||
|
||||
function onSelectProject(id: string) {
|
||||
selectedProjectId.value = id
|
||||
reportWorkflowId.value = ''
|
||||
creating.value = false
|
||||
history.pushState(null, '', `/projects/${id}`)
|
||||
}
|
||||
|
||||
async function onCreateProject() {
|
||||
const name = prompt('项目名称')
|
||||
if (!name) return
|
||||
function onStartCreate() {
|
||||
creating.value = true
|
||||
selectedProjectId.value = ''
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
|
||||
async function onConfirmCreate(req: string) {
|
||||
try {
|
||||
const project = await api.createProject(name)
|
||||
const project = await api.createProject('新项目')
|
||||
projects.value.unshift(project)
|
||||
await api.createWorkflow(project.id, req)
|
||||
creating.value = false
|
||||
selectedProjectId.value = project.id
|
||||
history.pushState(null, '', `/projects/${project.id}`)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function onProjectUpdate(projectId: string, name: string) {
|
||||
const p = projects.value.find(p => p.id === projectId)
|
||||
if (p) p.name = name
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<div v-if="isReportPage" class="report-fullpage">
|
||||
<ReportView :workflowId="reportWorkflowId" :key="reportWorkflowId" />
|
||||
</div>
|
||||
<div v-else class="app-layout">
|
||||
<Sidebar
|
||||
:projects="projects"
|
||||
:selectedId="selectedProjectId"
|
||||
@select="onSelectProject"
|
||||
@create="onCreateProject"
|
||||
@create="onStartCreate"
|
||||
/>
|
||||
<main class="main-content">
|
||||
<div v-if="error" class="error-banner">{{ error }}</div>
|
||||
<div v-if="!selectedProjectId" class="empty-state">
|
||||
<div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div>
|
||||
<div v-if="creating" class="empty-state">
|
||||
<CreateForm @submit="onConfirmCreate" @cancel="creating = false" />
|
||||
</div>
|
||||
<div v-else-if="!selectedProjectId" class="empty-state">
|
||||
选择或创建一个项目开始
|
||||
</div>
|
||||
<WorkflowView v-else :projectId="selectedProjectId" :key="selectedProjectId" />
|
||||
<WorkflowView
|
||||
v-else
|
||||
:projectId="selectedProjectId"
|
||||
:key="selectedProjectId"
|
||||
@projectUpdate="onProjectUpdate"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.report-fullpage {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
@@ -84,5 +144,6 @@ async function onCreateProject() {
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { Comment } from '../types'
|
||||
|
||||
defineProps<{
|
||||
comments: Comment[]
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
@@ -14,29 +12,31 @@ const emit = defineEmits<{
|
||||
const input = ref('')
|
||||
|
||||
function submit() {
|
||||
if (props.disabled) return
|
||||
const text = input.value.trim()
|
||||
if (!text) return
|
||||
emit('submit', text)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="comment-section">
|
||||
<div class="comments-display" v-if="comments.length">
|
||||
<div v-for="comment in comments" :key="comment.id" class="comment-item">
|
||||
<span class="comment-time">{{ new Date(comment.created_at).toLocaleTimeString() }}</span>
|
||||
<span class="comment-text">{{ comment.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-input">
|
||||
<textarea
|
||||
v-model="input"
|
||||
placeholder="输入反馈或调整指令... (Ctrl+Enter 发送)"
|
||||
rows="5"
|
||||
@keydown.ctrl.enter="submit"
|
||||
rows="3"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<button class="btn-send" :disabled="disabled" @click="submit">发送</button>
|
||||
<button class="btn-send" :disabled="disabled || !input.trim()" @click="submit">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,30 +49,6 @@ function submit() {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comments-display {
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.comment-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
142
web/src/components/CreateForm.vue
Normal file
142
web/src/components/CreateForm.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [requirement: string]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const requirement = ref('')
|
||||
const inputEl = ref<HTMLTextAreaElement>()
|
||||
|
||||
const examples = [
|
||||
{ label: 'Todo 应用', text: '做一个 Todo List 应用:前端展示任务列表(支持添加、完成、删除),后端 FastAPI 提供增删改查 REST API,数据存 SQLite。完成后用 curl 跑一遍 E2E 测试验证所有接口正常。' },
|
||||
{ label: '贪吃蛇+排行榜', text: '做一个贪吃蛇游戏网站,前端用 HTML/JS,后端用 FastAPI 存储排行榜,支持提交分数和查看 Top10' },
|
||||
{ label: '抓取豆瓣 Top250', text: '用 Python 抓取豆瓣电影 Top250 并生成分析报告' },
|
||||
]
|
||||
|
||||
onMounted(() => inputEl.value?.focus())
|
||||
|
||||
function onSubmit() {
|
||||
const text = requirement.value.trim()
|
||||
if (text) emit('submit', text)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="create-form">
|
||||
<h2>输入你的需求</h2>
|
||||
<div class="create-examples">
|
||||
<span
|
||||
v-for="ex in examples"
|
||||
:key="ex.label"
|
||||
class="example-tag"
|
||||
@click="requirement = ex.text"
|
||||
>{{ ex.label }}</span>
|
||||
</div>
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="requirement"
|
||||
class="create-textarea"
|
||||
placeholder="描述你想让 AI 做什么..."
|
||||
rows="4"
|
||||
@keydown.ctrl.enter="onSubmit"
|
||||
@keydown.meta.enter="onSubmit"
|
||||
/>
|
||||
<div class="create-hint">Ctrl+Enter 提交</div>
|
||||
<div class="create-actions">
|
||||
<button class="btn-cancel" @click="emit('cancel')">取消</button>
|
||||
<button class="btn-confirm" @click="onSubmit" :disabled="!requirement.trim()">开始</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 480px;
|
||||
}
|
||||
|
||||
.create-form h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.create-examples {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.example-tag {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.example-tag:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(79, 195, 247, 0.08);
|
||||
}
|
||||
|
||||
.create-textarea {
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.create-textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.create-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.create-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-confirm:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { PlanStep } from '../types'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import type { PlanStep, Comment } from '../types'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
steps: PlanStep[]
|
||||
planSteps: PlanStep[]
|
||||
comments: Comment[]
|
||||
requirement: string
|
||||
createdAt: string
|
||||
workflowStatus: string
|
||||
workflowId: string
|
||||
}>()
|
||||
|
||||
// Map plan step id -> step_order for showing badge
|
||||
const planStepOrderMap = computed(() => {
|
||||
const m: Record<string, number> = {}
|
||||
for (const ps of props.planSteps) {
|
||||
m[ps.id] = ps.step_order
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const userScrolledUp = ref(false)
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollContainer.value
|
||||
if (!el) return
|
||||
userScrolledUp.value = el.scrollTop + el.clientHeight < el.scrollHeight - 80
|
||||
}
|
||||
|
||||
const expandedSteps = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleStep(id: string) {
|
||||
@@ -16,6 +40,16 @@ function toggleStep(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
if (!t) return ''
|
||||
try {
|
||||
const d = new Date(t.includes('T') ? t : t + 'Z')
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'done': return '完成'
|
||||
@@ -24,31 +58,114 @@ function statusLabel(status: string) {
|
||||
default: return '等待'
|
||||
}
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: string
|
||||
type: 'requirement' | 'step' | 'comment' | 'report'
|
||||
time: string
|
||||
step?: PlanStep
|
||||
text?: string
|
||||
}
|
||||
|
||||
const logEntries = computed(() => {
|
||||
const entries: LogEntry[] = []
|
||||
|
||||
// Requirement
|
||||
if (props.requirement) {
|
||||
entries.push({ id: 'req', type: 'requirement', text: props.requirement, time: props.createdAt || '0' })
|
||||
}
|
||||
|
||||
// Steps
|
||||
for (const step of props.steps) {
|
||||
entries.push({ id: step.id, type: 'step', step, time: step.created_at || '' })
|
||||
}
|
||||
|
||||
// Comments
|
||||
for (const c of props.comments) {
|
||||
entries.push({ id: c.id, type: 'comment', text: c.content, time: c.created_at })
|
||||
}
|
||||
|
||||
// Sort by time, preserving order for entries without timestamps
|
||||
entries.sort((a, b) => {
|
||||
if (!a.time && !b.time) return 0
|
||||
if (!a.time) return -1
|
||||
if (!b.time) return 1
|
||||
return a.time.localeCompare(b.time)
|
||||
})
|
||||
|
||||
// Insert report links: after each contiguous block of steps that ends before a comment/requirement
|
||||
if (props.workflowId && (props.workflowStatus === 'done' || props.workflowStatus === 'failed')) {
|
||||
const result: LogEntry[] = []
|
||||
let lastWasStep = false
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'step') {
|
||||
lastWasStep = true
|
||||
} else if (lastWasStep && (entry.type === 'comment' || entry.type === 'requirement')) {
|
||||
// Insert report link before this comment/requirement
|
||||
result.push({ id: `report-${result.length}`, type: 'report', time: '' })
|
||||
lastWasStep = false
|
||||
} else {
|
||||
lastWasStep = false
|
||||
}
|
||||
result.push(entry)
|
||||
}
|
||||
// Final report link at the end if last entry was a step
|
||||
if (lastWasStep) {
|
||||
result.push({ id: 'report-final', type: 'report', time: '' })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return entries
|
||||
})
|
||||
|
||||
watch(logEntries, () => {
|
||||
if (userScrolledUp.value) return
|
||||
nextTick(() => {
|
||||
const el = scrollContainer.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="execution-section">
|
||||
<div class="execution-section" ref="scrollContainer" @scroll="onScroll">
|
||||
<div class="section-header">
|
||||
<h2>执行</h2>
|
||||
<h2>日志</h2>
|
||||
</div>
|
||||
<div class="exec-list">
|
||||
<div
|
||||
v-for="step in steps"
|
||||
:key="step.id"
|
||||
class="exec-item"
|
||||
:class="step.status"
|
||||
>
|
||||
<div class="exec-header" @click="toggleStep(step.id)">
|
||||
<span class="exec-toggle">{{ expandedSteps.has(step.id) ? '▾' : '▸' }}</span>
|
||||
<span class="exec-order">步骤 {{ step.step_order }}</span>
|
||||
<span class="exec-status" :class="step.status">{{ statusLabel(step.status) }}</span>
|
||||
<template v-for="entry in logEntries" :key="entry.id">
|
||||
<!-- User message (requirement or comment) -->
|
||||
<div v-if="entry.type === 'requirement' || entry.type === 'comment'" class="log-user">
|
||||
<span class="log-time" v-if="entry.time">{{ formatTime(entry.time) }}</span>
|
||||
<span class="log-tag">{{ entry.type === 'requirement' ? '需求' : '反馈' }}</span>
|
||||
<span class="log-text">{{ entry.text }}</span>
|
||||
</div>
|
||||
<div v-if="expandedSteps.has(step.id) && step.output" class="exec-output">
|
||||
<pre>{{ step.output }}</pre>
|
||||
|
||||
<!-- Report link -->
|
||||
<div v-else-if="entry.type === 'report'" class="report-link-bar">
|
||||
<a :href="'/report/' + workflowId" class="report-link" target="_blank">查看报告 →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!steps.length" class="empty-state">
|
||||
计划生成后,执行进度将显示在这里
|
||||
|
||||
<!-- Step -->
|
||||
<div v-else-if="entry.step" class="exec-item" :class="entry.step.status">
|
||||
<div class="exec-header" @click="toggleStep(entry.step!.id)">
|
||||
<span class="exec-time" v-if="entry.time">{{ formatTime(entry.time) }}</span>
|
||||
<span v-if="entry.step.plan_step_id && planStepOrderMap[entry.step.plan_step_id]" class="step-badge">{{ planStepOrderMap[entry.step.plan_step_id] }}</span>
|
||||
<span class="exec-toggle">{{ expandedSteps.has(entry.step!.id) ? '▾' : '▸' }}</span>
|
||||
<span class="exec-desc">{{ entry.step.description }}</span>
|
||||
<span class="exec-status" :class="entry.step.status">{{ statusLabel(entry.step.status) }}</span>
|
||||
</div>
|
||||
<div v-if="expandedSteps.has(entry.step!.id)" class="exec-detail">
|
||||
<div v-if="entry.step.command" class="exec-command">
|
||||
<code>$ {{ entry.step.command }}</code>
|
||||
</div>
|
||||
<pre v-if="entry.step.output">{{ entry.step.output }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!steps.length && !requirement" class="empty-state">
|
||||
提交需求后,日志将显示在这里
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,6 +200,35 @@ function statusLabel(status: string) {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.log-user {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.log-time, .exec-time {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.log-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.exec-item {
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
@@ -100,7 +246,20 @@ function statusLabel(status: string) {
|
||||
}
|
||||
|
||||
.exec-header:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.step-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border-radius: 9px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exec-toggle {
|
||||
@@ -110,7 +269,7 @@ function statusLabel(status: string) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exec-order {
|
||||
.exec-desc {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
@@ -123,24 +282,36 @@ function statusLabel(status: string) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.exec-status.done { background: var(--success); color: #000; }
|
||||
.exec-status.running { background: var(--accent); color: #000; }
|
||||
.exec-status.done { background: var(--success); color: #fff; }
|
||||
.exec-status.running { background: var(--accent); color: #fff; }
|
||||
.exec-status.failed { background: var(--error); color: #fff; }
|
||||
.exec-status.pending { background: var(--pending); color: #fff; }
|
||||
|
||||
.exec-output {
|
||||
padding: 8px 12px;
|
||||
.exec-detail {
|
||||
border-top: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.exec-output pre {
|
||||
.exec-command {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.exec-command code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.exec-detail pre {
|
||||
padding: 8px 12px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -149,4 +320,23 @@ function statusLabel(status: string) {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.report-link-bar {
|
||||
margin: 4px 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.report-link {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.report-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { PlanStep } from '../types'
|
||||
|
||||
defineProps<{
|
||||
steps: PlanStep[]
|
||||
}>()
|
||||
|
||||
const expandedSteps = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleStep(id: string) {
|
||||
if (expandedSteps.value.has(id)) {
|
||||
expandedSteps.value.delete(id)
|
||||
} else {
|
||||
expandedSteps.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
function statusIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'done': return '✓'
|
||||
@@ -18,7 +29,7 @@ function statusIcon(status: string) {
|
||||
<template>
|
||||
<div class="plan-section">
|
||||
<div class="section-header">
|
||||
<h2>Plan</h2>
|
||||
<h2>计划</h2>
|
||||
</div>
|
||||
<div class="steps-list">
|
||||
<div
|
||||
@@ -27,12 +38,18 @@ function statusIcon(status: string) {
|
||||
class="step-item"
|
||||
:class="step.status"
|
||||
>
|
||||
<span class="step-icon">{{ statusIcon(step.status) }}</span>
|
||||
<span class="step-order">{{ step.step_order }}.</span>
|
||||
<span class="step-desc">{{ step.description }}</span>
|
||||
<div class="step-header" @click="step.command ? toggleStep(step.id) : undefined">
|
||||
<span class="step-icon">{{ statusIcon(step.status) }}</span>
|
||||
<span class="step-order">{{ step.step_order }}.</span>
|
||||
<span class="step-title">{{ step.description }}</span>
|
||||
<span v-if="step.command" class="step-toggle">{{ expandedSteps.has(step.id) ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
<div v-if="step.command && expandedSteps.has(step.id)" class="step-detail">
|
||||
{{ step.command }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!steps.length" class="empty-state">
|
||||
提交需求后,AI 将在这里生成计划
|
||||
AI 将在这里展示执行计划
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,14 +85,9 @@ function statusIcon(status: string) {
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.step-item.done { border-left: 3px solid var(--success); }
|
||||
@@ -83,6 +95,24 @@ function statusIcon(status: string) {
|
||||
.step-item.failed { border-left: 3px solid var(--error); }
|
||||
.step-item.pending { border-left: 3px solid var(--pending); opacity: 0.7; }
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.step-header:has(.step-toggle) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.step-header:has(.step-toggle):hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
@@ -99,8 +129,24 @@ function statusIcon(status: string) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
.step-title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-toggle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-detail {
|
||||
padding: 6px 10px 10px 44px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
|
||||
232
web/src/components/ReportView.vue
Normal file
232
web/src/components/ReportView.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import mermaid from 'mermaid'
|
||||
import { api } from '../api'
|
||||
|
||||
const props = defineProps<{
|
||||
workflowId: string
|
||||
}>()
|
||||
|
||||
const html = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'default',
|
||||
})
|
||||
|
||||
async function renderMermaid() {
|
||||
await nextTick()
|
||||
const els = document.querySelectorAll('.report-body pre code.language-mermaid')
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
const el = els[i] as HTMLElement
|
||||
const pre = el.parentElement!
|
||||
const source = el.textContent || ''
|
||||
try {
|
||||
const { svg } = await mermaid.render(`mermaid-${i}`, source)
|
||||
const div = document.createElement('div')
|
||||
div.className = 'mermaid-chart'
|
||||
div.innerHTML = svg
|
||||
pre.replaceWith(div)
|
||||
} catch {
|
||||
// leave as code block on failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.getReport(props.workflowId)
|
||||
html.value = await marked.parse(res.report)
|
||||
await renderMermaid()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="report-page">
|
||||
<div class="report-toolbar">
|
||||
<a href="/" class="back-link">← 返回</a>
|
||||
<span class="report-title">执行报告</span>
|
||||
</div>
|
||||
<div v-if="loading" class="report-loading">加载中...</div>
|
||||
<div v-else-if="error" class="report-error">{{ error }}</div>
|
||||
<div v-else class="report-body" v-html="html"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.report-page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 32px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.report-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.report-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.report-loading,
|
||||
.report-error {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.report-error {
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Unscoped styles for rendered markdown */
|
||||
.report-body {
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.report-body h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.report-body h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0 12px;
|
||||
}
|
||||
|
||||
.report-body h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0 8px;
|
||||
}
|
||||
|
||||
.report-body p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.report-body ul,
|
||||
.report-body ol {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.report-body li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.report-body pre {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.report-body code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.report-body :not(pre) > code {
|
||||
background: var(--bg-secondary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.report-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.report-body th,
|
||||
.report-body td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.report-body th {
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-body blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: 16px;
|
||||
margin: 0 0 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.report-body a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.report-body a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.report-body .mermaid-chart {
|
||||
margin: 16px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.report-body .mermaid-chart svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.report-body img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.report-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 20px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
requirement: string
|
||||
@@ -13,6 +13,13 @@ const emit = defineEmits<{
|
||||
const input = ref('')
|
||||
const editing = ref(!props.requirement)
|
||||
|
||||
// 当 requirement 从外部更新(如 loadData 完成),自动退出编辑模式
|
||||
watch(() => props.requirement, (val) => {
|
||||
if (val && editing.value && !input.value.trim()) {
|
||||
editing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const text = input.value.trim()
|
||||
if (!text) return
|
||||
@@ -29,8 +36,9 @@ function submit() {
|
||||
{{ status }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!editing && requirement" class="requirement-display" @dblclick="editing = true">
|
||||
{{ requirement }}
|
||||
<div v-if="!editing && requirement" class="requirement-display">
|
||||
<span>{{ requirement }}</span>
|
||||
<button class="btn-edit" @click="editing = true; input = requirement">编辑</button>
|
||||
</div>
|
||||
<div v-else class="requirement-input">
|
||||
<textarea
|
||||
@@ -80,10 +88,27 @@ function submit() {
|
||||
.status-badge.failed { background: var(--error); color: #fff; }
|
||||
|
||||
.requirement-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.requirement-input {
|
||||
|
||||
317
web/src/components/TimerSection.vue
Normal file
317
web/src/components/TimerSection.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import type { Timer } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
}>()
|
||||
|
||||
const timers = ref<Timer[]>([])
|
||||
const showForm = ref(false)
|
||||
const formName = ref('')
|
||||
const formInterval = ref(300)
|
||||
const formRequirement = ref('')
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTimers()
|
||||
})
|
||||
|
||||
async function loadTimers() {
|
||||
try {
|
||||
timers.value = await api.listTimers(props.projectId)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
const name = formName.value.trim()
|
||||
const req = formRequirement.value.trim()
|
||||
if (!name || !req) return
|
||||
|
||||
try {
|
||||
const t = await api.createTimer(props.projectId, {
|
||||
name,
|
||||
interval_secs: formInterval.value,
|
||||
requirement: req,
|
||||
})
|
||||
timers.value.unshift(t)
|
||||
showForm.value = false
|
||||
formName.value = ''
|
||||
formInterval.value = 300
|
||||
formRequirement.value = ''
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggle(timer: Timer) {
|
||||
try {
|
||||
const updated = await api.updateTimer(timer.id, { enabled: !timer.enabled })
|
||||
const idx = timers.value.findIndex(t => t.id === timer.id)
|
||||
if (idx >= 0) timers.value[idx] = updated
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(timer: Timer) {
|
||||
try {
|
||||
await api.deleteTimer(timer.id)
|
||||
timers.value = timers.value.filter(t => t.id !== timer.id)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function formatInterval(secs: number): string {
|
||||
if (secs < 3600) return `${Math.round(secs / 60)}分钟`
|
||||
if (secs < 86400) return `${Math.round(secs / 3600)}小时`
|
||||
return `${Math.round(secs / 86400)}天`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timer-section">
|
||||
<div class="section-header">
|
||||
<h2>定时任务</h2>
|
||||
<button class="btn-add" @click="showForm = !showForm">{{ showForm ? '取消' : '+ 新建' }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="timer-error" @click="error = ''">{{ error }}</div>
|
||||
|
||||
<div v-if="showForm" class="timer-form">
|
||||
<input v-model="formName" class="form-input" placeholder="任务名称" />
|
||||
<div class="interval-row">
|
||||
<label>间隔</label>
|
||||
<select v-model="formInterval" class="form-select">
|
||||
<option :value="60">1 分钟</option>
|
||||
<option :value="300">5 分钟</option>
|
||||
<option :value="600">10 分钟</option>
|
||||
<option :value="1800">30 分钟</option>
|
||||
<option :value="3600">1 小时</option>
|
||||
<option :value="21600">6 小时</option>
|
||||
<option :value="43200">12 小时</option>
|
||||
<option :value="86400">1 天</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea v-model="formRequirement" class="form-textarea" placeholder="执行需求(和创建 workflow 一样)" rows="2" />
|
||||
<button class="btn-create" @click="onCreate" :disabled="!formName.trim() || !formRequirement.trim()">创建</button>
|
||||
</div>
|
||||
|
||||
<div class="timer-list">
|
||||
<div v-for="timer in timers" :key="timer.id" class="timer-item" :class="{ disabled: !timer.enabled }">
|
||||
<div class="timer-info">
|
||||
<span class="timer-name">{{ timer.name }}</span>
|
||||
<span class="timer-interval">{{ formatInterval(timer.interval_secs) }}</span>
|
||||
</div>
|
||||
<div class="timer-req">{{ timer.requirement }}</div>
|
||||
<div class="timer-actions">
|
||||
<button class="btn-toggle" :class="{ on: timer.enabled }" @click="onToggle(timer)">
|
||||
{{ timer.enabled ? '已启用' : '已停用' }}
|
||||
</button>
|
||||
<button class="btn-delete" @click="onDelete(timer)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!timers.length && !showForm" class="empty-state">暂无定时任务</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timer-section {
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.timer-error {
|
||||
background: rgba(239, 83, 80, 0.15);
|
||||
border: 1px solid var(--error);
|
||||
color: var(--error);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timer-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
padding: 7px 10px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.interval-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.interval-row label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
align-self: flex-end;
|
||||
padding: 6px 16px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-create:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.timer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.timer-item {
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.timer-item.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.timer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timer-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.timer-interval {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.timer-req {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.timer-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--pending);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-toggle.on {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import RequirementSection from './RequirementSection.vue'
|
||||
import PlanSection from './PlanSection.vue'
|
||||
import ExecutionSection from './ExecutionSection.vue'
|
||||
import CommentSection from './CommentSection.vue'
|
||||
import TimerSection from './TimerSection.vue'
|
||||
import { api } from '../api'
|
||||
import { connectWs } from '../ws'
|
||||
import type { Workflow, PlanStep, Comment } from '../types'
|
||||
@@ -13,10 +14,18 @@ const props = defineProps<{
|
||||
projectId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
projectUpdate: [projectId: string, name: string]
|
||||
}>()
|
||||
|
||||
const workflow = ref<Workflow | null>(null)
|
||||
const steps = ref<PlanStep[]>([])
|
||||
const comments = ref<Comment[]>([])
|
||||
const error = ref('')
|
||||
const rightTab = ref<'log' | 'timers'>('log')
|
||||
|
||||
const planSteps = computed(() => steps.value.filter(s => s.kind === 'plan'))
|
||||
const logSteps = computed(() => steps.value.filter(s => s.kind === 'log'))
|
||||
|
||||
let wsConn: { close: () => void } | null = null
|
||||
|
||||
@@ -46,7 +55,6 @@ function handleWsMessage(msg: WsMessage) {
|
||||
switch (msg.type) {
|
||||
case 'PlanUpdate':
|
||||
if (workflow.value && msg.workflow_id === workflow.value.id) {
|
||||
// Reload steps from API to get full DB records
|
||||
api.listSteps(workflow.value.id).then(s => { steps.value = s })
|
||||
}
|
||||
break
|
||||
@@ -56,7 +64,6 @@ function handleWsMessage(msg: WsMessage) {
|
||||
if (existing) {
|
||||
steps.value[idx] = { ...existing, status: msg.status as PlanStep['status'], output: msg.output }
|
||||
} else {
|
||||
// New step, reload
|
||||
if (workflow.value) {
|
||||
api.listSteps(workflow.value.id).then(s => { steps.value = s })
|
||||
}
|
||||
@@ -68,6 +75,19 @@ function handleWsMessage(msg: WsMessage) {
|
||||
workflow.value = { ...workflow.value, status: msg.status as any }
|
||||
}
|
||||
break
|
||||
case 'RequirementUpdate':
|
||||
if (workflow.value && msg.workflow_id === workflow.value.id) {
|
||||
workflow.value = { ...workflow.value, requirement: msg.requirement }
|
||||
}
|
||||
break
|
||||
case 'ReportReady':
|
||||
if (workflow.value && msg.workflow_id === workflow.value.id) {
|
||||
workflow.value = { ...workflow.value, status: workflow.value.status }
|
||||
}
|
||||
break
|
||||
case 'ProjectUpdate':
|
||||
emit('projectUpdate', msg.project_id, msg.name)
|
||||
break
|
||||
case 'Error':
|
||||
error.value = msg.message
|
||||
break
|
||||
@@ -124,11 +144,29 @@ async function onSubmitComment(text: string) {
|
||||
@submit="onSubmitRequirement"
|
||||
/>
|
||||
<div class="plan-exec-row">
|
||||
<PlanSection :steps="steps" />
|
||||
<ExecutionSection :steps="steps" />
|
||||
<PlanSection :steps="planSteps" />
|
||||
<div class="right-panel">
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn" :class="{ active: rightTab === 'log' }" @click="rightTab = 'log'">日志</button>
|
||||
<button class="tab-btn" :class="{ active: rightTab === 'timers' }" @click="rightTab = 'timers'">定时任务</button>
|
||||
</div>
|
||||
<ExecutionSection
|
||||
v-show="rightTab === 'log'"
|
||||
:steps="logSteps"
|
||||
:planSteps="planSteps"
|
||||
:comments="comments"
|
||||
:requirement="workflow?.requirement || ''"
|
||||
:createdAt="workflow?.created_at || ''"
|
||||
:workflowStatus="workflow?.status || 'pending'"
|
||||
:workflowId="workflow?.id || ''"
|
||||
/>
|
||||
<TimerSection
|
||||
v-show="rightTab === 'timers'"
|
||||
:projectId="projectId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CommentSection
|
||||
:comments="comments"
|
||||
:disabled="!workflow"
|
||||
@submit="onSubmitComment"
|
||||
/>
|
||||
@@ -162,4 +200,40 @@ async function onSubmitComment(text: string) {
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px 6px 0 0;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--accent);
|
||||
background: var(--bg-card);
|
||||
border-bottom-color: var(--bg-card);
|
||||
}
|
||||
|
||||
.tab-btn:hover:not(.active) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,19 +6,19 @@
|
||||
|
||||
:root {
|
||||
--sidebar-width: 240px;
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-tertiary: #0f3460;
|
||||
--bg-card: #1e2a4a;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0b0;
|
||||
--accent: #4fc3f7;
|
||||
--accent-hover: #29b6f6;
|
||||
--border: #2a3a5e;
|
||||
--success: #66bb6a;
|
||||
--warning: #ffa726;
|
||||
--error: #ef5350;
|
||||
--pending: #78909c;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f7f8fa;
|
||||
--bg-tertiary: #eef0f4;
|
||||
--bg-card: #ffffff;
|
||||
--text-primary: #1a1a2e;
|
||||
--text-secondary: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--border: #e2e5ea;
|
||||
--success: #16a34a;
|
||||
--warning: #d97706;
|
||||
--error: #dc2626;
|
||||
--pending: #9ca3af;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Workflow {
|
||||
requirement: string
|
||||
status: 'pending' | 'planning' | 'executing' | 'done' | 'failed'
|
||||
created_at: string
|
||||
report: string
|
||||
}
|
||||
|
||||
export interface PlanStep {
|
||||
@@ -19,8 +20,12 @@ export interface PlanStep {
|
||||
workflow_id: string
|
||||
step_order: number
|
||||
description: string
|
||||
command: string
|
||||
status: 'pending' | 'running' | 'done' | 'failed'
|
||||
output: string
|
||||
created_at: string
|
||||
kind: 'plan' | 'log'
|
||||
plan_step_id: string
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
@@ -29,3 +34,14 @@ export interface Comment {
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Timer {
|
||||
id: string
|
||||
project_id: string
|
||||
name: string
|
||||
interval_secs: number
|
||||
requirement: string
|
||||
enabled: boolean
|
||||
last_run_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -17,12 +17,29 @@ export interface WsWorkflowStatusUpdate {
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface WsRequirementUpdate {
|
||||
type: 'RequirementUpdate'
|
||||
workflow_id: string
|
||||
requirement: string
|
||||
}
|
||||
|
||||
export interface WsReportReady {
|
||||
type: 'ReportReady'
|
||||
workflow_id: string
|
||||
}
|
||||
|
||||
export interface WsProjectUpdate {
|
||||
type: 'ProjectUpdate'
|
||||
project_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface WsError {
|
||||
type: 'Error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type WsMessage = WsPlanUpdate | WsStepStatusUpdate | WsWorkflowStatusUpdate | WsError
|
||||
export type WsMessage = WsPlanUpdate | WsStepStatusUpdate | WsWorkflowStatusUpdate | WsRequirementUpdate | WsReportReady | WsProjectUpdate | WsError
|
||||
|
||||
export type WsHandler = (msg: WsMessage) => void
|
||||
|
||||
|
||||
Reference in New Issue
Block a user