优化: 所有剩余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
+34
View File
@@ -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) {
+6 -4
View File
@@ -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
+13 -9
View File
@@ -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 显示控制 */
+13 -4
View File
@@ -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)
+10 -4
View File
@@ -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 {
+18 -1
View File
@@ -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))
+77 -6
View File
@@ -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;