You've already forked agentic-coding-workflow
feat(web): issue list and detail views
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<AppShell>
|
||||
<NSpin :show="loading">
|
||||
<div v-if="issue" 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">#{{ issue.number }}</span>
|
||||
<h1 class="text-2xl font-semibold">{{ issue.title }}</h1>
|
||||
<NTag :type="issue.status === 'open' ? 'success' : 'default'" size="small">
|
||||
{{ issue.status === 'open' ? '开放' : '关闭' }}
|
||||
</NTag>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<NButton
|
||||
v-if="issue.status === 'open'"
|
||||
type="warning"
|
||||
size="small"
|
||||
:loading="toggling"
|
||||
@click="onClose"
|
||||
>
|
||||
关闭 Issue
|
||||
</NButton>
|
||||
<NButton
|
||||
v-else
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="toggling"
|
||||
@click="onReopen"
|
||||
>
|
||||
重新开放
|
||||
</NButton>
|
||||
<NButton size="small" @click="router.back()">返回</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mount card -->
|
||||
<NCard title="挂载">
|
||||
<NSelect
|
||||
:value="mountedRequirementNumber"
|
||||
:options="requirementOptions"
|
||||
placeholder="未挂载"
|
||||
clearable
|
||||
@update:value="onMountChange"
|
||||
/>
|
||||
</NCard>
|
||||
|
||||
<!-- Assignee card -->
|
||||
<NCard title="指派">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-gray-600">
|
||||
{{ issue.assignee_id ? `已指派:${issue.assignee_id}` : '暂未指派' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<NButton size="small" :loading="assigning" @click="onAssignToMe">指派给我</NButton>
|
||||
<NButton
|
||||
v-if="issue.assignee_id"
|
||||
size="small"
|
||||
:loading="assigning"
|
||||
@click="onUnassign"
|
||||
>
|
||||
取消指派
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<!-- Description card -->
|
||||
<NCard title="描述">
|
||||
<p v-if="issue.description" class="text-gray-700 whitespace-pre-wrap">
|
||||
{{ issue.description }}
|
||||
</p>
|
||||
<p v-else class="text-gray-400 text-sm">暂无描述</p>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loading" class="text-gray-400 text-sm py-8 text-center">
|
||||
Issue 不存在或无权访问。
|
||||
</div>
|
||||
</NSpin>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NCard, NButton, NTag, NSpin, NSelect } from 'naive-ui'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { projectsApi } from '@/api/projects'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Issue, Requirement } from '@/api/projects'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const slug = route.params.slug as string
|
||||
const number = Number(route.params.number)
|
||||
|
||||
const loading = ref(false)
|
||||
const toggling = ref(false)
|
||||
const assigning = ref(false)
|
||||
|
||||
const issue = ref<Issue | null>(null)
|
||||
const requirements = ref<Requirement[]>([])
|
||||
|
||||
const requirementOptions = computed(() =>
|
||||
requirements.value.map((r) => ({ label: `R-${r.number}: ${r.title}`, value: r.number })),
|
||||
)
|
||||
|
||||
const mountedRequirementNumber = computed<number | null>(() => {
|
||||
if (!issue.value?.requirement_id) return null
|
||||
const req = requirements.value.find((r) => r.id === issue.value!.requirement_id)
|
||||
return req?.number ?? null
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [fetchedIssue, reqs] = await Promise.all([
|
||||
projectsApi.getIssue(slug, number),
|
||||
projectsApi.listRequirements(slug),
|
||||
])
|
||||
issue.value = fetchedIssue
|
||||
requirements.value = reqs.items
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onMountChange(v: number | null) {
|
||||
if (!issue.value) return
|
||||
issue.value = await projectsApi.updateIssue(slug, number, { requirement_number: v })
|
||||
}
|
||||
|
||||
async function onClose() {
|
||||
toggling.value = true
|
||||
try {
|
||||
await projectsApi.closeIssue(slug, number)
|
||||
issue.value = await projectsApi.getIssue(slug, number)
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onReopen() {
|
||||
toggling.value = true
|
||||
try {
|
||||
await projectsApi.reopenIssue(slug, number)
|
||||
issue.value = await projectsApi.getIssue(slug, number)
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onAssignToMe() {
|
||||
if (!issue.value) return
|
||||
const userId = auth.user?.id
|
||||
if (!userId) return
|
||||
assigning.value = true
|
||||
try {
|
||||
issue.value = await projectsApi.assignIssue(slug, number, userId)
|
||||
} finally {
|
||||
assigning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnassign() {
|
||||
if (!issue.value) return
|
||||
assigning.value = true
|
||||
try {
|
||||
issue.value = await projectsApi.assignIssue(slug, number, null)
|
||||
} finally {
|
||||
assigning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<AppShell>
|
||||
<NSpin :show="loading">
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Issue 列表</h1>
|
||||
<NButton type="primary" @click="showCreate = true">新建 Issue</NButton>
|
||||
</div>
|
||||
|
||||
<!-- Filter chips -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<NButton
|
||||
:type="filter === 'all' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="setFilter('all')"
|
||||
>
|
||||
全部
|
||||
</NButton>
|
||||
<NButton
|
||||
:type="filter === 'none' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="setFilter('none')"
|
||||
>
|
||||
游离
|
||||
</NButton>
|
||||
<NButton
|
||||
v-for="req in requirements"
|
||||
:key="req.number"
|
||||
:type="filter === String(req.number) ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="setFilter(String(req.number))"
|
||||
>
|
||||
R-{{ req.number }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="items"
|
||||
:row-props="rowProps"
|
||||
:bordered="false"
|
||||
striped
|
||||
/>
|
||||
|
||||
<div v-if="!loading && items.length === 0" class="text-gray-400 text-sm py-8 text-center">
|
||||
暂无 Issue。
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
|
||||
<!-- Create modal -->
|
||||
<NModal v-model:show="showCreate" preset="card" title="新建 Issue" class="w-full max-w-md">
|
||||
<NForm @submit.prevent="onSubmit">
|
||||
<NFormItem label="标题">
|
||||
<NInput v-model:value="form.title" placeholder="Issue 标题" />
|
||||
</NFormItem>
|
||||
<NFormItem label="描述">
|
||||
<NInput v-model:value="form.description" type="textarea" :rows="4" placeholder="(可选)" />
|
||||
</NFormItem>
|
||||
<NFormItem label="挂载到 Requirement(可选)">
|
||||
<NSelect
|
||||
v-model:value="form.requirement_number"
|
||||
:options="requirementOptions"
|
||||
placeholder="不挂载"
|
||||
clearable
|
||||
/>
|
||||
</NFormItem>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<NButton @click="showCreate = false">取消</NButton>
|
||||
<NButton type="primary" attr-type="submit" :loading="creating">创建</NButton>
|
||||
</div>
|
||||
</NForm>
|
||||
<p v-if="createError" class="text-red-500 text-sm mt-2">{{ createError }}</p>
|
||||
</NModal>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
NButton,
|
||||
NTag,
|
||||
NDataTable,
|
||||
NModal,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NSelect,
|
||||
NSpin,
|
||||
} from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { projectsApi } from '@/api/projects'
|
||||
import type { Issue, Requirement } from '@/api/projects'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<Issue[]>([])
|
||||
const requirements = ref<Requirement[]>([])
|
||||
const filter = ref<'all' | 'none' | string>('all')
|
||||
|
||||
const requirementOptions = computed(() =>
|
||||
requirements.value.map((r) => ({ label: `R-${r.number}: ${r.title}`, value: r.number })),
|
||||
)
|
||||
|
||||
async function fetchIssues() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: { requirement?: number | 'none' } = {}
|
||||
if (filter.value === 'none') {
|
||||
params.requirement = 'none'
|
||||
} else if (filter.value !== 'all') {
|
||||
params.requirement = Number(filter.value)
|
||||
}
|
||||
const res = await projectsApi.listIssues(slug, params)
|
||||
items.value = res.items
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setFilter(value: 'all' | 'none' | string) {
|
||||
filter.value = value
|
||||
await fetchIssues()
|
||||
}
|
||||
|
||||
// ---- Table columns ----
|
||||
const columns: DataTableColumns<Issue> = [
|
||||
{
|
||||
title: '#',
|
||||
key: 'number',
|
||||
width: 60,
|
||||
render: (row) => h('span', { class: 'font-mono text-xs text-gray-400' }, `#${row.number}`),
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
},
|
||||
{
|
||||
title: 'Requirement',
|
||||
key: 'requirement_id',
|
||||
width: 120,
|
||||
render: (row) => {
|
||||
if (!row.requirement_id) return h('span', { class: 'text-gray-300 text-xs' }, '—')
|
||||
const req = requirements.value.find((r) => r.id === row.requirement_id)
|
||||
return req
|
||||
? h(NTag, { size: 'small' }, { default: () => `R-${req.number}` })
|
||||
: h('span', { class: 'text-gray-300 text-xs' }, '—')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: 'small', type: row.status === 'open' ? 'success' : 'default' },
|
||||
{ default: () => (row.status === 'open' ? '开放' : '关闭') },
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function rowProps(row: Issue) {
|
||||
return {
|
||||
style: 'cursor: pointer',
|
||||
onClick: () => router.push({ name: 'issue-detail', params: { slug, number: row.number } }),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Create modal ----
|
||||
const showCreate = ref(false)
|
||||
const creating = ref(false)
|
||||
const createError = ref('')
|
||||
const form = ref<{ title: string; description: string; requirement_number: number | null }>({
|
||||
title: '',
|
||||
description: '',
|
||||
requirement_number: null,
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
createError.value = ''
|
||||
if (!form.value.title.trim()) {
|
||||
createError.value = '标题不能为空'
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const body: { title: string; description?: string; requirement_number?: number } = {
|
||||
title: form.value.title,
|
||||
}
|
||||
if (form.value.description) body.description = form.value.description
|
||||
if (form.value.requirement_number !== null) body.requirement_number = form.value.requirement_number
|
||||
const created = await projectsApi.createIssue(slug, body)
|
||||
showCreate.value = false
|
||||
form.value = { title: '', description: '', requirement_number: null }
|
||||
router.push({ name: 'issue-detail', params: { slug, number: created.number } })
|
||||
} catch (e) {
|
||||
createError.value = e instanceof Error ? e.message : '创建失败'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [, reqs] = await Promise.all([fetchIssues(), projectsApi.listRequirements(slug)])
|
||||
requirements.value = reqs.items
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user