优化: 所有剩余UI/UX待办一批完成(持久化+AuditLog+解耦+total+原12大改+P2)

持久化(P1-c):新建 usePersistedRef composable,Tasks/AuditLog/ProjectDetail 等接入 localStorage

AuditLog(P1-d):后端 list_tool_executions 加 WHERE 筛选+返 {items,total,has_more},前端对接+长列折叠+筛选持久化

数据源解耦(P1-g):ProjectDetail projectTasks 按 project_id 独立加载 + ChatInput @项目联想独立加载(不读 store.tasks 当前页)

GitChanges(12a):后端 get_module_commits 加 git rev-list --count 返 total,前端显真实总数

原12大改:Dashboard 统计卡压底行(1)/Projects 列表卡片视图(2)/project_event 埋点排序(3)/TaskDetail 重设计(4)/IdeaDetail 重设计(5)/KnowledgeDetail 重设计(6)/界面持久化+侧栏Ctrl+B+审批数字键(7)/ProjectDetail 三栏改两栏(10)

P2打磨:文件浏览器(FileTree去重/FilePreview行号.md Diff/selectedFilePath归位)/settings反馈(假保存/端口校验)/AI会话(try-catch/scrollIntoView)/后端计数(move_queue事件/timeline total/workflow分页/import_batch分块)/杂项(TopBar/ConfirmDialog键盘/CIStatus i18n/ToolResultBody/ModuleNode/ApprovalDialog全选)
This commit is contained in:
lxy
2026-08-02 13:11:06 +08:00
parent caaabf0c15
commit f736f435bc
70 changed files with 2645 additions and 576 deletions
+299 -93
View File
@@ -2,58 +2,62 @@
<section class="idea-detail-panel">
<div class="detail-header">
<h2 class="detail-title">{{ idea.title }}</h2>
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
<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" @click="saveEdit">{{ $t('ideas.saveDesc') }}</button>
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('ideas.cancelEdit') }}</button>
<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>
<p
<!-- P1- 描述超阈值折叠:max-height:300px 截断,溢出显展开/收起(沿用 ProjectCard 测量模式) -->
<div
v-if="idea.description"
class="detail-desc ai-md"
v-html="renderedDesc"
></p>
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" @click="startEdit">{{ $t('ideas.editDesc') }}</button>
<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">
<!-- 正反方观点 -->
<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>
<!-- AI 分析师结论 -->
<!-- P0- 评估结论前置:分析师结论(徽章/评分/倾向/摘要)提到正反方论据之前,
一眼看到评估结果;详细论据(正反方/行动建议)折叠,点击展开 -->
<div class="analyst-conclusion">
<h4>{{ $t('ideas.analystTitle') }}</h4>
<div class="assessment-badge" :class="assessmentClass(adversarialEval.recommendation)">
@@ -66,21 +70,57 @@
<p class="summary">{{ adversarialEval.analyst.summary }}</p>
</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 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">
{{ evalError }}
<button class="btn-evaluate btn-retry" :disabled="evaluating" @click="$emit('evaluate')">
<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>
@@ -186,34 +226,37 @@
<div v-else class="eval-report eval-report--muted">{{ $t('ideas.noTags') }}</div>
</div>
<!-- 状态管理 -->
<!-- 状态管理:P1- select badge 点击(当前态显色 badge,点击展开下拉选项) -->
<div class="detail-section">
<h3>{{ $t('ideas.statusTitle') }}</h3>
<div class="status-controls">
<select :value="idea.status" @change="onStatusChange" class="status-select">
<option v-for="s in statusOptions" :key="s.value" :value="s.value">{{ $t(s.labelKey) }}</option>
</select>
<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 statusOptions"
: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
v-if="idea.status === 'approved' && !idea.promoted_to"
class="btn btn-primary"
:disabled="promoting"
@click="$emit('promote')"
>
{{ 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>
@@ -223,7 +266,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
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'
@@ -257,21 +300,91 @@ 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
}
function saveEdit() {
emit('update-desc', editDesc.value)
editing.value = false
// 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)
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
void nextTick(measureDesc)
})
watch(() => props.idea.description, () => void nextTick(measureDesc))
// P1-④ 状态切换 select → badge 点击:点击当前 badge 展开/收起下拉菜单,
// 选中项后 emit('status-change') 交父组件确认+落库(沿用既有契约,不破坏 statusOptions 来源)。
const statusMenuOpen = ref(false)
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 数组字符串)=====
// 解析 props.idea.related_ids(JSON 字符串数组,null/空/非法 → [])
const relatedIds = computed<string[]>(() => {
@@ -336,6 +449,8 @@ function statusLabelKey(status: IdeaStatus): string {
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 重复)。
@@ -383,9 +498,7 @@ function sentimentClass(sentiment: number) {
return 'neutral'
}
function onStatusChange(e: Event) {
emit('status-change', (e.target as HTMLSelectElement).value as IdeaStatus)
}
// (原 onStatusChange select 处理器已移除:状态切换改 badge 点击 pickStatus 触发 emit)
// ===== 评估历史(版本时间线)=====
const evalHistory = ref<IdeaEvaluationRecord[]>([])
@@ -463,9 +576,43 @@ watch(() => props.idea.id, loadHistory)
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); }
.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;
@@ -647,6 +794,28 @@ watch(() => props.idea.id, loadHistory)
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;
@@ -662,11 +831,14 @@ watch(() => props.idea.id, loadHistory)
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;
@@ -826,35 +998,69 @@ watch(() => props.idea.id, loadHistory)
background: var(--df-bg-raised);
}
/* 评估失败重试按钮(内联于错误提示) */
.btn-retry {
margin-left: 8px;
padding: 2px 10px;
font-size: 11px;
vertical-align: middle;
}
/* (旧 .btn-retry 内联 accent 按钮样式已废弃:重试改用 ghost btn-sm,统一样式收敛) */
/* ===== 状态管理 ===== */
/* ===== 状态管理:P1-④ select → badge 点击下拉 ===== */
.status-controls {
margin-top: 8px;
}
.status-select {
width: 100%;
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;
cursor: pointer;
.status-badge-select {
position: relative;
display: inline-block;
}
.status-select:focus {
outline: none;
border-color: var(--df-accent);
background: var(--df-bg-raised);
/* 当前态 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: rgba(108, 99, 255, 0.08); }
.status-menu-item.active { background: rgba(108, 99, 255, 0.12); font-weight: 500; }
/* menu 内 badge 取消 emoji 之外的全局 hover 等副作用,仅作色标 */
.status-menu-item .status-tag { cursor: pointer; }
/* ═══ 响应式:窄屏对抗式评估双栏堆叠 ═══ */
@media (max-width: 760px) {