新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理

后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
lxy
2026-06-21 20:51:26 +08:00
parent 330bb7f505
commit bd6a41fe6e
111 changed files with 11931 additions and 1033 deletions
+400 -47
View File
@@ -5,16 +5,26 @@
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
</div>
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable( B-24 TaskDetail),空值回退 -->
<p
v-if="idea.description"
class="detail-desc ai-md"
v-html="renderedDesc"
></p>
<p v-else class="detail-desc"></p>
<template v-if="editing">
<textarea v-model="editDesc" class="detail-desc-edit" rows="4"></textarea>
<div class="desc-edit-actions">
<button class="btn btn-primary btn-sm" @click="saveEdit">{{ $t('ideas.saveDesc') }}</button>
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('ideas.cancelEdit') }}</button>
</div>
</template>
<template v-else>
<p
v-if="idea.description"
class="detail-desc ai-md"
v-html="renderedDesc"
></p>
<p v-else class="detail-desc"></p>
<button class="btn btn-ghost btn-sm desc-edit-btn" @click="startEdit">{{ $t('ideas.editDesc') }}</button>
</template>
<!-- 对抗式评估 -->
<div class="detail-section">
<h3>{{ $t('ideas.adversarialTitle') }} <span class="eval-mode-tag">{{ $t('ideas.evalModeHeuristic') }}</span></h3>
<h3>{{ $t('ideas.adversarialTitle') }} <span class="eval-mode-tag">{{ $t(evalModeLabelKey()) }}</span></h3>
<div v-if="adversarialEval" class="adversarial-eval">
<!-- 正反方观点 -->
<div class="debate-container">
@@ -47,7 +57,7 @@
<div class="analyst-conclusion">
<h4>{{ $t('ideas.analystTitle') }}</h4>
<div class="assessment-badge" :class="assessmentClass(adversarialEval.recommendation)">
{{ assessmentLabel(adversarialEval.recommendation) }}
{{ localAssessmentLabel(adversarialEval.recommendation) }}
</div>
<p class="final-score">{{ $t('ideas.finalScore', { score: adversarialEval.final_score.toFixed(1) }) }}</p>
<div class="net-sentiment" :class="sentimentClass(adversarialEval.net_sentiment)">
@@ -68,15 +78,95 @@
<button class="btn-evaluate" :disabled="evaluating" @click="$emit('evaluate')">
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
</button>
<div v-if="evalError" class="eval-error"> {{ evalError }}</div>
<div v-if="evalError" class="eval-error">
{{ evalError }}
<button class="btn-evaluate btn-retry" :disabled="evaluating" @click="$emit('evaluate')">
{{ $t('ideas.retryEval') }}
</button>
</div>
</div>
</div>
<!-- 评估历史(版本时间线) -->
<div class="detail-section">
<h3>{{ $t('ideas.historyTitle') }}</h3>
<div v-if="historyLoading" class="eval-report" style="opacity:0.5">{{ $t('ideas.historyLoading') }}</div>
<div v-else-if="evalHistory.length === 0" class="eval-report" style="opacity:0.5">{{ $t('ideas.historyEmpty') }}</div>
<div v-else class="eval-history-list">
<div
v-for="rec in evalHistory"
:key="rec.id"
class="eval-history-item"
:class="{ expanded: expandedVersion === rec.version }"
@click="toggleVersion(rec.version)"
>
<div class="history-row-main">
<span class="version-badge">{{ $t('ideas.historyVersion') }} v{{ rec.version }}</span>
<span v-if="rec.version === latestVersion" class="latest-tag">{{ $t('ideas.historyLatest') }}</span>
<span class="history-eval-mode">{{ $t(evalModeLabelKeyFromStr(rec.evaluated_by)) }}</span>
<span class="history-score">{{ rec.score == null ? '—' : rec.score.toFixed(1) }}</span>
<span class="history-date">{{ formatDate(rec.evaluated_at) }}</span>
</div>
<div v-if="expandedVersion === rec.version" class="history-detail">
<template v-if="historySummary(rec.ai_analysis)">
<p class="history-summary">{{ historySummary(rec.ai_analysis)?.summary ?? '—' }}</p>
<p v-if="historySummary(rec.ai_analysis)?.recommendation" class="history-rec">
{{ historySummary(rec.ai_analysis)?.recommendation }}
</p>
</template>
<p v-else class="history-summary"></p>
</div>
</div>
</div>
</div>
<!-- 关联灵感 -->
<div class="detail-section">
<h3>{{ $t('ideas.relatedTitle') }}</h3>
<!-- 展示态 -->
<template v-if="!relatedEditing">
<div v-if="relatedIds.length > 0" class="related-list">
<template v-for="row in relatedRows" :key="row.id">
<router-link
v-if="row.title"
class="related-chip"
:to="`/ideas/${row.id}`"
>{{ row.title }}</router-link>
<span v-else class="related-chip related-chip-missing">#{{ row.id }}</span>
</template>
</div>
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.relatedEmpty') }}</div>
<button class="btn btn-ghost btn-sm" @click="startRelateEdit">{{ $t('ideas.relatedManage') }}</button>
</template>
<!-- 编辑态 -->
<template v-else>
<div v-if="candidateIdeas.length > 0" class="related-edit-list">
<label
v-for="idea in candidateIdeas"
:key="idea.id"
class="related-edit-item"
>
<input
type="checkbox"
:checked="relatedSelected.includes(idea.id)"
@change="toggleSelect(idea.id)"
/>
<span>{{ idea.title }}</span>
</label>
</div>
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.relatedEmpty') }}</div>
<div class="related-edit-actions">
<button class="btn btn-primary btn-sm" @click="confirmRelate">{{ $t('ideas.relatedConfirm') }}</button>
<button class="btn btn-ghost btn-sm" @click="cancelRelate">{{ $t('ideas.relatedCancel') }}</button>
</div>
</template>
</div>
<!-- 传统评分雷达图 -->
<div class="detail-section">
<h3>{{ $t('ideas.multiScoreTitle') }}</h3>
<div class="radar-chart" v-if="parseScores(idea).length > 0">
<div class="radar-row" v-for="dim in parseScores(idea)" :key="dim.name">
<div class="radar-chart" v-if="parseScores(idea.scores).length > 0">
<div class="radar-row" v-for="dim in parseScores(idea.scores)" :key="dim.name">
<span class="radar-label">{{ dim.name }}</span>
<div class="radar-bar-track">
<div class="radar-bar-fill" :style="{ width: dim.score + '%' }" :class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"></div>
@@ -117,6 +207,13 @@
>
{{ promoting ? $t('ideas.promoting') : $t('ideas.promoteToProject') }}
</button>
<router-link
v-if="idea.promoted_to"
class="btn btn-primary"
:to="`/projects/${idea.promoted_to}`"
>
🚀 {{ $t('ideas.promotedProject') }}
</router-link>
<button class="btn btn-ghost" :disabled="deleting" @click="$emit('delete')">
{{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }}
</button>
@@ -126,11 +223,15 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { parseTags } from '../../stores/knowledge'
import { useProjectStore } from '../../stores/project'
import { useRendered } from '../../composables/useMarkdown'
import type { IdeaRecord, IdeaStatus } from '../../api/types'
import { ideaApi } from '../../api'
import { formatDate } from '../../utils/time'
import { parseScores, assessmentClass, assessmentLabel } from '../../utils/ideaEval'
import type { IdeaRecord, IdeaStatus, IdeaEvaluationRecord } from '../../api/types'
const props = defineProps<{
idea: IdeaRecord
@@ -146,9 +247,82 @@ const emit = defineEmits<{
(e: 'promote'): void
(e: 'delete'): void
(e: 'status-change', value: IdeaStatus): void
(e: 'update-desc', value: string): void
(e: 'update-related', ids: string[]): void
}>()
const { t } = useI18n()
const store = useProjectStore()
// 描述可编辑模式
const editing = ref(false)
const editDesc = ref('')
function startEdit() {
editDesc.value = props.idea.description ?? ''
editing.value = true
}
function saveEdit() {
emit('update-desc', editDesc.value)
editing.value = false
}
function cancelEdit() {
editing.value = false
}
// ===== 关联灵感(related_ids JSON 数组字符串)=====
// 解析 props.idea.related_ids(JSON 字符串数组,null/空/非法 → [])
const relatedIds = computed<string[]>(() => {
const raw = props.idea.related_ids
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []
} catch {
return []
}
})
// 反查标题:relatedIds 在 store.ideas 中找对应灵感(找不到留待展示态显 #id)。
// 项3 DRY/性能:原模板 v-for 内对每个 id 调 relatedIdeas.find 两次(O(n)×2);
// 改为一次性构建 {id, title|null} 数组(保留原始顺序含缺失项),模板直接 v-for 对象,O(1)。
const relatedRows = computed<{ id: string; title: string | null }[]>(() => {
// store.ideas → Map 一次构建,O(1) 查找,避免对每个 relatedId 线性扫 store.ideas
const byId = new Map(store.ideas.map(i => [i.id, i.title] as const))
return relatedIds.value.map(id => ({ id, title: byId.get(id) ?? null }))
})
// 编辑态
const relatedEditing = ref(false)
const relatedSelected = ref<string[]>([])
// 可选关联的其他灵感(排除当前灵感自身)
const candidateIdeas = computed(() => store.ideas.filter(i => i.id !== props.idea.id))
function startRelateEdit() {
relatedSelected.value = [...relatedIds.value]
relatedEditing.value = true
}
function confirmRelate() {
emit('update-related', relatedSelected.value)
relatedEditing.value = false
}
function cancelRelate() {
relatedEditing.value = false
}
function toggleSelect(id: string) {
const idx = relatedSelected.value.indexOf(id)
if (idx >= 0) {
relatedSelected.value.splice(idx, 1)
} else {
relatedSelected.value.push(id)
}
}
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示
function statusLabelKey(status: IdeaStatus): string {
@@ -161,23 +335,7 @@ const { rendered: renderedDesc } = useRendered(
() => props.idea.description ?? '',
)
interface ScoreDimension { name: string; score: number }
function parseScores(idea: IdeaRecord): ScoreDimension[] {
if (!idea.scores) return []
try {
const parsed = JSON.parse(idea.scores)
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return Object.entries(parsed).map(([name, score]) => ({
name,
score: typeof score === 'number' ? score : 0,
}))
}
return []
} catch {
return []
}
}
// parseScores / assessmentClass / assessmentLabel 抽到 ../../utils/ideaEval 复用(消除与 ProjectDetail 的 DRY 重复)。
interface AdversarialEval {
positive_strength: number
@@ -190,6 +348,7 @@ interface AdversarialEval {
positive: { thesis: string; evidence: string[] }
negative: { thesis: string; evidence: string[] }
analyst: { summary: string }
evaluated_by?: string
}
// 对抗式评估数据持久化在 ai_analysis JSON 字段中,前端解析渲染
@@ -202,25 +361,18 @@ const adversarialEval = computed<AdversarialEval | null>(() => {
}
})
function assessmentClass(recommendation: string) {
// 映射到 CSS 定义的 badge 颜色类(.immediate/.soon/.conditional/.revised/.defer/.cancel
const map: Record<string, string> = {
'immediate action': 'immediate',
'soon': 'soon',
'with resources': 'conditional',
'research more': 'revised',
'monitor': 'defer',
'cancel': 'cancel',
}
return map[recommendation.toLowerCase()] ?? 'conditional'
// 评估深度标签动态化(P0):根据 evaluated_by 字段映射 i18n key
// 'Llm' → LLM 深度评估;'HeuristicFallback' → 启发式降级;
// 其他(undefined / 'Heuristic' / 老数据无此字段)→ 启发式
// 映射逻辑与 evalModeLabelKeyFromStr 同,这里直接透传避免重复(项2 DRY)
function evalModeLabelKey(): string {
return evalModeLabelKeyFromStr(adversarialEval.value?.evaluated_by)
}
function assessmentLabel(recommendation: string) {
const key = `ideas.assessment.${recommendation}`
// 未命中 i18n key 时回退到原始 recommendation 字符串
const translated = t(key)
return translated === key ? recommendation : translated
}
// assessmentClass 直接复用 ../../utils/ideaEval(无 i18n 依赖,模板可直调)。
// assessmentLabel 需要 t 注入,这里留一个绑定 t 的薄包装,消除映射逻辑重复(项1 DRY)。
const localAssessmentLabel = (recommendation: string): string =>
assessmentLabel(t, recommendation)
function sentimentClass(sentiment: number) {
// 统一三档(FR-C2: 原 >=0 与模板 >0 矛盾,net_sentiment=0 时文案负面样式 positive)
@@ -232,6 +384,63 @@ function sentimentClass(sentiment: number) {
function onStatusChange(e: Event) {
emit('status-change', (e.target as HTMLSelectElement).value as IdeaStatus)
}
// ===== 评估历史(版本时间线)=====
const evalHistory = ref<IdeaEvaluationRecord[]>([])
const historyLoading = ref(false)
const expandedVersion = ref<number | null>(null)
async function loadHistory() {
historyLoading.value = true
try {
evalHistory.value = await ideaApi.listEvaluations(props.idea.id)
} catch {
evalHistory.value = []
} finally {
historyLoading.value = false
}
}
// 最新版本 = version 最大者(后端按 DESC 返回,首项即最新;这里用 max 兜底)
const latestVersion = computed(() =>
evalHistory.value.reduce((max, r) => Math.max(max, r.version), -1),
)
function toggleVersion(v: number) {
expandedVersion.value = expandedVersion.value === v ? null : v
}
// 针对字符串 evaluated_by 映射 i18n key(与 evalModeLabelKey 同映射规则,接收裸字符串)
function evalModeLabelKeyFromStr(source: string | null | undefined): string {
if (source === 'Llm') return 'ideas.evalModeLlm'
if (source === 'HeuristicFallback') return 'ideas.evalModeFallback'
return 'ideas.evalModeHeuristic'
}
// 解析历史 ai_analysis JSON,取 summary / recommendation(对抗式评估格式)。
// 老数据或非 LLM 评估可能无该结构 → 返回 null。
interface HistorySummary { summary: string | null; recommendation: string | null }
function historySummary(aiAnalysis: string | null | undefined): HistorySummary | null {
if (!aiAnalysis) return null
try {
const parsed = JSON.parse(aiAnalysis)
if (typeof parsed !== 'object' || parsed === null) return null
const obj = parsed as Record<string, unknown>
const summary = typeof obj.summary === 'string' ? obj.summary
: (typeof obj.analyst === 'object' && obj.analyst !== null
&& typeof (obj.analyst as Record<string, unknown>).summary === 'string'
? ((obj.analyst as Record<string, unknown>).summary as string)
: null)
const recommendation = typeof obj.recommendation === 'string' ? obj.recommendation : null
if (!summary && !recommendation) return null
return { summary, recommendation }
} catch {
return null
}
}
onMounted(loadHistory)
watch(() => props.idea.id, loadHistory)
</script>
<style scoped>
@@ -486,6 +695,85 @@ function onStatusChange(e: Event) {
vertical-align: middle;
}
/* ===== 评估历史(版本时间线)===== */
.eval-history-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.eval-history-item {
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 8px 12px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.eval-history-item:hover {
background: var(--df-bg-card);
border-color: var(--df-accent);
}
.eval-history-item.expanded {
border-color: var(--df-accent);
}
.history-row-main {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
}
.version-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--df-radius-xs);
background: rgba(108, 99, 255, 0.12);
color: var(--df-accent);
}
.latest-tag {
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(100, 255, 218, 0.15);
color: var(--df-success);
}
.history-eval-mode {
font-size: 11px;
padding: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(255, 217, 61, 0.12);
color: var(--df-warning);
}
.history-score {
font-size: 12px;
font-weight: 500;
color: var(--df-text);
min-width: 32px;
}
.history-date {
font-size: 11px;
color: var(--df-text-dim);
margin-left: auto;
}
.history-detail {
margin-top: 8px;
padding-top: 8px;
border-top: 0.5px solid var(--df-border);
}
.history-summary {
font-size: 12px;
color: var(--df-text-secondary);
line-height: 1.5;
margin: 0;
}
.history-rec {
font-size: 11px;
color: var(--df-accent);
margin: 6px 0 0;
}
/* ===== 雷达图(div 模拟) ===== */
.radar-chart {
display: flex;
@@ -535,6 +823,37 @@ function onStatusChange(e: Event) {
color: var(--df-accent);
}
/* ===== 关联灵感 ===== */
.related-list { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.related-chip {
font-size: 12px; padding: 4px 10px;
border-radius: var(--df-radius-xs);
background: rgba(108, 99, 255, 0.1);
color: var(--df-accent);
text-decoration: none;
cursor: pointer;
transition: background 0.15s;
}
.related-chip:hover { background: rgba(108, 99, 255, 0.2); }
.related-chip-missing { cursor: default; opacity: 0.6; }
.related-edit-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.related-edit-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--df-text-secondary);
cursor: pointer;
}
.related-edit-item input { cursor: pointer; }
.related-edit-actions { display: flex; gap: 8px; }
/* ===== 按钮 ===== */
.btn {
padding: 8px 16px;
@@ -549,6 +868,40 @@ function onStatusChange(e: Event) {
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
/* 小尺寸按钮(描述编辑/重试等) */
.btn-sm { padding: 4px 10px; font-size: 12px; }
.desc-edit-btn { margin-bottom: var(--df-gap-page); margin-top: 4px; }
.desc-edit-actions { display: flex; gap: 8px; margin-bottom: var(--df-gap-page); margin-top: 8px; }
/* 描述编辑 textarea */
.detail-desc-edit {
width: 100%;
padding: 8px 10px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
font-size: 14px;
line-height: 1.6;
font-family: inherit;
resize: vertical;
margin-bottom: var(--df-gap-page);
box-sizing: border-box;
}
.detail-desc-edit:focus {
outline: none;
border-color: var(--df-accent);
background: var(--df-bg-raised);
}
/* 评估失败重试按钮(内联于错误提示) */
.btn-retry {
margin-left: 8px;
padding: 2px 10px;
font-size: 11px;
vertical-align: middle;
}
/* ===== 状态管理 ===== */
.status-controls {
margin-top: 8px;