feat(web): workspace selector on issue/requirement detail

This commit is contained in:
2026-05-02 23:39:44 +08:00
parent 0423f0566c
commit 7939960011
3 changed files with 65 additions and 3 deletions
+11 -1
View File
@@ -25,6 +25,7 @@ export interface Requirement {
phase: Phase phase: Phase
status: Status status: Status
owner_id: string owner_id: string
workspace_id: string | null
closed_at?: string | null closed_at?: string | null
created_at: string created_at: string
updated_at: string updated_at: string
@@ -34,6 +35,7 @@ export interface Issue {
id: string id: string
project_id: string project_id: string
requirement_id: string | null requirement_id: string | null
workspace_id: string | null
number: number number: number
title: string title: string
description: string description: string
@@ -92,7 +94,13 @@ export const projectsApi = {
updateRequirement: ( updateRequirement: (
slug: string, slug: string,
number: number, number: number,
body: Partial<{ title: string; description: string; owner_id: string }>, body: Partial<{
title: string
description: string
owner_id: string
// 三态:未传字段 / 显式 null(detach) / "<uuid>"(attach)
workspace_id: string | null
}>,
) => ) =>
request<Requirement>(`/api/v1/projects/${enc(slug)}/requirements/${number}`, { request<Requirement>(`/api/v1/projects/${enc(slug)}/requirements/${number}`, {
method: 'PATCH', method: 'PATCH',
@@ -141,6 +149,8 @@ export const projectsApi = {
description: string description: string
// 三态:未传字段 / 显式 null(detach) / number(attach/move) // 三态:未传字段 / 显式 null(detach) / number(attach/move)
requirement_number: number | null requirement_number: number | null
// 三态:未传字段 / 显式 null(detach) / "<uuid>"(attach)
workspace_id: string | null
}>, }>,
) => ) =>
request<Issue>(`/api/v1/projects/${enc(slug)}/issues/${number}`, { request<Issue>(`/api/v1/projects/${enc(slug)}/issues/${number}`, {
@@ -58,6 +58,17 @@
/> />
</NCard> </NCard>
<!-- Workspace card -->
<NCard title="工作区">
<NSelect
:value="issue.workspace_id"
:options="workspaceOptions"
placeholder="未关联"
clearable
@update:value="onWorkspaceChange"
/>
</NCard>
<!-- Assignee card --> <!-- Assignee card -->
<NCard title="指派"> <NCard title="指派">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -118,11 +129,13 @@ import { NCard, NButton, NTag, NSpin, NSelect } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import { projectsApi } from '@/api/projects' import { projectsApi } from '@/api/projects'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useWorkspacesStore } from '@/stores/workspaces'
import type { Issue, Requirement } from '@/api/projects' import type { Issue, Requirement } from '@/api/projects'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const wsStore = useWorkspacesStore()
const slug = route.params.slug as string const slug = route.params.slug as string
const number = Number(route.params.number) const number = Number(route.params.number)
@@ -138,6 +151,10 @@ const requirementOptions = computed(() =>
requirements.value.map((r) => ({ label: `R-${r.number}: ${r.title}`, value: r.number })), requirements.value.map((r) => ({ label: `R-${r.number}: ${r.title}`, value: r.number })),
) )
const workspaceOptions = computed(() =>
wsStore.list.map((w) => ({ label: `${w.name} (${w.slug})`, value: w.id })),
)
const mountedRequirementNumber = computed<number | null>(() => { const mountedRequirementNumber = computed<number | null>(() => {
if (!issue.value?.requirement_id) return null if (!issue.value?.requirement_id) return null
const req = requirements.value.find((r) => r.id === issue.value!.requirement_id) const req = requirements.value.find((r) => r.id === issue.value!.requirement_id)
@@ -150,6 +167,7 @@ async function fetchData() {
const [fetchedIssue, reqs] = await Promise.all([ const [fetchedIssue, reqs] = await Promise.all([
projectsApi.getIssue(slug, number), projectsApi.getIssue(slug, number),
projectsApi.listRequirements(slug), projectsApi.listRequirements(slug),
wsStore.fetchByProject(slug),
]) ])
issue.value = fetchedIssue issue.value = fetchedIssue
requirements.value = reqs.items requirements.value = reqs.items
@@ -163,6 +181,11 @@ async function onMountChange(v: number | null) {
issue.value = await projectsApi.updateIssue(slug, number, { requirement_number: v }) issue.value = await projectsApi.updateIssue(slug, number, { requirement_number: v })
} }
async function onWorkspaceChange(v: string | null) {
if (!issue.value) return
issue.value = await projectsApi.updateIssue(slug, number, { workspace_id: v })
}
async function onClose() { async function onClose() {
toggling.value = true toggling.value = true
try { try {
@@ -85,6 +85,17 @@
</p> </p>
</NCard> </NCard>
<!-- Workspace card -->
<NCard title="工作区">
<NSelect
:value="data.requirement.workspace_id"
:options="workspaceOptions"
placeholder="未关联"
clearable
@update:value="onWorkspaceChange"
/>
</NCard>
<!-- Linked Issues --> <!-- Linked Issues -->
<NCard title="关联 Issue"> <NCard title="关联 Issue">
<NList <NList
@@ -129,7 +140,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useDialog } from 'naive-ui' import { useDialog } from 'naive-ui'
import { import {
@@ -137,6 +148,7 @@ import {
NButton, NButton,
NTag, NTag,
NSpin, NSpin,
NSelect,
NList, NList,
NListItem, NListItem,
NThing, NThing,
@@ -145,6 +157,7 @@ import {
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import { projectsApi } from '@/api/projects' import { projectsApi } from '@/api/projects'
import { useRequirementsStore } from '@/stores/requirements' import { useRequirementsStore } from '@/stores/requirements'
import { useWorkspacesStore } from '@/stores/workspaces'
import type { Phase, RequirementDetail } from '@/api/projects' import type { Phase, RequirementDetail } from '@/api/projects'
const PHASES: Phase[] = ['planning', 'auditing', 'implementing', 'reviewing', 'done'] const PHASES: Phase[] = ['planning', 'auditing', 'implementing', 'reviewing', 'done']
@@ -161,6 +174,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const dialog = useDialog() const dialog = useDialog()
const store = useRequirementsStore() const store = useRequirementsStore()
const wsStore = useWorkspacesStore()
const slug = route.params.slug as string const slug = route.params.slug as string
const number = Number(route.params.number) const number = Number(route.params.number)
@@ -169,15 +183,30 @@ const loading = ref(false)
const toggling = ref(false) const toggling = ref(false)
const data = ref<RequirementDetail | null>(null) const data = ref<RequirementDetail | null>(null)
const workspaceOptions = computed(() =>
wsStore.list.map((w) => ({ label: `${w.name} (${w.slug})`, value: w.id })),
)
async function fetchData() { async function fetchData() {
loading.value = true loading.value = true
try { try {
data.value = await projectsApi.getRequirement(slug, number) const [detail] = await Promise.all([
projectsApi.getRequirement(slug, number),
wsStore.fetchByProject(slug),
])
data.value = detail
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function onWorkspaceChange(v: string | null) {
if (!data.value) return
await projectsApi.updateRequirement(slug, number, { workspace_id: v })
// Re-fetch to keep data.requirement and data.issues consistent (mirrors onPhaseClick)
data.value = await projectsApi.getRequirement(slug, number)
}
function onPhaseClick(target: Phase) { function onPhaseClick(target: Phase) {
if (!data.value || data.value.requirement.phase === target) return if (!data.value || data.value.requirement.phase === target) return
const targetLabel = PHASE_LABELS[target] const targetLabel = PHASE_LABELS[target]