Files
frontend/src/views/SessionView.vue
2026-03-10 10:37:05 +08:00

1474 lines
47 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>