Files
frontend/src/views/SessionView.vue

1474 lines
47 KiB
Vue
Raw Normal View History

2026-03-10 10:37:05 +08:00
<template>
<div class="session-wrap">
<!-- 顶部导航 -->
<div class="top-bar">
<el-button text @click="router.push('/')">
<el-icon><ArrowLeft /></el-icon> 返回首页
</el-button>
<span class="session-id">会话 {{ sessionId.slice(0, 8) }}</span>
</div>
<!-- 步骤条 -->
<div class="steps-container">
<el-steps :active="activeStep" finish-status="success" align-center>
<el-step title="需求确认" :icon="ChatLineRound" />
<el-step title="PM 分析" :icon="EditPen" />
<el-step title="QA 用例" :icon="List" />
<el-step title="Dev 代码" :icon="Monitor" />
<el-step title="测试执行" :icon="Cpu" />
</el-steps>
</div>
<div class="content-area">
<!-- ===================== STEP 0: 需求澄清 ===================== -->
<template v-if="activeStep === 0">
<el-card class="step-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon color="#409eff"><ChatLineRound /></el-icon>
<span>需求澄清</span>
<el-tag :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
</div>
</template>
<!-- 需求原文 -->
<div class="req-block">
<div class="block-label">📋 原始需求</div>
<div class="req-text">{{ rawRequirement }}</div>
</div>
<!-- 对话历史 -->
<div v-if="clarifyHistory.length" class="chat-history">
<div
v-for="(msg, i) in clarifyHistory"
:key="i"
:class="['chat-msg', msg.role]"
>
<div class="chat-bubble">{{ msg.content }}</div>
<div class="chat-role">{{ msg.role === 'assistant' ? '🤖 AI' : '👤 您' }}</div>
</div>
</div>
<!-- AI 追问区域 -->
<template v-if="status === 'clarifying'">
<el-divider />
<div class="block-label" style="margin-bottom:12px">💬 请回答 AI 的问题</div>
<el-input
v-model="clarifyInput"
type="textarea"
:rows="3"
placeholder="请输入您的补充说明…"
:disabled="loading"
/>
<div class="btn-row">
<el-button type="primary" :loading="loading" @click="handleClarify">
发送
</el-button>
</div>
</template>
<!-- 需求已确认可进入下一步 -->
<template v-if="status === 'pm_ready' || status === 'pm_done' || status === 'qa_done' || status === 'dev_done'">
<el-alert
title="需求已确认,可以开始 PM 需求分析"
type="success"
show-icon
:closable="false"
style="margin-top:16px"
/>
<div class="btn-row" style="margin-top:16px">
<el-button type="primary" :loading="loading" @click="handlePmRun">
开始 PM 分析
</el-button>
</div>
</template>
</el-card>
</template>
<!-- ===================== STEP 1: PM 分析 ===================== -->
<template v-if="activeStep === 1">
<el-card class="step-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon color="#e6a23c"><EditPen /></el-icon>
<span>PM 需求分析</span>
<el-tag :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
</div>
</template>
<div v-if="streaming" class="thinking-card">
<div class="thinking-header">
<span class="thinking-dots"><span></span><span></span><span></span></span>
<span class="thinking-title">PM Agent 正在分析需求</span>
<span class="thinking-count">{{ streamText.length }} 字符</span>
</div>
<div class="stream-sections-body" ref="pmStreamBodyRef">
<section v-if="pmStreamParsed.functional_requirements.items.length || pmStreamParsed.currentSection === 'functional_requirements'"
class="stream-section" :class="{ 'stream-section--active': pmStreamParsed.currentSection === 'functional_requirements' }">
<div class="stream-section-title">🔧 功能需求</div>
<ul class="stream-list">
<li v-for="(item, i) in pmStreamParsed.functional_requirements.items" :key="i">
{{ item.value }}<span v-if="item.active" class="cursor">|</span>
</li>
</ul>
</section>
<section v-if="pmStreamParsed.non_functional_requirements.items.length || pmStreamParsed.currentSection === 'non_functional_requirements'"
class="stream-section" :class="{ 'stream-section--active': pmStreamParsed.currentSection === 'non_functional_requirements' }">
<div class="stream-section-title"> 非功能需求</div>
<ul class="stream-list">
<li v-for="(item, i) in pmStreamParsed.non_functional_requirements.items" :key="i">
{{ item.value }}<span v-if="item.active" class="cursor">|</span>
</li>
</ul>
</section>
<section v-if="pmStreamParsed.acceptance_criteria.items.length || pmStreamParsed.currentSection === 'acceptance_criteria'"
class="stream-section" :class="{ 'stream-section--active': pmStreamParsed.currentSection === 'acceptance_criteria' }">
<div class="stream-section-title"> 验收标准</div>
<ul class="stream-list">
<li v-for="(item, i) in pmStreamParsed.acceptance_criteria.items" :key="i">
{{ item.value }}<span v-if="item.active" class="cursor">|</span>
</li>
</ul>
</section>
<section v-if="pmStreamParsed.edge_cases.items.length || pmStreamParsed.currentSection === 'edge_cases'"
class="stream-section" :class="{ 'stream-section--active': pmStreamParsed.currentSection === 'edge_cases' }">
<div class="stream-section-title">🚧 边界条件</div>
<ul class="stream-list">
<li v-for="(item, i) in pmStreamParsed.edge_cases.items" :key="i">
{{ item.value }}<span v-if="item.active" class="cursor">|</span>
</li>
</ul>
</section>
<section v-if="pmStreamParsed.summary.value || pmStreamParsed.currentSection === 'summary'"
class="stream-section" :class="{ 'stream-section--active': pmStreamParsed.currentSection === 'summary' }">
<div class="stream-section-title">📌 需求总结</div>
<p class="stream-text-content">{{ pmStreamParsed.summary.value }}<span v-if="pmStreamParsed.summary.active" class="cursor">|</span></p>
</section>
<div v-if="!pmStreamParsed.currentSection" class="stream-waiting">
<span class="thinking-dots"><span></span><span></span><span></span></span>
<span>等待 AI 开始输出</span>
</div>
</div>
</div>
<div v-else-if="loading" class="loading-area">
<el-icon class="spinning"><Loading /></el-icon>
<span>PM Agent 正在分析需求请稍候</span>
</div>
<template v-else-if="requirementAnalysis">
<!-- 需求总结 -->
<section v-if="requirementAnalysis.summary" class="result-section">
<div class="section-title">📌 需求总结</div>
<p class="section-text">{{ requirementAnalysis.summary }}</p>
</section>
<!-- 功能需求 -->
<section v-if="requirementAnalysis.functional_requirements?.length" class="result-section">
<div class="section-title">🔧 功能需求</div>
<ul class="feature-list">
<li v-for="(f, i) in requirementAnalysis.functional_requirements" :key="i">{{ f }}</li>
</ul>
</section>
<!-- 验收标准 -->
<section v-if="requirementAnalysis.acceptance_criteria?.length" class="result-section">
<div class="section-title"> 验收标准</div>
<ul class="feature-list">
<li v-for="(a, i) in requirementAnalysis.acceptance_criteria" :key="i">{{ a }}</li>
</ul>
</section>
<!-- 非功能需求 -->
<section v-if="requirementAnalysis.non_functional_requirements?.length" class="result-section">
<div class="section-title"> 非功能需求</div>
<ul class="feature-list">
<li v-for="(r, i) in requirementAnalysis.non_functional_requirements" :key="i">{{ r }}</li>
</ul>
</section>
<!-- 边界条件 -->
<section v-if="requirementAnalysis.edge_cases?.length" class="result-section">
<div class="section-title">🚧 边界条件</div>
<ul class="feature-list">
<li v-for="(e, i) in requirementAnalysis.edge_cases" :key="i">{{ e }}</li>
</ul>
</section>
<!-- 反馈行 -->
<el-divider />
<div class="feedback-area">
<div class="block-label">💡 对以上分析有疑问请提出修改意见</div>
<el-input
v-model="pmFeedback"
type="textarea"
:rows="2"
placeholder="例如:请补充异常情况处理,需要加上限流说明…"
:disabled="refining"
/>
<div class="btn-row">
<el-button :loading="refining" :disabled="!pmFeedback.trim()" @click="handlePmRefine">
重新生成
</el-button>
<el-button type="primary" :disabled="!!pmFeedback.trim()" @click="handleQaRun">
进入 QA 用例生成
</el-button>
</div>
</div>
</template>
</el-card>
</template>
<!-- ===================== STEP 2: QA 测试用例 ===================== -->
<template v-if="activeStep === 2">
<el-card class="step-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon color="#67c23a"><List /></el-icon>
<span>QA 测试用例</span>
<el-tag :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
</div>
</template>
<div v-if="streaming" class="thinking-card">
<div class="thinking-header">
<span class="thinking-dots"><span></span><span></span><span></span></span>
<span class="thinking-title">QA Agent 正在生成测试用例</span>
<span class="thinking-count">{{ qaStreamParsed.testCases.names.length }} 个用例已生成</span>
</div>
<div class="stream-sections-body" ref="qaStreamBodyRef">
<section class="stream-section" :class="{ 'stream-section--active': qaStreamParsed.currentSection === 'test_cases' }">
<div class="stream-section-title">🧪 测试用例</div>
<div class="qa-stream-cases">
<div v-for="(name, i) in qaStreamParsed.testCases.names" :key="i" class="qa-stream-case-item">
<span class="qa-case-badge">TC{{ String(i + 1).padStart(3, '0') }}</span>
{{ name }}
</div>
<div v-if="qaStreamParsed.testCases.active" class="qa-stream-case-writing">
<span class="thinking-dots"><span></span><span></span><span></span></span>
<span>正在生成下一个用例</span>
</div>
<div v-if="!qaStreamParsed.testCases.names.length && !qaStreamParsed.testCases.active" class="stream-waiting">
<span class="thinking-dots"><span></span><span></span><span></span></span>
<span>等待生成</span>
</div>
</div>
</section>
<section v-if="qaStreamParsed.testStrategy.value"
class="stream-section" :class="{ 'stream-section--active': qaStreamParsed.currentSection === 'test_strategy' }">
<div class="stream-section-title">📋 测试策略</div>
<p class="stream-text-content">{{ qaStreamParsed.testStrategy.value }}<span v-if="qaStreamParsed.testStrategy.active" class="cursor">|</span></p>
</section>
<section v-if="qaStreamParsed.coveragePlan.value"
class="stream-section" :class="{ 'stream-section--active': qaStreamParsed.currentSection === 'coverage_plan' }">
<div class="stream-section-title">📊 覆盖计划</div>
<p class="stream-text-content">{{ qaStreamParsed.coveragePlan.value }}<span v-if="qaStreamParsed.coveragePlan.active" class="cursor">|</span></p>
</section>
</div>
</div>
<div v-else-if="loading" class="loading-area">
<el-icon class="spinning"><Loading /></el-icon>
<span>QA Agent 正在生成测试用例请稍候</span>
</div>
<template v-else-if="testCases?.length">
<!-- 按类型分组 -->
<div v-for="(group, type) in groupedTestCases" :key="type" class="test-group">
<div class="test-group-title">
{{ typeEmoji[type] || '🧪' }} {{ type }}{{ group.length }}
</div>
<el-table :data="group" border size="small" class="test-table">
<el-table-column prop="test_id" label="ID" width="70" />
<el-table-column prop="test_name" label="用例名称" min-width="160" />
<el-table-column prop="precondition" label="前置条件" min-width="160" />
<el-table-column label="步骤" min-width="260">
<template #default="{ row }">
<ol class="step-list">
<li v-for="(s, i) in row.steps" :key="i">{{ s }}</li>
</ol>
</template>
</el-table-column>
<el-table-column prop="expected_result" label="预期结果" min-width="200" />
</el-table>
</div>
<!-- 测试策略 / 覆盖计划 -->
<template v-if="testStrategy || coveragePlan">
<el-divider />
<div class="qa-meta-row">
<div v-if="testStrategy" class="qa-meta-block">
<div class="block-label">📋 测试策略</div>
<p class="qa-meta-text">{{ testStrategy }}</p>
</div>
<div v-if="coveragePlan" class="qa-meta-block">
<div class="block-label">📊 覆盖计划</div>
<p class="qa-meta-text">{{ coveragePlan }}</p>
</div>
</div>
</template>
<!-- 反馈行 -->
<el-divider />
<div class="feedback-area">
<div class="block-label">💡 对测试用例有修改意见</div>
<el-input
v-model="qaFeedback"
type="textarea"
:rows="2"
placeholder="例如:请增加并发测试场景,添加输入长度边界测试…"
:disabled="refining"
/>
<div class="btn-row">
<el-button :loading="refining" :disabled="!qaFeedback.trim()" @click="handleQaRefine">
重新生成
</el-button>
<el-button type="primary" :disabled="!!qaFeedback.trim()" @click="handleDevRun">
进入 Dev 代码生成
</el-button>
</div>
</div>
</template>
</el-card>
</template>
<!-- ===================== STEP 3: Dev 代码生成 ===================== -->
<template v-if="activeStep === 3">
<el-card class="step-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon color="#909399"><Monitor /></el-icon>
<span>Dev 代码生成</span>
<el-tag :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
</div>
</template>
<div v-if="streaming" class="dev-stream-card">
<div class="thinking-header">
<span class="thinking-dots"><span></span><span></span><span></span></span>
<span class="thinking-title">Dev Agent 正在生成代码</span>
<span class="thinking-count">{{ streamText.length }} 字符代码量较大请耐心等待</span>
</div>
<el-tabs v-model="devStreamTab" type="card" class="code-tabs dev-stream-tabs">
<el-tab-pane label="🐍 业务代码" name="java_code">
<div class="code-block-wrap" style="padding-top:36px">
<div class="code-lang-badge">Python</div>
<pre class="code-block dev-stream-code" ref="devCodeRef">{{ devStreamParsed.java_code }}<span v-if="devStreamParsed.currentField === 'java_code'" class="cursor dev-cursor">|</span></pre>
</div>
</el-tab-pane>
<el-tab-pane label="🧪 单元测试" name="unit_tests">
<div class="code-block-wrap" style="padding-top:36px">
<div class="code-lang-badge">Python</div>
<pre class="code-block dev-stream-code" ref="devTestsRef">{{ devStreamParsed.unit_tests }}<span v-if="devStreamParsed.currentField === 'unit_tests'" class="cursor dev-cursor">|</span></pre>
</div>
</el-tab-pane>
<el-tab-pane label="📄 实现思路" name="implementation_notes">
<div class="dev-stream-notes" ref="devNotesRef">
{{ devStreamParsed.implementation_notes }}<span v-if="devStreamParsed.currentField === 'implementation_notes'" class="cursor">|</span>
</div>
</el-tab-pane>
</el-tabs>
</div>
<div v-else-if="loading" class="loading-area">
<el-icon class="spinning"><Loading /></el-icon>
<span>Dev Agent 正在生成代码请稍候通常需要 3060 </span>
</div>
<template v-else-if="codeGeneration">
<!-- 开发建议 -->
<section v-if="codeGeneration.implementation_notes" class="result-section">
<div class="section-title">📝 实现思路</div>
<ol class="feature-list">
<li v-for="(line, i) in parseNotes(codeGeneration.implementation_notes)" :key="i">{{ line }}</li>
</ol>
</section>
<!-- 代码 Tabs -->
<el-tabs v-model="activeCodeTab" type="card" class="code-tabs">
<el-tab-pane label="业务代码" name="business">
<div class="code-block-wrap">
<div class="code-lang-badge">Python</div>
<el-button class="copy-btn" size="small" text @click="copyCode(codeGeneration.java_code)">
<el-icon><CopyDocument /></el-icon> 复制
</el-button>
<pre class="code-block"><code>{{ codeGeneration.java_code }}</code></pre>
</div>
</el-tab-pane>
<el-tab-pane label="单元测试" name="test">
<div class="code-block-wrap">
<div class="code-lang-badge">Python</div>
<el-button class="copy-btn" size="small" text @click="copyCode(codeGeneration.unit_tests)">
<el-icon><CopyDocument /></el-icon> 复制
</el-button>
<pre class="code-block"><code>{{ codeGeneration.unit_tests }}</code></pre>
</div>
</el-tab-pane>
</el-tabs>
<!-- 完成操作 -->
<div class="btn-row" style="margin-top:24px">
<el-button type="primary" size="large" :loading="loadingStep === 4" @click="handleTestRun">
执行单元测试
</el-button>
</div>
</template>
</el-card>
</template>
<!-- ===================== STEP 4: 测试执行 ===================== -->
<template v-if="activeStep === 4">
<el-card class="step-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon color="#67c23a"><Cpu /></el-icon>
<span>单元测试执行</span>
<el-tag :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
</div>
</template>
<div v-if="loading" class="loading-area">
<el-icon class="spinning"><Loading /></el-icon>
<span>正在执行单元测试请稍候</span>
</div>
<template v-else>
<!-- 无结果时提示 -->
<div v-if="!testExecution" class="loading-area" style="padding:60px 0">
<span style="color:#909399">准备就绪点击执行按钮运行测试</span>
</div>
<!-- 有测试结果 -->
<template v-else>
<el-alert
:title="testExecution.success ? `✅ 全部测试通过!共 ${testExecution.total} 个` : `❌ ${testExecution.failed + testExecution.errors} 个测试失败`"
:type="testExecution.success ? 'success' : 'error'"
show-icon
:closable="false"
style="margin-bottom:16px"
/>
<div class="stat-row">
<div class="stat-card">
<div class="stat-num" style="color:#67c23a">{{ testExecution.passed }}</div>
<div class="stat-label">通过</div>
</div>
<div class="stat-card">
<div class="stat-num" style="color:#f56c6c">{{ testExecution.failed }}</div>
<div class="stat-label">失败</div>
</div>
<div class="stat-card">
<div class="stat-num" style="color:#e6a23c">{{ testExecution.errors }}</div>
<div class="stat-label">错误</div>
</div>
<div class="stat-card">
<div class="stat-num">{{ testExecution.total }}</div>
<div class="stat-label">合计</div>
</div>
</div>
<section class="result-section">
<div class="section-title">🖥 pytest 输出</div>
<div class="code-block-wrap">
<el-button class="copy-btn" size="small" text @click="copyCode(testExecution.output)">
<el-icon><CopyDocument /></el-icon> 复制
</el-button>
<pre class="code-block" style="max-height:400px;overflow:auto">{{ testExecution.output }}</pre>
</div>
</section>
</template>
<div class="btn-row" style="margin-top:16px">
<el-button @click="doTestRun" :loading="loading">🔄 重新运行</el-button>
<el-button
v-if="testExecution && !testExecution.success"
type="warning"
:loading="refining"
@click="handleTestFix"
>🔧 AI 自动修复</el-button>
<el-button type="success" @click="router.push('/')"> 完成返回首页</el-button>
</div>
</template>
</el-card>
</template>
<!-- 错误提示 -->
<div v-if="errorMsg" style="margin-top:16px">
<el-alert :title="errorMsg" type="error" show-icon :closable="true" @close="errorMsg=''" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowLeft, ChatLineRound, EditPen, List, Monitor, Cpu,
Loading, CopyDocument,
} from '@element-plus/icons-vue'
import { sessionApi } from '@/api/session'
const route = useRoute()
const router = useRouter()
const sessionId = route.params.id
// ---- 状态 ----
const status = ref('')
const rawRequirement = ref('')
const clarifyHistory = ref([])
const requirementAnalysis = ref(null)
const testCases = ref([])
const testStrategy = ref('')
const coveragePlan = ref('')
const codeGeneration = ref(null)
const testExecution = ref(null)
const loading = ref(false)
const refining = ref(false)
const streaming = ref(false)
const streamText = ref('')
const errorMsg = ref('')
const clarifyInput = ref('')
const pmFeedback = ref('')
const qaFeedback = ref('')
const devFeedback = ref('')
const activeCodeTab = ref('business')
const devStreamTab = ref('implementation_notes')
const loadingStep = ref(null) // 跨步骤操作时立即更新,避免显示错误的加载状态
const streamContainer = ref(null)
const pmStreamBodyRef = ref(null)
const qaStreamBodyRef = ref(null)
const devNotesRef = ref(null) // Dev 实现思路容器
const devCodeRef = ref(null) // Dev 业务代码容器
const devTestsRef = ref(null) // Dev 单元测试容器
// ---- 计算属性 ----
const activeStep = computed(() => {
if (loadingStep.value !== null) return loadingStep.value
const map = {
clarifying: 0,
pm_ready: 0,
pm_done: 1,
qa_ready: 1,
qa_done: 2,
dev_ready: 2,
dev_done: 3,
test_done: 4,
}
return map[status.value] ?? 0
})
const statusLabel = computed(() => {
const map = {
clarifying: '需求追问中',
pm_ready: '可开始分析',
pm_done: 'PM 完成',
qa_ready: '可生成用例',
qa_done: 'QA 完成',
dev_ready: '可生成代码',
dev_done: '代码就绪',
test_done: '测试完成',
}
return map[status.value] || status.value
})
const statusTagType = computed(() => {
const map = {
clarifying: 'warning',
pm_ready: 'info',
pm_done: '',
qa_ready: 'info',
qa_done: '',
dev_ready: 'info',
dev_done: '',
test_done: 'success',
}
return map[status.value] ?? ''
})
const typeEmoji = { '功能测试': '🧩', '性能测试': '⚡', '安全测试': '🔒' }
const groupedTestCases = computed(() => {
const groups = {}
for (const tc of testCases.value) {
const t = tc.test_type || '其他'
if (!groups[t]) groups[t] = []
groups[t].push(tc)
}
return groups
})
const passRate = computed(() => {
const total = codeGeneration.value?.unit_tests_count
const passed = codeGeneration.value?.passed_tests_count
if (!total) return '—'
return `${Math.round((passed / total) * 100)}%`
})
// 增量解析 Dev Agent 流式 JSON
const devStreamParsed = computed(() => {
const text = streamText.value
const result = { java_code: '', unit_tests: '', implementation_notes: '', currentField: null }
if (!text) return result
const fields = ['implementation_notes', 'java_code', 'unit_tests', 'unit_tests_count', 'passed_tests_count']
for (const field of fields) {
const key = `"${field}"`
const idx = text.indexOf(key)
if (idx === -1) continue
let start = idx + key.length
// 跳过展展、蛇形、引号
while (start < text.length && (text[start] === ' ' || text[start] === ':')) start++
if (start >= text.length || text[start] !== '"') continue
start++ // 跳过开头引号
let value = ''
let i = start
let closed = false
while (i < text.length) {
if (text[i] === '\\' && i + 1 < text.length) {
const esc = text[i + 1]
if (esc === 'n') value += '\n'
else if (esc === 't') value += '\t'
else if (esc === 'r') value += ''
else value += esc
i += 2
} else if (text[i] === '"') {
closed = true
break
} else {
value += text[i]
i++
}
}
if (field in result) result[field] = value
if (!closed) result.currentField = field
}
return result
})
// ---- 流式解析工具函数 ----
function _parseStreamStr(text, fieldName) {
const idx = text.indexOf(`"${fieldName}"`)
if (idx === -1) return { value: '', active: false }
let start = idx + fieldName.length + 2
while (start < text.length && (text[start] === ' ' || text[start] === ':')) start++
if (start >= text.length || text[start] !== '"') return { value: '', active: false }
start++
let value = '', i = start, closed = false
while (i < text.length) {
if (text[i] === '\\' && i + 1 < text.length) {
const e = text[i + 1]
value += e === 'n' ? '\n' : e === 't' ? '\t' : e
i += 2
} else if (text[i] === '"') { closed = true; break
} else { value += text[i++] }
}
return { value, active: !closed }
}
function _parseStreamStrArray(text, fieldName) {
const idx = text.indexOf(`"${fieldName}"`)
if (idx === -1) return { items: [], active: false }
let start = idx + fieldName.length + 2
while (start < text.length && (text[start] === ' ' || text[start] === ':')) start++
if (start >= text.length || text[start] !== '[') return { items: [], active: false }
start++
const items = []
let i = start, arrayClosed = false
while (i < text.length) {
if (text[i] === ']') { arrayClosed = true; break }
if (text[i] === '"') {
i++
let value = '', itemClosed = false
while (i < text.length) {
if (text[i] === '\\' && i + 1 < text.length) {
const e = text[i + 1]
value += e === 'n' ? '\n' : e === 't' ? '\t' : e
i += 2
} else if (text[i] === '"') { itemClosed = true; i++; break
} else { value += text[i++] }
}
if (value.trim()) items.push({ value, active: !itemClosed })
} else { i++ }
}
return { items, active: !arrayClosed }
}
function _parseStreamTestCases(text) {
const idx = text.indexOf('"test_cases"')
if (idx === -1) return { names: [], active: false }
let start = idx + 12
while (start < text.length && (text[start] === ' ' || text[start] === ':')) start++
if (start >= text.length || text[start] !== '[') return { names: [], active: false }
// 用括号深度跟踪真正的 test_cases 数组结束位置,跳过内部 steps[] 等嵌套方括号
let depth = 0, inStr = false, arrayClosed = false
for (let i = start; i < text.length; i++) {
const ch = text[i]
if (inStr) {
if (ch === '\\') i++ // 跳过转义字符
else if (ch === '"') inStr = false
} else {
if (ch === '"') inStr = true
else if (ch === '[') depth++
else if (ch === ']') { depth--; if (depth === 0) { arrayClosed = true; break } }
}
}
const arrayText = text.slice(start)
const names = []
const re = /"test_name"\s*:\s*"((?:[^"\\]|\\.)*)"/g
let m
while ((m = re.exec(arrayText)) !== null) {
names.push(m[1].replace(/\\n/g, ' ').replace(/\\"/g, '"'))
}
return { names, active: !arrayClosed }
}
// PM 流式解析
const pmStreamParsed = computed(() => {
const t = streamText.value
const fr = _parseStreamStrArray(t, 'functional_requirements')
const nfr = _parseStreamStrArray(t, 'non_functional_requirements')
const ac = _parseStreamStrArray(t, 'acceptance_criteria')
const ec = _parseStreamStrArray(t, 'edge_cases')
const sum = _parseStreamStr(t, 'summary')
let currentSection = null
for (const [key, val] of [['functional_requirements', fr], ['non_functional_requirements', nfr], ['acceptance_criteria', ac], ['edge_cases', ec]]) {
if (val.active || val.items.some(i => i.active)) { currentSection = key; break }
}
if (!currentSection && sum.active) currentSection = 'summary'
return { functional_requirements: fr, non_functional_requirements: nfr, acceptance_criteria: ac, edge_cases: ec, summary: sum, currentSection }
})
// QA 流式解析
const qaStreamParsed = computed(() => {
const t = streamText.value
const testCases = _parseStreamTestCases(t)
const testStrategy = _parseStreamStr(t, 'test_strategy')
const coveragePlan = _parseStreamStr(t, 'coverage_plan')
let currentSection = null
if (testCases.active) currentSection = 'test_cases'
else if (testStrategy.active) currentSection = 'test_strategy'
else if (coveragePlan.active) currentSection = 'coverage_plan'
else if (testCases.names.length) currentSection = 'test_cases'
return { testCases, testStrategy, coveragePlan, currentSection }
})
// 当当前字段切换时自动跳转 Tab
watch(
() => devStreamParsed.value.currentField,
(field) => {
if (field === 'implementation_notes' || field === 'java_code' || field === 'unit_tests') {
devStreamTab.value = field
}
}
)
// 每次收到 chunk时滚动当前活动容器到底部
watch(streamText, () => {
if (!streaming.value) return
nextTick(() => {
// PM / QA 结构化预览區
if (pmStreamBodyRef.value) pmStreamBodyRef.value.scrollTop = pmStreamBodyRef.value.scrollHeight
if (qaStreamBodyRef.value) qaStreamBodyRef.value.scrollTop = qaStreamBodyRef.value.scrollHeight
// Dev 各个容器
const tabRefMap = {
implementation_notes: devNotesRef.value,
java_code: devCodeRef.value,
unit_tests: devTestsRef.value,
}
const el = tabRefMap[devStreamTab.value]
if (el) el.scrollTop = el.scrollHeight
})
})
// ---- 工具 ----
function priorityTag(p) {
const map = { : 'danger', P0: 'danger', P1: 'warning', : 'warning', : 'info', P2: 'info' }
return map[p] ?? ''
}
function parseNotes(text) {
if (!text) return []
// 按换行分割,去掉行首的 "1. " "2. " 等序号,过滤空行
return text
.split(/\n/)
.map(l => l.replace(/^\d+\.\s*/, '').trim())
.filter(l => l.length > 0)
}
function applySessionResponse(res) {
status.value = res.status
if (res.data?.requirement_analysis) requirementAnalysis.value = res.data.requirement_analysis
if (res.data?.test_cases) {
const tc = res.data.test_cases
if (tc.test_cases?.length) testCases.value = tc.test_cases
if (tc.test_strategy) testStrategy.value = tc.test_strategy
if (tc.coverage_plan) coveragePlan.value = tc.coverage_plan
}
if (res.data?.code_generation) codeGeneration.value = res.data.code_generation
if (res.data?.test_execution) testExecution.value = res.data.test_execution
}
async function withLoading(fn, isRefine = false) {
if (isRefine) refining.value = true
else loading.value = true
errorMsg.value = ''
try {
const res = await fn()
applySessionResponse(res)
return res
} catch (e) {
errorMsg.value = e.message
ElMessage.error(e.message)
} finally {
loading.value = false
refining.value = false
}
}
async function copyCode(code) {
await navigator.clipboard.writeText(code)
ElMessage.success('已复制到剪贴板')
}
// ---- 生命周期 ----
onMounted(async () => {
// 加载初始会话状态
loading.value = true
try {
const res = await sessionApi.getSession(sessionId)
status.value = res.status
rawRequirement.value = res.data?.raw_requirement || ''
clarifyHistory.value = res.data?.clarify_history || []
if (res.data?.requirement_analysis) requirementAnalysis.value = res.data.requirement_analysis
if (res.data?.test_cases) {
const tc = res.data.test_cases
if (tc.test_cases?.length) testCases.value = tc.test_cases
if (tc.test_strategy) testStrategy.value = tc.test_strategy
if (tc.coverage_plan) coveragePlan.value = tc.coverage_plan
}
if (res.data?.code_generation) codeGeneration.value = res.data.code_generation
if (res.data?.test_execution) testExecution.value = res.data.test_execution
} catch (e) {
errorMsg.value = e.message
} finally {
loading.value = false
}
})
// ---- 操作处理 ----
async function handleClarify() {
if (!clarifyInput.value.trim()) return
const msg = clarifyInput.value.trim()
clarifyInput.value = ''
clarifyHistory.value.push({ role: 'user', content: msg })
await withLoading(async () => {
const res = await sessionApi.clarify(sessionId, msg)
if (res.question) {
clarifyHistory.value.push({ role: 'assistant', content: res.question })
}
return res
})
}
// ---- 流式 SSE 请求工具 ----
async function withStreaming(fetchFn, step) {
streamText.value = ''
streaming.value = true
loadingStep.value = step
devStreamTab.value = 'implementation_notes' // 每次重新生成从第一个 tab 开始
errorMsg.value = ''
try {
const response = await fetchFn()
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: '请求失败' }))
throw new Error(err.detail || '请求失败')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// 按 \n\n 分割完整 SSE 事件
const parts = buffer.split('\n\n')
buffer = parts.pop() // 保留最后不完整的新事件
for (const part of parts) {
const line = part.trim()
if (!line.startsWith('data: ')) continue
const data = JSON.parse(line.slice(6))
if (data.type === 'chunk') {
streamText.value += data.text
// 滚动由 watch(streamText) 统一处理
} else if (data.type === 'done') {
applySessionResponse(data)
} else if (data.type === 'error') {
throw new Error(data.message)
}
}
}
} catch (e) {
errorMsg.value = e.message
ElMessage.error(e.message)
} finally {
streaming.value = false
loadingStep.value = null
}
}
async function handlePmRun() {
await withStreaming(() => sessionApi.pmStream(sessionId), 1)
}
async function handleQaRun() {
await withStreaming(() => sessionApi.qaStream(sessionId), 2)
}
async function handleDevRun() {
await withStreaming(() => sessionApi.devStream(sessionId), 3)
}
async function handlePmRefine() {
if (!pmFeedback.value.trim()) return
const fb = pmFeedback.value.trim()
pmFeedback.value = ''
await withStreaming(() => sessionApi.pmRefineStream(sessionId, fb), 1)
}
async function handleQaRefine() {
if (!qaFeedback.value.trim()) return
const fb = qaFeedback.value.trim()
qaFeedback.value = ''
await withStreaming(() => sessionApi.qaRefineStream(sessionId, fb), 2)
}
async function handleDevRefine() {
if (!devFeedback.value.trim()) return
const fb = devFeedback.value.trim()
devFeedback.value = ''
await withLoading(() => sessionApi.devRefine(sessionId, fb), true)
}
async function handleTestRun() {
loadingStep.value = 4
await withLoading(() => sessionApi.testRun(sessionId))
loadingStep.value = null
}
async function doTestRun() {
await withLoading(() => sessionApi.testRun(sessionId))
}
async function handleTestFix() {
loadingStep.value = 3 // 立即导航到 Dev 页
testExecution.value = null
await withStreaming(() => sessionApi.testFixStream(sessionId), 3)
}
</script>
<style scoped>
.session-wrap {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 60px;
}
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #ebeef5;
position: sticky;
top: 0;
z-index: 100;
}
.session-id {
font-size: 12px;
color: #909399;
}
.steps-container {
background: #fff;
padding: 24px 40px 16px;
border-bottom: 1px solid #ebeef5;
}
.content-area {
max-width: 900px;
margin: 32px auto;
padding: 0 20px;
}
.step-card {
border-radius: 12px;
border: 1px solid #ebeef5;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
}
/* AI 思考中卡片PM / QA*/
.thinking-card {
border: 1px solid #e0eaff;
border-radius: 12px;
overflow: hidden;
background: #f6f8ff;
}
/* Dev Agent 流式代码卡片 */
.dev-stream-card {
border: 1px solid #e0eaff;
border-radius: 12px;
overflow: hidden;
background: #fff;
}
.dev-stream-card .thinking-header {
border-radius: 0;
}
.dev-stream-tabs {
padding: 0 12px 12px;
}
.dev-stream-notes {
padding: 14px 16px;
font-size: 13px;
line-height: 1.8;
color: #4a5568;
white-space: pre-wrap;
min-height: 80px;
max-height: 260px;
overflow-y: auto;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', sans-serif;
}
/* 代码块里的打字光标 */
.dev-cursor {
color: #67c23a;
font-weight: 100;
}
/* 流式输出时的代码 pre固定高度 + 纵向滚动,供 scrollTop 控制 */
.dev-stream-code {
max-height: 480px;
overflow-y: auto;
overflow-x: auto;
}
.thinking-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
background: linear-gradient(90deg, #eef2ff 0%, #f6f8ff 100%);
border-bottom: 1px solid #e0eaff;
font-size: 13px;
}
.thinking-title {
font-weight: 600;
color: #4f6ef7;
flex: 1;
}
.thinking-count {
font-size: 11px;
color: #a0aec0;
font-variant-numeric: tabular-nums;
}
/* 三点动画 */
.thinking-dots {
display: inline-flex;
gap: 4px;
align-items: center;
}
.thinking-dots span {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #4f6ef7;
animation: dot-bounce 1.2s infinite ease-in-out;
}
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.thinking-body {
padding: 16px 18px;
font-size: 13px;
line-height: 1.8;
color: #4a5568;
white-space: pre-wrap;
word-break: break-all;
max-height: 380px;
overflow-y: auto;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', sans-serif;
}
/* PM / QA 结构化流式预览 */
.stream-sections-body {
max-height: 420px;
overflow-y: auto;
padding: 6px 4px;
}
.stream-section {
margin: 0 8px 10px;
padding: 10px 14px;
border-radius: 8px;
border-left: 3px solid transparent;
background: #f8f9ff;
transition: border-color 0.2s, background 0.2s;
}
.stream-section--active {
border-left-color: #4f6ef7;
background: #eef1ff;
}
.stream-section-title {
font-size: 12px;
font-weight: 600;
color: #4f6ef7;
margin-bottom: 6px;
letter-spacing: 0.03em;
}
.stream-list {
margin: 0;
padding-left: 18px;
list-style: disc;
}
.stream-list li {
font-size: 13px;
line-height: 1.7;
color: #374151;
margin-bottom: 2px;
word-break: break-word;
}
.stream-text-content {
font-size: 13px;
line-height: 1.8;
color: #374151;
margin: 0;
word-break: break-word;
white-space: pre-wrap;
}
.stream-waiting {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
color: #9ca3af;
font-size: 13px;
}
/* QA 测试用例条目 */
.qa-stream-cases {
display: flex;
flex-direction: column;
gap: 4px;
}
.qa-stream-case-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #374151;
padding: 3px 0;
}
.qa-case-badge {
display: inline-block;
background: #dbeafe;
color: #1d4ed8;
border-radius: 4px;
padding: 1px 6px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.qa-stream-case-writing {
display: flex;
align-items: center;
gap: 6px;
color: #9ca3af;
font-size: 13px;
padding: 3px 0;
}
/* 光标闪烁 */
.cursor {
display: inline-block;
width: 2px;
color: #4f6ef7;
animation: blink 0.8s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* 加载态 */
.loading-area {
display: flex;
align-items: center;
gap: 12px;
padding: 40px 0;
justify-content: center;
color: #909399;
font-size: 14px;
}
.spinning {
animation: spin 1s linear infinite;
font-size: 24px;
color: #409eff;
}
@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
/* 需求块 */
.req-block {
background: #f8f9fa;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.block-label {
font-size: 13px;
font-weight: 600;
color: #606266;
margin-bottom: 8px;
}
.req-text {
font-size: 14px;
color: #303133;
white-space: pre-wrap;
line-height: 1.7;
}
/* 对话历史 */
.chat-history {
display: flex;
flex-direction: column;
gap: 12px;
margin: 16px 0;
}
.chat-msg {
display: flex;
flex-direction: column;
max-width: 80%;
}
.chat-msg.assistant {
align-self: flex-start;
align-items: flex-start;
}
.chat-msg.user {
align-self: flex-end;
align-items: flex-end;
}
.chat-bubble {
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.chat-msg.assistant .chat-bubble {
background: #ecf5ff;
color: #303133;
border-bottom-left-radius: 4px;
}
.chat-msg.user .chat-bubble {
background: #409eff;
color: #fff;
border-bottom-right-radius: 4px;
}
.chat-role {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
/* 结果区块 */
.result-section {
margin-bottom: 20px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.section-text {
font-size: 14px;
color: #606266;
line-height: 1.7;
margin: 0;
}
.feature-list {
padding-left: 20px;
margin: 0;
font-size: 14px;
color: #606266;
line-height: 2;
}
/* 测试用例 */
.test-group {
margin-bottom: 24px;
}
.test-group-title {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
.test-table :deep(.cell) {
white-space: pre-wrap;
line-height: 1.6;
}
.step-list {
padding-left: 16px;
margin: 0;
font-size: 13px;
line-height: 1.8;
}
/* 统计卡片 */
.stat-row {
display: flex;
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
flex: 1;
background: #f8f9fa;
border-radius: 10px;
padding: 16px;
text-align: center;
}
.stat-num {
font-size: 28px;
font-weight: 700;
color: #409eff;
}
.stat-label {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
/* 代码块 */
.code-tabs {
margin-top: 16px;
}
.code-block-wrap {
position: relative;
background: #1e1e2e;
border-radius: 8px;
padding: 40px 16px 16px;
overflow: auto;
}
.code-lang-badge {
position: absolute;
top: 10px;
left: 16px;
font-size: 11px;
color: #888;
text-transform: uppercase;
letter-spacing: 1px;
}
.copy-btn {
position: absolute;
top: 6px;
right: 12px;
color: #888 !important;
}
.code-block {
margin: 0;
font-family: 'Fira Code', 'JetBrains Mono', Consolas, monospace;
font-size: 13px;
line-height: 1.7;
color: #cdd6f4;
white-space: pre;
overflow-x: auto;
}
/* 反馈区 */
.qa-meta-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
margin: 4px 0 8px;
}
.qa-meta-block {
flex: 1;
min-width: 220px;
}
.qa-meta-text {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.7;
color: #374151;
white-space: pre-wrap;
word-break: break-word;
}
.feedback-area {
margin-top: 8px;
}
.btn-row {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
</style>