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

281 lines
9.8 KiB
Vue

<template>
<AppShell>
<NSpin :show="loading">
<div v-if="data" class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-gray-400 font-mono text-sm">#{{ data.requirement.number }}</span>
<h1 class="text-2xl font-semibold">
{{ data.requirement.title }}
</h1>
<NTag :type="data.requirement.status === 'open' ? 'success' : 'default'" size="small">
{{ data.requirement.status === 'open' ? '开放' : '关闭' }}
</NTag>
</div>
<div class="flex items-center gap-2">
<NButton
v-if="data.requirement.status === 'open'"
type="warning"
size="small"
:loading="toggling"
@click="onClose"
>
关闭需求
</NButton>
<NButton v-else type="primary" size="small" :loading="toggling" @click="onReopen">
重新开放
</NButton>
<NButton size="small" @click="router.back()"> 返回 </NButton>
</div>
</div>
<!-- Phase flow bar阶段跳转始终由用户确认触发 -->
<div class="flex items-center gap-1">
<template v-for="(phase, idx) in PHASES" :key="phase">
<NButton
:type="phase === data.requirement.phase ? 'primary' : 'default'"
:secondary="phase !== data.requirement.phase"
size="small"
:disabled="phase === data.requirement.phase"
@click="onPhaseClick(phase)"
>
{{ PHASE_LABELS[phase] }}
</NButton>
<span v-if="idx < PHASES.length - 1" class="text-gray-300 text-xs"></span>
</template>
</div>
<!-- 左右布局=需求信息+当前阶段产物=按阶段切换的 AI 工作面板 -->
<div class="flex gap-6 items-start">
<div class="w-[45%] shrink-0 space-y-6">
<!-- Description -->
<NCard title="描述">
<p v-if="data.requirement.description" class="text-gray-700 whitespace-pre-wrap">
{{ data.requirement.description }}
</p>
<p v-else class="text-gray-400 text-sm">暂无描述</p>
</NCard>
<!-- 当前阶段产物 AI 对话三阶段显示保留编辑功能 -->
<ArtifactPanel
v-if="chatPhase"
ref="artifactPanelRef"
:slug="slug"
:number="number"
:phase="chatPhase"
: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
:value="data.requirement.workspace_id"
:options="workspaceOptions"
placeholder="未关联"
clearable
@update:value="onWorkspaceChange"
/>
</NCard>
<!-- Linked Issues -->
<NCard title="关联 Issue">
<NList v-if="data.issues.length > 0" :show-divider="false">
<NListItem v-for="issue in data.issues" :key="issue.id">
<NThing>
<template #header>
<span class="font-mono text-xs text-gray-400 mr-2">#{{ issue.number }}</span>
<span class="text-sm font-medium">{{ issue.title }}</span>
</template>
<template #header-extra>
<NTag :type="issue.status === 'open' ? 'success' : 'default'" size="tiny">
{{ issue.status === 'open' ? '开放' : '关闭' }}
</NTag>
</template>
</NThing>
</NListItem>
</NList>
<NEmpty v-else description="暂无关联 Issue" />
</NCard>
</div>
<!-- 右栏v-if 切换保证阶段变化时旧面板卸载自动断流 -->
<div class="flex-1 min-w-0">
<RequirementChatPanel
v-if="chatPhase"
:slug="slug"
:requirement="data.requirement"
:phase="chatPhase"
:prev-artifact="prevArtifact"
:readonly="data.requirement.status !== 'open'"
class="min-h-[70vh]"
@artifact-saved="onArtifactSaved"
/>
<RequirementAcpPanel
v-else-if="
data.requirement.phase === 'implementing' || data.requirement.phase === 'reviewing'
"
:slug="slug"
:requirement="data.requirement"
/>
<RequirementSummaryPanel v-else :slug="slug" :number="number" />
</div>
</div>
</div>
<div v-else-if="!loading" class="text-gray-400 text-sm py-8 text-center">
需求不存在或无权访问。
</div>
</NSpin>
</AppShell>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDialog } from 'naive-ui'
import { NCard, NButton, NTag, NSpin, NSelect, NList, NListItem, NThing, NEmpty } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { projectsApi } from '@/api/projects'
import { useRequirementsStore } from '@/stores/requirements'
import { useWorkspacesStore } from '@/stores/workspaces'
import { PREV_ARTIFACT_PHASE } from '@/constants/requirementPhasePrompts'
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']
const PHASE_LABELS: Record<Phase, string> = {
planning: '规划中',
prototyping: '原型设计',
auditing: '评审中',
implementing: '实施中',
reviewing: '验收中',
done: '已完成',
}
const CHAT_PHASES: ArtifactPhase[] = ['planning', 'prototyping', 'auditing']
const route = useRoute()
const router = useRouter()
const dialog = useDialog()
const store = useRequirementsStore()
const wsStore = useWorkspacesStore()
const slug = route.params.slug as string
const number = Number(route.params.number)
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
return p && (CHAT_PHASES as Phase[]).includes(p) ? (p as ArtifactPhase) : null
})
async function fetchData() {
loading.value = true
try {
const [detail] = await Promise.all([
projectsApi.getRequirement(slug, number),
wsStore.fetchByProject(slug),
])
data.value = detail
} finally {
loading.value = false
}
}
// 上一阶段产物最新版:注入新会话 system prompt(planning 无上一阶段)。
async function loadPrevArtifact() {
prevArtifact.value = null
if (!chatPhase.value) return
const prevPhase = PREV_ARTIFACT_PHASE[chatPhase.value]
if (!prevPhase) return
const list = await store.listArtifacts(slug, number, prevPhase)
prevArtifact.value = list.length > 0 ? list[list.length - 1] : null
}
watch(chatPhase, loadPrevArtifact)
function onArtifactSaved() {
artifactPanelRef.value?.refresh()
historyPanelRef.value?.refresh()
}
async function onWorkspaceChange(v: string | null) {
if (!data.value) return
await projectsApi.updateRequirement(slug, number, { workspace_id: v })
data.value.requirement.workspace_id = v
}
function onPhaseClick(target: Phase) {
if (!data.value || data.value.requirement.phase === target) return
const targetLabel = PHASE_LABELS[target]
dialog.warning({
title: '切换阶段',
content: `确认将需求移至「${targetLabel}」阶段?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
const updated = await store.changePhase(slug, number, target)
data.value!.requirement = updated
},
})
}
async function onClose() {
toggling.value = true
try {
await store.close(slug, number)
data.value!.requirement.status = 'closed'
} finally {
toggling.value = false
}
}
async function onReopen() {
toggling.value = true
try {
await store.reopen(slug, number)
data.value!.requirement.status = 'open'
} finally {
toggling.value = false
}
}
onMounted(async () => {
await fetchData()
await loadPrevArtifact()
})
</script>