Files
DevFlow/src/components/ideas/IdeaDetail.vue
T

1194 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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>
<section class="idea-detail-panel">
<div class="detail-header">
<h2 class="detail-title">{{ idea.title }}</h2>
<div class="detail-header-actions">
<!-- P0- 转化为项目按钮前置到标题行右侧(原底部操作区按钮移入),保持 promoting -->
<button
v-if="idea.status === 'approved' && !idea.promoted_to"
class="btn btn-primary btn-sm"
:disabled="promoting"
@click="$emit('promote')"
>
{{ promoting ? $t('ideas.promoting') : $t('ideas.promoteToProject') }}
</button>
<router-link
v-else-if="idea.promoted_to"
class="btn btn-primary btn-sm"
:to="`/projects/${idea.promoted_to}`"
>
🚀 {{ $t('ideas.promotedProject') }}
</router-link>
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
</div>
</div>
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable( B-24 TaskDetail),空值回退 -->
<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" :disabled="savingDesc" @click="saveEdit">
{{ savingDesc ? $t('common.loading') : $t('ideas.saveDesc') }}
</button>
<button class="btn btn-ghost btn-sm" :disabled="savingDesc" @click="cancelEdit">{{ $t('ideas.cancelEdit') }}</button>
</div>
</template>
<template v-else>
<!-- P1-③ 描述超阈值折叠:max-height:300px 截断,溢出显展开/收起」(沿用 ProjectCard 测量模式) -->
<div
v-if="idea.description"
class="detail-desc-wrap"
:class="{ 'is-expanded': descExpanded }"
>
<p
ref="descRef"
class="detail-desc ai-md"
v-html="renderedDesc"
></p>
<button v-if="descOverflow" class="desc-toggle" @click="descExpanded = !descExpanded">
{{ descExpanded ? $t('common.collapse') : $t('common.expand') }}
</button>
</div>
<p v-else class="detail-desc"></p>
<button class="btn btn-ghost btn-sm desc-edit-btn" :disabled="savingDesc" @click="startEdit">{{ $t('ideas.editDesc') }}</button>
</template>
<!-- 对抗式评估 -->
<div class="detail-section">
<h3>{{ $t('ideas.adversarialTitle') }} <span class="eval-mode-tag">{{ $t(evalModeLabelKey()) }}</span></h3>
<div v-if="adversarialEval" class="adversarial-eval">
<!-- P0-① 评估结论前置:分析师结论(徽章/评分/倾向/摘要)提到正反方论据之前,
一眼看到评估结果;详细论据(正反方/行动建议)折叠,点击展开 -->
<div class="analyst-conclusion">
<h4>{{ $t('ideas.analystTitle') }}</h4>
<div class="assessment-badge" :class="assessmentClass(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)">
{{ $t('ideas.netSentiment', { tone: adversarialEval.net_sentiment > 0 ? $t('ideas.sentimentPositive') : (adversarialEval.net_sentiment < 0 ? $t('ideas.sentimentNegative') : $t('ideas.sentimentNeutral')), n: (adversarialEval.net_sentiment * 100).toFixed(0) }) }}
</div>
<p class="summary">{{ adversarialEval.analyst.summary }}</p>
</div>
<!-- 详细论据(正反方观点 + 行动建议):折叠态默认收起,降低首屏信息密度 -->
<div class="debate-toggle-row">
<button class="debate-toggle-btn" @click="debateExpanded = !debateExpanded">
{{ debateExpanded ? $t('common.collapse') : $t('common.expand') }} · {{ $t('ideas.positive') }} / {{ $t('ideas.negative') }}
<span class="debate-toggle-arrow" :class="{ 'is-open': debateExpanded }"></span>
</button>
</div>
<div v-show="debateExpanded" class="debate-detail">
<div class="debate-container">
<div class="debate-column positive">
<h4>{{ $t('ideas.positive') }}</h4>
<div class="confidence-bar">
<div class="confidence-fill" :style="{ width: (adversarialEval.positive_strength * 100) + '%' }"></div>
</div>
<div class="confidence-text">{{ $t('ideas.confidence', { n: (adversarialEval.positive_strength * 100).toFixed(0) }) }}</div>
<p class="thesis">{{ adversarialEval.positive.thesis }}</p>
<ul>
<li v-for="evidence in adversarialEval.positive.evidence" :key="evidence"> {{ evidence }}</li>
</ul>
</div>
<div class="debate-column negative">
<h4>{{ $t('ideas.negative') }}</h4>
<div class="confidence-bar">
<div class="confidence-fill" :style="{ width: (adversarialEval.negative_strength * 100) + '%' }"></div>
</div>
<div class="confidence-text">{{ $t('ideas.confidence', { n: (adversarialEval.negative_strength * 100).toFixed(0) }) }}</div>
<p class="thesis">{{ adversarialEval.negative.thesis }}</p>
<ul>
<li v-for="evidence in adversarialEval.negative.evidence" :key="evidence"> {{ evidence }}</li>
</ul>
</div>
</div>
<!-- 行动建议 -->
<div class="action-recommendations">
<h4>{{ $t('ideas.actionTitle') }}</h4>
<ul>
<li v-for="action in adversarialEval.action_items" :key="action"> {{ action }}</li>
</ul>
</div>
</div>
</div>
<div v-else class="eval-report eval-report--muted">
<button class="btn-evaluate" :disabled="evaluating" @click="$emit('evaluate')">
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
</button>
<!-- P1-⑥ 重试样式:错误提示分两行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) -->
<div v-if="evalError" class="eval-error">
<p class="eval-error-msg">⚠️ {{ evalError }}</p>
<button class="btn btn-ghost btn-sm 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 eval-report--muted">{{ $t('ideas.historyLoading') }}</div>
<div v-else-if="evalHistory.length === 0" class="eval-report eval-report--muted">{{ $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 eval-report--muted">{{ $t('ideas.relatedEmpty') }}</div>
<button class="btn btn-ghost btn-sm" @click="startRelateEdit">{{ $t('ideas.relatedManage') }}</button>
</template>
<!-- 编辑态 -->
<template v-else>
<div class="related-search" v-if="candidateIdeas.length > 0">
<input
v-model="relatedSearch"
class="related-search-input"
:placeholder="$t('ideas.relatedSearchPlaceholder')"
/>
</div>
<div v-if="filteredCandidates.length > 0" class="related-edit-list">
<label
v-for="idea in filteredCandidates"
: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 eval-report--muted">{{ $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>
<!-- 传统评分条(score-bar,从旧评估体系保留,默认折叠以降低信息密度) -->
<div class="detail-section">
<h3>
<button class="section-toggle-btn" @click="scoreBarExpanded = !scoreBarExpanded">
{{ $t('ideas.multiScoreTitle') }}
<span class="section-toggle-arrow" :class="{ 'is-open': scoreBarExpanded }"></span>
</button>
</h3>
<div v-show="scoreBarExpanded" class="score-bar-chart" v-if="parseScores(idea.scores).length > 0">
<div class="score-bar-row" v-for="dim in parseScores(idea.scores)" :key="dim.name">
<span class="score-bar-label">{{ dim.name }}</span>
<div class="score-bar-track">
<div class="score-bar-fill" :style="{ width: dim.score + '%' }" :class="'fill-' + scoreTier(dim.score)"></div>
</div>
<span class="score-bar-value">{{ dim.score }}</span>
</div>
</div>
<div v-else class="eval-report eval-report--muted">{{ $t('ideas.noEval') }}</div>
</div>
<!-- 标签 -->
<div class="detail-section">
<h3>{{ $t('ideas.tagsTitle') }}</h3>
<template v-if="tagsEditing">
<input
v-model="tagsEditInput"
class="detail-tags-edit-input"
:placeholder="$t('ideas.tagsPlaceholder')"
@keyup.enter="saveTagsEdit"
/>
<div class="tags-edit-actions">
<button class="btn btn-primary btn-sm" :disabled="savingTags" @click="saveTagsEdit">
{{ savingTags ? $t('common.loading') : $t('common.save') }}
</button>
<button class="btn btn-ghost btn-sm" :disabled="savingTags" @click="cancelTagsEdit">{{ $t('common.cancel') }}</button>
</div>
</template>
<template v-else>
<div class="tag-list" v-if="parseTags(idea.tags).length > 0">
<span class="tag" v-for="tag in parseTags(idea.tags)" :key="tag">{{ tag }}</span>
<button class="btn btn-ghost btn-sm" @click="startTagsEdit">{{ $t('ideas.editTags') }}</button>
</div>
<div v-else class="eval-report eval-report--muted">
{{ $t('ideas.noTags') }}
<button class="btn btn-ghost btn-sm" @click="startTagsEdit">{{ $t('ideas.editTags') }}</button>
</div>
</template>
</div>
<!-- 状态管理:P1-④ select badge 点击(当前态显色 badge,点击展开下拉选项) -->
<div class="detail-section">
<h3>{{ $t('ideas.statusTitle') }}</h3>
<div class="status-controls">
<div class="status-badge-select" v-click-outside="closeStatusMenu">
<button
class="status-tag status-current"
:class="'status-' + idea.status"
@click="statusMenuOpen = !statusMenuOpen"
>
{{ $t(statusLabelKey(idea.status)) }}
<span class="status-caret" :class="{ 'is-open': statusMenuOpen }"></span>
</button>
<ul v-show="statusMenuOpen" class="status-menu">
<li
v-for="s in menuStatusOptions"
:key="s.value"
class="status-menu-item"
:class="{ active: s.value === idea.status }"
@click="pickStatus(s.value)"
>
<span class="status-tag" :class="'status-' + s.value">{{ $t(s.labelKey) }}</span>
</li>
</ul>
</div>
</div>
</div>
<!-- 操作(P0- 立项按钮已上移至标题行,此处仅留删除) -->
<div class="detail-section" style="margin-top:8px">
<div class="action-buttons">
<button class="btn btn-ghost" :disabled="deleting" @click="$emit('delete')">
{{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }}
</button>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, nextTick, type Directive } from 'vue'
import { useI18n } from 'vue-i18n'
import { parseTags } from '../../stores/knowledge'
import { useProjectStore } from '../../stores/project'
import { useRendered } from '../../composables/useMarkdown'
import { ideaApi } from '../../api'
import { formatDate } from '../../utils/time'
import { parseScores, assessmentClass, assessmentLabel, scoreTier } from '../../utils/ideaEval'
import type { IdeaRecord, IdeaStatus, IdeaEvaluationRecord } from '../../api/types'
const props = defineProps<{
idea: IdeaRecord
evaluating: boolean
evalError: string
promoting: boolean
deleting: boolean
statusOptions: { value: IdeaStatus; labelKey: string }[]
}>()
const emit = defineEmits<{
(e: 'evaluate'): void
(e: 'promote'): void
(e: 'delete'): void
(e: 'status-change', value: IdeaStatus): void
(e: 'update-desc', value: string): void
(e: 'update-related', ids: string[]): void
(e: 'update-tags', tags: string): void
}>()
const { t } = useI18n()
const store = useProjectStore()
// 描述可编辑模式
const editing = ref(false)
const editDesc = ref('')
// P1-⑥ 描述编辑 loading:saveEdit 为异步(emit → 父 store.updateIdea → IPC),期间禁用按钮防双击
const savingDesc = ref(false)
function startEdit() {
editDesc.value = props.idea.description ?? ''
editing.value = true
}
// P1-⑥ saveEdit 改异步:emit 后父组件 store.updateIdea 是 Promise,但 emit 不回传结果;
// 这里用本地 savingDesc 包裹一个最小延迟窗口(等待父组件 patch 同步 props.idea.description
// 完成),保证 UI 反馈一致。父组件 updateIdea 失败会 throw 但不冒泡回子组件(沿用既有契约)。
async function saveEdit() {
if (savingDesc.value) return
savingDesc.value = true
try {
emit('update-desc', editDesc.value)
editing.value = false
// 等下一帧让父组件 patch 生效再释放(微观反馈,失败也释放不卡死)
await nextTick()
} finally {
savingDesc.value = false
}
}
function cancelEdit() {
editing.value = false
}
// P1-③ 描述溢出测量(沿用 ProjectCard 测量模式):scrollHeight > clientHeight 说明被
// CSS max-height 截断。Markdown 异步渲染 + 切 idea 都需重测。
const descRef = ref<HTMLParagraphElement | null>(null)
const descOverflow = ref(false)
const descExpanded = ref(false)
// P0-① 详细论据(正反方/行动建议)折叠:默认收起,降低首屏信息密度,结论前置后用户按需展开
const debateExpanded = ref(false)
// score-bar 默认折叠(旧评估体系,降低信息密度)
const scoreBarExpanded = ref(false)
function measureDesc() {
const el = descRef.value
if (!el) return
descOverflow.value = el.scrollHeight - el.clientHeight > 1
}
// 切换 idea 重置展开态 + 重测;描述 Markdown 异步渲染完成会触发 props.idea.description
// 不变但 DOM 文本变,因此额外 watch renderedDesc(渲染输出)重测 —— 见 useRendered 之后
watch(() => props.idea.id, () => {
descExpanded.value = false
debateExpanded.value = false
scoreBarExpanded.value = false
void nextTick(measureDesc)
})
watch(() => props.idea.description, () => void nextTick(measureDesc))
// P1-④ 状态切换 select → badge 点击:点击当前 badge 展开/收起下拉菜单,
// 选中项后 emit('status-change') 交父组件确认+落库(沿用既有契约,不破坏 statusOptions 来源)。
const statusMenuOpen = ref(false)
// WK-5:状态下拉排除 promoted(立项走独立 promoteToProject 流程,列表状态菜单同排除,
// 见 Ideas.vue onStatusChange 兜底);statusOptions 仍含 promoted 供 label 映射(已转立项的灵感 badge 正常显示)。
const menuStatusOptions = computed(() => props.statusOptions.filter(s => s.value !== 'promoted'))
function closeStatusMenu() {
statusMenuOpen.value = false
}
function pickStatus(s: IdeaStatus) {
statusMenuOpen.value = false
if (s === props.idea.status) return
emit('status-change', s)
}
// v-click-outside:轻量内联指令(状态菜单点击外部关闭),不引入新依赖。
// mounted 时绑定 mousedown 监听,点击落点不在 el 内 → 调 binding.value()。
interface ClickOutsideEl extends HTMLElement { _dfClickOutside?: ((e: MouseEvent) => void) | null }
const vClickOutside: Directive<HTMLElement, () => void> = {
mounted(el, binding) {
const host = el as ClickOutsideEl
host._dfClickOutside = (e: MouseEvent) => {
if (el.contains(e.target as Node)) return
binding.value?.()
}
document.addEventListener('mousedown', host._dfClickOutside)
},
unmounted(el) {
const host = el as ClickOutsideEl
if (host._dfClickOutside) {
document.removeEventListener('mousedown', host._dfClickOutside)
host._dfClickOutside = null
}
},
}
// ===== 关联灵感(related_ids JSON 数组字符串)=====
// 关联编辑搜索
const relatedSearch = ref('')
const filteredCandidates = computed(() => {
const q = relatedSearch.value.trim().toLowerCase()
if (!q) return candidateIdeas.value
return candidateIdeas.value.filter(
i => (i.title ?? '').toLowerCase().includes(q) || (i.description ?? '').toLowerCase().includes(q),
)
})
// 解析 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
relatedSearch.value = ''
}
function toggleSelect(id: string) {
const idx = relatedSelected.value.indexOf(id)
if (idx >= 0) {
relatedSelected.value.splice(idx, 1)
} else {
relatedSelected.value.push(id)
}
}
// ===== 标签编辑(show + edit 双态)=====
const tagsEditing = ref(false)
const tagsEditInput = ref('')
const savingTags = ref(false)
function startTagsEdit() {
tagsEditInput.value = parseTags(props.idea.tags).join(', ')
tagsEditing.value = true
}
async function saveTagsEdit() {
if (savingTags.value) return
savingTags.value = true
try {
const tags = tagsEditInput.value.split(',').map(t => t.trim()).filter(Boolean)
emit('update-tags', JSON.stringify(tags))
tagsEditing.value = false
await nextTick()
} finally {
savingTags.value = false
}
}
function cancelTagsEdit() {
tagsEditing.value = false
}
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示
function statusLabelKey(status: IdeaStatus): string {
return props.statusOptions.find(s => s.value === status)?.labelKey ?? status
}
// B-260615-25:灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
// BUG-2026-07-08:原漏解构 ensureLoaded + onMounted 未调用,冷启动(marked 未被其他组件预热)
// 时 mdReady 永远 false,renderMd 走 escapeFallback 纯文本降级,Markdown 语法不渲染。
const { rendered: renderedDesc, ensureLoaded: ensureMdLoaded } = useRendered(
() => props.idea.description ?? '',
)
// Markdown 异步渲染完成(renderedDesc 变化)后重测描述溢出(max-height 截断阈值依赖渲染后的高度)
watch(renderedDesc, () => void nextTick(measureDesc))
// parseScores / assessmentClass / assessmentLabel 抽到 ../../utils/ideaEval 复用(消除与 ProjectDetail 的 DRY 重复)。
interface AdversarialEval {
positive_strength: number
negative_strength: number
net_sentiment: number
recommendation: string
final_score: number
summary: string
action_items: string[]
positive: { thesis: string; evidence: string[] }
negative: { thesis: string; evidence: string[] }
analyst: { summary: string }
evaluated_by?: string
}
// 对抗式评估数据持久化在 ai_analysis JSON 字段中,前端解析渲染
const adversarialEval = computed<AdversarialEval | null>(() => {
if (!props.idea.ai_analysis) return null
try {
return JSON.parse(props.idea.ai_analysis) as AdversarialEval
} catch {
return null
}
})
// 评估深度标签动态化(P0):根据 evaluated_by 字段映射 i18n key
// 'Llm' → LLM 深度评估;'HeuristicFallback' → 启发式降级;
// 其他(undefined / 'Heuristic' / 老数据无此字段)→ 启发式
// 映射逻辑与 evalModeLabelKeyFromStr 同,这里直接透传避免重复(项2 DRY)
function evalModeLabelKey(): string {
return evalModeLabelKeyFromStr(adversarialEval.value?.evaluated_by)
}
// 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)
if (sentiment > 0) return 'positive'
if (sentiment < 0) return 'negative'
return 'neutral'
}
// (原 onStatusChange select 处理器已移除:状态切换改 badge 点击 pickStatus 触发 emit)
// ===== 评估历史(版本时间线)=====
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(() => {
ensureMdLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
loadHistory()
})
watch(() => props.idea.id, loadHistory)
</script>
<style scoped>
/* ===== 右侧详情 ===== */
.idea-detail-panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: var(--df-gap-head);
}
.detail-title { font-size: 20px; font-weight: 500; color: var(--df-text); flex: 1; min-width: 0; }
/* P0-② 标题行右侧操作组:立项按钮 + 状态 badge 一行排布 */
.detail-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* P1-③ 描述折叠容器:默认 max-height:300px 截断,展开态取消 */
.detail-desc-wrap {
position: relative;
margin-bottom: var(--df-gap-page);
}
.detail-desc-wrap .detail-desc { margin-bottom: 0; }
.detail-desc-wrap:not(.is-expanded) .detail-desc {
max-height: 300px;
overflow: hidden;
/* 底部渐变提示「下方还有」,与 max-height 截断视觉呼应 */
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent);
mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent);
}
.desc-toggle {
display: inline-block;
margin-top: 6px;
padding: 0;
background: transparent;
border: none;
color: var(--df-accent);
font-size: 12px;
cursor: pointer;
line-height: 1.4;
}
.desc-toggle:hover { text-decoration: underline; }
.detail-desc {
font-size: 14px;
color: var(--df-text-secondary);
line-height: 1.6;
margin-bottom: var(--df-gap-page);
}
/* ===== 灵感描述 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css) ===== */
.detail-desc.ai-md { white-space: normal; color: var(--df-text-secondary); }
.detail-section {
margin-bottom: var(--df-gap-page);
}
.detail-section h3 {
font-size: 14px;
font-weight: 500;
color: var(--df-text);
margin-bottom: 12px;
}
.eval-report {
font-size: 13px;
color: var(--df-text-secondary);
line-height: 1.6;
padding: 14px 16px;
background: var(--df-accent-bg);
border-left: 3px solid var(--df-accent);
border-radius: 0 8px 8px 0;
}
/* 弱化态(空数据/加载中占位,原 inline opacity:0.5 统一为 class) */
.eval-report--muted { opacity: 0.5; }
/* ===== 状态标签已提取到 styles/components.css 全局(.status-tag 系列) ===== */
/* ===== 对抗式评估 ===== */
.adversarial-eval {
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 1rem;
margin-bottom: 1rem;
}
.debate-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
}
.debate-column {
padding: 1rem;
border-radius: var(--df-radius);
position: relative;
}
.debate-column.positive {
background: rgba(61, 219, 160, 0.08);
border: 0.5px solid rgba(61, 219, 160, 0.2);
}
.debate-column.negative {
background: rgba(240, 101, 101, 0.08);
border: 0.5px solid rgba(240, 101, 101, 0.2);
}
.debate-column h4 {
font-size: 14px;
font-weight: 500;
margin: 0 0 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.confidence-bar {
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: var(--df-radius-xs);
margin-bottom: 0.25rem;
overflow: hidden;
}
.confidence-fill {
height: 100%;
border-radius: var(--df-radius-xs);
transition: width 0.4s;
}
.debate-column.positive .confidence-fill { background: var(--df-success); }
.debate-column.negative .confidence-fill { background: var(--df-danger); }
.confidence-text {
font-size: 11px;
color: var(--df-text-dim);
margin-bottom: 0.5rem;
}
.debate-column .thesis {
font-size: 12px;
color: var(--df-text);
margin-bottom: 0.5rem;
font-weight: 500;
}
.debate-column ul {
list-style: none;
padding: 0;
margin: 0;
}
.debate-column li {
font-size: 11px;
color: var(--df-text-secondary);
margin-bottom: 0.25rem;
padding-left: 0.5rem;
}
.analyst-conclusion {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 1rem;
margin-bottom: 1rem;
}
/* assessment-badge 系列已提取到 styles/components.css 全局 */
.final-score {
font-size: 14px;
font-weight: 500;
color: var(--df-text);
margin: 0.5rem 0;
}
.net-sentiment {
font-size: 12px;
padding: 4px 8px;
border-radius: var(--df-radius-xs);
display: inline-block;
margin: 0.5rem 0;
}
.net-sentiment.positive { background: rgba(61, 219, 160, 0.15); color: var(--df-success); }
.net-sentiment.negative { background: rgba(240, 101, 101, 0.15); color: var(--df-danger); }
.net-sentiment.neutral { background: var(--df-bg-raised); color: var(--df-text-secondary); }
.summary {
font-size: 12px;
color: var(--df-text-secondary);
line-height: 1.5;
margin: 0.5rem 0;
}
.action-recommendations {
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 1rem;
}
.action-recommendations h4 {
font-size: 14px;
font-weight: 500;
margin: 0 0 0.5rem;
}
.action-recommendations ul {
list-style: none;
padding: 0;
margin: 0;
}
.action-recommendations li {
font-size: 12px;
color: var(--df-text-secondary);
margin-bottom: 0.25rem;
padding-left: 0.5rem;
}
/* P0-① 详细论据折叠切换条 */
.debate-toggle-row { margin-bottom: 0.75rem; }
.debate-toggle-btn {
background: transparent;
border: none;
color: var(--df-accent);
font-size: 12px;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
gap: 4px;
}
.debate-toggle-btn:hover { text-decoration: underline; }
.debate-toggle-arrow {
font-size: 10px;
transition: transform 0.15s;
}
.debate-toggle-arrow.is-open { transform: rotate(180deg); }
.debate-detail { margin-top: 0.5rem; }
.debate-detail .debate-container { margin-bottom: 0.75rem; }
.btn-evaluate {
background: var(--df-accent);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: var(--df-radius);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.btn-evaluate:hover {
background: var(--df-accent-hover);
}
/* P1-⑥ 重试样式:错误提示与重试按钮分行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) */
.eval-error {
margin-top: 8px;
font-size: 12px;
color: var(--df-danger);
}
.eval-error-msg { margin: 0 0 8px; }
.btn-retry { margin-top: 4px; }
.eval-mode-tag {
font-size: 11px;
font-weight: 400;
padding: 1px 8px;
border-radius: var(--df-radius-xs);
background: rgba(255, 217, 61, 0.15);
color: var(--df-warning);
margin-left: 6px;
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: var(--df-accent-soft);
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;
}
/* ===== 多维评分条已提取到 styles/components.css 全局(.score-bar-* 系列) ===== */
/* ===== 标签 ===== */
.tag-list { display: flex; gap: 8px; flex-wrap: wrap; }
.tag {
font-size: 12px; padding: 4px 10px;
border-radius: var(--df-radius-xs);
background: var(--df-accent-bg);
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: var(--df-accent-bg);
color: var(--df-accent);
text-decoration: none;
cursor: pointer;
transition: background 0.15s;
}
.related-chip:hover { background: var(--df-accent-soft); }
.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; }
/* 关联编辑搜索框 */
.related-search { margin-bottom: 8px; }
.related-search-input {
width: 100%;
box-sizing: border-box;
padding: 6px 10px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
font-size: 13px;
outline: none;
}
.related-search-input:focus {
border-color: var(--df-accent);
}
/* ===== 标签编辑 ===== */
.detail-tags-edit-input {
width: 100%;
box-sizing: border-box;
padding: 6px 10px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
font-size: 13px;
outline: none;
}
.detail-tags-edit-input:focus {
border-color: var(--df-accent);
}
.tags-edit-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.tag-list .btn-ghost {
font-size: 11px;
padding: 2px 8px;
}
.eval-report .btn-ghost {
font-size: 11px;
padding: 2px 8px;
margin-left: 6px;
}
/* ===== 按钮已提取到 global.css 全局(.btn/.btn-primary/.btn-ghost/.btn-sm) ===== */
/* IdeaDetail 特有按钮定位 */
.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 内联 accent 按钮样式已废弃:重试改用 ghost btn-sm,统一样式收敛) */
/* ===== 状态管理:P1-④ select → badge 点击下拉 ===== */
.status-controls {
margin-top: 8px;
}
.status-badge-select {
position: relative;
display: inline-block;
}
/* 当前态 badge:点击展开下拉(复用全局 .status-tag 显色,加 cursor + caret) */
.status-current {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
user-select: none;
border: 0.5px solid transparent;
transition: border-color 0.15s;
}
.status-current:hover { border-color: var(--df-accent); }
.status-caret {
font-size: 10px;
opacity: 0.7;
transition: transform 0.15s;
}
.status-caret.is-open { transform: rotate(180deg); }
/* 下拉菜单(绝对定位浮层) */
.status-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 10;
list-style: none;
margin: 0;
padding: 6px;
min-width: 160px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
gap: 4px;
}
.status-menu-item {
cursor: pointer;
padding: 4px 6px;
border-radius: var(--df-radius-xs);
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
transition: background 0.15s;
}
.status-menu-item:hover { background: var(--df-accent-bg); }
.status-menu-item.active { background: var(--df-accent-soft); font-weight: 500; }
/* menu 内 badge 取消 emoji 之外的全局 hover 等副作用,仅作色标 */
.status-menu-item .status-tag { cursor: pointer; }
/* ═══ 响应式:窄屏对抗式评估双栏堆叠 ═══ */
@media (max-width: 760px) {
.debate-container {
grid-template-columns: 1fr;
}
}
</style>