This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+32
View File
@@ -0,0 +1,32 @@
import { request } from './client'
import type {
ChangeRequest,
CreateChangeRequestBody,
ReviewChangeRequestBody,
MergeChangeRequestBody,
} from './types'
const enc = (v: string) => encodeURIComponent(v)
export const changeRequestsApi = {
list: (wsID: string) =>
request<ChangeRequest[]>(`/api/v1/workspaces/${enc(wsID)}/change-requests`),
create: (wsID: string, body: CreateChangeRequestBody) =>
request<ChangeRequest>(`/api/v1/workspaces/${enc(wsID)}/change-requests`, {
method: 'POST',
body,
}),
get: (id: string) => request<ChangeRequest>(`/api/v1/change-requests/${enc(id)}`),
review: (id: string, body: ReviewChangeRequestBody) =>
request<ChangeRequest>(`/api/v1/change-requests/${enc(id)}/review`, {
method: 'POST',
body,
}),
merge: (id: string, body?: MergeChangeRequestBody) =>
request<ChangeRequest>(`/api/v1/change-requests/${enc(id)}/merge`, {
method: 'POST',
body: body ?? {},
}),
sync: (id: string) =>
request<ChangeRequest>(`/api/v1/change-requests/${enc(id)}/sync`, { method: 'POST' }),
}
+57
View File
@@ -0,0 +1,57 @@
import { request } from './client'
import type { Phase } from './projects'
export type OrchestratorRunStatus =
| 'pending'
| 'running'
| 'paused'
| 'succeeded'
| 'failed'
| 'canceled'
export interface OrchestratorRun {
id: string
project_id: string
requirement_id: string
workspace_id: string
owner_id: string
status: OrchestratorRunStatus
current_phase: Phase
last_error?: string
created_at: string
updated_at: string
ended_at?: string | null
}
export interface StartOrchestratorRunBody {
project_slug: string
requirement_number: number
start_phase?: Phase
phase_agent_kinds?: Record<string, string>
max_attempts_per_phase?: number
max_depth?: number
fan_out_limit?: number
}
const enc = (v: string) => encodeURIComponent(v)
export const orchestratorApi = {
listRuns: (
params: { project_id?: string; requirement_id?: string; status?: OrchestratorRunStatus } = {},
) => {
const q = new URLSearchParams()
if (params.project_id) q.set('project_id', params.project_id)
if (params.requirement_id) q.set('requirement_id', params.requirement_id)
if (params.status) q.set('status', params.status)
const qs = q.toString()
return request<OrchestratorRun[]>(`/api/v1/orchestrator/runs/${qs ? '?' + qs : ''}`)
},
startRun: (body: StartOrchestratorRunBody) =>
request<OrchestratorRun>('/api/v1/orchestrator/runs/', { method: 'POST', body }),
pauseRun: (id: string) =>
request<OrchestratorRun>(`/api/v1/orchestrator/runs/${enc(id)}/pause`, { method: 'POST' }),
resumeRun: (id: string) =>
request<OrchestratorRun>(`/api/v1/orchestrator/runs/${enc(id)}/resume`, { method: 'POST' }),
cancelRun: (id: string) =>
request<OrchestratorRun>(`/api/v1/orchestrator/runs/${enc(id)}/cancel`, { method: 'POST' }),
}
+57
View File
@@ -102,3 +102,60 @@ export interface CommitLog {
date: string
subject: string
}
export interface DiffResp {
diff: string
truncated: boolean
}
export interface DiffParams {
ref?: string
against?: string
staged?: boolean
paths?: string
context?: number
}
export interface ChangeRequest {
id: string
project_id: string
workspace_id: string
requirement_id: string | null
issue_id: string | null
number: number
title: string
description: string
source_branch: string
target_branch: string
state: 'open' | 'merged' | 'closed'
review_verdict: 'pending' | 'approved' | 'changes_requested' | 'rejected'
ci_state: 'unknown' | 'pending' | 'success' | 'failure'
provider: string
external_id: number | null
external_url: string
merge_commit_sha: string
reviewed_by: string | null
reviewed_at: string | null
created_by: string
merged_at: string | null
created_at: string
updated_at: string
}
export interface CreateChangeRequestBody {
source_branch: string
target_branch?: string
title: string
description?: string
requirement_id?: string | null
issue_id?: string | null
}
export interface ReviewChangeRequestBody {
verdict: 'approved' | 'changes_requested' | 'rejected'
note?: string
}
export interface MergeChangeRequestBody {
method?: 'merge' | 'squash' | 'rebase'
}
+25 -6
View File
@@ -11,10 +11,24 @@ import type {
CommitBody,
CommitResp,
CommitLog,
DiffResp,
DiffParams,
} from './types'
const enc = (v: string) => encodeURIComponent(v)
function diffQuery(params?: DiffParams): string {
if (!params) return ''
const q = new URLSearchParams()
if (params.ref) q.set('ref', params.ref)
if (params.against) q.set('against', params.against)
if (params.staged) q.set('staged', 'true')
if (params.paths) q.set('paths', params.paths)
if (params.context != null) q.set('context', String(params.context))
const s = q.toString()
return s ? `?${s}` : ''
}
export const workspacesApi = {
// ===== Workspace =====
list: (projectSlug: string) =>
@@ -27,8 +41,7 @@ export const workspacesApi = {
get: (wsID: string) => request<Workspace>(`/api/v1/workspaces/${enc(wsID)}`),
update: (wsID: string, body: UpdateWorkspaceBody) =>
request<Workspace>(`/api/v1/workspaces/${enc(wsID)}`, { method: 'PATCH', body }),
delete: (wsID: string) =>
request<void>(`/api/v1/workspaces/${enc(wsID)}`, { method: 'DELETE' }),
delete: (wsID: string) => request<void>(`/api/v1/workspaces/${enc(wsID)}`, { method: 'DELETE' }),
sync: (wsID: string) =>
request<Workspace>(`/api/v1/workspaces/${enc(wsID)}/sync`, { method: 'POST' }),
@@ -42,8 +55,7 @@ export const workspacesApi = {
request<CredentialMeta>(`/api/v1/workspaces/${enc(wsID)}/credential`),
// ===== Worktree =====
listWorktrees: (wsID: string) =>
request<Worktree[]>(`/api/v1/workspaces/${enc(wsID)}/worktrees`),
listWorktrees: (wsID: string) => request<Worktree[]>(`/api/v1/workspaces/${enc(wsID)}/worktrees`),
createWorktree: (wsID: string, body: CreateWorktreeBody) =>
request<Worktree>(`/api/v1/workspaces/${enc(wsID)}/worktrees`, { method: 'POST', body }),
deleteWorktree: (wtID: string) =>
@@ -54,8 +66,9 @@ export const workspacesApi = {
request<Worktree>(`/api/v1/worktrees/${enc(wtID)}/release`, { method: 'POST' }),
listBranches: (wsID: string): Promise<string[]> =>
request<{ branches: string[] }>(`/api/v1/workspaces/${enc(wsID)}/branches`)
.then(r => r.branches),
request<{ branches: string[] }>(`/api/v1/workspaces/${enc(wsID)}/branches`).then(
(r) => r.branches,
),
// ===== Git ops =====
statusOnMain: (wsID: string) =>
@@ -74,4 +87,10 @@ export const workspacesApi = {
request<void>(`/api/v1/worktrees/${enc(wtID)}/push`, { method: 'POST' }),
logOnWorktree: (wtID: string, n = 50) =>
request<CommitLog[]>(`/api/v1/worktrees/${enc(wtID)}/log?n=${n}`),
// ===== Diff =====
diff: (wsID: string, params?: DiffParams) =>
request<DiffResp>(`/api/v1/workspaces/${enc(wsID)}/diff${diffQuery(params)}`),
diffOnWorktree: (wtID: string, params?: DiffParams) =>
request<DiffResp>(`/api/v1/worktrees/${enc(wtID)}/diff${diffQuery(params)}`),
}
+154
View File
@@ -0,0 +1,154 @@
<template>
<div class="diff-viewer">
<NAlert v-if="truncated" type="warning" :show-icon="true" class="mb-2">
差异内容过大已截断显示上限 1 MiB
</NAlert>
<NEmpty v-if="files.length === 0" description="没有差异" />
<div v-for="(file, fi) in files" :key="fi" class="diff-file">
<div class="diff-file-header">
{{ file.header }}
</div>
<table class="diff-table">
<tbody>
<tr v-for="(line, li) in file.lines" :key="li" :class="lineClass(line.kind)">
<td class="diff-gutter">
{{
line.kind === 'add'
? '+'
: line.kind === 'del'
? '-'
: line.kind === 'hunk'
? ''
: ' '
}}
</td>
<td class="diff-code">
<pre>{{ line.text }}</pre>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { NAlert, NEmpty } from 'naive-ui'
type LineKind = 'add' | 'del' | 'context' | 'hunk' | 'meta'
interface DiffLine {
kind: LineKind
text: string
}
interface DiffFile {
header: string
lines: DiffLine[]
}
const props = defineProps<{ diff: string; truncated?: boolean }>()
// parseUnifiedDiff splits raw `git diff` text into per-file blocks and classifies
// each line for +/- coloring. No external lib: the format is simple enough.
const files = computed<DiffFile[]>(() => {
const out: DiffFile[] = []
if (!props.diff) return out
let current: DiffFile | null = null
for (const raw of props.diff.split('\n')) {
if (raw.startsWith('diff --git')) {
current = { header: raw.replace('diff --git ', ''), lines: [] }
out.push(current)
continue
}
if (!current) {
current = { header: '(diff)', lines: [] }
out.push(current)
}
let kind: LineKind = 'context'
if (raw.startsWith('@@')) kind = 'hunk'
else if (
raw.startsWith('+++') ||
raw.startsWith('---') ||
raw.startsWith('index ') ||
raw.startsWith('new file') ||
raw.startsWith('deleted file') ||
raw.startsWith('similarity') ||
raw.startsWith('rename ')
)
kind = 'meta'
else if (raw.startsWith('+')) kind = 'add'
else if (raw.startsWith('-')) kind = 'del'
const text = kind === 'add' || kind === 'del' ? raw.slice(1) : raw
current.lines.push({ kind, text })
}
return out
})
const truncated = computed(() => props.truncated === true)
function lineClass(kind: LineKind): string {
return `diff-line diff-${kind}`
}
</script>
<style scoped>
.diff-file {
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 4px;
margin-bottom: 12px;
overflow: hidden;
}
.diff-file-header {
background: #f5f5f7;
padding: 6px 10px;
font-family: monospace;
font-size: 12px;
font-weight: 600;
border-bottom: 1px solid #e0e0e6;
}
.diff-table {
width: 100%;
border-collapse: collapse;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
}
.diff-gutter {
width: 24px;
text-align: center;
color: #999;
user-select: none;
padding: 0 4px;
vertical-align: top;
}
.diff-code {
white-space: pre-wrap;
word-break: break-all;
padding: 0 6px;
}
.diff-code pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
.diff-add {
background: #e6ffed;
}
.diff-add .diff-gutter {
color: #22863a;
}
.diff-del {
background: #ffeef0;
}
.diff-del .diff-gutter {
color: #cb2431;
}
.diff-hunk {
background: #f1f8ff;
color: #6f42c1;
}
.diff-meta {
color: #6a737d;
}
</style>
@@ -1,58 +1,11 @@
import { describe, it, expect } from 'vitest'
import type { Artifact } from '@/api/projects'
import {
buildRequirementSystemPrompt,
INTERACTIVE_QUESTION_PROMPT,
PHASE_ROLE_PROMPTS,
PREV_ARTIFACT_PHASE,
} from './requirementPhasePrompts'
import { PREV_ARTIFACT_PHASE } from './requirementPhasePrompts'
const req = { number: 7, title: '导出报表', description: '支持导出 Excel' }
function artifact(content: string): Artifact {
return {
id: 'a1',
requirement_id: 'r1',
phase: 'planning',
version: 2,
content,
note: '',
created_by: 'u1',
created_at: '',
}
}
describe('buildRequirementSystemPrompt', () => {
it('planning:角色模板 + 需求信息,无上一阶段产物', () => {
const out = buildRequirementSystemPrompt('planning', req)
expect(out).toContain(PHASE_ROLE_PROMPTS.planning)
expect(out).toContain(INTERACTIVE_QUESTION_PROMPT)
expect(out).toContain('需求 #7:导出报表')
expect(out).toContain('支持导出 Excel')
expect(out).not.toContain('上一阶段')
})
it('prototyping:注入上一阶段(planning)产物', () => {
const out = buildRequirementSystemPrompt('prototyping', req, artifact('# 规划文档内容'))
expect(out).toContain(PHASE_ROLE_PROMPTS.prototyping)
expect(out).toContain('上一阶段(planning)产物 v2')
expect(out).toContain('# 规划文档内容')
})
it('描述为空时不渲染需求描述段', () => {
const out = buildRequirementSystemPrompt('planning', { ...req, description: '' })
expect(out).not.toContain('需求描述')
})
it('超长产物内容截断并标注', () => {
const long = 'x'.repeat(10000)
const out = buildRequirementSystemPrompt('auditing', req, artifact(long))
expect(out).toContain('已截断')
expect(out.length).toBeLessThan(10000 + PHASE_ROLE_PROMPTS.auditing.length)
})
it('PREV_ARTIFACT_PHASE 链:planning←无、prototyping←planning、auditing←prototyping', () => {
// 角色模板 / 交互协议 / system prompt 组装已迁移服务端(internal/brief),相关测试
// 覆盖随之移至 internal/brief/composer_test.go。此处仅保留前端仍拥有的阶段链映射。
describe('PREV_ARTIFACT_PHASE', () => {
it('链:planning←无、prototyping←planning、auditing←prototyping', () => {
expect(PREV_ARTIFACT_PHASE.planning).toBeNull()
expect(PREV_ARTIFACT_PHASE.prototyping).toBe('planning')
expect(PREV_ARTIFACT_PHASE.auditing).toBe('prototyping')
+5 -63
View File
@@ -1,45 +1,9 @@
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
import type { ArtifactPhase } from '@/api/projects'
// 上一阶段产物注入 system prompt 的最大字符数,超出截断并标注。
const MAX_PREV_ARTIFACT_CHARS = 8000
// 三个 AI 对话阶段的角色模板。创建会话时与需求上下文拼接为 system prompt,
// 组装结果在创建表单中可见可改(前端组装方案,零后端耦合)。
export const PHASE_ROLE_PROMPTS: Record<ArtifactPhase, string> = {
planning: `你是一名资深产品规划助手。你的任务是与用户深入讨论需求,澄清目标、范围、用户场景与约束,最终形成一份结构化的规划文档(Markdown)。
讨论时主动提问补全缺失信息;输出规划文档时包含:背景与目标、范围(含不做什么)、用户场景、功能拆解、风险与依赖、验收标准。`,
prototyping: `你是一名资深原型设计助手。基于已确认的规划,与用户讨论并产出原型设计文档(Markdown)。
内容应包含:页面/界面结构、交互流程、状态与边界情况、数据展示与输入约束;可用文字线框、列表与表格描述布局。`,
auditing: `你是一名严格的方案评审助手。基于规划与原型,对方案进行评审并产出评审报告(Markdown)。
评审维度:完整性(是否覆盖规划目标)、一致性(原型与规划是否矛盾)、可行性(技术与排期风险)、边界情况遗漏、验收标准可测性。明确给出问题清单与修改建议,并给出评审结论(通过 / 有条件通过 / 不通过)。`,
}
export const INTERACTIVE_QUESTION_PROMPT = [
'## 交互式澄清问题协议',
'当信息不足且用户直接输入大段文字的成本较高时,优先提出一个结构化澄清问题,方便用户点选。',
'每次最多输出 1 个结构化问题;问题必须有 2-5 个选项;只有选项允许多选时 mode 才使用 multiple。',
'结构化问题必须放在 acw-question 代码块中,代码块内只能是合法 JSON,不能输出 HTML。',
'字段格式如下:',
'```acw-question',
'{',
' "version": 1,',
' "id": "stable_question_id",',
' "title": "需要用户确认的问题",',
' "description": "选择这个问题的原因,可省略",',
' "mode": "single",',
' "options": [',
' {',
' "id": "stable_option_id",',
' "label": "给用户看的短选项",',
' "summary": "一句话解释,可省略",',
' "details": "更完整的影响说明,可省略"',
' }',
' ],',
' "allowCustom": true',
'}',
'```',
'用户回答结构化问题后,继续基于回答推进本阶段讨论或产物输出。',
].join('\n')
// 阶段角色模板 / 交互式澄清协议 / system prompt 组装已迁移至服务端
// (internal/brief.Composer),不再由前端拼装。前端创建会话时只需传 requirement_id /
// project_id,服务端在 composeHistory 注入 FK 上下文简报。此文件仅保留前端仍需的
// 阶段链映射(用于展示"上一阶段产物"等纯 UI 逻辑)。
// 上一阶段产物来源:prototyping ← planning、auditing ← prototyping、planning 无。
export const PREV_ARTIFACT_PHASE: Record<ArtifactPhase, ArtifactPhase | null> = {
@@ -47,25 +11,3 @@ export const PREV_ARTIFACT_PHASE: Record<ArtifactPhase, ArtifactPhase | null> =
prototyping: 'planning',
auditing: 'prototyping',
}
// buildRequirementSystemPrompt 组装阶段会话的 system prompt:
// 角色模板 + 需求标题/描述 + 上一阶段产物最新版(如有,超长截断)。
export function buildRequirementSystemPrompt(
phase: ArtifactPhase,
requirement: Pick<Requirement, 'number' | 'title' | 'description'>,
prevArtifact?: Artifact | null,
): string {
const parts: string[] = [PHASE_ROLE_PROMPTS[phase], INTERACTIVE_QUESTION_PROMPT]
parts.push(`## 当前需求\n需求 #${requirement.number}${requirement.title}`)
if (requirement.description) {
parts.push(`### 需求描述\n${requirement.description}`)
}
if (prevArtifact) {
let content = prevArtifact.content
if (content.length > MAX_PREV_ARTIFACT_CHARS) {
content = content.slice(0, MAX_PREV_ARTIFACT_CHARS) + '\n\n……(内容过长,已截断)'
}
parts.push(`## 上一阶段(${prevArtifact.phase})产物 v${prevArtifact.version}\n${content}`)
}
return parts.join('\n\n')
}
+8 -21
View File
@@ -2,15 +2,9 @@
<AppShell>
<div class="space-y-3">
<div class="flex justify-between items-center">
<h2 class="text-lg font-semibold">
终端
</h2>
<h2 class="text-lg font-semibold">终端</h2>
<div class="flex items-center gap-3">
<NTag
:type="statusType"
size="small"
round
>
<NTag :type="statusType" size="small" round>
{{ statusLabel }}
</NTag>
<NButton
@@ -23,20 +17,13 @@
</div>
</div>
<NAlert
type="warning"
:show-icon="true"
title="管理员终端"
>
命令在 server 宿主进程部署时即 runtime 容器内执行权限等同容器内 shell
安装 ACP 客户端示例<code>npm i -g @anthropic-ai/claude-code</code>
安装完成后将 <code>binary_path</code> 填入对应的 ACP 客户端配置
<NAlert type="warning" :show-icon="true" title="管理员终端">
命令在 server 宿主进程部署时即 runtime 容器内执行权限等同容器内 shell 安装 ACP
客户端示例<code>npm i -g @anthropic-ai/claude-code</code> 安装完成后将
<code>binary_path</code> 填入对应的 ACP 客户端配置
</NAlert>
<div
ref="containerEl"
class="terminal-host"
/>
<div ref="containerEl" class="terminal-host" />
</div>
</AppShell>
</template>
@@ -69,7 +56,7 @@ const sock = useTerminalSocket({
initialRows: term.rows,
initialCols: term.cols,
})
term.onData((d) => sock.sendInput(d))
term.onData((d: string) => sock.sendInput(d))
let resizeObserver: ResizeObserver | null = null
@@ -57,6 +57,12 @@
<p v-else class="text-gray-400 text-sm">暂无描述</p>
</NCard>
<RequirementOrchestratorPanel
:slug="slug"
:requirement="data.requirement"
@changed="onOrchestratorChanged"
/>
<!-- 当前阶段产物 AI 对话三阶段显示保留编辑功能 -->
<ArtifactPanel
v-if="chatPhase"
@@ -156,6 +162,7 @@ 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 RequirementOrchestratorPanel from './components/RequirementOrchestratorPanel.vue'
import type { Artifact, ArtifactPhase, Phase, RequirementDetail } from '@/api/projects'
const PHASES: Phase[] = ['planning', 'prototyping', 'auditing', 'implementing', 'reviewing', 'done']
@@ -232,6 +239,11 @@ function onArtifactSaved() {
historyPanelRef.value?.refresh()
}
async function onOrchestratorChanged() {
await fetchData()
await loadPrevArtifact()
}
async function onWorkspaceChange(v: string | null) {
if (!data.value) return
await projectsApi.updateRequirement(slug, number, { workspace_id: v })
@@ -109,7 +109,6 @@ import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
import { formatDateTime } from '@/utils/date'
import { useConversationSession } from '@/composables/useConversationSession'
import { buildRequirementSystemPrompt } from '@/constants/requirementPhasePrompts'
import {
formatInteractiveQuestionAnswer,
type InteractiveQuestionAnswer,
@@ -167,11 +166,9 @@ async function loadConversations() {
}
function openCreateForm() {
newSystemPrompt.value = buildRequirementSystemPrompt(
props.phase,
props.requirement,
props.prevArtifact,
)
// system prompt 的角色模板 + 需求/产物上下文已由服务端组装(internal/brief 经
// composeHistory 注入 FK 上下文)。前端默认留空,仅在操作者想补充额外指令时手填。
newSystemPrompt.value = ''
newFirstMessage.value = DEFAULT_FIRST_MESSAGE
showCreateForm.value = true
}
@@ -0,0 +1,205 @@
<template>
<NCard title="自治编排">
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<NTag v-if="activeRun" :type="statusType(activeRun.status)" size="small">
{{ statusLabel(activeRun.status) }}
</NTag>
<NTag v-else size="small" type="default"> 未启动 </NTag>
<span v-if="activeRun" class="text-xs text-gray-400 font-mono truncate">
{{ activeRun.id.slice(0, 8) }}
</span>
</div>
<div v-if="activeRun" class="mt-1 text-xs text-gray-500">
{{ phaseLabel(activeRun.current_phase) }} · {{ formatDateTime(activeRun.updated_at) }}
</div>
</div>
<NButton size="small" quaternary circle :loading="loading" @click="refresh">
<template #icon>
<NIcon><RefreshOutline /></NIcon>
</template>
</NButton>
</div>
<NAlert v-if="activeRun?.last_error" type="warning" size="small" :show-icon="false">
{{ activeRun.last_error }}
</NAlert>
<div class="flex flex-wrap gap-2">
<NTooltip :disabled="canStart">
<template #trigger>
<NButton
size="small"
type="primary"
:disabled="!canStart"
:loading="acting === 'start'"
@click="start"
>
启动
</NButton>
</template>
需保持需求开放已关联 workspace且没有活跃 run
</NTooltip>
<NButton size="small" :disabled="!canPause" :loading="acting === 'pause'" @click="pause">
暂停
</NButton>
<NButton size="small" :disabled="!canResume" :loading="acting === 'resume'" @click="resume">
继续
</NButton>
<NButton
size="small"
type="error"
secondary
:disabled="!canCancel"
:loading="acting === 'cancel'"
@click="cancel"
>
取消
</NButton>
</div>
<div v-if="runs.length > 1" class="text-xs text-gray-400">历史运行 {{ runs.length }} </div>
</div>
</NCard>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { NAlert, NButton, NCard, NIcon, NTag, NTooltip, useMessage } from 'naive-ui'
import { RefreshOutline } from '@vicons/ionicons5'
import {
orchestratorApi,
type OrchestratorRun,
type OrchestratorRunStatus,
} from '@/api/orchestrator'
import type { Phase, Requirement } from '@/api/projects'
import { formatDateTime } from '@/utils/date'
const PHASE_LABELS: Record<Phase, string> = {
planning: '规划中',
prototyping: '原型设计',
auditing: '评审中',
implementing: '实施中',
reviewing: '验收中',
done: '已完成',
}
const props = defineProps<{
slug: string
requirement: Requirement
}>()
const emit = defineEmits<{ changed: [] }>()
const msg = useMessage()
const runs = ref<OrchestratorRun[]>([])
const loading = ref(false)
const acting = ref<'start' | 'pause' | 'resume' | 'cancel' | ''>('')
const activeRun = computed(() => runs.value[0] ?? null)
const hasActiveRun = computed(() => {
const status = activeRun.value?.status
return status === 'pending' || status === 'running' || status === 'paused'
})
const canStart = computed(
() =>
props.requirement.status === 'open' && !!props.requirement.workspace_id && !hasActiveRun.value,
)
const canPause = computed(() => ['pending', 'running'].includes(activeRun.value?.status ?? ''))
const canResume = computed(() => activeRun.value?.status === 'paused')
const canCancel = computed(() => hasActiveRun.value)
function phaseLabel(phase: Phase) {
return PHASE_LABELS[phase] ?? phase
}
function statusLabel(status: OrchestratorRunStatus) {
switch (status) {
case 'pending':
return '等待中'
case 'running':
return '运行中'
case 'paused':
return '已暂停'
case 'succeeded':
return '已成功'
case 'failed':
return '失败'
case 'canceled':
return '已取消'
default:
return status
}
}
function statusType(
status: OrchestratorRunStatus,
): 'default' | 'success' | 'warning' | 'error' | 'info' {
switch (status) {
case 'running':
return 'info'
case 'succeeded':
return 'success'
case 'paused':
case 'pending':
return 'warning'
case 'failed':
case 'canceled':
return 'error'
default:
return 'default'
}
}
async function refresh() {
loading.value = true
try {
runs.value = await orchestratorApi.listRuns({ requirement_id: props.requirement.id })
} finally {
loading.value = false
}
}
async function withAction(action: typeof acting.value, fn: () => Promise<unknown>) {
acting.value = action
try {
await fn()
await refresh()
emit('changed')
} catch (e: unknown) {
msg.error(e instanceof Error ? e.message : String(e))
} finally {
acting.value = ''
}
}
async function start() {
await withAction('start', () =>
orchestratorApi.startRun({
project_slug: props.slug,
requirement_number: props.requirement.number,
}),
)
}
async function pause() {
if (!activeRun.value) return
await withAction('pause', () => orchestratorApi.pauseRun(activeRun.value!.id))
}
async function resume() {
if (!activeRun.value) return
await withAction('resume', () => orchestratorApi.resumeRun(activeRun.value!.id))
}
async function cancel() {
if (!activeRun.value) return
await withAction('cancel', () => orchestratorApi.cancelRun(activeRun.value!.id))
}
onMounted(refresh)
watch(() => props.requirement.id, refresh)
</script>
+23 -49
View File
@@ -7,59 +7,29 @@
</template>
</NPageHeader>
<SyncBar
v-if="store.current"
:ws="store.current"
/>
<SyncBar v-if="store.current" :ws="store.current" />
<NTabs
v-model:value="tab"
type="line"
>
<NTabPane
name="worktrees"
tab="Worktrees"
>
<WorktreesTab
v-if="store.current"
:ws="store.current"
/>
<NTabs v-model:value="tab" type="line">
<NTabPane name="worktrees" tab="Worktrees">
<WorktreesTab v-if="store.current" :ws="store.current" />
</NTabPane>
<NTabPane
name="main"
tab="Main"
>
<MainTab
v-if="store.current"
:ws="store.current"
/>
<NTabPane name="main" tab="Main">
<MainTab v-if="store.current" :ws="store.current" />
</NTabPane>
<NTabPane
name="commits"
tab="Commits"
>
<CommitHistoryTab
v-if="store.current"
:ws="store.current"
/>
<NTabPane name="commits" tab="Commits">
<CommitHistoryTab v-if="store.current" :ws="store.current" />
</NTabPane>
<NTabPane
name="runs"
tab="Run"
>
<RunsTab
v-if="store.current"
:ws="store.current"
/>
<NTabPane name="diff" tab="Diff">
<DiffTab v-if="store.current" :ws="store.current" />
</NTabPane>
<NTabPane
name="settings"
tab="Settings"
>
<SettingsTab
v-if="store.current"
:ws="store.current"
/>
<NTabPane name="prs" tab="Pull Requests">
<ChangeRequestsTab v-if="store.current" :ws="store.current" />
</NTabPane>
<NTabPane name="runs" tab="Run">
<RunsTab v-if="store.current" :ws="store.current" />
</NTabPane>
<NTabPane name="settings" tab="Settings">
<SettingsTab v-if="store.current" :ws="store.current" />
</NTabPane>
</NTabs>
</div>
@@ -78,11 +48,15 @@ const MainTab = defineAsyncComponent(() => import('./components/MainTab.vue'))
const CommitHistoryTab = defineAsyncComponent(() => import('./components/CommitHistoryTab.vue'))
const SettingsTab = defineAsyncComponent(() => import('./components/SettingsTab.vue'))
const RunsTab = defineAsyncComponent(() => import('./components/RunsTab.vue'))
const DiffTab = defineAsyncComponent(() => import('./components/DiffTab.vue'))
const ChangeRequestsTab = defineAsyncComponent(() => import('./components/ChangeRequestsTab.vue'))
const route = useRoute()
const store = useWorkspacesStore()
const msg = useMessage()
const tab = ref<'worktrees' | 'main' | 'commits' | 'settings' | 'runs'>('worktrees')
const tab = ref<'worktrees' | 'main' | 'commits' | 'diff' | 'prs' | 'settings' | 'runs'>(
'worktrees',
)
const projectSlug = computed(() => route.params.slug as string)
const wsSlug = computed(() => route.params.wsSlug as string)
@@ -0,0 +1,208 @@
<template>
<div class="space-y-4">
<NSpace align="center">
<NButton type="primary" size="small" @click="showCreate = true"> 新建 PR </NButton>
<NButton size="small" :loading="loading" @click="refresh"> 刷新 </NButton>
</NSpace>
<NDataTable :columns="columns" :data="rows" :loading="loading" size="small" :scroll-x="1000" />
<NModal v-model:show="showCreate" preset="card" title="新建 Pull Request" style="width: 520px">
<NForm>
<NFormItem label="源分支">
<NInput v-model:value="form.source_branch" placeholder="feature/x" />
</NFormItem>
<NFormItem label="目标分支 (留空用默认分支)">
<NInput v-model:value="form.target_branch" :placeholder="ws.default_branch" />
</NFormItem>
<NFormItem label="标题">
<NInput v-model:value="form.title" />
</NFormItem>
<NFormItem label="描述">
<NInput v-model:value="form.description" type="textarea" />
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="showCreate = false"> 取消 </NButton>
<NButton type="primary" :loading="creating" @click="create"> 创建 </NButton>
</NSpace>
</template>
</NModal>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref, watch } from 'vue'
import {
NButton,
NDataTable,
NModal,
NForm,
NFormItem,
NInput,
NSpace,
NTag,
useMessage,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { changeRequestsApi } from '@/api/changeRequests'
import type { ChangeRequest, Workspace } from '@/api/types'
const props = defineProps<{ ws: Workspace }>()
const msg = useMessage()
const rows = ref<ChangeRequest[]>([])
const loading = ref(false)
const showCreate = ref(false)
const creating = ref(false)
const form = ref({ source_branch: '', target_branch: '', title: '', description: '' })
async function refresh() {
loading.value = true
try {
rows.value = await changeRequestsApi.list(props.ws.id)
} catch (e: unknown) {
msg.error('加载 PR 列表失败:' + (e instanceof Error ? e.message : String(e)))
} finally {
loading.value = false
}
}
async function create() {
creating.value = true
try {
await changeRequestsApi.create(props.ws.id, {
source_branch: form.value.source_branch,
target_branch: form.value.target_branch || undefined,
title: form.value.title,
description: form.value.description || undefined,
})
msg.success('已创建 PR')
showCreate.value = false
form.value = { source_branch: '', target_branch: '', title: '', description: '' }
await refresh()
} catch (e: unknown) {
msg.error('创建失败:' + (e instanceof Error ? e.message : String(e)))
} finally {
creating.value = false
}
}
async function review(cr: ChangeRequest, verdict: 'approved' | 'changes_requested') {
try {
await changeRequestsApi.review(cr.id, { verdict })
msg.success('已提交评审')
await refresh()
} catch (e: unknown) {
msg.error('评审失败:' + (e instanceof Error ? e.message : String(e)))
}
}
async function merge(cr: ChangeRequest) {
try {
await changeRequestsApi.merge(cr.id, {})
msg.success('已合并')
await refresh()
} catch (e: unknown) {
msg.error('合并失败:' + (e instanceof Error ? e.message : String(e)))
}
}
async function sync(cr: ChangeRequest) {
try {
await changeRequestsApi.sync(cr.id)
await refresh()
} catch (e: unknown) {
msg.error('同步失败:' + (e instanceof Error ? e.message : String(e)))
}
}
function verdictColor(v: string): 'default' | 'success' | 'warning' | 'error' {
if (v === 'approved') return 'success'
if (v === 'changes_requested') return 'warning'
if (v === 'rejected') return 'error'
return 'default'
}
function ciColor(v: string): 'default' | 'success' | 'warning' | 'error' {
if (v === 'success') return 'success'
if (v === 'failure') return 'error'
if (v === 'pending') return 'warning'
return 'default'
}
const columns = computed<DataTableColumns<ChangeRequest>>(() => [
{ title: '#', key: 'number', width: 50 },
{ title: '标题', key: 'title', ellipsis: { tooltip: true } },
{
title: '分支',
key: 'branch',
render: (r) => `${r.source_branch}${r.target_branch}`,
},
{
title: '状态',
key: 'state',
render: (r) => h(NTag, { size: 'small' }, { default: () => r.state }),
},
{
title: '评审',
key: 'review_verdict',
render: (r) =>
h(
NTag,
{ size: 'small', type: verdictColor(r.review_verdict) },
{ default: () => r.review_verdict },
),
},
{
title: 'CI',
key: 'ci_state',
render: (r) =>
h(NTag, { size: 'small', type: ciColor(r.ci_state) }, { default: () => r.ci_state }),
},
{
title: '操作',
key: 'actions',
width: 320,
render: (r) =>
h(
NSpace,
{ size: 'small' },
{
default: () => [
h(
NButton,
{ size: 'tiny', disabled: r.state !== 'open', onClick: () => review(r, 'approved') },
{ default: () => '批准' },
),
h(
NButton,
{
size: 'tiny',
disabled: r.state !== 'open',
onClick: () => review(r, 'changes_requested'),
},
{ default: () => '请求修改' },
),
h(
NButton,
{
size: 'tiny',
type: 'primary',
disabled: r.state !== 'open' || r.review_verdict !== 'approved',
onClick: () => merge(r),
},
{ default: () => '合并' },
),
h(NButton, { size: 'tiny', onClick: () => sync(r) }, { default: () => '同步' }),
],
},
),
},
])
onMounted(refresh)
watch(() => props.ws.id, refresh)
</script>
@@ -0,0 +1,82 @@
<template>
<div class="space-y-3">
<NSpace align="center">
<NSelect v-model:value="target" :options="targetOptions" style="width: 240px" size="small" />
<NCheckbox v-model:checked="staged"> 已暂存 (--cached) </NCheckbox>
<NInput v-model:value="refModel" placeholder="ref (可选)" size="small" style="width: 140px" />
<NInput
v-model:value="against"
placeholder="against (可选)"
size="small"
style="width: 140px"
/>
<NButton type="primary" size="small" :loading="loading" @click="load"> 刷新差异 </NButton>
</NSpace>
<NSpin :show="loading">
<DiffViewer :diff="diff" :truncated="truncated" />
</NSpin>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { NSpace, NSelect, NCheckbox, NInput, NButton, NSpin, useMessage } from 'naive-ui'
import DiffViewer from '@/components/DiffViewer.vue'
import { workspacesApi } from '@/api/workspaces'
import type { Workspace, Worktree, DiffParams } from '@/api/types'
const props = defineProps<{ ws: Workspace }>()
const msg = useMessage()
const worktrees = ref<Worktree[]>([])
const target = ref<string>('main')
const staged = ref<boolean>(false)
const refModel = ref<string>('')
const against = ref<string>('')
const diff = ref<string>('')
const truncated = ref<boolean>(false)
const loading = ref<boolean>(false)
const targetOptions = computed(() => {
const opts = [{ label: 'Main 工作区', value: 'main' }]
for (const wt of worktrees.value) {
opts.push({ label: `Worktree: ${wt.branch}`, value: `wt:${wt.id}` })
}
return opts
})
async function load() {
loading.value = true
try {
const params: DiffParams = {
staged: staged.value,
ref: refModel.value || undefined,
against: against.value || undefined,
}
let res
if (target.value.startsWith('wt:')) {
res = await workspacesApi.diffOnWorktree(target.value.slice(3), params)
} else {
res = await workspacesApi.diff(props.ws.id, params)
}
diff.value = res.diff
truncated.value = res.truncated
} catch (e: unknown) {
msg.error('加载差异失败:' + (e instanceof Error ? e.message : String(e)))
} finally {
loading.value = false
}
}
onMounted(async () => {
try {
worktrees.value = await workspacesApi.listWorktrees(props.ws.id)
} catch {
// worktree list optional for diff
}
await load()
})
watch(() => props.ws.id, load)
</script>