Files
ai-write/src/components/RequirementParserPanel.vue
empty 29bb7e2e87 feat: 范式检查和复检功能支持智能粒度过滤和批量处理
## 核心功能
- 范式检查和复检支持自动分批处理(15句/批)
- 智能粒度过滤:根据选中句子数量自动调整检查粒度
  * 1-5句:仅句子级检查(术语、语气、表达)
  * 6-20句:句子级+段落级检查(逻辑结构、递进关系)
  * 21+句:全文级检查(章节完整性、篇幅占比等)

## 新增文件
- src/stores/paradigm.js:自定义范式管理 store
  * inferScope:智能推断检查项粒度
  * ensureGuidelinesHasScope:为旧范式补充 scope 字段
- src/utils/requirementParser.js:需求文档解析工具
  * buildRequirementParserPrompt:生成解析 prompt
  * validateParadigm:范式验证(兼容字符串/对象格式)
- src/components/RequirementParserPanel.vue:需求解析面板
  * 支持粘贴文本或选择文件
  * AI 自动生成范式配置(specializedPrompt + expertGuidelines)
  * 可视化编辑检查项(title/description/scope)

## 主要改进
- ArticleRewritePanel.vue:
  * 范式检查支持粒度过滤和批量处理
  * 复检功能支持粒度过滤和批量处理
  * 集成 paradigm store 自动补充 scope 字段
  * 添加批次进度显示
- RequirementParserPanel.vue:
  * 修复对象格式 expertGuidelines 显示问题
  * 支持编辑 title/description/scope 三个字段
  * 添加粒度下拉选择器(句子级/段落级/全文级)

## 技术细节
- 向后兼容:支持字符串和对象两种 expertGuidelines 格式
- 自动转换:旧范式字符串格式自动转换为对象格式并推断 scope
- 错误处理:JSON 解析失败时提供降级方案
- 控制台日志:[复检] 前缀标识复检相关日志

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-11 13:51:40 +08:00

432 lines
16 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="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="w-full max-w-4xl bg-slate-800 rounded-lg shadow-2xl border border-slate-700 max-h-[90vh] flex flex-col">
<!-- 头部 -->
<header class="p-4 border-b border-slate-700 flex items-center justify-between shrink-0">
<h2 class="font-bold text-lg text-white flex items-center gap-2">
<span class="text-2xl">🎯</span> 需求文档解析
</h2>
<button
@click="$emit('close')"
class="text-slate-400 hover:text-white transition"
>
<span class="text-xl">×</span>
</button>
</header>
<!-- 主体内容 -->
<div class="flex-1 overflow-y-auto p-6">
<!-- 步骤1输入需求文档 -->
<section v-if="step === 1" class="space-y-4">
<div class="flex items-center gap-2 text-sm text-slate-400">
<span class="bg-indigo-600 text-white w-6 h-6 rounded-full flex items-center justify-center text-xs">1</span>
<span>输入需求文档</span>
</div>
<div class="space-y-3">
<!-- 文档来源选择 -->
<div class="flex items-center gap-3">
<span class="text-sm text-slate-400">来源</span>
<div class="flex bg-slate-900 rounded p-1 border border-slate-700">
<button
@click="sourceType = 'text'"
:class="['text-xs px-3 py-1 rounded transition',
sourceType === 'text' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white']"
>
粘贴文本
</button>
<button
@click="sourceType = 'file'"
:class="['text-xs px-3 py-1 rounded transition',
sourceType === 'file' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white']"
>
选择文件
</button>
</div>
</div>
<!-- 文本输入区 -->
<div v-if="sourceType === 'text'">
<textarea
v-model="requirementText"
placeholder="请粘贴需求文档内容,例如会议要求、文稿规范等..."
class="w-full h-64 p-4 bg-slate-900 border border-slate-700 rounded text-sm text-slate-200 placeholder-slate-600 focus:outline-none focus:border-indigo-500 resize-none"
></textarea>
<div class="flex items-center justify-between mt-2 text-xs text-slate-500">
<span>{{ requirementText.length }} 字符</span>
<span v-if="requirementText.length > 10000" class="text-amber-500">
文档过长可能影响解析质量建议控制在10000字符以内
</span>
</div>
</div>
<!-- 文件选择区 -->
<div v-if="sourceType === 'file'" class="space-y-3">
<input
type="file"
ref="fileInput"
@change="handleFileSelect"
accept=".txt,.md"
class="hidden"
/>
<button
@click="$refs.fileInput.click()"
class="w-full p-6 border-2 border-dashed border-slate-700 rounded-lg hover:border-indigo-500 transition text-slate-400 hover:text-indigo-400 flex flex-col items-center gap-2"
>
<span class="text-3xl">📄</span>
<span class="text-sm">点击选择文件支持 .txt, .md</span>
<span v-if="selectedFileName" class="text-xs text-indigo-400 mt-2">
已选择{{ selectedFileName }}
</span>
</button>
<div v-if="requirementText" class="bg-slate-900 p-4 rounded border border-slate-700 max-h-48 overflow-y-auto">
<pre class="text-xs text-slate-400 whitespace-pre-wrap">{{ requirementText.slice(0, 500) }}{{ requirementText.length > 500 ? '...' : '' }}</pre>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end gap-2 pt-4">
<button
@click="$emit('close')"
class="px-4 py-2 text-sm rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>
取消
</button>
<button
@click="startParsing"
:disabled="!requirementText || isParsing"
class="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-500 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<span v-if="isParsing" class="animate-spin"></span>
<span>{{ isParsing ? '解析中...' : '开始解析' }}</span>
</button>
</div>
</section>
<!-- 步骤2解析进度 -->
<section v-if="step === 2" class="space-y-4">
<div class="flex items-center gap-2 text-sm text-slate-400 mb-4">
<span class="bg-indigo-600 text-white w-6 h-6 rounded-full flex items-center justify-center text-xs animate-pulse">2</span>
<span>AI 解析中...</span>
</div>
<div class="bg-slate-900 p-6 rounded-lg border border-slate-700 space-y-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
<span class="text-slate-300">{{ parsingProgress || '正在分析需求文档...' }}</span>
</div>
<div class="text-xs text-slate-500 leading-relaxed">
AI 正在提取文档中的核心要求生成范式配置这可能需要 10-30 ...
</div>
</div>
</section>
<!-- 步骤3编辑确认 -->
<section v-if="step === 3" class="space-y-4">
<div class="flex items-center gap-2 text-sm text-slate-400 mb-4">
<span class="bg-green-600 text-white w-6 h-6 rounded-full flex items-center justify-center text-xs">3</span>
<span>编辑确认</span>
</div>
<div v-if="parsedParadigm" class="space-y-4">
<!-- 范式名称 -->
<div>
<label class="text-sm text-slate-400 block mb-2">范式名称</label>
<input
v-model="parsedParadigm.metadata.name"
placeholder="如2025年组织生活会对照检查材料"
class="w-full p-2 bg-slate-900 border border-slate-700 rounded text-sm text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<!-- 范式描述 -->
<div>
<label class="text-sm text-slate-400 block mb-2">描述</label>
<input
v-model="parsedParadigm.metadata.description"
placeholder="简要描述该范式的用途..."
class="w-full p-2 bg-slate-900 border border-slate-700 rounded text-sm text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<!-- System Prompt -->
<div>
<label class="text-sm text-slate-400 block mb-2 flex items-center justify-between">
<span>System Prompt系统提示词</span>
<span class="text-xs text-slate-600">{{ parsedParadigm.specializedPrompt.length }} 字符</span>
</label>
<textarea
v-model="parsedParadigm.specializedPrompt"
class="w-full h-48 p-3 bg-slate-900 border border-slate-700 rounded text-xs text-slate-200 font-mono focus:outline-none focus:border-indigo-500 resize-none"
></textarea>
</div>
<!-- Expert Guidelines -->
<div>
<label class="text-sm text-slate-400 block mb-2 flex items-center justify-between">
<span>专家检查指令</span>
<button
@click="addGuideline"
class="text-xs px-2 py-1 rounded bg-green-600 text-white hover:bg-green-500 transition"
>
+ 添加
</button>
</label>
<div class="space-y-3">
<div
v-for="(guideline, index) in parsedParadigm.expertGuidelines"
:key="index"
class="bg-slate-900 border border-slate-700 rounded p-3 space-y-2"
>
<div class="flex items-start gap-2">
<span class="text-xs text-slate-600 mt-2 shrink-0">{{ index + 1 }}.</span>
<div class="flex-1 space-y-2">
<!-- 标题 -->
<input
v-model="guideline.title"
placeholder="检查项标题"
class="w-full p-2 bg-slate-800 border border-slate-600 rounded text-xs text-white focus:outline-none focus:border-indigo-500"
/>
<!-- 描述 -->
<textarea
v-model="guideline.description"
placeholder="检查项详细描述"
rows="2"
class="w-full p-2 bg-slate-800 border border-slate-600 rounded text-xs text-white focus:outline-none focus:border-indigo-500 resize-none"
></textarea>
<!-- Scope 选择 -->
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">粒度</span>
<select
v-model="guideline.scope"
class="text-xs px-2 py-1 bg-slate-800 border border-slate-600 rounded text-white focus:outline-none focus:border-indigo-500"
>
<option value="sentence">句子级</option>
<option value="paragraph">段落级</option>
<option value="document">全文级</option>
</select>
</div>
</div>
<button
@click="removeGuideline(index)"
class="text-xs px-2 py-2 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30 transition shrink-0"
>
删除
</button>
</div>
</div>
</div>
</div>
<!-- 关键要求只读预览 -->
<div v-if="parsedParadigm.metadata.keyRequirements">
<label class="text-sm text-slate-400 block mb-2">核心要求关键词</label>
<div class="flex flex-wrap gap-2">
<span
v-for="(keyword, index) in parsedParadigm.metadata.keyRequirements"
:key="index"
class="px-2 py-1 bg-indigo-900/50 text-indigo-300 rounded text-xs"
>
{{ keyword }}
</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end gap-2 pt-4">
<button
@click="step = 1"
class="px-4 py-2 text-sm rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>
重新解析
</button>
<button
@click="saveParadigm"
class="px-4 py-2 text-sm rounded bg-green-600 text-white hover:bg-green-500 transition flex items-center gap-2"
>
<span></span>
<span>保存并使用</span>
</button>
</div>
</section>
<!-- 解析失败 -->
<section v-if="step === 'error'" class="space-y-4">
<div class="bg-red-900/20 border border-red-700 rounded-lg p-6 text-center">
<span class="text-4xl block mb-3"></span>
<p class="text-red-400 mb-2">解析失败</p>
<p class="text-xs text-slate-500">{{ errorMessage }}</p>
<button
@click="step = 1"
class="mt-4 px-4 py-2 text-sm rounded bg-slate-700 text-white hover:bg-slate-600 transition"
>
返回重试
</button>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useParadigmStore } from '../stores/paradigm'
import DeepSeekAPI from '../api/deepseek'
import { config } from '../utils/config'
import {
buildRequirementParserPrompt,
parseParadigmConfig,
buildParadigmObject,
validateParadigm
} from '../utils/requirementParser'
const emit = defineEmits(['close', 'paradigm-created'])
const paradigmStore = useParadigmStore()
// 状态
const step = ref(1) // 1: 输入, 2: 解析中, 3: 编辑确认, 'error': 失败
const sourceType = ref('text') // 'text' | 'file'
const requirementText = ref('')
const selectedFileName = ref('')
const isParsing = ref(false)
const parsingProgress = ref('')
const parsedParadigm = ref(null)
const errorMessage = ref('')
// 文件输入引用
const fileInput = ref(null)
/**
* 处理文件选择
*/
async function handleFileSelect(event) {
const file = event.target.files[0]
if (!file) return
selectedFileName.value = file.name
try {
const text = await file.text()
requirementText.value = text
} catch (error) {
console.error('读取文件失败:', error)
alert('读取文件失败,请重试')
}
}
/**
* 开始解析
*/
async function startParsing() {
if (!requirementText.value.trim()) {
alert('请先输入需求文档内容')
return
}
isParsing.value = true
step.value = 2
parsingProgress.value = '正在生成解析 Prompt...'
try {
// 构建 Prompt
const prompt = buildRequirementParserPrompt(requirementText.value)
parsingProgress.value = '正在调用 AI 分析...'
// 调用 DeepSeek API - 传递配置
const api = new DeepSeekAPI({
url: config.apiUrl,
key: config.apiKey
})
let responseText = ''
await api.generateContent(
prompt,
(chunk) => {
responseText += chunk
parsingProgress.value = `已接收 ${responseText.length} 字符...`
},
{ temperature: 0.7 } // 使用较高温度以获得更有创造性的输出
)
parsingProgress.value = '正在解析 AI 返回结果...'
// 调试:输出响应
console.log('收到的完整响应长度:', responseText.length);
console.log('响应内容:', responseText);
// 解析返回的配置
const parsedConfig = parseParadigmConfig(responseText)
if (!parsedConfig) {
throw new Error(`AI 返回的配置格式不正确。响应长度:${responseText.length} 字符。请检查浏览器控制台查看完整输出。`)
}
// 构建完整的范式对象
const paradigm = buildParadigmObject(parsedConfig)
// 验证
const validation = validateParadigm(paradigm)
if (!validation.valid) {
console.warn('范式验证警告:', validation.errors)
// 仍然允许用户编辑,只是给出提示
}
parsedParadigm.value = paradigm
step.value = 3
} catch (error) {
console.error('解析失败:', error)
errorMessage.value = error.message || '未知错误'
step.value = 'error'
} finally {
isParsing.value = false
parsingProgress.value = ''
}
}
/**
* 添加新的检查指令
*/
function addGuideline() {
if (!parsedParadigm.value) return
parsedParadigm.value.expertGuidelines.push({
title: '新的检查项',
description: '请填写检查项的详细描述...',
scope: 'sentence'
})
}
/**
* 删除检查指令
*/
function removeGuideline(index) {
if (!parsedParadigm.value) return
parsedParadigm.value.expertGuidelines.splice(index, 1)
}
/**
* 保存范式
*/
function saveParadigm() {
if (!parsedParadigm.value) return
// 再次验证
const validation = validateParadigm(parsedParadigm.value)
if (!validation.valid) {
if (!confirm(`范式存在以下问题:\n${validation.errors.join('\n')}\n\n是否仍然保存`)) {
return
}
}
// 保存到 store
paradigmStore.addCustomParadigm(parsedParadigm.value)
// 通知父组件
emit('paradigm-created', parsedParadigm.value)
emit('close')
}
</script>