原型阶段

This commit is contained in:
2026-06-09 23:44:31 +08:00
parent ea1d0fb3bd
commit 01b3375395
22 changed files with 1077 additions and 27 deletions
+26 -1
View File
@@ -1,6 +1,6 @@
import { request } from './client'
export type Phase = 'planning' | 'auditing' | 'implementing' | 'reviewing' | 'done'
export type Phase = 'planning' | 'prototyping' | 'auditing' | 'implementing' | 'reviewing' | 'done'
export type Status = 'open' | 'closed'
export type Visibility = 'private' | 'internal'
@@ -47,6 +47,16 @@ export interface Issue {
updated_at: string
}
export interface Prototype {
id: string
requirement_id: string
version: number
content: string
note: string
created_by: string
created_at: string
}
export interface ListWrapper<T> {
items: T[]
}
@@ -116,6 +126,21 @@ export const projectsApi = {
reopenRequirement: (slug: string, number: number) =>
request<void>(`/api/v1/projects/${enc(slug)}/requirements/${number}/reopen`, { method: 'POST' }),
// ===== Prototype =====
listPrototypes: (slug: string, number: number) =>
request<{ prototypes: Prototype[] }>(
`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`,
),
createPrototype: (slug: string, number: number, body: { content: string; note?: string }) =>
request<Prototype>(`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`, {
method: 'POST',
body,
}),
getPrototype: (slug: string, number: number, version: number) =>
request<{ prototype: Prototype }>(
`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes/${version}`,
),
// ===== Issue =====
listIssues: (
slug: string,
+44 -2
View File
@@ -1,6 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { projectsApi, type Phase, type Requirement, type Status } from '@/api/projects'
import {
projectsApi,
type Phase,
type Prototype,
type Requirement,
type Status,
} from '@/api/projects'
export const useRequirementsStore = defineStore('requirements', () => {
const items = ref<Requirement[]>([])
@@ -45,5 +51,41 @@ export const useRequirementsStore = defineStore('requirements', () => {
await refresh(forSlug)
}
return { items, slug, loading, filterPhase, filterStatus, refresh, create, changePhase, close, reopen }
async function listPrototypes(forSlug: string, number: number): Promise<Prototype[]> {
const out = await projectsApi.listPrototypes(forSlug, number)
return out.prototypes
}
async function createPrototype(
forSlug: string,
number: number,
body: { content: string; note?: string },
): Promise<Prototype> {
return await projectsApi.createPrototype(forSlug, number, body)
}
async function getPrototype(
forSlug: string,
number: number,
version: number,
): Promise<Prototype> {
const out = await projectsApi.getPrototype(forSlug, number, version)
return out.prototype
}
return {
items,
slug,
loading,
filterPhase,
filterStatus,
refresh,
create,
changePhase,
close,
reopen,
listPrototypes,
createPrototype,
getPrototype,
}
})
@@ -85,6 +85,99 @@
</p>
</NCard>
<!-- Prototypes -->
<NCard title="原型">
<div class="flex gap-4">
<!-- Version list -->
<div class="w-56 shrink-0">
<NList
v-if="prototypes.length > 0"
:show-divider="false"
>
<NListItem
v-for="p in prototypes"
:key="p.id"
>
<NThing
class="cursor-pointer"
@click="onSelectPrototype(p.version)"
>
<template #header>
<span
class="text-sm font-medium"
:class="selectedVersion === p.version ? 'text-blue-600' : ''"
>v{{ p.version }}</span>
</template>
<template #header-extra>
<span class="text-xs text-gray-400">
{{ new Date(p.created_at).toLocaleString() }}
</span>
</template>
<span
v-if="p.note"
class="text-xs text-gray-500"
>{{ p.note }}</span>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无原型版本"
size="small"
/>
</div>
<!-- Selected content -->
<div class="flex-1 min-w-0">
<p
v-if="selectedPrototype"
class="text-gray-700 whitespace-pre-wrap text-sm"
>
{{ selectedPrototype.content }}
</p>
<p
v-else
class="text-gray-400 text-sm"
>
选择左侧版本以查看内容
</p>
</div>
</div>
<!-- New version form -->
<NDivider />
<div
v-if="data.requirement.status === 'open'"
class="space-y-2"
>
<NInput
v-model:value="newContent"
type="textarea"
placeholder="原型内容"
:autosize="{ minRows: 4, maxRows: 12 }"
/>
<NInput
v-model:value="newNote"
placeholder="备注(可选)"
/>
<NButton
type="primary"
size="small"
:loading="submitting"
:disabled="!newContent.trim()"
@click="onSubmitPrototype"
>
提交新版本
</NButton>
</div>
<p
v-else
class="text-gray-400 text-sm"
>
需求已关闭无法提交新版本
</p>
</NCard>
<!-- Workspace card -->
<NCard title="工作区">
<NSelect
@@ -153,17 +246,20 @@ import {
NListItem,
NThing,
NEmpty,
NInput,
NDivider,
} 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 type { Phase, RequirementDetail } from '@/api/projects'
import type { Phase, Prototype, RequirementDetail } from '@/api/projects'
const PHASES: Phase[] = ['planning', 'auditing', 'implementing', 'reviewing', 'done']
const PHASES: Phase[] = ['planning', 'prototyping', 'auditing', 'implementing', 'reviewing', 'done']
const PHASE_LABELS: Record<Phase, string> = {
planning: '规划中',
prototyping: '原型设计',
auditing: '审核中',
implementing: '实施中',
reviewing: '评审中',
@@ -183,10 +279,20 @@ const loading = ref(false)
const toggling = ref(false)
const data = ref<RequirementDetail | null>(null)
const prototypes = ref<Prototype[]>([])
const selectedVersion = ref<number | null>(null)
const newContent = ref('')
const newNote = ref('')
const submitting = ref(false)
const workspaceOptions = computed(() =>
wsStore.list.map((w) => ({ label: `${w.name} (${w.slug})`, value: w.id })),
)
const selectedPrototype = computed(() =>
prototypes.value.find((p) => p.version === selectedVersion.value) ?? null,
)
async function fetchData() {
loading.value = true
try {
@@ -195,11 +301,46 @@ async function fetchData() {
wsStore.fetchByProject(slug),
])
data.value = detail
await refreshPrototypes()
} finally {
loading.value = false
}
}
async function refreshPrototypes() {
prototypes.value = await store.listPrototypes(slug, number)
// 默认选中最新版本
if (prototypes.value.length > 0) {
const latest = prototypes.value[prototypes.value.length - 1]
if (selectedVersion.value === null || !selectedPrototype.value) {
selectedVersion.value = latest.version
}
} else {
selectedVersion.value = null
}
}
function onSelectPrototype(version: number) {
selectedVersion.value = version
}
async function onSubmitPrototype() {
if (!newContent.value.trim()) return
submitting.value = true
try {
const created = await store.createPrototype(slug, number, {
content: newContent.value,
note: newNote.value || undefined,
})
newContent.value = ''
newNote.value = ''
selectedVersion.value = created.version
await refreshPrototypes()
} finally {
submitting.value = false
}
}
async function onWorkspaceChange(v: string | null) {
if (!data.value) return
await projectsApi.updateRequirement(slug, number, { workspace_id: v })
@@ -162,10 +162,11 @@ import AppShell from '@/layouts/AppShell.vue'
import { useRequirementsStore } from '@/stores/requirements'
import type { Phase, Requirement } from '@/api/projects'
const PHASES: Phase[] = ['planning', 'auditing', 'implementing', 'reviewing', 'done']
const PHASES: Phase[] = ['planning', 'prototyping', 'auditing', 'implementing', 'reviewing', 'done']
const PHASE_LABELS: Record<Phase, string> = {
planning: '规划中',
prototyping: '原型设计',
auditing: '审核中',
implementing: '实施中',
reviewing: '评审中',