1474 lines
47 KiB
Vue
1474 lines
47 KiB
Vue
|
|
<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 正在生成代码,请稍候…(通常需要 30–60 秒)</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>
|