You've already forked agentic-coding-workflow
可快速切换面板
This commit is contained in:
@@ -88,7 +88,7 @@
|
||||
</p>
|
||||
</NCard>
|
||||
|
||||
<!-- 当前阶段产物(仅 AI 对话三阶段显示) -->
|
||||
<!-- 当前阶段产物(仅 AI 对话三阶段显示,保留编辑功能) -->
|
||||
<ArtifactPanel
|
||||
v-if="chatPhase"
|
||||
ref="artifactPanelRef"
|
||||
@@ -98,6 +98,17 @@
|
||||
:readonly="data.requirement.status !== 'open'"
|
||||
/>
|
||||
|
||||
<!-- 全阶段产物与会话历史(新增) -->
|
||||
<RequirementHistoryPanel
|
||||
v-if="data"
|
||||
ref="historyPanelRef"
|
||||
:slug="slug"
|
||||
:number="number"
|
||||
:requirement-id="data.requirement.id"
|
||||
:workspace-slug="workspaceSlug"
|
||||
:current-phase="data.requirement.phase"
|
||||
/>
|
||||
|
||||
<!-- Workspace card -->
|
||||
<NCard title="工作区">
|
||||
<NSelect
|
||||
@@ -204,6 +215,7 @@ import ArtifactPanel from './components/ArtifactPanel.vue'
|
||||
import RequirementChatPanel from './components/RequirementChatPanel.vue'
|
||||
import RequirementAcpPanel from './components/RequirementAcpPanel.vue'
|
||||
import RequirementSummaryPanel from './components/RequirementSummaryPanel.vue'
|
||||
import RequirementHistoryPanel from './components/RequirementHistoryPanel.vue'
|
||||
import type { Artifact, ArtifactPhase, Phase, RequirementDetail } from '@/api/projects'
|
||||
|
||||
const PHASES: Phase[] = ['planning', 'prototyping', 'auditing', 'implementing', 'reviewing', 'done']
|
||||
@@ -232,12 +244,18 @@ const loading = ref(false)
|
||||
const toggling = ref(false)
|
||||
const data = ref<RequirementDetail | null>(null)
|
||||
const artifactPanelRef = ref<InstanceType<typeof ArtifactPanel> | null>(null)
|
||||
const historyPanelRef = ref<InstanceType<typeof RequirementHistoryPanel> | null>(null)
|
||||
const prevArtifact = ref<Artifact | null>(null)
|
||||
|
||||
const workspaceOptions = computed(() =>
|
||||
wsStore.list.map((w) => ({ label: `${w.name} (${w.slug})`, value: w.id })),
|
||||
)
|
||||
|
||||
const workspaceSlug = computed(() => {
|
||||
if (!data.value?.requirement.workspace_id) return null
|
||||
return wsStore.list.find((w) => w.id === data.value!.requirement.workspace_id)?.slug ?? null
|
||||
})
|
||||
|
||||
// 当前阶段属于 AI 对话三阶段时返回该阶段,否则 null。
|
||||
const chatPhase = computed<ArtifactPhase | null>(() => {
|
||||
const p = data.value?.requirement.phase
|
||||
@@ -271,6 +289,7 @@ watch(chatPhase, loadPrevArtifact)
|
||||
|
||||
function onArtifactSaved() {
|
||||
artifactPanelRef.value?.refresh()
|
||||
historyPanelRef.value?.refresh()
|
||||
}
|
||||
|
||||
async function onWorkspaceChange(v: string | null) {
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<NCard title="产物与会话历史">
|
||||
<NSpin :show="loading">
|
||||
<NCollapse
|
||||
v-if="hasAnyContent"
|
||||
:default-expanded-names="defaultExpandedNames"
|
||||
>
|
||||
<!-- 产物:按阶段分组 -->
|
||||
<NCollapseItem
|
||||
v-for="phase in ARTIFACT_PHASES"
|
||||
:key="phase"
|
||||
:name="phase"
|
||||
:disabled="artifactsByPhase[phase].length === 0"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ PHASE_TITLE[phase] }}产物</span>
|
||||
<NTag
|
||||
size="tiny"
|
||||
:type="phase === currentPhase ? 'primary' : 'default'"
|
||||
>
|
||||
{{ artifactsByPhase[phase].length }}
|
||||
</NTag>
|
||||
<span
|
||||
v-if="phase === currentPhase"
|
||||
class="text-xs text-blue-500"
|
||||
>当前</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NList
|
||||
v-if="artifactsByPhase[phase].length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="a in artifactsByPhase[phase]"
|
||||
:key="a.id"
|
||||
>
|
||||
<NThing
|
||||
class="cursor-pointer"
|
||||
@click="toggleArtifact(a)"
|
||||
>
|
||||
<template #header>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="selectedArtifact?.id === a.id ? 'text-blue-600' : ''"
|
||||
>v{{ a.version }}</span>
|
||||
<NTag
|
||||
v-if="a.source_message_id"
|
||||
size="tiny"
|
||||
class="ml-1"
|
||||
>
|
||||
AI
|
||||
</NTag>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ formatDate(a.created_at) }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="a.note"
|
||||
class="text-xs text-gray-500"
|
||||
>{{ a.note }}</span>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无产物"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<!-- 选中产物内容预览 -->
|
||||
<div
|
||||
v-if="selectedArtifact && selectedArtifact.phase === phase"
|
||||
class="mt-3"
|
||||
>
|
||||
<NDivider />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
class="prose prose-sm max-w-none"
|
||||
v-html="renderedArtifact"
|
||||
/>
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
|
||||
<!-- AI 对话 -->
|
||||
<NCollapseItem
|
||||
name="conversations"
|
||||
:disabled="conversations.length === 0"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">AI 对话</span>
|
||||
<NTag size="tiny">{{ conversations.length }}</NTag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NList
|
||||
v-if="conversations.length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="c in conversations"
|
||||
:key="c.id"
|
||||
>
|
||||
<NThing
|
||||
class="cursor-pointer"
|
||||
@click="goConversation(c)"
|
||||
>
|
||||
<template #header>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="selectedConversation?.id === c.id ? 'text-blue-600' : ''"
|
||||
>
|
||||
{{ c.title || '(未命名)' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ formatDateTime(c.updated_at) }}
|
||||
</span>
|
||||
</template>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无对话"
|
||||
size="small"
|
||||
/>
|
||||
</NCollapseItem>
|
||||
|
||||
<!-- ACP 会话 -->
|
||||
<NCollapseItem
|
||||
name="acp-sessions"
|
||||
:disabled="acpSessions.length === 0"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">ACP 会话</span>
|
||||
<NTag size="tiny">{{ acpSessions.length }}</NTag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NList
|
||||
v-if="acpSessions.length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="s in acpSessions"
|
||||
:key="s.id"
|
||||
>
|
||||
<NThing
|
||||
:class="workspaceSlug ? 'cursor-pointer' : ''"
|
||||
@click="workspaceSlug ? goAcpSession(s) : undefined"
|
||||
>
|
||||
<template #header>
|
||||
<span class="font-mono text-sm">{{ s.branch }}</span>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<SessionStatusBadge :status="s.status" />
|
||||
</template>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ formatDateTime(s.started_at) }}
|
||||
<template v-if="s.last_error"> · {{ s.last_error }}</template>
|
||||
</span>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无会话"
|
||||
size="small"
|
||||
/>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
|
||||
<NEmpty
|
||||
v-else-if="!loading"
|
||||
description="暂无历史产物与会话"
|
||||
size="small"
|
||||
/>
|
||||
</NSpin>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
NCard,
|
||||
NCollapse,
|
||||
NCollapseItem,
|
||||
NDivider,
|
||||
NEmpty,
|
||||
NList,
|
||||
NListItem,
|
||||
NSpin,
|
||||
NTag,
|
||||
NThing,
|
||||
} from 'naive-ui'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
import { chatApi, type Conversation } from '@/api/chat'
|
||||
import { acpApi, type AcpSession } from '@/api/acp'
|
||||
import type { Artifact, ArtifactPhase, Phase } from '@/api/projects'
|
||||
import { useRequirementsStore } from '@/stores/requirements'
|
||||
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
||||
|
||||
const ARTIFACT_PHASES: ArtifactPhase[] = ['planning', 'prototyping', 'auditing']
|
||||
|
||||
const PHASE_TITLE: Record<ArtifactPhase, string> = {
|
||||
planning: '规划',
|
||||
prototyping: '原型',
|
||||
auditing: '评审',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
slug: string
|
||||
number: number
|
||||
requirementId: string
|
||||
workspaceSlug: string | null
|
||||
currentPhase?: Phase | null
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const store = useRequirementsStore()
|
||||
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
|
||||
|
||||
const loading = ref(false)
|
||||
const artifacts = ref<Artifact[]>([])
|
||||
const conversations = ref<Conversation[]>([])
|
||||
const acpSessions = ref<AcpSession[]>([])
|
||||
|
||||
const selectedArtifact = ref<Artifact | null>(null)
|
||||
const selectedConversation = ref<Conversation | null>(null)
|
||||
|
||||
const artifactsByPhase = computed(() => {
|
||||
const map: Record<ArtifactPhase, Artifact[]> = {
|
||||
planning: [],
|
||||
prototyping: [],
|
||||
auditing: [],
|
||||
}
|
||||
for (const a of artifacts.value) {
|
||||
if (map[a.phase]) {
|
||||
map[a.phase].push(a)
|
||||
}
|
||||
}
|
||||
// 每个阶段内按版本号排序
|
||||
for (const phase of ARTIFACT_PHASES) {
|
||||
map[phase].sort((a, b) => a.version - b.version)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const hasAnyContent = computed(() =>
|
||||
artifacts.value.length > 0 || conversations.value.length > 0 || acpSessions.value.length > 0,
|
||||
)
|
||||
|
||||
// 默认展开有内容的第一项(优先当前阶段)
|
||||
const defaultExpandedNames = computed(() => {
|
||||
const names: (ArtifactPhase | 'conversations' | 'acp-sessions')[] = []
|
||||
if (props.currentPhase && (ARTIFACT_PHASES as Phase[]).includes(props.currentPhase)) {
|
||||
const cp = props.currentPhase as ArtifactPhase
|
||||
if (artifactsByPhase.value[cp].length > 0) names.push(cp)
|
||||
}
|
||||
for (const phase of ARTIFACT_PHASES) {
|
||||
if (artifactsByPhase.value[phase].length > 0 && !names.includes(phase)) {
|
||||
names.push(phase)
|
||||
}
|
||||
}
|
||||
if (conversations.value.length > 0) names.push('conversations')
|
||||
if (acpSessions.value.length > 0) names.push('acp-sessions')
|
||||
return names.slice(0, 2) // 最多默认展开前两项,避免过长
|
||||
})
|
||||
|
||||
const renderedArtifact = computed(() =>
|
||||
selectedArtifact.value ? md.render(selectedArtifact.value.content) : '',
|
||||
)
|
||||
|
||||
function toggleArtifact(a: Artifact) {
|
||||
selectedArtifact.value = selectedArtifact.value?.id === a.id ? null : a
|
||||
}
|
||||
|
||||
function goConversation(c: Conversation) {
|
||||
selectedConversation.value = c
|
||||
router.push({
|
||||
name: 'chat-detail',
|
||||
params: { conversationId: c.id },
|
||||
})
|
||||
}
|
||||
|
||||
function goAcpSession(s: AcpSession) {
|
||||
if (!props.workspaceSlug) return
|
||||
router.push({
|
||||
name: 'acp-sessions-detail',
|
||||
params: { slug: props.slug, wsSlug: props.workspaceSlug, sessionId: s.id },
|
||||
})
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function formatDateTime(d: string) {
|
||||
return new Date(d).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [arts, convs, sessions] = await Promise.all([
|
||||
store.listArtifacts(props.slug, props.number),
|
||||
chatApi.listConversations({ requirement_id: props.requirementId }),
|
||||
acpApi.sessions.list({ requirement_id: props.requirementId }),
|
||||
])
|
||||
artifacts.value = arts
|
||||
conversations.value = convs
|
||||
acpSessions.value = sessions
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
Reference in New Issue
Block a user