可快速切换面板

This commit is contained in:
2026-06-11 14:26:27 +08:00
parent 0561e126cd
commit fba3a14367
2 changed files with 350 additions and 1 deletions
@@ -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>