You've already forked agentic-coding-workflow
feat(web): requirement kanban list and detail with phase flow
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useDialog } from 'naive-ui'
|
||||
import {
|
||||
NCard,
|
||||
NButton,
|
||||
NTag,
|
||||
NSpin,
|
||||
NList,
|
||||
NListItem,
|
||||
NThing,
|
||||
NEmpty,
|
||||
} from 'naive-ui'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { projectsApi } from '@/api/projects'
|
||||
import { useRequirementsStore } from '@/stores/requirements'
|
||||
import type { Phase, RequirementDetail } from '@/api/projects'
|
||||
|
||||
const PHASES: Phase[] = ['planning', 'auditing', 'implementing', 'reviewing', 'done']
|
||||
|
||||
const PHASE_LABELS: Record<Phase, string> = {
|
||||
planning: '规划中',
|
||||
auditing: '审核中',
|
||||
implementing: '实施中',
|
||||
reviewing: '评审中',
|
||||
done: '已完成',
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const dialog = useDialog()
|
||||
const store = useRequirementsStore()
|
||||
|
||||
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)
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await projectsApi.getRequirement(slug, number)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 () => {
|
||||
await store.changePhase(slug, number, target)
|
||||
// Refresh local data to reflect the change
|
||||
data.value = await projectsApi.getRequirement(slug, number)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function onClose() {
|
||||
toggling.value = true
|
||||
try {
|
||||
await store.close(slug, number)
|
||||
data.value = await projectsApi.getRequirement(slug, number)
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onReopen() {
|
||||
toggling.value = true
|
||||
try {
|
||||
await store.reopen(slug, number)
|
||||
data.value = await projectsApi.getRequirement(slug, number)
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
Reference in New Issue
Block a user