1194 lines
39 KiB
Vue
1194 lines
39 KiB
Vue
<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>
|