You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -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' }),
|
||||
}
|
||||
@@ -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' }),
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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)}`),
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user