优化: 所有剩余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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user