优化: 所有剩余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:
@@ -15,7 +15,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible: boolean
|
||||
msg: string
|
||||
/** 危险按钮文案;不传则回退到 common.confirm(语义中性,删除等场景应显式传 common.delete) */
|
||||
@@ -24,9 +26,35 @@ withDefaults(defineProps<{
|
||||
dangerLabel: '',
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
result: [ok: boolean]
|
||||
}>()
|
||||
|
||||
// 键盘交互:可见时监听全局 keydown(Esc=取消,Enter=确认),不可见时移除监听。
|
||||
// 与全屏对话框的 mask 点击同语义,补齐无鼠标可达性。
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (!props.visible) return
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
emit('result', false)
|
||||
} else if (e.key === 'Enter') {
|
||||
// Enter 确认:危险操作(删除)仍走 confirm 分支,父级二次语义由 dangerLabel 区分;
|
||||
// 避免在 input/textarea 内回车误触发(SVG/按钮 mask 场景无输入控件,守卫保留)。
|
||||
const target = e.target as HTMLElement
|
||||
if (target && (target.tagName === 'TEXTAREA' || (target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit' && (target as HTMLInputElement).type !== 'button'))) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
emit('result', true)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKey))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
||||
// visible 切换不改监听器(props 读取实时值),watch 占位避免未来静态分析告警
|
||||
watch(() => props.visible, () => {})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -626,7 +626,9 @@ watch(() => props.tc.status, (s) => {
|
||||
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
|
||||
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
|
||||
|
||||
/* -- Generic Result -- */
|
||||
/* -- Generic Result --
|
||||
max-height 100px→200px:通用结果常含 JSON/结构化文本,100px 仅约 5 行,默认滚动条挤压可读性;
|
||||
对齐 ai-tool-file-pre--collapsed(180px) 与 ai-tool-dir-entries(220px) 量级。 */
|
||||
.ai-tool-result {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
@@ -636,7 +638,7 @@ watch(() => props.tc.status, (s) => {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-success);
|
||||
max-height: 100px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="ci-status" v-if="statuses.length > 0">
|
||||
<div class="ci-header">
|
||||
<span class="ci-title">CI 检查</span>
|
||||
<span class="ci-title">{{ $t('aiChat.ciTitle') }}</span>
|
||||
<span class="ci-summary" :class="summaryClass">{{ summaryText }}</span>
|
||||
</div>
|
||||
<div class="ci-list">
|
||||
@@ -23,8 +23,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface CheckStatus {
|
||||
name: string
|
||||
status: string
|
||||
@@ -50,9 +53,9 @@ const summaryText = computed(() => {
|
||||
const pass = statuses.value.filter(s => s.status === 'success').length
|
||||
const fail = statuses.value.filter(s => s.status === 'failure').length
|
||||
const pending = statuses.value.filter(s => s.status === 'pending').length
|
||||
if (fail > 0) return `${fail} 项失败`
|
||||
if (pending > 0) return `${pending} 项进行中`
|
||||
return `${pass} 项通过`
|
||||
if (fail > 0) return t('aiChat.ciNFailed', { n: fail })
|
||||
if (pending > 0) return t('aiChat.ciNPending', { n: pending })
|
||||
return t('aiChat.ciNPassed', { n: pass })
|
||||
})
|
||||
|
||||
function statusIcon(status: string): string {
|
||||
|
||||
@@ -110,6 +110,7 @@ import SkillMention from './SkillMention.vue'
|
||||
import ImageInput from './ImageInput.vue'
|
||||
import MentionPopover from './MentionPopover.vue'
|
||||
import EnrichmentPanel from './EnrichmentPanel.vue'
|
||||
import { taskApi } from '../../api/task'
|
||||
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
||||
import type { MentionItem } from './MentionItem'
|
||||
|
||||
@@ -323,7 +324,8 @@ function clearSkill() {
|
||||
|
||||
// ── UX-10 §1.4: @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记,后端 system prompt 注入摘要) ──
|
||||
// 架构复用 / 技能联想:独立状态(mentionOpen/mentionIndex/mentionQuery),不与 skill 混。
|
||||
// 联想源只读 projectsStore.projects / projectsStore.tasks(前端 store 现有数据,不新增 IPC)。
|
||||
// 联想源:projects/ideas 读 store;tasks 联想候选读 store.tasks,但 @项目 enrichment
|
||||
// 反查任务走 projectTasksCache(独立 list_tasks,隔离 Tasks 视图筛选,见 P1-g)。
|
||||
// 触发:输入框光标前最近的 @ 后无空白/换行 → 激活;Esc/光标移开/选中插入 → 关闭。
|
||||
// Enter 不冲突:popover 开时 Enter 插入选中(覆盖发送),关时 Enter 发送(原逻辑)。
|
||||
// MentionItem 接口已抽至 ./MentionItem.ts(MentionPopover 子组件共用)。
|
||||
@@ -340,6 +342,32 @@ const pendingMentionSpans = ref<MentionSpan[]>([])
|
||||
// ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ──
|
||||
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
|
||||
// 在输入区下方显式 badge + 可展开的上下文参考面板。
|
||||
|
||||
// P1-g: 任务联想源隔离 store.tasks(后者反映 Tasks 视图当前筛选+分页,B-29 契约)。
|
||||
// @项目 enrichment 按项目反查时读 store.tasks 会被当前页筛选污染(只见到筛进来的子集)。
|
||||
// 改为按 project_id 独立 invoke list_tasks({project_id, limit}) + 本地 Map 缓存,
|
||||
// 切项目(projectsStore.projects 列表重载/项目删除)清空缓存防陈旧。
|
||||
const MENTION_TASK_LIMIT = 50
|
||||
const projectTasksCache = ref<Map<string, TaskRecord[]>>(new Map())
|
||||
|
||||
/** 拉取指定项目的任务并缓存(已缓存则跳过);失败静默降级为空数组(不阻塞 enrichment 渲染) */
|
||||
async function ensureProjectTasks(projectId: string): Promise<void> {
|
||||
if (projectTasksCache.value.has(projectId)) return
|
||||
try {
|
||||
const list = await taskApi.list({ project_id: projectId, limit: MENTION_TASK_LIMIT })
|
||||
// 写新 Map(响应式触发:Map 重赋值让 computed 重算,mutate 旧 Map 不可靠)
|
||||
const next = new Map(projectTasksCache.value)
|
||||
next.set(projectId, list)
|
||||
projectTasksCache.value = next
|
||||
} catch (e) {
|
||||
// 失败占位空数组避免反复重试,下次切项目自然清缓存重拉
|
||||
const next = new Map(projectTasksCache.value)
|
||||
next.set(projectId, [])
|
||||
projectTasksCache.value = next
|
||||
console.error('[ChatInput] 加载项目任务失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
interface ProjectEnrichment {
|
||||
id: string
|
||||
name: string
|
||||
@@ -358,13 +386,14 @@ const projectEnrichments = computed<ProjectEnrichment[]>(() => {
|
||||
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
|
||||
if (projectSpans.length === 0) return []
|
||||
const projects = (projectsStore.projects || []) as ProjectRecord[]
|
||||
const tasks = (projectsStore.tasks || []) as TaskRecord[]
|
||||
// P1-g: 任务从 projectTasksCache 读(按 project_id 独立拉取),
|
||||
// 不读 store.tasks(后者反映 Tasks 视图筛选+分页,会污染 @项目 enrichment)。
|
||||
const ideas = (projectsStore.ideas || []) as IdeaRecord[]
|
||||
const results: ProjectEnrichment[] = []
|
||||
for (const span of projectSpans) {
|
||||
const project = projects.find(p => p.id === span.refId)
|
||||
if (!project) continue
|
||||
const relTasks = tasks.filter(t => t.project_id === project.id)
|
||||
const relTasks = (projectTasksCache.value.get(project.id) || []) as TaskRecord[]
|
||||
// ideas linking to this project via promoted_to (project name matching)
|
||||
const relIdeas = ideas.filter(i => i.promoted_to === project.name || i.promoted_to === project.id)
|
||||
const running = relTasks.filter(t => t.status === 'in_progress').slice(0, 20)
|
||||
@@ -430,7 +459,9 @@ function dismissEnrichment() {
|
||||
for (const span of projectSpans) {
|
||||
const label = text.slice(span.start, span.start + span.length)
|
||||
// 替换标记 + 前后可能的空格为单个空格
|
||||
const pattern = new RegExp(`\\s*${escapeRegex(label)}\s*`)
|
||||
// M2: 原尾段 \s* 在模板字符串里是单反斜杠 → 解析成字面 s(零或多个 s 字母),
|
||||
// 应为 \\s*(双反斜杠 → 正则 \s*),与首段 \s* 对称匹配尾部空白。
|
||||
const pattern = new RegExp(`\\s*${escapeRegex(label)}\\s*`)
|
||||
text = text.replace(pattern, ' ').trim()
|
||||
}
|
||||
inputText.value = text
|
||||
@@ -443,6 +474,27 @@ function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
// P1-g: @项目 mention 选中后懒加载该项目的任务(独立 list_tasks 通道,隔离 store.tasks 筛选)。
|
||||
// 选中即触发,首次拉取后入缓存,后续同一项目不重复拉。
|
||||
watch(
|
||||
() => pendingMentionSpans.value.filter(sp => sp.kind === 'project').map(sp => sp.refId),
|
||||
(projectIds) => {
|
||||
for (const pid of projectIds) {
|
||||
if (pid) void ensureProjectTasks(pid)
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
// P1-g: 切项目清缓存 —— projectsStore.projects 列表重载/增删会让缓存 id 失效(项目删除后
|
||||
// 缓存条目悬空)。监听 projects 引用变化即清空,下次 @项目 自然重拉最新。
|
||||
watch(
|
||||
() => projectsStore.projects,
|
||||
() => {
|
||||
if (projectTasksCache.value.size > 0) projectTasksCache.value = new Map()
|
||||
},
|
||||
)
|
||||
|
||||
const mentionGroupLabel = computed(() => ({
|
||||
project: t('aiChat.mentionGroupProject'),
|
||||
task: t('aiChat.mentionGroupTask'),
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
v-for="conv in group.items"
|
||||
v-show="!store.state.foldedGroups[group.key]"
|
||||
:key="conv.id"
|
||||
:ref="(el) => setItemEl(conv.id, el)"
|
||||
class="ai-conv-item"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="store.switchConversation(conv.id)"
|
||||
@@ -168,6 +169,7 @@
|
||||
v-for="conv in archivedConvs"
|
||||
v-show="!store.state.archivedCollapsed"
|
||||
:key="'a-' + conv.id"
|
||||
:ref="(el) => setItemEl(conv.id, el)"
|
||||
class="ai-conv-item ai-conv-item--archived"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="store.switchConversation(conv.id)"
|
||||
@@ -230,7 +232,7 @@
|
||||
* 本组件自管的纯侧栏态:titleFlash/重命名/更多操作菜单/侧栏拖拽/搜索结果/时间分组。
|
||||
* 样式:零 scoped style(沿用 AiChat.vue 全局/父级样式,样式后续批下沉)。
|
||||
*/
|
||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { formatRelative } from '../../utils/time'
|
||||
@@ -322,10 +324,51 @@ function onActionsOutsideClick(e: MouseEvent) {
|
||||
|
||||
/**
|
||||
* UX-260617-23:搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
|
||||
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组
|
||||
* 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。
|
||||
*/
|
||||
function onSelectSearchResult(id: string) {
|
||||
void store.switchConversation(id)
|
||||
store.state.searchQuery = ''
|
||||
// nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位
|
||||
void nextTick(() => ensureActiveVisible())
|
||||
}
|
||||
|
||||
// ── M3:活跃会话项 DOM ref 收集(id → el),用于展开折叠组后 scrollIntoView ──
|
||||
// Vue 3 函数 ref:每项 :ref="(el) => setItemEl(id, el)"。卸载时 el 为 null,删 key 防 Map 泄漏。
|
||||
const itemEls = new Map<string, Element>()
|
||||
function setItemEl(id: string, el: Element | ComponentPublicInstance | null): void {
|
||||
if (!el) {
|
||||
itemEls.delete(id)
|
||||
return
|
||||
}
|
||||
// 组件实例取 $el,但本项是普通 div(el 即 Element)
|
||||
itemEls.set(id, el as Element)
|
||||
}
|
||||
|
||||
/**
|
||||
* M3:确保当前活跃会话在分组视图可见。若它落在折叠的时间分组(今天/昨天/更早)或折叠的归档分组,
|
||||
* 展开该组让 v-show 渲染出来,再 scrollIntoView。用于搜索点选、切对话后选中态不被折叠吞掉。
|
||||
*/
|
||||
function ensureActiveVisible(): void {
|
||||
const id = store.state.activeConversationId
|
||||
if (!id) return
|
||||
const conv = store.state.conversations.find(c => c.id === id)
|
||||
if (!conv) return
|
||||
// 先按归档态/时间桶决定应展开哪个组折叠态
|
||||
if (conv.archived) {
|
||||
if (store.state.archivedCollapsed) store.state.archivedCollapsed = false
|
||||
} else {
|
||||
const bucket = timeBucket(conv.updated_at)
|
||||
if (store.state.foldedGroups[bucket]) store.state.foldedGroups[bucket] = false
|
||||
}
|
||||
// 折叠态翻转后 v-show 需一帧才渲染,再 nextTick 取 el 滚动
|
||||
void nextTick(() => {
|
||||
const el = itemEls.get(id)
|
||||
if (el) {
|
||||
;(el as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
|
||||
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
|
||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||
<div v-for="item in visibleDirAuths" :key="item.id" class="ai-dir-auth">
|
||||
<div v-for="item in visibleDirAuths" :key="item.id" ref="dirAuthRefs" class="ai-dir-auth">
|
||||
<span class="ai-dir-auth-text">{{ $t('aiChat.dirAuthRequired') }}</span>
|
||||
<span class="ai-dir-auth-hint">
|
||||
{{ $t('aiChat.dirAuthHint', { tool: item.tool, path: item.path }) }}
|
||||
@@ -40,7 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
import { reactive, computed, watch, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingDirAuths } from '../../composables/ai/useAiEvents'
|
||||
@@ -77,6 +77,10 @@ const visibleDirAuths = computed(() => {
|
||||
// reactive Set 的增删驱动按钮 disabled;Set 判定 O(1)。
|
||||
const actingIds = reactive(new Set<string>())
|
||||
|
||||
// M8:卡片根元素 ref 数组(v-for ref 收集)。挂起弹窗出现在输入区上方,常被消息流/进度条挤出视口,
|
||||
// 用户看不到按钮 → 挂起无声卡死。新增项时滚动定位到卡片让审批入口立即可见。
|
||||
const dirAuthRefs = ref<HTMLElement[]>([])
|
||||
|
||||
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
|
||||
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
|
||||
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
|
||||
@@ -105,6 +109,16 @@ watch(() => pendingDirAuths.value, (items) => {
|
||||
if (!liveIds.has(id)) actingIds.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
/** M8:visibleDirAuths 出现新挂起项时,scrollIntoView 让审批入口立刻可见(挂起弹窗在输入区上方,
|
||||
* 易被消息流/进度条挤出视口,用户不滚动看不到 → 挂起无声卡死)。仅新增项触发滚动,避免已可见项
|
||||
* 反复跳动打扰。 */
|
||||
watch(() => visibleDirAuths.value.map(d => d.id).join('|'), async () => {
|
||||
await nextTick()
|
||||
// 滚到最新(数组末)项,block:'center' 让按钮组居中可见
|
||||
const last = dirAuthRefs.value[dirAuthRefs.value.length - 1]
|
||||
if (last) last.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
选 option → 仅关闭求助卡(后端 loop 已终止,用户重新发消息即"换思路/人工接管"入口,
|
||||
路径授权走 DirAuthDialog 既有链路)。机制优先 prompt 说教:代码强制熔断 + 结构化求助,
|
||||
非"教 AI 失败就问用户"(LLM 不可靠)。 -->
|
||||
<div v-if="helpActive" class="ai-help-required">
|
||||
<div v-if="helpActive" ref="helpRef" class="ai-help-required">
|
||||
<span class="ai-help-required-text">{{ helpActive.reason }}</span>
|
||||
<span class="ai-help-required-hint">{{ helpActive.context }}</span>
|
||||
<div class="ai-help-required-options">
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingHelp } from '../../composables/ai/useAiEvents'
|
||||
@@ -49,6 +49,18 @@ const helpActive = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
// M8:卡片根 ref。断路器熔断常发生在多轮迭代后,消息流已长,求助卡被挤出视口用户看不到 → 求助无声
|
||||
// 卡死。卡片显示时 scrollIntoView 让 option 按钮组立即进入视口。
|
||||
const helpRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** M8:helpActive 从 null→非 null(求助挂起出现)时 scrollIntoView 让操作入口立即可见。
|
||||
* 仅监 truthy 翻转,不监 reason/context 变化避免已可见时反复跳。 */
|
||||
watch(helpActive, async (p) => {
|
||||
if (!p) return
|
||||
await nextTick()
|
||||
helpRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
|
||||
/** 选 option:关求助卡即可。后端 loop 已在 AiHelpRequired 时终止(guard.reset + return),
|
||||
* 用户选 option 后的"换思路/授权路径/人工接管"由用户重新发消息/操作触发(无独立 IPC)。
|
||||
* 点"授权路径"提示用户路径授权链路(DirAuthDialog 自动触发,无需此卡代发)。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
点停止 → ai_stop_loop(后端走 AiCompleted 收尾)→ 卡片隐藏。
|
||||
store 单例共享(state/aiApi),pendingMaxRounds 模块级 ref(useAiEvents)直接导入。
|
||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||
<div v-if="showMaxRoundsCard" class="ai-max-rounds">
|
||||
<div v-if="showMaxRoundsCard" ref="maxRoundsRef" class="ai-max-rounds">
|
||||
<span class="ai-max-rounds-text">{{ $t('aiChat.maxRoundsReached') }}</span>
|
||||
<span class="ai-max-rounds-hint">{{ $t('aiChat.maxRoundsHint') }}</span>
|
||||
<div class="ai-max-rounds-actions">
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pendingMaxRounds, getConvState } from '../../composables/ai/useAiEvents'
|
||||
@@ -73,6 +73,10 @@ const showMaxRoundsCard = computed(() =>
|
||||
&& isViewingGeneratingState.value,
|
||||
)
|
||||
|
||||
// M8:卡片根 ref。达 max 挂起卡位于输入区上方,多轮迭代后消息流已很长,卡片常被挤出视口 → 用户看不到
|
||||
// "继续/停止" → generating 永真卡死。卡片显示时 scrollIntoView 让操作入口立即可见。
|
||||
const maxRoundsRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
|
||||
* 不主动清 pendingMaxRounds——由后端新一轮事件(AiAgentRound)与最终 AiCompleted/AiError
|
||||
* 在 useAiEvents 内清。IPC 失败回滚 acting 让用户可重试。 */
|
||||
@@ -108,6 +112,14 @@ async function handleStopLoop(): Promise<void> {
|
||||
watch(() => pendingMaxRounds.value, (v) => {
|
||||
if (!v) maxRoundsActing.value = false
|
||||
})
|
||||
|
||||
/** M8:卡片显示(从无→有)时 scrollIntoView 让"继续/停止"按钮组立即进入视口。
|
||||
* 仅监 showMaxRoundsCard 真值翻转,不监 acting 等内部态避免已可见时反复跳。 */
|
||||
watch(showMaxRoundsCard, async (show) => {
|
||||
if (!show) return
|
||||
await nextTick()
|
||||
maxRoundsRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -53,8 +53,14 @@
|
||||
<button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
</button>
|
||||
<!-- 清空对话(真删 DB messages,带二次确认防误删)。高频常用,常驻。 -->
|
||||
<button class="ai-btn-icon" @click="emit('clear-chat')" :title="$t('aiChat.clearChat')">
|
||||
<!-- 清空对话(真删 DB messages,带二次确认防误删)。高频常用,常驻。
|
||||
disabled:无活跃消息(空会话)或流式中(避免并发删/写),对齐 clear-context 项语义。 -->
|
||||
<button
|
||||
class="ai-btn-icon"
|
||||
:disabled="!hasActiveMessages || store.state.streaming"
|
||||
:title="$t('aiChat.clearChat')"
|
||||
@click="emit('clear-chat')"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -391,8 +397,22 @@ const historyExpanded = ref(false)
|
||||
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */
|
||||
function scrollToMessage(msgId: string) {
|
||||
historyExpanded.value = false
|
||||
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位
|
||||
const el = document.querySelector(`[data-msg-id="${msgId}"]`)
|
||||
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位。
|
||||
// BUG-260802:msgId 由后端生成,可能含 CSS 选择器元字符(: . [ ] / 空格 等),
|
||||
// 模板拼 selector 会被选择器解析器当伪类/类名异常 → querySelector 抛错或返回 null。
|
||||
// 用 CSS.escape 转义 msgId,确保任意字符串安全嵌入属性选择器。
|
||||
// 旧浏览器无 CSS.escape 的兜底:encodeURIComponent + attr*= 模糊匹配(非精确,降级路径)。
|
||||
let el: Element | null = null
|
||||
const escaped = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
||||
? CSS.escape(msgId)
|
||||
: encodeURIComponent(msgId)
|
||||
try {
|
||||
el = document.querySelector(`[data-msg-id="${escaped}"]`)
|
||||
} catch {
|
||||
// 转义后仍异常(理论不会)→ 退化为遍历查找
|
||||
const all = document.querySelectorAll('[data-msg-id]')
|
||||
el = Array.from(all).find(n => n.getAttribute('data-msg-id') === msgId) ?? null
|
||||
}
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
@@ -62,11 +62,15 @@ function getProjectTaskCount(projectId: string): number {
|
||||
// 直接拼接 i18n key dashboard.stage.<stage>,与 constants/project.ts PROJECT_STAGE_INFO 单一来源。
|
||||
// 面板名为"活跃项目",只显示 active 状态(DB 实际默认值,ProjectStatus union 历史遗留未含
|
||||
// 'active',此处断言绕过;DB 实际无 planning/in_progress 等值产生,详见功能决策记录)。
|
||||
// 按更新时间倒序取前 6 条(completed 归档项目不混入)。
|
||||
//
|
||||
// 问题3:按「最近活跃」排序(last_active_at 优先,回退 updated_at),反映业务活跃
|
||||
// (任务/灵感/状态推进事件)而非元信息修改时间。取前 6 条(completed 归档项目不混入)。
|
||||
const displayProjects = computed(() =>
|
||||
store.projects
|
||||
.filter(p => (p.status as string) === 'active')
|
||||
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
||||
.sort((a, b) =>
|
||||
(b.last_active_at ?? b.updated_at).localeCompare(a.last_active_at ?? a.updated_at)
|
||||
)
|
||||
.slice(0, 6)
|
||||
.map(p => {
|
||||
const info = projectStageInfo(p.status)
|
||||
@@ -77,7 +81,7 @@ const displayProjects = computed(() =>
|
||||
stageLabelKey: info.stage,
|
||||
progress: info.progress,
|
||||
activeTasks: getProjectTaskCount(p.id),
|
||||
lastActivity: formatRelative(p.updated_at),
|
||||
lastActivity: formatRelative(p.last_active_at ?? p.updated_at),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -33,11 +33,19 @@
|
||||
<!-- B-260615-25:知识内容 Markdown 渲染(展示态),复用 useMarkdown composable(同 B-24 TaskDetail);编辑态保持 textarea -->
|
||||
<div
|
||||
v-if="!editing && detail.knowledge.content"
|
||||
ref="contentEl"
|
||||
class="detail-content ai-md"
|
||||
:class="{ 'content-collapsed': !contentExpanded }"
|
||||
v-html="renderedContent"
|
||||
></div>
|
||||
<div v-else-if="!editing" class="detail-content">—</div>
|
||||
<textarea v-else v-model="editForm.content" class="edit-input edit-textarea"></textarea>
|
||||
<!-- P1 内容超长折叠:渲染后高度 > 300px 显示展开/收起 -->
|
||||
<button
|
||||
v-if="!editing && contentOverflow"
|
||||
class="btn btn-ghost btn-sm fold-toggle"
|
||||
@click="contentExpanded = !contentExpanded"
|
||||
>{{ contentExpanded ? t('common.collapse') : t('common.expand') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-field">
|
||||
@@ -50,46 +58,69 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ② 溯源 -->
|
||||
<!-- ② 溯源(P0 折叠:标题点击切换,默认展开)-->
|
||||
<section class="detail-section">
|
||||
<div class="section-title">{{ t('knowledge.traceTitle') }}</div>
|
||||
<div class="trace-row">
|
||||
<span class="trace-label">{{ t('knowledge.traceMethod') }}</span>
|
||||
<span>{{ originMethod }}</span>
|
||||
<div class="section-title section-title-toggle" @click="sectionOpen.trace = !sectionOpen.trace">
|
||||
<span>{{ t('knowledge.traceTitle') }}</span>
|
||||
<span class="section-chevron">{{ sectionOpen.trace ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
<div class="trace-row" v-if="originConvTitle">
|
||||
<span class="trace-label">{{ t('knowledge.traceSource') }}</span>
|
||||
<span>{{ originConvTitle }}</span>
|
||||
</div>
|
||||
<div class="trace-row" v-if="originTime">
|
||||
<span class="trace-label">{{ t('knowledge.traceTime') }}</span>
|
||||
<span>{{ originTime }}</span>
|
||||
</div>
|
||||
<div class="detail-field" v-if="reasoningText">
|
||||
<label>{{ t('knowledge.reasoningLabel') }}</label>
|
||||
<div class="reasoning-box">{{ reasoningText }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ③ 引用 -->
|
||||
<section class="detail-section">
|
||||
<div class="section-title">{{ t('knowledge.refTitle', { n: referenceEvents.length }) }}</div>
|
||||
<div v-if="referenceEvents.length === 0" class="muted">{{ t('knowledge.refEmpty') }}</div>
|
||||
<div v-else class="ref-list">
|
||||
<div v-for="ref in referenceEvents.slice(0, refLimit)" :key="ref.id" class="ref-item">
|
||||
<span class="ref-time">{{ relativeTime(ref.timestamp) }}</span>
|
||||
<span class="ref-conv">{{ refConvTitle(ref) }}</span>
|
||||
<span class="ref-query" v-if="refQuery(ref)">“{{ refQuery(ref) }}”</span>
|
||||
<div v-show="sectionOpen.trace" class="section-body">
|
||||
<div class="trace-row">
|
||||
<span class="trace-label">{{ t('knowledge.traceMethod') }}</span>
|
||||
<span>{{ originMethod }}</span>
|
||||
</div>
|
||||
<div class="trace-row" v-if="originConvTitle">
|
||||
<span class="trace-label">{{ t('knowledge.traceSource') }}</span>
|
||||
<span>{{ originConvTitle }}</span>
|
||||
</div>
|
||||
<div class="trace-row" v-if="originTime">
|
||||
<span class="trace-label">{{ t('knowledge.traceTime') }}</span>
|
||||
<span>{{ originTime }}</span>
|
||||
</div>
|
||||
<div class="detail-field" v-if="reasoningText">
|
||||
<label>{{ t('knowledge.reasoningLabel') }}</label>
|
||||
<!-- P1 reasoning 折叠:长文本默认收起,避免挤占视口 -->
|
||||
<div
|
||||
class="reasoning-box"
|
||||
:class="{ 'reasoning-collapsed': !reasoningExpanded }"
|
||||
>{{ reasoningText }}</div>
|
||||
<button
|
||||
v-if="reasoningOverflow || reasoningExpanded"
|
||||
class="btn btn-ghost btn-sm fold-toggle"
|
||||
@click="reasoningExpanded = !reasoningExpanded"
|
||||
>{{ reasoningExpanded ? t('common.collapse') : t('common.expand') }}</button>
|
||||
</div>
|
||||
<button v-if="referenceEvents.length > refLimit" class="btn btn-ghost btn-sm load-more" @click="refLimit += 10">
|
||||
{{ t('knowledge.loadMore', { n: referenceEvents.length - refLimit }) }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ④ 生命周期时间线 -->
|
||||
<!-- ③ 引用(P0 折叠)-->
|
||||
<section class="detail-section">
|
||||
<div class="section-title">{{ t('knowledge.lifecycleTitle') }}</div>
|
||||
<div class="section-title section-title-toggle" @click="sectionOpen.refs = !sectionOpen.refs">
|
||||
<span>{{ t('knowledge.refTitle', { n: referenceEvents.length }) }}</span>
|
||||
<span class="section-chevron">{{ sectionOpen.refs ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
<div v-show="sectionOpen.refs" class="section-body">
|
||||
<div v-if="referenceEvents.length === 0" class="muted">{{ t('knowledge.refEmpty') }}</div>
|
||||
<div v-else class="ref-list">
|
||||
<div v-for="ref in referenceEvents.slice(0, refLimit)" :key="ref.id" class="ref-item">
|
||||
<span class="ref-time">{{ relativeTime(ref.timestamp) }}</span>
|
||||
<span class="ref-conv">{{ refConvTitle(ref) }}</span>
|
||||
<span class="ref-query" v-if="refQuery(ref)">“{{ refQuery(ref) }}”</span>
|
||||
</div>
|
||||
<button v-if="referenceEvents.length > refLimit" class="btn btn-ghost btn-sm load-more" @click="refLimit += 10">
|
||||
{{ t('knowledge.loadMore', { n: referenceEvents.length - refLimit }) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ④ 生命周期时间线(P0 折叠:默认收起,回顾性信息通常无需常驻)-->
|
||||
<section class="detail-section">
|
||||
<div class="section-title section-title-toggle" @click="sectionOpen.lifecycle = !sectionOpen.lifecycle">
|
||||
<span>{{ t('knowledge.lifecycleTitle') }}</span>
|
||||
<span class="section-chevron">{{ sectionOpen.lifecycle ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
<div v-show="sectionOpen.lifecycle" class="section-body">
|
||||
<div class="timeline">
|
||||
<div v-for="(node, idx) in timelineNodes" :key="node.id" class="tl-node">
|
||||
<div class="tl-dot">{{ node.icon }}</div>
|
||||
@@ -101,11 +132,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
parseTags,
|
||||
@@ -177,6 +209,41 @@ const { rendered: renderedContent, ensureLoaded } = useRendered(
|
||||
// 引用列表「加载更多」上限
|
||||
const refLimit = ref(10)
|
||||
|
||||
// ===== 折叠态(P0 溯源/引用/生命周期段折叠 + P1 内容/reasoning 超长折叠)=====
|
||||
// 段落默认开合:溯源/引用默认展开(常用),生命周期默认收起(回顾性,通常无需常驻)
|
||||
const sectionOpen = ref({ trace: true, refs: true, lifecycle: false })
|
||||
|
||||
// 内容/reasoning 折叠:超阈值(300px / 120px)才显示「展开/收起」按钮
|
||||
const CONTENT_FOLD_THRESHOLD = 300
|
||||
const REASONING_FOLD_THRESHOLD = 120
|
||||
const contentEl = ref<HTMLElement | null>(null)
|
||||
const contentExpanded = ref(false)
|
||||
const contentOverflow = ref(false)
|
||||
const reasoningExpanded = ref(false)
|
||||
const reasoningOverflow = ref(false)
|
||||
|
||||
// 测量内容/reasoning 实际高度,决定是否需折叠按钮(渲染异步,DOM 更新后测)
|
||||
async function measureOverflow() {
|
||||
await nextTick()
|
||||
if (contentEl.value) {
|
||||
// 临时移除折叠限制测量真实高度
|
||||
contentEl.value.style.maxHeight = 'none'
|
||||
const h = contentEl.value.scrollHeight
|
||||
contentEl.value.style.maxHeight = ''
|
||||
contentOverflow.value = h > CONTENT_FOLD_THRESHOLD
|
||||
if (!contentOverflow.value) contentExpanded.value = false
|
||||
}
|
||||
// reasoning 用纯文本高度估算(行数 * 行高 1.6 * 13px ≈ 20.8px/行)
|
||||
const rText: string = reasoningText.value || ''
|
||||
if (rText) {
|
||||
const approxLines = rText.split('\n').reduce<number>((acc, line) => acc + Math.max(1, Math.ceil(line.length / 60)), 0)
|
||||
reasoningOverflow.value = approxLines * 21 > REASONING_FOLD_THRESHOLD
|
||||
if (!reasoningOverflow.value) reasoningExpanded.value = false
|
||||
} else {
|
||||
reasoningOverflow.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ ensureLoaded })
|
||||
|
||||
// ===== 事件解析辅助 =====
|
||||
@@ -266,9 +333,18 @@ function relativeTime(millisStr: string): string {
|
||||
return t('common.ago', { time: new Date(ms).toLocaleDateString() })
|
||||
}
|
||||
|
||||
// 切换知识时重置引用列表「加载更多」上限(对齐原 Knowledge.vue selectKnowledge 内 refLimit=10 行为)
|
||||
// 切换知识时重置引用列表「加载更多」上限 + 折叠态(对齐原 Knowledge.vue selectKnowledge 内 refLimit=10 行为)
|
||||
watch(() => props.detail.knowledge.id, () => {
|
||||
refLimit.value = 10
|
||||
sectionOpen.value = { trace: true, refs: true, lifecycle: false }
|
||||
contentExpanded.value = false
|
||||
reasoningExpanded.value = false
|
||||
measureOverflow()
|
||||
})
|
||||
|
||||
// 内容渲染完成后(异步 v-html)重测高度:renderedContent 变化时
|
||||
watch(renderedContent, () => {
|
||||
measureOverflow()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -307,6 +383,13 @@ watch(() => props.detail.knowledge.id, () => {
|
||||
|
||||
/* 溯源 */
|
||||
.section-title { font-size: 13px; font-weight: 500; color: var(--df-text); margin-bottom: 10px; }
|
||||
.section-title-toggle {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
cursor: pointer; user-select: none; padding: 2px 0;
|
||||
}
|
||||
.section-title-toggle:hover { color: var(--df-accent); }
|
||||
.section-chevron { font-size: 11px; color: var(--df-text-dim); }
|
||||
.section-body { /* 折叠体容器,无额外样式 */ }
|
||||
.trace-row { display: flex; gap: 12px; font-size: 12px; margin-bottom: 6px; }
|
||||
.trace-label { color: var(--df-text-dim); min-width: 72px; }
|
||||
.reasoning-box {
|
||||
@@ -314,6 +397,26 @@ watch(() => props.detail.knowledge.id, () => {
|
||||
background: var(--df-bg-card); border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius); padding: 10px 12px; white-space: pre-wrap;
|
||||
}
|
||||
/* P1 reasoning 折叠:超过阈值收起为限定高度,渐隐边缘提示可展开 */
|
||||
.reasoning-collapsed {
|
||||
max-height: 120px; overflow: hidden; position: relative;
|
||||
}
|
||||
.reasoning-collapsed::after {
|
||||
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 24px;
|
||||
background: linear-gradient(to bottom, transparent, var(--df-bg-card));
|
||||
pointer-events: none;
|
||||
}
|
||||
/* P1 内容折叠:超过 300px 收起 */
|
||||
.detail-content.content-collapsed {
|
||||
max-height: 300px; overflow: hidden; position: relative;
|
||||
}
|
||||
.detail-content.content-collapsed::after {
|
||||
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 28px;
|
||||
background: linear-gradient(to bottom, transparent, var(--df-bg-panel, var(--df-bg-card)));
|
||||
pointer-events: none;
|
||||
}
|
||||
.detail-content { position: relative; }
|
||||
.fold-toggle { margin-top: 6px; font-size: 11px; }
|
||||
|
||||
/* 引用 */
|
||||
.ref-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
@@ -24,18 +24,80 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAiStore } from '@/stores/ai'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
isDetached: boolean
|
||||
}>()
|
||||
|
||||
const aiStore = useAiStore()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// ── 7a:路由路径+查询启动恢复(仅主窗口)──
|
||||
// 持久化到 localStorage(轻量、同步、读早于 appSettings.loadAll 完成,避免闪烁)。
|
||||
// 各列表页(Tasks/Ideas/...)的筛选恢复由各自 agent 读写各自 localStorage key;
|
||||
// 此处只负责「上次所在页面 + query」级别的恢复(如 /tasks?status=done)。
|
||||
// 分离窗口(ai-detached/file-explorer-detached/approval-popup)不参与:它们有专属
|
||||
// 入口路由,且会被 watch 误覆盖主窗口记录。
|
||||
const ROUTE_STORAGE_KEY = 'df-last-route'
|
||||
// 这些路径不持久化(过渡/分离/弹窗/根跳转):避免分离窗口覆盖、避免 '/' redirect 噪音
|
||||
const NON_PERSIST_PATHS = new Set(['/', '/ai-detached', '/file-explorer-detached', '/approval-popup'])
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** 写入当前 path + query 到 localStorage(忽略 NON_PERSIST 路径) */
|
||||
function persistRoute(path: string, query: Record<string, string>) {
|
||||
if (props.isDetached || NON_PERSIST_PATHS.has(path)) return
|
||||
try {
|
||||
localStorage.setItem(ROUTE_STORAGE_KEY, JSON.stringify({ path, query }))
|
||||
} catch {
|
||||
/* 配额溢出/隐私模式,静默 */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 分离窗口不恢复(它们的入口路由由打开方指定)
|
||||
if (props.isDetached) return
|
||||
// 首屏若落在根 redirect('/') 或 ai-home,尝试恢复上次路由
|
||||
try {
|
||||
const raw = localStorage.getItem(ROUTE_STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const saved = JSON.parse(raw) as { path?: string; query?: Record<string, string> }
|
||||
const targetPath = saved.path
|
||||
if (!targetPath || NON_PERSIST_PATHS.has(targetPath)) return
|
||||
// 仅在当前确实处于根/首页时恢复(避免覆盖用户深链接或刷新某具体页)
|
||||
if (route.path !== '/' && route.path !== '/ai-home') {
|
||||
// 用户已在某具体页:仅补持久化当前页,不抢断
|
||||
persistRoute(route.path, { ...route.query } as Record<string, string>)
|
||||
return
|
||||
}
|
||||
// 恢复:仅当目标路由存在(catch-all 会 redirect,这里 router.replace 自身已容错)
|
||||
const targetQuery = saved.query || {}
|
||||
// 防自循环:目标与当前位置完全一致则不 replace
|
||||
if (targetPath === route.path && Object.keys(targetQuery).length === 0) return
|
||||
router.replace({ path: targetPath, query: targetQuery })
|
||||
} catch {
|
||||
/* 解析失败:数据损坏,忽略(下次 watch 会覆写正确值) */
|
||||
}
|
||||
})
|
||||
|
||||
// watch 路由变化:持久化最新 path + query(主窗口)
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => persistRoute(path, { ...route.query } as Record<string, string>),
|
||||
)
|
||||
// query 变化(同 path 不同 query,如筛选)也要持久化
|
||||
watch(
|
||||
() => route.query,
|
||||
(q) => persistRoute(route.path, { ...(q as Record<string, string>) }),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// ── AI 面板拖拽调宽 ──
|
||||
// useSetting 监听 appSettings 缓存:loadAll 异步填充后会自动刷新 ref
|
||||
const panelWidth = appSettings.useSetting<number>('df-ai-width', 500)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<aside class="df-sidebar" :class="{ 'df-sidebar--collapsed': collapsed }">
|
||||
<!-- Logo -->
|
||||
<!-- Logo(7b:brand 区只保留品牌标识,收缩按钮已移到底部操作区) -->
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-mark">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
@@ -12,16 +12,8 @@
|
||||
</div>
|
||||
<span v-if="!collapsed" class="brand-text">DevFlow</span>
|
||||
<span v-if="!collapsed" class="brand-version">v0.1</span>
|
||||
<button v-if="!collapsed" class="collapse-btn" @click="toggleCollapsed" :title="$t('nav.collapse')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 收缩态展开按钮(底部固定位置,仅在 collapsed 时显示) -->
|
||||
<button v-if="collapsed" class="expand-btn" @click="toggleCollapsed" :title="$t('nav.expand')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-group">
|
||||
@@ -63,6 +55,16 @@
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ $t('nav.systemReady') }}</span>
|
||||
</div>
|
||||
<!-- 7b:收缩/展开切换按钮(统一置于底部操作区,与 AI 面板/设置同列)。
|
||||
collapsed 时显展开箭头(→),展开时显收起箭头(←),快捷键 Ctrl+B 同效。 -->
|
||||
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')">
|
||||
<span class="nav-link-icon">
|
||||
<svg v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</span>
|
||||
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.collapse') }}</span>
|
||||
<span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+B</span>
|
||||
</button>
|
||||
<!-- AI 面板切换按钮 -->
|
||||
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined">
|
||||
<span class="nav-link-icon ai-spark-icon">
|
||||
@@ -111,16 +113,28 @@ function checkWindowWidth() {
|
||||
isNarrowWindow.value = window.innerWidth < COLLAPSE_THRESHOLD
|
||||
}
|
||||
|
||||
// 7b:Ctrl+B / Cmd+B 切换侧边栏收缩(与 AI 面板 Ctrl+I 同款全局快捷键风格)
|
||||
// 仅响应 ctrl 或 meta 任一修饰键(Cmd+I 同款),忽略纯 'b'(避免拦截正常输入)
|
||||
function handleCollapseKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'b' || e.key === 'B')) {
|
||||
e.preventDefault()
|
||||
toggleCollapsed()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 恢复用户偏好(默认展开)
|
||||
userCollapsed.value = appSettings.get('df-sidebar-collapsed', false)
|
||||
// 检查当前窗口宽度
|
||||
checkWindowWidth()
|
||||
window.addEventListener('resize', checkWindowWidth)
|
||||
// Ctrl+B 切换快捷键
|
||||
window.addEventListener('keydown', handleCollapseKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkWindowWidth)
|
||||
window.removeEventListener('keydown', handleCollapseKeydown)
|
||||
})
|
||||
|
||||
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
|
||||
@@ -199,39 +213,20 @@ const secondaryNav = [
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
/* 收缩按钮(展开态 brand 区右侧) */
|
||||
.collapse-btn {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px; height: 22px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
/* 7b:收缩/展开切换按钮(底部操作区,复用 nav-link 样式 + button 重置)
|
||||
与 .ai-toggle-btn 同款 button-over-nav-link 模式:cursor/border/background/font 重置 */
|
||||
.collapse-toggle-btn {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s var(--df-ease);
|
||||
}
|
||||
.collapse-btn:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
/* 展开按钮(收缩态 brand 区下方) */
|
||||
.expand-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s var(--df-ease);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.expand-btn:hover {
|
||||
.collapse-toggle-btn:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,19 @@
|
||||
<p style="margin-bottom: 8px; font-size: 14px;">{{ $t('projectDetail.approvalHint') }}</p>
|
||||
<!-- F-260615-01: select_type=multiple → checkbox 多选,缺省 single → 按钮单选 -->
|
||||
<template v-if="isMultipleSelect">
|
||||
<!-- 全选/反选(多选审批快捷操作):复用 btn-ghost 弱视觉,常驻显隐按钮态 -->
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="submitting || allSelected"
|
||||
@click="selectAll"
|
||||
>{{ $t('projectDetail.approvalSelectAll') }}</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="submitting || pendingApproval.options.length === 0"
|
||||
@click="invertSelection"
|
||||
>{{ $t('projectDetail.approvalInvert') }}</button>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px;">
|
||||
<label
|
||||
v-for="(option, idx) in pendingApproval.options"
|
||||
@@ -84,6 +97,27 @@ const submitting = ref(false)
|
||||
const { t } = useI18n()
|
||||
const { toast, showToast } = useToast()
|
||||
|
||||
// 全选态(全部 options 已选):驱动「全选」按钮 disabled,避免无意义点击
|
||||
const allSelected = computed(() => {
|
||||
const opts = pendingApproval.value?.options ?? []
|
||||
return opts.length > 0 && multiDecisions.value.length === opts.length
|
||||
})
|
||||
|
||||
/** 全选:填入全部 options(去重保险)。submitting 中禁用防与提交并发 */
|
||||
function selectAll() {
|
||||
if (submitting.value) return
|
||||
const opts = pendingApproval.value?.options ?? []
|
||||
multiDecisions.value = [...opts]
|
||||
}
|
||||
|
||||
/** 反选:options 中未在 multiDecisions 的项成为新选中集 */
|
||||
function invertSelection() {
|
||||
if (submitting.value) return
|
||||
const opts = pendingApproval.value?.options ?? []
|
||||
const selected = new Set(multiDecisions.value)
|
||||
multiDecisions.value = opts.filter(o => !selected.has(o))
|
||||
}
|
||||
|
||||
// M31: store.approveHumanApproval 内部 try/catch 吞错(只 console.error + 写 state.error,不 rethrow),
|
||||
// 失败时 state.pendingApproval 不清空 → 以此为失败信号。失败显 toast、对话框保持开启,用户可重试或改取消。
|
||||
async function handleApproval(decision: string) {
|
||||
|
||||
@@ -257,7 +257,7 @@ async function loadModules() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置树状态(切工程时调用,清展开/缓存/选中文件;刷新时保留选中文件由 refresh 单独处理)。 */
|
||||
/** 重置树状态(切工程时调用,清展开/缓存/选中文件;refresh 也归零选中)。 */
|
||||
function resetTreeState() {
|
||||
expandedPaths.clear()
|
||||
loadedChildren.clear()
|
||||
@@ -303,13 +303,15 @@ function onLoadChildren(_path: string) {
|
||||
// 占位:FileTree 内部已自拉并缓存到 loadedChildren;此处保留事件入口便于未来扩展(如统一节流)。
|
||||
}
|
||||
|
||||
/** 刷新根目录(清展开/缓存,保留已打开的文件预览不变)。 */
|
||||
/** 刷新根目录(清展开/缓存,并把选中文件归位)。
|
||||
* 切工程走 resetTreeState(同样清选中);此处独立清是为了让刷新后树回到根、预览回到占位,
|
||||
* 避免选中文件指向已不存在的路径(刷新后 git 状态/文件内容可能已变)。 */
|
||||
async function refresh() {
|
||||
refreshing.value = true
|
||||
// 仅清树状态(展开目录 + 缓存条目),不清 selectedFilePath/selectedFileGitStatus,
|
||||
// 让用户刷新的同时仍能看到正在查看的文件内容。
|
||||
expandedPaths.clear()
|
||||
loadedChildren.clear()
|
||||
selectedFilePath.value = null
|
||||
selectedFileGitStatus.value = undefined
|
||||
// 等一个 tick 让 FileTree watch 触发重拉;实际拉取在子组件,这里只做状态清空。
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
refreshing.value = false
|
||||
|
||||
@@ -59,10 +59,7 @@
|
||||
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
|
||||
</div>
|
||||
|
||||
<!-- Markdown 渲染(marked + DOMPurify + 代码高亮) -->
|
||||
<div v-else-if="isMarkdown" class="preview-md ai-md" v-html="renderedMd"></div>
|
||||
|
||||
<!-- Diff 视图(切到 diff 模式时显示) -->
|
||||
<!-- Diff 视图(切到 diff 模式时显示;置于 Markdown 之前,使 .md 文件也支持 Diff) -->
|
||||
<div v-else-if="showDiff && diffContent" class="preview-diff">
|
||||
<div v-for="(ln, idx) in diffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
|
||||
<span class="diff-line-num">{{ ln.oldNum || '' }}</span>
|
||||
@@ -75,6 +72,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Markdown 渲染(marked + DOMPurify + 代码高亮) -->
|
||||
<div v-else-if="isMarkdown" class="preview-md ai-md" v-html="renderedMd"></div>
|
||||
|
||||
<!-- 文本/代码(highlight.js 语法高亮 + 行号;非 diff 模式时显示) -->
|
||||
<div v-else class="preview-code-scroll">
|
||||
<div class="preview-line-numbers" aria-hidden="true">
|
||||
@@ -110,12 +110,16 @@ const fileSize = ref<number | null>(null)
|
||||
const truncated = ref(false)
|
||||
const imageUrl = ref<string | null>(null)
|
||||
|
||||
/** 行号显示 — 根据 highlight.js 输出的 HTML 行数计算。 */
|
||||
/** 行号显示 — 按源文本真实行数计算(非 highlight.js 渲染 HTML 行数)。
|
||||
* 高亮 HTML 可能因多行 token / 转义与源码行数不一致,直接切分 htmlContent 易错位。
|
||||
* 末尾无换行时 split 会多出空串,需按实际换行符计数对齐渲染。 */
|
||||
const lineCount = computed(() => {
|
||||
if (!htmlContent.value) return 0
|
||||
// highlight.js 用 \n 分隔行,计算行数
|
||||
const lines = htmlContent.value.split('\n')
|
||||
return lines.length
|
||||
if (!content.value) return 0
|
||||
const text = content.value
|
||||
// 末尾换行不计为新一行的可视行号(渲染时 <pre> 也不会显示空行)。
|
||||
const norm = text.endsWith('\n') ? text.slice(0, -1) : text
|
||||
if (norm === '') return 1
|
||||
return norm.split('\n').length
|
||||
})
|
||||
|
||||
/** Diff 显示控制 */
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
* 子组件只负责 emit('toggle-dir', path, entries) / emit('load-children', path),
|
||||
* 实际的网络请求与状态更新统一在 FileExplorer 中处理。
|
||||
* - 本组件自身的 loading/entries 仅用于"首次挂载时拉取自己 sub_path 的根条目";
|
||||
* 递归子树复用同一份 props/事件,不重复拉取。
|
||||
* 递归子树复用同一份 props/事件,且 loadEntries 会先查 loadedChildren 缓存命中即复用,
|
||||
* 避免父级 toggleDir 已拉取后子树 onMounted 再次重复请求同目录。
|
||||
*
|
||||
* 这样设计的好处:刷新(refresh)只需 FileExplorer 清空 loadedChildren 重拉根,
|
||||
* 所有展开的子树因 expandedPaths 被清而卸载,下次展开重新拉取,无脏数据。
|
||||
@@ -130,13 +131,21 @@ const error = ref<string | null>(null)
|
||||
async function loadEntries() {
|
||||
// moduleId 为空时不发起请求(工程列表还在加载中)
|
||||
if (!props.moduleId) return
|
||||
// 去重:递归子树实例挂载时,父级 toggleDir 通常已为本目录拉取并缓存。
|
||||
// 命中缓存则直接复用,不再发同样的请求(避免 onMounted 与 toggleDir 双重拉取)。
|
||||
const cacheKey = props.subPath || ''
|
||||
const cached = props.loadedChildren.get(cacheKey)
|
||||
if (cached) {
|
||||
entries.value = cached
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await moduleApi.getModuleFileTree(props.moduleId, props.subPath || undefined)
|
||||
entries.value = res.entries
|
||||
// 缓存到顶层 loadedChildren(供 toggle 判断是否已有数据,避免重复请求)。
|
||||
props.loadedChildren.set(props.subPath || '', res.entries)
|
||||
props.loadedChildren.set(cacheKey, res.entries)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
@@ -153,10 +162,10 @@ async function toggleDir(entry: FileTreeEntry) {
|
||||
// 收起:仅移除展开标记(loadedChildren 缓存保留,下次展开秒开)。
|
||||
emit('toggle-dir', entry.path, [])
|
||||
} else {
|
||||
// 展开:若未加载过则先拉取(由顶层处理缓存命中),再 emit 通知展开。
|
||||
// 展开:若未加载过则先拉取并缓存(去重——只在这里拉一次;
|
||||
// 递归子树实例 onMounted 时会复用此缓存,不重复请求同目录)。
|
||||
if (!props.loadedChildren.has(entry.path)) {
|
||||
emit('load-children', entry.path)
|
||||
// 立即拉取本目录(子组件实例挂载后会自取 loadedChildren;此处同步拉保响应即时)。
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await moduleApi.getModuleFileTree(props.moduleId, entry.path)
|
||||
|
||||
@@ -403,10 +403,15 @@ async function loadStatus() {
|
||||
timestamp: c.timestamp,
|
||||
author: c.author,
|
||||
}))
|
||||
totalCommits.value = commits.value.length
|
||||
// 全量提交数读后端 total_commits(git rev-list --count HEAD),非 commits.length(后者受分页限制)。
|
||||
const backendTotal = gitStatus.value?.total_commits ?? 0
|
||||
totalCommits.value = backendTotal
|
||||
commitSkip.value = commits.value.length
|
||||
// recent_commits 通常为 50 条;若返回等于页大小则假定还有更多(精确 total 需后端,见 12a)。
|
||||
hasMoreCommits.value = commits.value.length >= COMMIT_PAGE_SIZE
|
||||
// has_more:后端无 total 时(commits 仍可能未全载)用页大小推断;有 total 时按已载 < total 判定。
|
||||
hasMoreCommits.value =
|
||||
backendTotal > 0
|
||||
? commits.value.length < backendTotal
|
||||
: commits.value.length >= COMMIT_PAGE_SIZE
|
||||
} catch {
|
||||
gitStatus.value = null
|
||||
commits.value = []
|
||||
@@ -455,7 +460,8 @@ async function loadMoreCommits() {
|
||||
}
|
||||
hasMoreCommits.value = res.has_more
|
||||
commitSkip.value += res.commits.length
|
||||
totalCommits.value = commits.value.length
|
||||
// 全量提交数读后端 total(git rev-list --count HEAD),非 commits.length(后者受分页限制)。
|
||||
totalCommits.value = res.total
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
|
||||
@@ -25,10 +25,27 @@ const props = defineProps<{
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
// 路径截断:保留首段根目录(盘符/家目录锚点)+末尾目录,丢中间段。
|
||||
// 旧实现 '...' + slice(-29) 仅留末尾,丢失前缀致同末不同源目录难辨(如 C:/a 与 D:/b 同名)。
|
||||
// 现:根段(/第一段分隔符前)+ … + 末尾(容纳 32 字符上限内)。短路径直返。
|
||||
const truncatedPath = computed(() => {
|
||||
const p = props.data?.path || ''
|
||||
if (p.length <= 32) return p
|
||||
return '...' + p.slice(-29)
|
||||
// 拆根段:取首个路径分隔符(/ 或 \,跨平台)前部分作锚点(盘符 C: 或家目录片段)
|
||||
const sepMatch = p.match(/[\\/]/)
|
||||
if (!sepMatch) {
|
||||
// 无分隔符的单段长名,退化为末尾
|
||||
return '...' + p.slice(-29)
|
||||
}
|
||||
const sepIdx = sepMatch.index! + 1
|
||||
const head = p.slice(0, sepIdx)
|
||||
const tailBudget = 32 - head.length - 1 // -1 留给 …(此处用 …)
|
||||
if (tailBudget < 4) {
|
||||
// 根段本身就长,退化为末尾(保留原行为)
|
||||
return '...' + p.slice(-29)
|
||||
}
|
||||
const tail = p.slice(-tailBudget)
|
||||
return head + '…' + tail
|
||||
})
|
||||
|
||||
const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="project-card" @click="router.push('/projects/' + project.id)">
|
||||
<div class="project-card" :class="{ 'project-card--list': view === 'list' }" @click="router.push('/projects/' + project.id)">
|
||||
<div class="card-top">
|
||||
<div class="card-title-row">
|
||||
<h2 class="card-name">{{ project.name }}</h2>
|
||||
@@ -10,7 +10,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="card-desc">{{ stripMd(project.description) }}</p>
|
||||
<!-- 描述:max-height + 省略号,展开/收起;list 视图更紧凑(2 行),card 视图宽松(3 行) -->
|
||||
<div class="card-desc-wrap" :class="{ 'is-expanded': descExpanded }">
|
||||
<p ref="descRef" class="card-desc">{{ stripMd(project.description) }}</p>
|
||||
<button
|
||||
v-if="descOverflow"
|
||||
class="card-desc-toggle"
|
||||
@click.stop="descExpanded = !descExpanded"
|
||||
>{{ descExpanded ? $t('common.collapse') : $t('common.expand') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="card-footer">
|
||||
@@ -24,7 +32,7 @@
|
||||
</div>
|
||||
<div class="footer-stat">
|
||||
<span class="stat-icon">🕐</span>
|
||||
<span>{{ formatDate(project.updated_at) }}</span>
|
||||
<span>{{ formatDate(project.last_active_at ?? project.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,8 +46,9 @@
|
||||
<script setup lang="ts">
|
||||
// 项目列表卡片 — 从 Projects.vue 抽出(单卡片渲染 + 导航 + 删除触发)。
|
||||
// 依赖均为无副作用纯函数 + 共享常量;删除走 emit 交回父组件处理(confirm 弹层 + store 调用)。
|
||||
// view prop:'list'(紧凑,默认)/'card'(宽松);描述 max-height 截断 + 展开/收起。
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { formatDate } from '@/utils/time'
|
||||
import { parseStack } from '@/utils/project'
|
||||
import { stripMd } from '@/utils/markdown'
|
||||
@@ -47,7 +56,13 @@ import { moduleApi } from '@/api/module'
|
||||
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '@/constants/project'
|
||||
import type { ProjectRecord } from '@/api/types'
|
||||
|
||||
const props = defineProps<{ project: ProjectRecord }>()
|
||||
const props = withDefaults(defineProps<{
|
||||
project: ProjectRecord
|
||||
/** 视图模式:'list'(紧凑,默认)/'card'(宽松);仅影响描述行数阈值与内边距 */
|
||||
view?: 'list' | 'card'
|
||||
}>(), {
|
||||
view: 'list',
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete', project: ProjectRecord): void
|
||||
}>()
|
||||
@@ -61,7 +76,28 @@ onMounted(async () => {
|
||||
const list = await moduleApi.listProjectModules(props.project.id)
|
||||
moduleCount.value = list.length
|
||||
} catch { /* 老项目无工程记录不报错 */ }
|
||||
// DOM 渲染完成后测描述是否溢出(决定是否显示「展开」)
|
||||
await nextTick()
|
||||
measureDesc()
|
||||
})
|
||||
|
||||
// ── 描述截断/展开 ──
|
||||
// descOverflow=true 才渲染「展开/收起」按钮;阈值由 CSS max-height 控制(-webkit-line-clamp),
|
||||
// 这里只负责测量实际内容高度是否超出 max-height。
|
||||
const descRef = ref<HTMLParagraphElement | null>(null)
|
||||
const descOverflow = ref(false)
|
||||
const descExpanded = ref(false)
|
||||
|
||||
function measureDesc() {
|
||||
const el = descRef.value
|
||||
if (!el) return
|
||||
// scrollHeight > clientHeight 说明被 max-height 截断了
|
||||
descOverflow.value = el.scrollHeight - el.clientHeight > 1
|
||||
}
|
||||
|
||||
// 描述/视图模式变化后重新测量(描述异步入库或切 view 行数阈值变,按钮显隐需重算)
|
||||
watch(() => props.project.description, () => nextTick(measureDesc))
|
||||
watch(() => props.view, () => nextTick(measureDesc))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -101,12 +137,47 @@ onMounted(async () => {
|
||||
.stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
|
||||
.card-desc-wrap {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
margin: 0;
|
||||
/* 默认截断:2 行(list 视图紧凑,单屏多张)。展开态取消截断。 */
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
/* card 视图略宽松:3 行 */
|
||||
.project-card--list .card-desc { -webkit-line-clamp: 2; }
|
||||
.project-card:not(.project-card--list) .card-desc { -webkit-line-clamp: 3; }
|
||||
.card-desc-wrap.is-expanded .card-desc {
|
||||
-webkit-line-clamp: unset;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 展开/收起按钮:右对齐、低调,避免抢卡片点击 */
|
||||
.card-desc-toggle {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--df-accent);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.card-desc-toggle:hover { text-decoration: underline; }
|
||||
|
||||
/* list 视图:更紧凑的内边距,单屏容纳更多卡片 */
|
||||
.project-card--list { padding: calc(var(--df-pad-panel) * 0.7); }
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow ref="rowLogLevel" :label="$t('settings.labelLogLevel')" :desc="$t('settings.descLogLevel')">
|
||||
<select v-model="settings.logLevel" class="setting-select" @change="markSaved">
|
||||
<select v-model="settings.logLevel" class="setting-select" @change="onLogLevelChange">
|
||||
<option value="error">Error</option>
|
||||
<option value="warn">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
@@ -110,11 +110,21 @@ function clampMode(raw: string): AutoMode {
|
||||
return (VALID_MODES as readonly string[]).includes(raw) ? (raw as AutoMode) : 'low'
|
||||
}
|
||||
|
||||
// M15: logLevel 合法值白名单(对齐 <select> option)+ 脏值兜底(默认 info)。
|
||||
// 防 KV 里历史脏值(手改/旧版本/导入备份)落到 select 显示空。
|
||||
const VALID_LOG_LEVELS = ['error', 'warn', 'info', 'debug'] as const
|
||||
type LogLevel = (typeof VALID_LOG_LEVELS)[number]
|
||||
function clampLogLevel(raw: string): LogLevel {
|
||||
return (VALID_LOG_LEVELS as readonly string[]).includes(raw) ? (raw as LogLevel) : 'info'
|
||||
}
|
||||
|
||||
const settings = reactive({
|
||||
// 三档 low/medium/all,默认 low(=旧 autoExecute=false 现状),KV 持久化 df-ai-auto-execute-mode。
|
||||
// clampMode 兜底脏值。
|
||||
autoExecuteMode: clampMode(appSettings.get<string>('df-ai-auto-execute-mode', 'low')),
|
||||
logLevel: 'info',
|
||||
// M15: logLevel 真持久化——从 KV 读回(默认 info),onLogLevelChange 写回 KV。
|
||||
// 原 bug:仅 reactive 内存态,刷新即失,@change 显「已保存」是假成功。
|
||||
logLevel: clampLogLevel(appSettings.get<string>('df-log-level', 'info')),
|
||||
// 审批超时(ms):0=不限时,缺省 900000(15min);仅固定合法选项,脏值回退默认
|
||||
approvalTimeout: clampApprovalTimeout(appSettings.get<number>('df-approval-timeout', 900000)),
|
||||
})
|
||||
@@ -158,8 +168,12 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// logLevel 行即时反馈(无参:logLevel 仅 session 内 reactive 无持久化,markSaved 只显「已保存」反馈)
|
||||
function markSaved() {
|
||||
// M15: logLevel 真持久化——@change 写 KV(df-log-level)+ 显「已保存」反馈。
|
||||
// 原 bug:仅 reactive session 态,markSaved 显「已保存」是假成功(刷新即失)。
|
||||
// 注:仅持久化偏好值;运行时日志过滤器是否实时读此值取决于后端日志初始化策略
|
||||
// (本任务范围只修「假保存」,运行时生效链路另行评估,不擅自扩展)。
|
||||
function onLogLevelChange() {
|
||||
void appSettings.set('df-log-level', settings.logLevel)
|
||||
rowLogLevel.value?.markSaved()
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">{{ $t('settings.labelPort') }}</label>
|
||||
<input v-model.number="connForm.port" class="setting-input" type="number" :placeholder="$t('settings.phPort')" />
|
||||
<input v-model.number="connForm.port" class="setting-input" type="number" min="1" max="65535" :placeholder="$t('settings.phPort')" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">{{ $t('settings.labelUser') }}</label>
|
||||
@@ -158,6 +158,12 @@ function saveConn() {
|
||||
emit('toast', t('settings.toastConnIncomplete'), 'warning')
|
||||
return
|
||||
}
|
||||
// L20: 端口范围校验(1-65535)。v-model.number 清空输入会得 NaN,一并拦截。
|
||||
const port = connForm.port
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
emit('toast', t('settings.toastConnPortInvalid'), 'warning')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const record: ConnRecord = {
|
||||
|
||||
@@ -86,6 +86,9 @@ async function loadKnowledgeConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// M33: 跟踪待反馈的行 key——@change 触发 markSaved 不再立即显「已保存」,
|
||||
// 改为记下 key,等真正 IPC saveConfig resolve 后才显,防假成功(乐观 UI 误导)。
|
||||
let _pendingSavedKey: SavedKey | null = null
|
||||
let _knowledgeSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function saveKnowledgeConfigDebounced() {
|
||||
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
|
||||
@@ -96,8 +99,14 @@ function saveKnowledgeConfigDebounced() {
|
||||
// cfg 形态与后端 KnowledgeConfig 对齐(auto_extract/trigger_mode/min_messages/
|
||||
// auto_inject/vector_enabled/embedding_provider_id/embedding_model),无需 as any
|
||||
await knowledgeApi.saveConfig(cfg)
|
||||
// M33: IPC 成功后才显「已保存」(防假成功)。失败走 catch 不显,避免误导。
|
||||
if (_pendingSavedKey) flashRow(_pendingSavedKey)
|
||||
} catch (e) {
|
||||
console.error('保存知识库配置失败:', e)
|
||||
// M33: 失败时不显「已保存」;后端错误已 console,前端无行级错误态(对齐原实现,
|
||||
// 原实现也只是吞错)。如需更明显反馈可在此 emit toast,但本任务范围只防假成功。
|
||||
} finally {
|
||||
_pendingSavedKey = null
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
@@ -118,8 +127,20 @@ const rowAutoInject = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoIn
|
||||
const rowVectorEnabled = useTemplateRef<InstanceType<typeof SettingRow>>('rowVectorEnabled')
|
||||
const rowEmbeddingProvider = useTemplateRef<InstanceType<typeof SettingRow>>('rowEmbeddingProvider')
|
||||
const rowEmbeddingModel = useTemplateRef<InstanceType<typeof SettingRow>>('rowEmbeddingModel')
|
||||
function markSaved(key: 'autoExtract' | 'triggerMode' | 'minMessages' | 'autoInject' | 'vectorEnabled' | 'embeddingProvider' | 'embeddingModel') {
|
||||
const map = {
|
||||
type SavedKey = 'autoExtract' | 'triggerMode' | 'minMessages' | 'autoInject' | 'vectorEnabled' | 'embeddingProvider' | 'embeddingModel'
|
||||
|
||||
/**
|
||||
* M33: @change 触发入口——不立即显「已保存」,先记 pending key,
|
||||
* 等 saveKnowledgeConfigDebounced 内 IPC saveConfig 成功后调 flashRow 显反馈(防假成功)。
|
||||
* 连续多字段改动时取最后一次 key(用户视线所在),旧 key 反馈被新 key 覆盖可接受。
|
||||
*/
|
||||
function markSaved(key: SavedKey) {
|
||||
_pendingSavedKey = key
|
||||
}
|
||||
|
||||
/** 真正点亮行内「✓已保存」反馈(仅 IPC 成功路径调用)。 */
|
||||
function flashRow(key: SavedKey) {
|
||||
const map: Record<SavedKey, InstanceType<typeof SettingRow> | null> = {
|
||||
autoExtract: rowAutoExtract.value,
|
||||
triggerMode: rowTriggerMode.value,
|
||||
minMessages: rowMinMessages.value,
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
<section class="panel" v-if="providerForm.visible">
|
||||
<div class="panel-header">
|
||||
<h2>{{ providerForm.editId ? $t('settings.editProviderTitle') : $t('settings.addProviderTitle') }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="providerForm.visible = false">{{ $t('common.cancel') }}</button>
|
||||
<!-- L19: 取消走 closeProviderForm,脏表单二次确认后再关(防误弃改动) -->
|
||||
<button class="btn btn-ghost btn-sm" @click="closeProviderForm">{{ $t('common.cancel') }}</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
@@ -139,6 +140,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-list-hint">{{ $t('settings.modelListHint') }}</div>
|
||||
<!-- M32: 模型列表 enabled/weight 改了未保存时显提示(关闭/切走将丢弃) -->
|
||||
<div v-if="isFormDirty" class="model-list-unsaved-hint">{{ $t('settings.modelListUnsavedHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -200,8 +203,51 @@ const providerForm = reactive({
|
||||
// toast 3s 自隐易被忽略,保存失败时表单内常驻错误条 + 可操作建议,
|
||||
// 直到下次保存尝试/重开表单才清除。null=无错误。
|
||||
saveError: null as string | null,
|
||||
// L19/M32: 表单打开/保存成功时的「已落库快照」JSON 串,用于 isFormDirty 比对。
|
||||
// openProviderForm 写入初始快照,saveProvider 成功后刷新;关闭表单清空。
|
||||
savedSnapshot: '' as string,
|
||||
})
|
||||
|
||||
/**
|
||||
* L19/M32: 表单脏判定——当前表单状态与已落库快照不一致即脏。
|
||||
* 触发场景:① 取消/关闭有改动的表单 → 二次确认放弃(L19);
|
||||
* ② 模型列表 enabled/weight 改了未保存 → 表单内提示(M32)。
|
||||
* 比对用 JSON.stringify(整体快照语义,简单可靠;表单字段少,性能无虞)。
|
||||
* 不含 saving/fetching/saveError/visible 等 UI 态字段(只比业务字段)。
|
||||
*/
|
||||
const isFormDirty = computed(() => {
|
||||
if (!providerForm.visible) return false
|
||||
return formBusinessSnapshot() !== providerForm.savedSnapshot
|
||||
})
|
||||
|
||||
function formBusinessSnapshot(): string {
|
||||
return JSON.stringify({
|
||||
name: providerForm.name,
|
||||
providerType: providerForm.providerType,
|
||||
baseUrl: providerForm.baseUrl,
|
||||
apiKey: providerForm.apiKey,
|
||||
defaultModel: providerForm.defaultModel,
|
||||
models: providerForm.models.map(m => ({
|
||||
model_id: m.model_id,
|
||||
enabled: m.enabled,
|
||||
weight: m.weight,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* L19: 关闭表单入口——脏表单二次确认,清表单后关闭。
|
||||
* 用于模板「取消」按钮 + saveProvider 成功后(无脏直接关)。
|
||||
*/
|
||||
async function closeProviderForm() {
|
||||
if (isFormDirty.value) {
|
||||
const ok = await props.confirmDialog(t('settings.confirmDiscardUnsaved'))
|
||||
if (!ok) return // 用户取消关闭,继续编辑
|
||||
}
|
||||
providerForm.visible = false
|
||||
providerForm.savedSnapshot = ''
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
try {
|
||||
aiProviders.value = await aiApi.listProviders()
|
||||
@@ -241,6 +287,8 @@ function openProviderForm(p?: AiProviderConfig) {
|
||||
providerForm.fetching = false
|
||||
providerForm.saveError = null // P0-2: 重开表单清旧错误横幅
|
||||
providerForm.visible = true
|
||||
// L19/M32: 记初始快照(在 visible=true、字段回填完成后),isFormDirty 据此比对。
|
||||
providerForm.savedSnapshot = formBusinessSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,6 +402,7 @@ async function saveProvider() {
|
||||
// P0-2: 成功明确反馈——清错误横幅 + 关表单 + 绿色「已保存」toast
|
||||
providerForm.saveError = null
|
||||
providerForm.visible = false
|
||||
providerForm.savedSnapshot = '' // L19: 关表单清快照
|
||||
await loadProviders()
|
||||
emit('toast', t('settings.toastSaved'), 'success')
|
||||
} catch (e) {
|
||||
@@ -445,10 +494,14 @@ function maskKey(key: string): string {
|
||||
return key.slice(0, 4) + '••••••••' + key.slice(-4)
|
||||
}
|
||||
|
||||
/** 暴露给 shell:loadProviders 供外部主动刷新 / Knowledge 读 aiProviders / 卸载清 timer */
|
||||
/** 暴露给 shell:loadProviders 供外部主动刷新 / Knowledge 读 aiProviders / 卸载清 timer。
|
||||
* L19: isFormDirty + closeProviderForm 暴露,供外部(如导航切走前)查询/触发拦截。
|
||||
* 当前 Settings.vue 未接入 nav 拦截(不在本次改文件集),接口先行供后续 shell 接入。 */
|
||||
defineExpose({
|
||||
aiProviders,
|
||||
loadProviders,
|
||||
isFormDirty,
|
||||
closeProviderForm,
|
||||
clearPoolTimer() { if (_poolTimer) clearTimeout(_poolTimer) },
|
||||
})
|
||||
|
||||
@@ -563,4 +616,12 @@ onMounted(loadProviders)
|
||||
.toggle--sm { width: 32px; height: 18px; }
|
||||
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
|
||||
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
|
||||
|
||||
/* M32: 模型列表有未保存调整时的提示(弱 warning 色,贴合 model-list-hint 之下) */
|
||||
.model-list-unsaved-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user