Files
agentic-coding-workflow/web/src/views/projects/components/RequirementHistoryPanel.vue
T
2026-06-12 14:36:41 +08:00

270 lines
8.8 KiB
Vue

<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 vue/no-v-html -->
<div class="prose prose-sm max-w-none" v-html="renderedArtifact" />
<!-- eslint-enable vue/no-v-html -->
</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, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
NCard,
NCollapse,
NCollapseItem,
NDivider,
NEmpty,
NList,
NListItem,
NSpin,
NTag,
NThing,
} from 'naive-ui'
import { chatApi, type Conversation } from '@/api/chat'
import { acpApi, type AcpSession } from '@/api/acp'
import type { Artifact, ArtifactPhase, Phase } from '@/api/projects'
import { md } from '@/utils/markdown'
import { formatDate, formatDateTime } from '@/utils/date'
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 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 = ref<(ArtifactPhase | 'conversations' | 'acp-sessions')[]>([])
watch(
[
() => props.currentPhase,
() => artifacts.value.length,
() => conversations.value.length,
() => acpSessions.value.length,
],
() => {
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')
defaultExpandedNames.value = names.slice(0, 2) // 最多默认展开前两项,避免过长
},
{ immediate: true },
)
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 },
})
}
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>