优化: 所有剩余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
+58 -6
View File
@@ -72,7 +72,7 @@
>
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthOnce') }}
{{ $t('aiChat.dirAuthOnce') }}<span class="popup-btn-key">1</span>
</button>
<button
class="popup-btn popup-btn--session"
@@ -88,7 +88,7 @@
@click.stop="onApproveAlways(tc)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthAlways') }}
{{ $t('aiChat.dirAuthAlways') }}<span class="popup-btn-key">2</span>
</button>
<button
class="popup-btn popup-btn--reject"
@@ -96,7 +96,7 @@
@click.stop="onReject(tc)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiChat.dirAuthDeny') }}
{{ $t('aiChat.dirAuthDeny') }}<span class="popup-btn-key">3</span>
</button>
</div>
<div v-else class="popup-item-actions">
@@ -107,7 +107,7 @@
>
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiTool.approve') }}
{{ $t('aiTool.approve') }}<span class="popup-btn-key">1</span>
</button>
<button
class="popup-btn popup-btn--reject"
@@ -115,7 +115,7 @@
@click.stop="onReject(tc)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiTool.reject') }}
{{ $t('aiTool.reject') }}<span class="popup-btn-key">3</span>
</button>
</div>
</div>
@@ -244,11 +244,45 @@ let _closeFallbackTimer: ReturnType<typeof setTimeout> | null = null
const CLOSE_TIMEOUT_MS = 1500
/** Esc 键关浮窗(键盘可达性)。模块级命名函数,onBeforeUnmount 据此精确移除同一引用。
* 仅响应 Esc(KeyDown),其他键透传(不拦截 Tab/Enter 等原生 button 导航)。 */
* 仅响应 Esc(KeyDown),其他键透传(不拦截 Tab/Enter 等原生 button 导航)。
*
* 7c:数字键快捷审批(对列表顶部第一条生效,多审批场景用户应逐条点或用方向键导航)。
* - path 类:1=once / 2=always / 3=deny(deny 仍走二次确认,与按钮同款不可逆守卫)
* - risk 类:1=approve / 3=reject(2 仅 path 类有 always,risk 类忽略 '2')
* 守卫:
* - 确认对话框打开时(confirmState.visible)禁用快捷键,避免与确认按钮的 Enter/Esc 冲突
* - 处理中(processingId)时忽略,防重复触发
* - 输入元素聚焦时不拦截(input/textarea/contenteditable 场景,本浮窗暂无 input 但防御)
* - Ctrl/Meta/Alt 组合不拦截(避免劫持浏览器/系统快捷键) */
function _onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
void onClose()
return
}
// 7c:数字键快捷审批
if (e.key === '1' || e.key === '2' || e.key === '3') {
// 组合键(Ctrl+1 等)不拦截;确认对话框打开时不拦截;处理中不拦截
if (e.ctrlKey || e.metaKey || e.altKey) return
if (confirmState.visible) return
if (processingId.value) return
// 输入元素聚焦时不拦截(防御:本浮窗无 input,但 ConfirmDialog 未来或有)
const tgt = e.target as HTMLElement | null
if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) return
const first = approvals.value[0]
if (!first) return
e.preventDefault()
if (e.key === '1') {
// 1 = once(path) / approve(risk)
if (first.kind === 'path') void onApproveOnce(first)
else void onApprove(first)
} else if (e.key === '2') {
// 2 = always(仅 path 类;risk 类无 always,忽略)
if (first.kind === 'path') void onApproveAlways(first)
} else {
// 3 = deny(path) / reject(risk)
void onReject(first)
}
}
}
@@ -683,6 +717,24 @@ function onDragEnd() { isDragging.value = false }
opacity: 0.6;
cursor: not-allowed;
}
/* 7c:数字键快捷键标记(1/2/3 对应 once/approve、always、deny/reject)。
小尺寸单字符徽章,提示键盘可达性;继承父按钮对比色但降透明度作辅助信息层。 */
.popup-btn-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 13px;
height: 13px;
padding: 0 3px;
margin-left: 1px;
border-radius: var(--df-radius-xs, 3px);
font-family: var(--df-font-mono);
font-size: 9px;
font-weight: 700;
line-height: 1;
background: rgba(0, 0, 0, 0.18);
opacity: 0.7;
}
.popup-btn--approve {
background: var(--df-accent);
color: #fff;
+82 -31
View File
@@ -92,17 +92,18 @@
<!-- 分页 -->
<div v-if="!loading && !errorMsg" class="pager">
<button class="btn btn-ghost btn-sm" :disabled="offset === 0" @click="prevPage">{{ t('auditLog.pager.prev') }}</button>
<span class="pager-info">{{ t('auditLog.pager.page', { n: page }) }}{{ hasMore ? '' : t('auditLog.pager.last') }}</span>
<span class="pager-info">{{ t('auditLog.pager.page', { n: page }) }}{{ hasMore ? '' : t('auditLog.pager.last') }}<span class="pager-total">{{ t('auditLog.pager.total', { n: total }) }}</span></span>
<button class="btn btn-ghost btn-sm" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { invoke } from '@tauri-apps/api/core'
import { formatRelative, formatDate } from '@/utils/time'
import { usePersistedRef } from '@/composables/usePersistedRef'
const { t } = useI18n()
@@ -122,40 +123,61 @@ interface ToolExecutionRecord {
decided_by: string | null
}
// 后端分页结果 {items,total,has_more}(对标项目通用结构)
interface ToolExecutionPage {
items: ToolExecutionRecord[]
total: number
has_more: boolean
}
const PAGE_SIZE = 50
const records = ref<ToolExecutionRecord[]>([])
const total = ref(0)
// hasMore 来自后端(基于 offset+items.len()<total 推断),非"本页满 PAGE_SIZE"启发式
const hasMore = ref(false)
const offset = ref(0)
const loading = ref(false)
const errorMsg = ref('')
// 筛选状态(客户端过滤,数据量轻量)
const statusFilter = ref('')
const riskFilter = ref('')
const toolSearch = ref('')
// 筛选状态(后端 WHERE 收口,非客户端 filter 当前页)。持久化到 localStorage。
// 空串 = 不过滤(对应后端 Option<String>=None)。
const statusFilter = usePersistedRef('auditLog.statusFilter', '')
const riskFilter = usePersistedRef('auditLog.riskFilter', '')
const toolSearch = usePersistedRef('auditLog.toolSearch', '')
// 展开行(查看完整 args/result)
const expandedId = ref<string | null>(null)
const statusKeys = ['pending', 'approved', 'rejected', 'executing', 'completed', 'failed']
// interrupted 来自 cleanup_stale_pending(超时 pending 标记),与 statusKeys 一并展示
const statusKeys = ['pending', 'approved', 'rejected', 'executing', 'completed', 'failed', 'interrupted']
const riskKeys = ['low', 'medium', 'high']
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
// 下一页存在性:本页满 PAGE_SIZE 视为可能还有更多(hasMore);末页不足时点下一页会拉空,自动回退
const hasMore = computed(() => records.value.length === PAGE_SIZE)
// 客户端筛选(状态/风险/工具名)
// 后端 list_tool_executions 不支持 WHERE 筛选,数据量小(每页 50),前端 filter 轻量。
const filteredRecords = computed(() => {
let list = records.value
if (statusFilter.value) list = list.filter(r => r.status === statusFilter.value)
if (riskFilter.value) list = list.filter(r => r.risk_level === riskFilter.value)
const q = toolSearch.value.trim().toLowerCase()
if (q) list = list.filter(r => r.tool_name.toLowerCase().includes(q))
return list
// 筛选条件变化 → 重置分页 + 关闭展开行 + 重拉(后端 WHERE)
// 下拉框(status/risk)即变即查;工具名自由文本输入走 300ms 防抖(对齐 Tasks.vue searchKeyword)。
watch([statusFilter, riskFilter], () => {
offset.value = 0
expandedId.value = null
void load()
})
// 筛选变化时关闭展开行(避免展开行被筛掉后残留)
watch([statusFilter, riskFilter, toolSearch], () => { expandedId.value = null })
let _searchTimer: ReturnType<typeof setTimeout> | null = null
watch(toolSearch, () => {
if (_searchTimer) clearTimeout(_searchTimer)
_searchTimer = setTimeout(() => {
offset.value = 0
expandedId.value = null
void load()
}, 300)
})
onUnmounted(() => {
if (_searchTimer) clearTimeout(_searchTimer)
})
// 列表展示用 records(后端已按筛选条件过滤,前端不再二次 filter)
const filteredRecords = computed(() => records.value)
function toggleExpand(id: string) {
expandedId.value = expandedId.value === id ? null : id
@@ -172,20 +194,33 @@ function durationLabel(requested: string, executed: string): string {
return `${m}m${s % 60}s`
}
// 构造后端查询参数(空串/纯空白 → undefined = 不过滤)
function buildQuery() {
const q: {
status?: string
risk_level?: string
tool_keyword?: string
limit: number
offset: number
} = { limit: PAGE_SIZE, offset: offset.value }
if (statusFilter.value) q.status = statusFilter.value
if (riskFilter.value) q.risk_level = riskFilter.value
const kw = toolSearch.value.trim()
if (kw) q.tool_keyword = kw
return q
}
async function load() {
loading.value = true
errorMsg.value = ''
try {
const list = await invoke<ToolExecutionRecord[]>('list_tool_executions', {
limit: PAGE_SIZE,
offset: offset.value,
const result = await invoke<ToolExecutionPage>('list_tool_executions', {
query: buildQuery(),
})
records.value = list
// 下一页拉到空数组 → 应回退到上一页(避免停在空页)
if (list.length === 0 && offset.value > 0) {
offset.value = Math.max(0, offset.value - PAGE_SIZE)
await load()
}
records.value = result.items
total.value = result.total
hasMore.value = result.has_more
// 后端 total 已正确,空结果即真无数据(不再像旧"满页推断"需回退)
} catch (e) {
errorMsg.value = String(e)
} finally {
@@ -206,6 +241,7 @@ function prevPage() {
void load()
}
function nextPage() {
if (!hasMore.value) return
offset.value += PAGE_SIZE
expandedId.value = null
void load()
@@ -230,6 +266,7 @@ function statusClass(s: string): string {
executing: 'status-executing',
completed: 'status-completed',
failed: 'status-failed',
interrupted: 'status-interrupted',
} as Record<string, string>)[s] ?? 'status-pending'
}
function decidedLabel(d: string): string {
@@ -322,8 +359,16 @@ onMounted(() => {
.col-risk { width: 56px; }
.col-status { width: 80px; }
.col-decided { width: 64px; }
.col-args { min-width: 200px; }
.col-result { min-width: 220px; }
.col-args { min-width: 200px; max-width: 320px; }
.col-result { min-width: 220px; max-width: 360px; }
/* 表格内 args/result 单元格限高 + 截断(长内容不撑爆行),完整内容点行展开查 detail-pre */
.col-args .brief, .col-result .brief {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
max-height: 48px; /* 约 3 行(行高 ~16px),超长内容折叠,展开行看完整 */
}
/* 可点击行 */
.audit-row { cursor: pointer; transition: background 0.12s; }
@@ -397,6 +442,7 @@ onMounted(() => {
.status-executing { background: var(--df-info-bg); color: var(--df-info); }
.status-completed { background: var(--df-success-bg); color: var(--df-success); }
.status-failed { background: var(--df-danger-bg); color: var(--df-danger); }
.status-interrupted { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
/* 决策者 tag */
.decided-tag {
@@ -423,6 +469,11 @@ onMounted(() => {
color: var(--df-text-dim);
font-family: var(--df-font-mono);
}
.pager-total {
margin-left: 8px;
color: var(--df-text-dim);
opacity: 0.7;
}
/* empty-state 已提取到 global.css 全局 */
</style>
+59 -6
View File
@@ -24,9 +24,6 @@
<button class="error-dismiss" @click="errorMsg = ''">✕</button>
</div>
<!-- ═══ Stat Cards ═══ -->
<StatCardRow />
<!-- ═══ Main Grid ═══ -->
<div class="dash-grid">
<!-- Left: Active Projects -->
@@ -38,17 +35,36 @@
<IdeasPanel />
</div>
</div>
<!-- ═══ 统计概要(底部一行小字,原独立统计卡已下沉) ═══ -->
<div class="dash-stats-inline">
<span class="dash-stats-item clickable" @click="go('/ideas')" :title="$t('dashboard.viewAll')">
<span class="dash-stats-num">{{ store.stats.ideas }}</span>{{ $t('dashboard.stats.ideas') }}
</span>
<span class="dash-stats-sep">·</span>
<span class="dash-stats-item clickable" @click="go('/projects')" :title="$t('dashboard.viewAll')">
<span class="dash-stats-num">{{ store.stats.projects }}</span>{{ $t('dashboard.stats.projects') }}
</span>
<span class="dash-stats-sep">·</span>
<span class="dash-stats-item clickable" @click="go('/tasks')" :title="$t('dashboard.viewAll')">
<span class="dash-stats-num">{{ store.stats.activeTasks }}</span>{{ $t('dashboard.stats.activeTasks') }}
</span>
<span class="dash-stats-sep">·</span>
<span class="dash-stats-item">
<span class="dash-stats-num">{{ store.stats.drafts }}</span>{{ $t('dashboard.stats.drafts') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
// 仪表盘页壳 — 仅保留:Header(refresh/quickCapture) + 错误条 + 布局栅格 + 加载编排(loadAll)。
// 统计卡/项目面板/灵感面板已抽至 components/dashboard/ 子组件,各自共享 project store(全局单例),无需 props。
// 仪表盘页壳 — 仅保留:Header(refresh/quickCapture) + 错误条 + 布局栅格 + 加载编排(loadAll) +
// 底部统计概要行(原独立统计卡 StatCardRow 已下沉为底部一行小字,空间让给项目/灵感面板)。
// 项目面板/灵感面板已抽至 components/dashboard/ 子组件,各自共享 project store(全局单例),无需 props。
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project'
import StatCardRow from '@/components/dashboard/StatCardRow.vue'
import ActiveProjectsPanel from '@/components/dashboard/ActiveProjectsPanel.vue'
import IdeasPanel from '@/components/dashboard/IdeasPanel.vue'
@@ -89,6 +105,11 @@ function quickCapture() {
router.push('/ideas')
}
// 底部统计概要项点击跳转(可点击项:ideas/projects/tasks;drafts 不可点)
function go(link: string) {
router.push(link)
}
onMounted(() => {
loadAll()
})
@@ -143,6 +164,38 @@ onMounted(() => {
}
.dash-right { display: flex; flex-direction: column; gap: 12px; }
/* ═══ 底部统计概要(一行小字,原独立统计卡下沉) ═══ */
.dash-stats-inline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
padding-top: 10px;
border-top: 0.5px solid var(--df-border);
font-size: 11px;
color: var(--df-text-dim);
line-height: 1.4;
}
.dash-stats-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 450;
}
.dash-stats-item.clickable {
cursor: pointer;
transition: color 0.15s var(--df-ease);
}
.dash-stats-item.clickable:hover { color: var(--df-accent); }
.dash-stats-num {
font-size: 13px;
font-weight: 600;
color: var(--df-text);
letter-spacing: -0.3px;
}
.dash-stats-sep { color: var(--df-text-dim); opacity: 0.6; }
/* ═══ Responsive ═══ */
/* 窄窗口:单栏(与 AppLayout 768px 断点对齐) */
+31 -4
View File
@@ -160,6 +160,7 @@ import { scoreTier } from '../utils/ideaEval'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import IdeaDetail from '../components/ideas/IdeaDetail.vue'
import { useConfirm } from '../composables/useConfirm'
import { usePersistedRef } from '../composables/usePersistedRef'
import Paginator from '../components/Paginator.vue'
const { t } = useI18n()
@@ -172,15 +173,17 @@ const { confirmState, confirmDialog, answerConfirm } = useConfirm()
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
const activeFilter = ref<FilterKey>('all')
// ⑤ 列表筛选/排序/分页状态持久化 localStorage(对齐 Tasks.vue:193-201 用 usePersistedRef),
// key 前缀 'ideas.',刷新页面/重开恢复用户的筛选视图。selectedId 不持久化(随路由恢复)。
const activeFilter = usePersistedRef<FilterKey>('ideas.activeFilter', 'all')
const selectedId = ref<string | null>(null)
const searchQuery = ref('')
// 列表排序:score 高分在前 / time 新在前(默认 score,避免高分灵感被淹没)
const sortMode = ref<'score' | 'time'>('score')
const sortMode = usePersistedRef<'score' | 'time'>('ideas.sortMode', 'score')
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片
const page = ref(1)
const pageSize = ref(0)
const page = usePersistedRef<number>('ideas.page', 1)
const pageSize = usePersistedRef<number>('ideas.pageSize', 0)
// ── 新建灵感模态框 ──
const showCaptureModal = ref(false)
@@ -285,6 +288,17 @@ const currentIdea = computed(() => {
return store.ideas.find(i => i.id === selectedId.value) ?? null
})
// ⑥ 选中态保留:分页启用时,选中灵感可能落在当前页窗口外(切筛选/排序/页码后),
// 右侧详情仍显示(currentIdea 从 store.ideas 全量查),但列表不滚动到它 — 视觉上「丢失」。
// 这里在筛选/数据变化后,若选中项仍在过滤结果中,自动跳到它所在页,保持选中可见。
watch([filteredIdeas, () => selectedId.value], () => {
if (pageSize.value <= 0 || !selectedId.value) return
const idx = filteredIdeas.value.findIndex(i => i.id === selectedId.value)
if (idx < 0) return // 不在当前筛选结果中(被过滤),不强制跳页,保留详情面板显示
const targetPage = Math.floor(idx / pageSize.value) + 1
if (targetPage !== page.value) page.value = targetPage
})
// B-260615-25:灵感描述 Markdown 渲染已下沉至 IdeaDetail 子组件(共享模块单例渲染器)
// 评分档位 class(高/中/低 三色标),阈值统一走 scoreTier(单一来源,消除 4 处重复)
@@ -373,6 +387,19 @@ async function evaluateCurrentIdea() {
async function onStatusChange(newStatus: IdeaStatus) {
if (!currentIdea.value) return
// ⑥ 状态切换确认:落到 promoted/rejected/archived 等终态(或离开 approved 失去立项入口)
// 需用户确认,避免误点。中性流转(draft↔pending_review↔approved)直接落库。
const from = currentIdea.value.status
const needsConfirm = newStatus === 'promoted' || newStatus === 'rejected' || newStatus === 'archived'
|| (from === 'approved' && newStatus !== 'approved')
if (needsConfirm) {
const msg = t('ideas.confirmStatus', {
title: currentIdea.value.title,
from: t(statusOptions.find(s => s.value === from)?.labelKey ?? from),
to: t(statusOptions.find(s => s.value === newStatus)?.labelKey ?? newStatus),
})
if (!await confirmDialog(msg, t('common.confirm'))) return
}
await store.updateIdea(currentIdea.value.id, 'status', newStatus)
}
+113 -14
View File
@@ -8,6 +8,11 @@
</div>
</header>
<!-- ⑤ 操作反馈 toast(保存/发布/拒绝/归档/创建成功 + 保存失败)-->
<Transition name="toast">
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
</Transition>
<!-- 错误条:消费 store.error(样式全局 .error-banner,仅间距用页面变量) -->
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
<span class="error-text">{{ store.error }}</span>
@@ -155,15 +160,48 @@ import {
knowledgeConfidenceLabel as confidenceLabel,
} from '@/stores/knowledge'
import { useRendered } from '@/composables/useMarkdown'
import { useToast } from '@/composables/useToast'
import { stripMd } from '@/utils/markdown'
import KnowledgeDetail from '@/components/knowledge/KnowledgeDetail.vue'
import type { KnowledgeDetailPayload } from '@/api/types'
import type { KnowledgeDetailPayload, KnowledgeRecord } from '@/api/types'
const { t } = useI18n()
const store = useKnowledgeStore()
// ⑤ 编辑/审核反馈:成功/失败明确提示(对齐 Settings.vue/Projects.vue useToast 模式)
const { toast, showToast } = useToast()
// ④ topTab/activeKind/searchQuery 持久化 localStorage(跨刷新保留用户视图态)
// 仅持久化视图态:不持久化详情选中(id 跨刷新可能已失效)。容错:解析失败/无效值回退默认。
const LS_KEY = 'kn.view'
function loadView(): { topTab: 'library' | 'inbox'; activeKind: string; searchQuery: string } {
try {
const raw = localStorage.getItem(LS_KEY)
if (!raw) return { topTab: 'library', activeKind: 'all', searchQuery: '' }
const v = JSON.parse(raw)
return {
topTab: v.topTab === 'inbox' ? 'inbox' : 'library',
activeKind: typeof v.activeKind === 'string' ? v.activeKind : 'all',
searchQuery: typeof v.searchQuery === 'string' ? v.searchQuery : '',
}
} catch {
return { topTab: 'library', activeKind: 'all', searchQuery: '' }
}
}
function saveView() {
try {
localStorage.setItem(LS_KEY, JSON.stringify({
topTab: topTab.value,
activeKind: activeKind.value,
searchQuery: searchQuery.value,
}))
} catch {
/* localStorage 不可用(隐私模式/配额)静默降级,视图态非关键 */
}
}
const persisted = loadView()
// 顶层 Tab: library(知识库) | inbox(审核收件箱)
const topTab = ref<'library' | 'inbox'>('library')
const topTab = ref<'library' | 'inbox'>(persisted.topTab)
const categories = [
{ key: 'all', icon: '📦' },
@@ -173,8 +211,8 @@ const categories = [
function catLabel(key: string): string {
return key === 'all' ? t('knowledge.categoryAll') : kindText(key)
}
const activeKind = ref('all')
const searchQuery = ref('')
const activeKind = ref(persisted.activeKind)
const searchQuery = ref(persisted.searchQuery)
let searchTimer: ReturnType<typeof setTimeout> | null = null
function onSearchInput() {
@@ -208,6 +246,9 @@ function switchTopTab(tab: 'library' | 'inbox') {
}
}
// ④ 视图态(topTab/activeKind/searchQuery)持久化:任一变化即写 localStorage
watch([topTab, activeKind, searchQuery], saveView)
// 关闭错误条
function clearError() {
store.clearError()
@@ -263,19 +304,26 @@ async function onDetailSave(payload: { id: string; data: { title: string; conten
await store.update(payload.id, payload.data)
// 重新拉详情
await selectKnowledge(payload.id)
// ⑤ 编辑保存成功反馈(此前静默无反馈,用户不知是否生效)
showToast(t('knowledge.savedToast'), 'success')
} catch (e: any) {
// 保存失败:不重新抛出(emit 调用方未 await,抛出无人处理),仅 toast 反馈
showToast(e?.toString() ?? t('knowledge.err.saveFailed'), 'error')
} finally {
submitting.value = false
}
}
// ===== 审核操作(收件箱) =====
// P0-②:发布/拒绝/归档后自动选中下一条(而非清空详情),减少审核者来回点击
async function publishCurrent() {
if (!detail.value) return
const currentId = detail.value.knowledge.id
submitting.value = true
try {
await store.updateStatus(detail.value.knowledge.id, 'published')
selectedId.value = null
detail.value = null
await store.updateStatus(currentId, 'published')
await selectNextCandidate(currentId)
showToast(t('knowledge.publishedToast'), 'success')
} finally {
submitting.value = false
}
@@ -283,11 +331,12 @@ async function publishCurrent() {
async function rejectCurrent() {
if (!detail.value) return
const currentId = detail.value.knowledge.id
submitting.value = true
try {
await store.archive(detail.value.knowledge.id)
selectedId.value = null
detail.value = null
await store.archive(currentId)
await selectNextCandidate(currentId)
showToast(t('knowledge.rejectedToast'), 'info')
} finally {
submitting.value = false
}
@@ -295,16 +344,43 @@ async function rejectCurrent() {
async function archiveCurrent() {
if (!detail.value) return
const currentId = detail.value.knowledge.id
submitting.value = true
try {
await store.archive(detail.value.knowledge.id)
selectedId.value = null
detail.value = null
await store.archive(currentId)
// 知识库 tab 无「下一条」语义(library 不限 candidate),归档后清空详情
if (topTab.value === 'inbox') {
await selectNextCandidate(currentId)
} else {
selectedId.value = null
detail.value = null
}
showToast(t('knowledge.archivedToast'), 'info')
} finally {
submitting.value = false
}
}
// 当前条目从 candidates 移除后,选中同位置后续条目;末尾则清空详情
// 实现策略:操作前记下 currentId 在原列表中的 index,移除后从剩余列表的同
// index 取(若无则取前一条),保证「下一条」语义而非跳回开头
function pickNextCandidate(remaining: KnowledgeRecord[], currentIndex: number): KnowledgeRecord | null {
return remaining[currentIndex] ?? remaining[currentIndex - 1] ?? null
}
async function selectNextCandidate(currentId: string) {
const before = store.candidates
const idx = before.findIndex(c => c.id === currentId)
const remaining = store.candidates // store 操作后已 filter 移除 currentId
const next = idx >= 0 ? pickNextCandidate(remaining, idx) : null
if (next) {
await selectKnowledge(next.id)
} else {
selectedId.value = null
detail.value = null
}
}
// ===== 新增对话框 =====
const showCreateModal = ref(false)
const form = ref({ kind: 'pitfall', title: '', content: '', tagsInput: '', confidence: '' })
@@ -329,6 +405,7 @@ async function submitCreate() {
})
showCreateModal.value = false
switchTopTab('inbox')
showToast(t('knowledge.createdToast'), 'success')
} finally {
submitting.value = false
}
@@ -339,7 +416,16 @@ async function submitCreate() {
onMounted(() => {
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
store.loadList()
// ④ 按持久化视图态加载初始数据源(topTab 决定加载 items 还是 candidates)
if (topTab.value === 'inbox') {
store.loadCandidates()
} else if (searchQuery.value.trim()) {
// 持久化的搜索词:恢复搜索结果而非空列表
store.search(searchQuery.value)
} else {
store.loadList()
}
// candidates badge 始终预加载(顶部 Tab 计数用)
store.loadCandidates()
})
</script>
@@ -347,6 +433,19 @@ onMounted(() => {
<style scoped>
.knowledge { padding: 16px 20px 20px; height: 100%; display: flex; flex-direction: column; }
/* ===== 顶部 Toast 提示(⑤ 操作反馈,对齐 Settings.vue)===== */
.toast {
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
z-index: 1000; padding: 8px 16px; border-radius: var(--df-radius-sm);
font-size: 13px; box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
.toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
/* page-header / btn / modal 等已提取到 global.css 全局 */
/* ===== 错误条样式已提取到 styles/components.css 全局(.error-banner) ===== */
+121 -42
View File
@@ -55,7 +55,7 @@
class="tab-btn"
:class="{ 'tab-active': activeTab === 'overview' }"
type="button"
@click="activeTab = 'overview'"
@click="setActiveTab('overview')"
>
{{ $t('projectDetail.tabOverview') }}
</button>
@@ -63,7 +63,7 @@
class="tab-btn"
:class="{ 'tab-active': activeTab === 'files' }"
type="button"
@click="activeTab = 'files'"
@click="setActiveTab('files')"
>
{{ $t('fileExplorer.tabTitle') }}
</button>
@@ -71,7 +71,7 @@
class="tab-btn"
:class="{ 'tab-active': activeTab === 'graph' }"
type="button"
@click="activeTab = 'graph'"
@click="setActiveTab('graph')"
>
{{ $t('dependencyGraph.tabTitle') }}
</button>
@@ -87,8 +87,9 @@
<DependencyGraph :project-id="projectId" />
</section>
<!-- 概览 Tab:原有主体两栏 -->
<div v-else class="detail-grid">
<!-- 概览 Tab:两栏(项目信息 + 任务列表),工作流日志移至下方(P1-g+ 问题10 三栏拥挤) -->
<div v-else class="detail-overview">
<div class="detail-grid">
<!-- 左栏:项目信息 -->
<section class="panel">
<div class="panel-header">
@@ -133,12 +134,22 @@
</div>
<div class="info-item info-block">
<span class="label">{{ $t('projectDetail.description') }}</span>
<!-- B-260615-25:项目描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail) -->
<span
v-if="currentProject.description"
class="value description ai-md"
v-html="renderedDesc"
></span>
<!-- B-260615-25:项目描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail)
P1-g+ 问题10③:长描述折叠(对齐 TaskDetail 长内容),默认折叠截 4 行,展开看全文 -->
<div v-if="currentProject.description" class="description-wrap" :class="{ 'is-collapsed': !descExpanded }">
<span
class="value description ai-md"
v-html="renderedDesc"
></span>
<button
v-if="descCollapsible"
class="btn btn-ghost btn-sm desc-toggle"
type="button"
@click="descExpanded = !descExpanded"
>
{{ descExpanded ? $t('common.collapse') : $t('common.expand') }}
</button>
</div>
<span v-else>—</span>
</div>
<div class="info-item">
@@ -207,7 +218,7 @@
</div>
</section>
<!-- 左栏:任务列表 -->
<!-- 右栏:任务列表(本项目独立 fetch,不读 store.tasks,避免 Tasks 分页污染) -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.taskListTitle') }}</h2>
@@ -232,24 +243,30 @@
</div>
<div v-if="projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
</section>
<!-- 右栏 -->
<div class="right-column">
<!-- 工作流执行日志 -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.workflowLogTitle') }}</h2>
</div>
<div class="log-list" ref="logListRef">
<div class="log-item" v-for="(evt, idx) in formattedEvents" :key="idx" :class="'log-' + evt.level">
<span class="log-time">{{ evt.time }}</span>
<span class="log-level">{{ evt.level }}</span>
<span class="log-msg">{{ evt.message }}</span>
</div>
<div v-if="formattedEvents.length === 0" class="empty-hint">{{ $t('projectDetail.emptyWorkflowLog') }}</div>
</div>
</section>
</div>
<!-- 工作流日志:无记录时折叠为摘要(标题 + 计数),有记录才展开列表 -->
<section v-if="formattedEvents.length > 0 || !workflowLogCollapsed" class="panel workflow-log-panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.workflowLogTitle') }}</h2>
<span v-if="formattedEvents.length === 0" class="task-count">{{ $t('projectDetail.emptyWorkflowLog') }}</span>
<button
v-else
class="btn btn-ghost btn-sm"
type="button"
@click="workflowLogCollapsed = !workflowLogCollapsed"
>
{{ workflowLogCollapsed ? $t('common.expand') : $t('common.collapse') }}
</button>
</div>
<div v-if="formattedEvents.length > 0 && !workflowLogCollapsed" class="log-list" ref="logListRef">
<div class="log-item" v-for="(evt, idx) in formattedEvents" :key="idx" :class="'log-' + evt.level">
<span class="log-time">{{ evt.time }}</span>
<span class="log-level">{{ evt.level }}</span>
<span class="log-msg">{{ evt.message }}</span>
</div>
</div>
</section>
</div>
<!-- 人工审批对话框(抽离至 components/project/ApprovalDialog.vue)-->
@@ -273,7 +290,7 @@ import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import { open } from '@tauri-apps/plugin-dialog'
import { useProjectStore } from '@/stores/project'
import { projectApi } from '@/api'
import { projectApi, taskApi } from '@/api'
import { formatDate } from '@/utils/time'
import { parseStack } from '@/utils/project'
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n, scoreTier } from '@/utils/ideaEval'
@@ -284,7 +301,7 @@ import FileExplorer from '@/components/project/FileExplorer.vue'
import DependencyGraph from '@/components/project/DependencyGraph.vue'
import { useConfirm } from '@/composables/useConfirm'
import { useRendered } from '@/composables/useMarkdown'
import type { ProjectId } from '@/api/types'
import type { ProjectId, TaskRecord } from '@/api/types'
const route = useRoute()
const router = useRouter()
@@ -295,7 +312,31 @@ const showApprovalDialog = ref(false)
let unlisten: (() => void) | null = null
// Tab 导航:概览(项目信息/任务/工作流日志) vs 文件浏览器(Batch 10)。
const activeTab = ref<'overview' | 'files' | 'graph'>('overview')
// P1-g+ 问题10④:activeTab 持久化 localStorage(按项目隔离,切换项目不串扰)。
const TAB_STORAGE_PREFIX = 'df-project-tab-'
const validTabs = ['overview', 'files', 'graph'] as const
type DetailTab = typeof validTabs[number]
function readStoredTab(projectId: string): DetailTab {
try {
const v = localStorage.getItem(TAB_STORAGE_PREFIX + projectId)
return (v && (validTabs as readonly string[]).includes(v)) ? (v as DetailTab) : 'overview'
} catch { return 'overview' }
}
const activeTab = ref<DetailTab>('overview')
function setActiveTab(tab: DetailTab) {
activeTab.value = tab
try { localStorage.setItem(TAB_STORAGE_PREFIX + projectId.value, tab) } catch { /* ignore */ }
}
// 描述折叠(P1-g+ 问题10③):长描述默认折叠截 4 行,展开看全文。
// descCollapsible 由描述长度阈值决定(渲染后行数不可靠,用源字符数近似 ≥120 字符)。
const DESC_COLLAPSE_THRESHOLD = 120
const descExpanded = ref(false)
const descCollapsible = computed(() => (currentProject.value?.description?.length ?? 0) >= DESC_COLLAPSE_THRESHOLD)
// 工作流日志折叠(P1-g+ 问题10②):无记录时折叠为摘要(标题 + emptyWorkflowLog 提示),
// 有记录默认展开。collapse 由用户主动操作,避免空态占整栏。
const workflowLogCollapsed = ref(false)
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
@@ -364,9 +405,18 @@ const assessmentLabel = (recommendation: string): string =>
assessmentLabelI18n(t, recommendation)
// ── 任务 ──
const projectTasks = computed(() =>
store.tasks.filter(t => t.project_id === projectId.value)
)
// P1-g+ 问题10①:projectTasks 不再读 store.tasks(被 Tasks 视图分页/筛选污染),
// 改为按 project_id 走后端 list_tasks {project_id} 独立拉取,存本地 ref。
// TaskQuery.project_id 命中 idx_tasks_project_id(见 api/types.ts:191)。
const projectTasks = ref<TaskRecord[]>([])
async function loadProjectTasks() {
try {
projectTasks.value = await taskApi.list({ project_id: projectId.value })
} catch (e) {
console.error('加载项目任务失败:', e)
projectTasks.value = []
}
}
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
// ── 工作流 ──
@@ -417,6 +467,8 @@ async function submitNewTask() {
branch_name: newTaskBranch.value.trim() || undefined,
})
if (!r) return // 失败已 toast,保持弹窗不关
// 同步到本地 ref(store.createTask 已 push 全局 tasks,但本视图不再读 store.tasks)
projectTasks.value = [r, ...projectTasks.value]
showNewTaskModal.value = false
newTaskTitle.value = ''
newTaskDesc.value = ''
@@ -428,7 +480,8 @@ async function submitNewTask() {
// ── 同步 ──
async function handleSync() {
await Promise.all([store.loadProjects(), store.loadTasks()])
await store.loadProjects()
await loadProjectTasks() // 本项目任务独立拉取,不依赖 store.loadTasks
await checkPath()
}
@@ -534,8 +587,11 @@ watch(() => store.pendingApproval, (newApproval) => {
// ── 生命周期 ──
onMounted(async () => {
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
// 恢复持久化的 Tab 选择(按项目隔离)
activeTab.value = readStoredTab(projectId.value)
await store.loadProjects()
await store.loadTasks() // 全量加载,详情页用 computed 过滤本项目(避免覆盖全局 tasks 单例)
// 本项目任务独立拉取(不调 store.loadTasks,避免被 Tasks 视图分页/筛选污染)
await loadProjectTasks()
// 来源灵感回溯:项目有 idea_id 但 store.ideas 为空(本页未 load 过)时补拉,
// 否则 sourceIdea computed 永远找不到对应灵感记录
await store.loadIdeas()
@@ -543,6 +599,13 @@ onMounted(async () => {
await checkPath()
})
// 路由切换到不同项目(URL /projects/:id 变化)时,重新拉取本项目任务 + 恢复 Tab + 检测目录
watch(projectId, async (newId) => {
if (!newId) return
activeTab.value = readStoredTab(newId)
await loadProjectTasks()
})
// 目录变更(重定位后)重新检测存在性
watch(() => currentProject.value?.path, () => { checkPath() })
@@ -611,6 +674,13 @@ onUnmounted(() => {
/* ===== 项目描述 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css) ===== */
.description.ai-md { font-size: 14px; color: var(--df-text); line-height: 1.6; }
/* P1-g+ 问题10③:描述折叠(对齐 TaskDetail 长内容)— 折叠时 max-height 截断 + 渐变遮罩 */
.description-wrap { position: relative; }
.description-wrap.is-collapsed .description {
max-height: 6em; /* 约 4 行(line-height 1.5) */
overflow: hidden;
}
.desc-toggle { margin-top: 6px; }
/* ===== 来源灵感卡片(晋升携带评估结论回溯)===== */
.source-idea-card {
@@ -727,17 +797,25 @@ onUnmounted(() => {
/* .btn 系列(btn/btn-primary/btn-ghost/btn-sm/btn-danger)已提取到 styles/global.css 全局 */
/* ===== 两栏布局 — 自己滚动,不撑大父级 ===== */
.detail-grid {
display: grid;
grid-template-columns: 1fr 380px;
/* ===== 概览布局(P1-g+ 问题10②:三栏拥挤 → 两栏 + 日志下方)=====
.detail-overview 整体滚动;内部 .detail-grid 两栏(项目信息 + 任务列表),
.workflow-log-panel 跨整宽放下方。无日志记录时折叠为摘要不占整栏。 */
.detail-overview {
display: flex;
flex-direction: column;
gap: var(--df-gap-page);
overflow-y: auto;
min-height: 0;
max-height: 100%;
}
/* 两栏布局 — 项目信息 + 任务列表 */
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--df-gap-page);
align-content: start;
}
.right-column { display: flex; flex-direction: column; gap: var(--df-gap-grid); }
.workflow-log-panel { flex: 0 0 auto; }
/* ===== 面板 ===== */
/* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619),
@@ -826,5 +904,6 @@ onUnmounted(() => {
.detail-grid {
grid-template-columns: 1fr;
}
.log-list { max-height: 200px; }
}
</style>
+65 -7
View File
@@ -3,6 +3,21 @@
<header class="page-header">
<h1>{{ $t('projects.title') }}</h1>
<div class="header-actions">
<!-- 视图模式切换(默认 list 紧凑;card 宽松):切换持久化 localStorage -->
<div class="view-toggle" :title="$t('projects.viewModeTitle')">
<button
class="view-toggle-btn"
:class="{ active: viewMode === 'list' }"
:title="$t('projects.viewList')"
@click="viewMode = 'list'"
>☰</button>
<button
class="view-toggle-btn"
:class="{ active: viewMode === 'card' }"
:title="$t('projects.viewCard')"
@click="viewMode = 'card'"
>▦</button>
</div>
<button class="btn btn-ghost" @click="openImportModal">{{ $t('projects.importHistory') }}</button>
<button class="btn btn-ghost" @click="openTrash">{{ $t('projects.trash') }}</button>
<button class="btn btn-primary" @click="showCreateModal = true">{{ $t('projects.create') }}</button>
@@ -139,8 +154,15 @@
</div>
<!-- 项目卡片网格(单卡片渲染抽至 components/project/ProjectCard.vue,删除走 emit 回父处理) -->
<div class="project-grid">
<ProjectCard v-for="project in pagedProjects" :key="project.id" :project="project" @delete="handleDelete" />
<!-- viewMode='list' → 单列紧凑;viewMode='card' → 双列宽松(默认改造前形态) -->
<div class="project-grid" :class="'grid-' + viewMode">
<ProjectCard
v-for="project in pagedProjects"
:key="project.id"
:project="project"
:view="viewMode"
@delete="handleDelete"
/>
</div>
<!-- 分页器(F-260621-02 P3):pageSize=0 不分页(全量,等价旧行为);用户选 N 条触发客户端切片 -->
@@ -175,15 +197,20 @@ import ProjectCard from '@/components/project/ProjectCard.vue'
import Paginator from '@/components/Paginator.vue'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import { usePersistedRef } from '@/composables/usePersistedRef'
import type { ProjectRecord } from '@/api/types'
import type { ScannedProjectItem } from '@/api/project'
const store = useProjectStore()
const { t } = useI18n()
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片
const page = ref(1)
const pageSize = ref(0)
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片。
// page/pageSize 持久化 localStorage(刷新/重开记住用户选择)。
const page = usePersistedRef('projects.page', 1)
const pageSize = usePersistedRef('projects.pageSize', 0)
// 视图模式:'list'(紧凑单列,默认)/'card'(宽松双列)。持久化 localStorage。
const viewMode = usePersistedRef<'list' | 'card'>('projects.viewMode', 'list')
// 分页视图:pageSize=0 → 全量;否则取当前页窗口
const pagedProjects = computed(() => {
@@ -426,11 +453,41 @@ async function runImport() {
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ===== 卡片网格 ===== */
/* 默认改造前为双列;viewMode 驱动 list(单列紧凑)/card(双列宽松) */
.project-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--df-gap-grid);
}
.project-grid.grid-card {
grid-template-columns: repeat(2, 1fr);
}
.project-grid.grid-list {
grid-template-columns: 1fr;
}
/* ===== 视图模式切换 ===== */
.view-toggle {
display: inline-flex;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
overflow: hidden;
}
.view-toggle-btn {
min-width: 30px;
padding: 6px 10px;
background: transparent;
border: none;
color: var(--df-text-dim);
font-size: 14px;
line-height: 1;
cursor: pointer;
transition: all 0.15s;
}
.view-toggle-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
.view-toggle-btn.active {
background: rgba(108, 99, 255, 0.12);
color: var(--df-accent);
}
/* ===== 进度条 ===== */
.card-progress { margin-bottom: 16px; }
@@ -498,7 +555,8 @@ async function runImport() {
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.projects { padding: 16px; }
.project-grid {
/* 移动端:card 视图也降为单列;list 本就单列 */
.project-grid.grid-card {
grid-template-columns: 1fr;
}
}
+32 -4
View File
@@ -125,12 +125,40 @@ function onResize() {
// ------------------------------------------------------------
// SettingsNav 搜索命中首匹配项时上抛 scroll-target(key)。
// 此处据 key 查 SETTINGS_INDEX 取 labelKey → t() 得当前 locale 文本,
// 在右侧内容 DOM 中找匹配文本的 .setting-label(表单型 Section)或
// 在右侧内容 DOM 中找匹配的 .setting-label(表单型 Section)或
// .panel-header h2(列表型面板如 ProviderPanel),scrollIntoView 平滑定位。
// 用文本匹配而非 data-* 属性:避免改动各 Section(严守"仅改 plan 列出文件")。
//
// 匹配策略(2026-08-02 加固):
// 旧实现用 textContent.trim() === labelText 严格相等,SettingRow 的 .setting-label
// 常带 next-round-badge 子 span(「下次对话生效」) → textContent 拼接出
// 「主题 下次对话生效」≠「主题」,严格相等失效,搜索定位静默无命中。
// 现改读 .setting-label 的"首子文本节点"(纯 label,排除 badge/desc 等子元素),
// 与 labelText trim 后 includes 匹配(双向 includes 兜底首尾空白/标点)。
// h2 同理改 includes 提升容错。理想方案是 .setting-label 加 data-setting-key,
// 但那需改 SettingRow.vue + 6 个 Section(越"仅改指定文件"范围),故此处加固文本匹配。
// ============================================================
const contentRef = ref<HTMLElement | null>(null)
/** 取元素"首文本节点"内容(跳过子元素,排除 SettingRow 的 next-round-badge 拼接干扰) */
function firstTextNodeText(el: HTMLElement | null): string {
if (!el) return ''
// 优先 childNodes 中的首个 text node;退而 textContent(无子元素场景)
for (const node of Array.from(el.childNodes)) {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || ''
}
}
return el.textContent || ''
}
/** 双向 includes 匹配(容错首尾空白/标点差异;空串不命中) */
function fuzzyMatch(a: string, b: string): boolean {
const sa = a.trim()
const sb = b.trim()
if (!sa || !sb) return false
return sa.includes(sb) || sb.includes(sa)
}
function scrollToSettingItem(itemKey: string) {
const entry = SETTINGS_INDEX.find((e) => e.key === itemKey)
if (!entry) return
@@ -145,7 +173,7 @@ function scrollToSettingItem(itemKey: string) {
const labels = root.querySelectorAll<HTMLElement>('.setting-label')
let target: HTMLElement | null = null
for (const el of labels) {
if (el.textContent?.trim() === labelText.trim()) {
if (fuzzyMatch(firstTextNodeText(el), labelText)) {
target = el.closest<HTMLElement>('.setting-row') ?? el
break
}
@@ -153,7 +181,7 @@ function scrollToSettingItem(itemKey: string) {
if (!target) {
const headers = root.querySelectorAll<HTMLElement>('.panel-header h2')
for (const el of headers) {
if (el.textContent?.trim() === labelText.trim()) {
if (fuzzyMatch(firstTextNodeText(el), labelText)) {
target = el
break
}
+247 -138
View File
@@ -1,15 +1,42 @@
<template>
<div class="task-detail">
<!-- 页面头部 -->
<header class="page-header">
<!-- 页面头部(吸顶):标题 + 状态 + 优先级 在左,推进按钮在右(滚动始终可见)。
Problem4 ① 推进按钮从面板内移到 header 右侧,与 refresh 同区。-->
<header class="page-header task-detail-header">
<div class="header-left">
<router-link to="/tasks" class="back-link">{{ $t('taskDetail.backToList') }}</router-link>
<h1>{{ task?.title ?? '...' }}</h1>
<span v-if="task" class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
<!-- F-04 review 轮次显示(>0 才显示,in_review→in_progress / testing→in_review 退回时后端 +1) -->
<span v-if="task && task.review_rounds > 0" class="review-rounds-badge">
{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}
</span>
<span v-if="task" class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
</div>
<div class="header-actions">
<button class="btn btn-ghost" type="button" @click="refresh">{{ $t('taskDetail.refresh') }}</button>
<button class="btn btn-ghost btn-sm" type="button" @click="refresh">{{ $t('taskDetail.refresh') }}</button>
<!-- F-260616-06 ①-1 / B-41 工作流推进按钮(联动任务,按 target_status 后端自动选推进链模板)
与手动 advance 并存:仅当当前态的 primary 前向推进目标 ∈ {in_progress, testing, done}
(即 template_for 有对应模板)时显示。手动 advance 仍走 advance_task 直 IPC;工作流推进
走 run_workflow,经 AiNode/HumanNode 执行后再由后端回调推进任务。
独立 loading 标志 wfAdvancing 与 advancing 互不干扰。 -->
<button
v-if="wfAdvanceAction"
type="button"
class="btn btn-sm btn-primary"
:disabled="wfAdvancing || advancing"
@click="handleWorkflowAdvance(wfAdvanceAction.target)"
>{{ wfAdvancing ? $t('taskDetail.workflowAdvancing') : $t('taskDetail.workflowAdvance') }}</button>
<!-- F-05 推进按钮:按当前 status 显示状态机合法下一态(todo/done/cancelled 终态无按钮) -->
<button
v-for="act in advanceActions"
:key="act.target"
type="button"
class="btn btn-sm"
:class="act.variant"
:disabled="advancing"
@click="handleAdvance(act.target)"
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
</div>
</header>
@@ -17,144 +44,111 @@
<div v-if="loading" class="empty-hint">{{ $t('taskDetail.loading') }}</div>
<div v-else-if="errorMsg" class="empty-hint error-hint">⚠ {{ errorMsg }}</div>
<!-- 主体 -->
<!-- 主体:两栏布局(Problem4 ⑤)。左栏=描述+关联信息+时间戳,右栏=任务产出+工作流。
窄屏自动单列。-->
<div v-else-if="task" class="detail-grid">
<section class="panel">
<div class="panel-header">
<h2>{{ $t('taskDetail.infoTitle') }}</h2>
</div>
<div class="task-info">
<div class="info-item">
<span class="label">{{ $t('taskDetail.title') }}</span>
<span class="value">{{ task.title }}</span>
</div>
<div class="info-item info-block">
<span class="label">{{ $t('taskDetail.description') }}</span>
<!-- ============ 左栏 ============ -->
<div class="left-column">
<!-- 描述(可折叠:Problem4 ② 超 400px 显展开按钮) -->
<section v-if="task.description" class="panel">
<div class="panel-header"><h2>{{ $t('taskDetail.description') }}</h2></div>
<div class="description-wrap" :style="descWrapStyle">
<!-- B-24:任务描述 Markdown 渲染(## 标题 / - 列表 等),v-html 经 useMarkdown DOMPurify sanitize -->
<span
class="value description ai-md"
v-if="task.description"
v-html="renderedDesc"
></span>
<span v-else class="value description">—</span>
<span ref="descEl" class="value description ai-md" v-html="renderedDesc"></span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.status') }}</span>
<span class="value">
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
<!-- F-04 review 轮次显示(>0 才显示,in_review→in_progress / testing→in_review 退回时后端 +1) -->
<span v-if="task.review_rounds > 0" class="review-rounds-badge">
{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}
<button
v-if="descCollapsible"
class="btn btn-ghost btn-sm desc-toggle"
type="button"
@click="descExpanded = !descExpanded"
>{{ descExpanded ? $t('taskDetail.collapse') : $t('taskDetail.expand') }}</button>
<!-- 折叠态淡出渐变蒙层(展开态不显) -->
<div v-if="descCollapsible && !descExpanded" class="desc-fade"></div>
</section>
<!-- 关联信息(Problem4 ③④ 去重 + 空值不显行)。
标题/状态/优先级已在 header 显示,这里不重复。-->
<section class="panel">
<div class="panel-header"><h2>{{ $t('taskDetail.relatedTitle') }}</h2></div>
<div class="task-info">
<div class="info-item">
<span class="label">{{ $t('taskDetail.project') }}</span>
<span class="value">
<router-link v-if="task.project_id" :to="`/projects/${task.project_id}`" class="project-link">
{{ projectName }}
</router-link>
<span v-else>—</span>
</span>
</div>
<!-- F-260619-01:关联灵感(1对1 单向,idea_id 解析为友好 title,非裸 id) -->
<div v-if="task.idea_id" class="info-item">
<span class="label">{{ $t('taskDetail.relatedIdea') }}</span>
<span class="value">
<router-link :to="`/ideas/${task.idea_id}`" class="project-link">{{ ideaTitle }}</router-link>
</span>
</div>
<!-- 分支(有值才显) -->
<div v-if="task.branch_name" class="info-item">
<span class="label">{{ $t('taskDetail.branch') }}</span>
<span class="value">
<span class="branch-tag"><span class="branch-icon">⑂</span>{{ task.branch_name }}</span>
</span>
</div>
<!-- 负责人(空值不显行,Problem4 ③) -->
<div v-if="task.assignee" class="info-item">
<span class="label">{{ $t('taskDetail.assignee') }}</span>
<span class="value">{{ task.assignee }}</span>
</div>
<!-- 基础分支(空值不显行) -->
<div v-if="task.base_branch" class="info-item">
<span class="label">{{ $t('taskDetail.baseBranch') }}</span>
<span class="value mono">{{ task.base_branch }}</span>
</div>
<!-- 工作流定义(空值不显行) -->
<div v-if="task.workflow_def_id" class="info-item">
<span class="label">{{ $t('taskDetail.workflowDef') }}</span>
<span class="value mono">{{ task.workflow_def_id }}</span>
</div>
<!-- 创建/更新时间:最底小字(Problem4 ⑥) -->
<div class="info-item timestamps">
<span class="timestamp">{{ $t('taskDetail.createdAt') }}: {{ formatDate(task.created_at) }}</span>
<span class="timestamp">{{ $t('taskDetail.updatedAt') }}: {{ formatDate(task.updated_at) }}</span>
</div>
</div>
</section>
</div>
<!-- ============ 右栏:任务产出 + 工作流进度 ============ -->
<div class="right-column">
<!-- F-AiNodeSelfReview: 任务产出 + AI 自审结果展示(抽至 TaskOutputCard 子组件,零行为变更) -->
<section class="panel">
<div class="panel-header"><h2>{{ $t('taskDetail.output') }}</h2></div>
<!-- 无产出时占位(任务未跑过 AiNode 产出),右栏保留产出栏框架便于后续产出出现即填入 -->
<div v-if="!task.output_json" class="empty-hint output-empty">—</div>
<TaskOutputCard v-else :output-json="task.output_json" />
</section>
<!-- B-41 工作流推进轻量进度(从面板内 advance-row 提取,推进按钮已移 header) -->
<section v-if="wfAdvancing || wfProgressHint" class="panel">
<div class="panel-header"><h2>{{ $t('taskDetail.workflowAdvanceTitle') }}</h2></div>
<div class="wf-progress">
<span v-if="wfRunningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfRunningNode }) }}</span>
<span v-else-if="wfDoneTotal > 0" class="wf-progress-count">
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
</span>
</div>
<!-- F-05 推进按钮:按当前 status 显示状态机合法下一态(todo/done/cancelled 终态无按钮) -->
<div v-if="advanceActions.length" class="info-item info-block advance-row">
<span class="label">{{ $t('taskDetail.advanceTitle') }}</span>
<div class="advance-actions">
<button
v-for="act in advanceActions"
:key="act.target"
type="button"
class="btn btn-sm"
:class="act.variant"
:disabled="advancing"
@click="handleAdvance(act.target)"
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
</div>
</div>
<!-- F-260616-06 ①-1 / B-41 工作流推进按钮(联动任务,按 target_status 后端自动选推进链模板)
与手动 advance 并存:仅当当前态的 primary 前向推进目标 ∈ {in_progress, testing, done}
(即 template_for 有对应模板)时显示。手动 advance 仍走 advance_task 直 IPC;工作流推进
走 run_workflow,经 AiNode/HumanNode 执行后再由后端回调推进任务。
独立 loading 标志 wfAdvancing 与 advancing 互不干扰。 -->
<div v-if="wfAdvanceAction" class="info-item info-block advance-row">
<span class="label">{{ $t('taskDetail.workflowAdvanceTitle') }}</span>
<div class="advance-actions">
<button
type="button"
class="btn btn-sm btn-primary"
:disabled="wfAdvancing || advancing"
@click="handleWorkflowAdvance(wfAdvanceAction.target)"
>{{ wfAdvancing ? $t('taskDetail.workflowAdvancing') : $t('taskDetail.workflowAdvance') }}</button>
</div>
<!-- B-41 轻量进度(监听 workflow-event: NodeStarted/NodeCompleted/WorkflowFailed)
完整进度条/预估留后续批;完成/失败后任务态由后端回调推进,df-data-changed 自动刷新 task -->
<div v-if="wfAdvancing || wfProgressHint" class="wf-progress">
<span v-if="wfRunningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfRunningNode }) }}</span>
<span v-else-if="wfDoneTotal > 0" class="wf-progress-count">
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
</span>
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
<span v-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
</div>
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
<span v-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
</div>
<!-- 工作流 DAG 结构 -->
<WorkflowDagDisplay v-if="wfDagJson" :dag-json="wfDagJson" :node-statuses="wfNodeStatuses" />
<div class="info-item">
<span class="label">{{ $t('taskDetail.priority') }}</span>
<span class="value">
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.project') }}</span>
<span class="value">
<router-link v-if="task.project_id" :to="`/projects/${task.project_id}`" class="project-link">
{{ projectName }}
</router-link>
<span v-else>—</span>
</span>
</div>
<!-- F-260619-01:关联灵感(1对1 单向,idea_id 解析为友好 title,非裸 id) -->
<div class="info-item">
<span class="label">{{ $t('taskDetail.relatedIdea') }}</span>
<span class="value">
<router-link v-if="task.idea_id" :to="`/ideas/${task.idea_id}`" class="project-link">
{{ ideaTitle }}
</router-link>
<span v-else>—</span>
</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.branch') }}</span>
<span class="value">
<span v-if="task.branch_name" class="branch-tag">
<span class="branch-icon">⑂</span>{{ task.branch_name }}
</span>
<span v-else>—</span>
</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.assignee') }}</span>
<span class="value">{{ task.assignee ?? '—' }}</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.baseBranch') }}</span>
<span class="value">{{ task.base_branch ?? '—' }}</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.workflowDef') }}</span>
<span class="value mono">{{ task.workflow_def_id ?? '—' }}</span>
</div>
<!-- F-AiNodeSelfReview: 任务产出 + AI 自审结果展示(抽至 TaskOutputCard 子组件,零行为变更) -->
<TaskOutputCard :output-json="task.output_json" />
<div class="info-item">
<span class="label">{{ $t('taskDetail.createdAt') }}</span>
<span class="value">{{ formatDate(task.created_at) }}</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.updatedAt') }}</span>
<span class="value">{{ formatDate(task.updated_at) }}</span>
</div>
</div>
</section>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { listen } from '@tauri-apps/api/event'
@@ -223,6 +217,37 @@ const { rendered: renderedDesc, ensureLoaded } = useRendered(
() => task.value?.description ?? '',
)
// ============================================================
// Problem4 ②:描述可折叠 — 超 400px 显展开按钮(测真实 DOM 高度)
// ------------------------------------------------------------
// 不用字符数近似(渲染后 Markdown 列表/标题行高不一,字符数与 px 无线性关系),
// 改用 descEl 实测 scrollHeight:首次渲染 + task 切换后 nextTick 测量。
// descCollapsible=true 时才显按钮;默认折叠(!descExpanded)即首次进入截 400px。
// 折叠态 max-height 由 descWrapStyle 内联注入(展开态不设上限,自适应)。
// .description-wrap 的 overflow:hidden + transition 让折叠/展开有过渡。
// 蒙层 .desc-fade 暗示折叠态下方还有内容。
const DESC_COLLAPSE_PX = 400
const descEl = ref<HTMLElement | null>(null)
const descExpanded = ref(false)
const descOverflow = ref(false) // 真实高度是否超阈值(控制按钮显隐)
const descCollapsible = computed(() => descOverflow.value)
// 折叠态 max-height 内联(过渡友好):展开态不设上限(自适应)
const descWrapStyle = computed(() =>
descExpanded.value ? {} : { maxHeight: DESC_COLLAPSE_PX + 'px' },
)
async function measureDescHeight() {
// descEl 是 <span>(描述内容),自身无 max-height 限制 → scrollHeight 即其真实渲染高度,
// 不受父 .description-wrap 的 max-height 裁剪影响(span 在父内若被裁,其 scrollHeight
// 仍反映完整内容高)。直接读无需临时改父样式。
await nextTick()
const el = descEl.value
if (!el) { descOverflow.value = false; return }
descOverflow.value = el.scrollHeight > DESC_COLLAPSE_PX
}
// F-AiNodeSelfReview: output_json 解析 + AI 产出/自审渲染已抽至 TaskOutputCard 子组件
// (components/task/TaskOutputCard.vue),父级只传 output-json prop,零行为变更。
@@ -448,6 +473,11 @@ function refresh() {
// 路由参数变化(id 变化)时重新加载
watch(taskId, () => { load() })
// Problem4 ②:切换 task / 描述渲染完成 → 重置折叠态 + 重测高度。
// renderedDesc 异步(Markdown 经 marked 渲染),内容变化后 nextTick 测真实 px。
watch(() => task.value?.id, () => { descExpanded.value = false })
watch(renderedDesc, () => { measureDescHeight() })
// B-260616-18: 数据变更联动刷新 unlistener(onMounted 注册,onBeforeUnmount 释放)
// 对齐 AiChat _unlistenToolSlow 生命周期模式。本视图绕 store 直调 taskApi.get/projectApi.list,
// 不享受 store 全局 df-data-changed 监听(该监听只刷 store.tasks 列表,不含本视图的当前 task 单体),
@@ -492,15 +522,33 @@ onBeforeUnmount(() => {
.task-detail { padding: 16px 20px 20px; }
/* page-header / btn / back-link 等已提取到 global.css 全局 */
.header-left { display: flex; align-items: center; gap: 12px; }
/* F-05 推进操作区 */
.advance-row .advance-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
.header-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
.header-left h1 {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
min-width: 0;
}
/* Problem4 ① 吸顶 header(推进按钮始终可见) */
.task-detail-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--df-bg);
/* 留底部细分割线,与全局 page-header margin-bottom 协同(0.5px 边框规范) */
padding-bottom: 8px;
border-bottom: 0.5px solid var(--df-border);
/* 顶负 padding 抵消 .task-detail 的 16px 顶 padding,吸顶贴顶 */
margin: -16px -20px 0;
padding-left: 20px;
padding-right: 20px;
padding-top: 16px;
align-items: center;
}
/* header-actions 内多个推进按钮 + refresh 排列(对齐全局 .header-actions gap:10px) */
.task-detail-header .header-actions { flex-wrap: wrap; justify-content: flex-end; }
/* advance 操作区已移入 header-actions,旧 .advance-row 样式删除 */
/* B-41 工作流推进轻量进度提示 */
.wf-progress {
display: flex;
@@ -552,7 +600,21 @@ onBeforeUnmount(() => {
.priority-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
/* ===== 面板 ===== */
.detail-grid { display: grid; grid-template-columns: 1fr; gap: var(--df-gap-grid); }
/* Problem4 ⑤ 宽屏两栏:左栏(描述+关联信息+时间戳) / 右栏(任务产出+工作流进度)。
窄屏(<960px)单列。对齐 ProjectDetail 两栏模式但阈值更宽(产出栏更窄)。
不设 margin-top —— 全局 .page-header 已有 margin-bottom: var(--df-gap-page) 提供分隔。*/
.detail-grid {
display: grid;
grid-template-columns: 1fr 380px;
gap: var(--df-gap-page);
align-items: start; /* 两栏顶对齐,长栏不撑高短栏 */
}
.left-column, .right-column {
display: flex;
flex-direction: column;
gap: var(--df-gap-grid);
min-width: 0; /* 网格项防溢出(长 url/代码块) */
}
/* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619)。 */
@@ -565,8 +627,44 @@ onBeforeUnmount(() => {
/* B-260615-31:字段同行布局(label 固定宽 + value 占余),描述字段 info-block 保持块状)
基础 .info-item/.label/.value 已收敛至全局 components.css(DRY 收口 B-260619),
此处仅保留本组件特有覆盖。 */
/* B-24 漏 white-space 覆盖致 v-html 后 HTML 标签间 \n 被 pre-wrap 渲染为空行间距——移除 pre-wrap,描述由 .ai-md 接管(line-height 1.5 对齐 AiChat li) */
.info-item .value.description { line-height: 1.5; }
/* B-24 漏 white-space 覆盖致 v-html 后 HTML 标签间 \n 被 pre-wrap 渲染为空行间距——移除 pre-wrap,描述由 .ai-md 接管(line-height 1.5 对齐 AiChat li)。
Problem4 重设计:描述区从 .info-item 内移到独立 .description-wrap(可折叠容器),
故选择器改 .description-wrap。display:block 让 <span> 有块盒模型,scrollHeight 可读。*/
.description-wrap .value.description { line-height: 1.5; display: block; }
/* Problem4 ② 描述可折叠:折叠态 max-height(内联注入 400px) + overflow hidden + 过渡。
展开态 max-height none(内联不设),自然撑开。蒙层 .desc-fade 暗示下方有内容。*/
.description-wrap {
position: relative;
overflow: hidden;
transition: max-height 0.25s var(--df-ease);
}
.desc-toggle {
margin-top: 8px;
}
.desc-fade {
/* 折叠态底部渐变蒙层,提示"下方还有内容"(展开态不渲染此元素) */
position: absolute;
left: 0; right: 0; bottom: 0;
height: 32px;
background: linear-gradient(to bottom, transparent, var(--df-bg-card));
pointer-events: none;
}
/* Problem4 ⑥ 创建/更新时间最底小字(独立行,非 label/value 对) */
.info-item.timestamps {
flex-direction: row;
gap: 16px;
padding-top: var(--df-gap-grid);
border-top: 0.5px solid var(--df-border);
}
.timestamp {
font-size: 11px;
color: var(--df-text-dim);
}
/* 右栏空产出占位提示 */
.output-empty { padding: 24px 12px; }
/* F-AiNodeSelfReview output_json 区块样式已随子组件移至 TaskOutputCard.vue */
@@ -599,4 +697,15 @@ onBeforeUnmount(() => {
font-size: 13px; color: var(--df-text-dim);
}
.error-hint { color: var(--df-danger); }
/* Problem4 ⑤ 响应式:窄屏(<960px)两栏退单列,产出栏移到下方 */
@media (max-width: 960px) {
.detail-grid { grid-template-columns: 1fr; }
}
/* 窄屏 header-actions 内推进按钮可能换行,允许并压缩间距 */
@media (max-width: 720px) {
.task-detail-header { flex-wrap: wrap; }
.task-detail-header .header-left { flex: 1 1 100%; margin-bottom: 8px; }
}
</style>
+9 -6
View File
@@ -181,21 +181,24 @@ import type { TaskRecord, TaskQuery, ProjectId } from '@/api/types'
import Paginator from '../components/Paginator.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useConfirm } from '@/composables/useConfirm'
import { usePersistedRef } from '@/composables/usePersistedRef'
const router = useRouter()
const store = useProjectStore()
const { t } = useI18n()
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
const activeProject = ref('all')
const activeStatus = ref('all')
const sortBy = ref('updated_at')
const searchKeyword = ref('')
// 列表筛选/分页状态持久化到 localStorage(key 前缀 'tasks.'),
// 刷新页面后保留用户上次选择。collapsedGroups 单独走 df-tasks-collapsed(沿用既有实现)。
const activeProject = usePersistedRef('tasks.activeProject', 'all')
const activeStatus = usePersistedRef('tasks.activeStatus', 'all')
const sortBy = usePersistedRef('tasks.sortBy', 'updated_at')
const searchKeyword = usePersistedRef('tasks.searchKeyword', '')
const loading = ref(false)
// 分页(默认开启 20 条/页)
const page = ref(1)
const pageSize = ref(20)
const page = usePersistedRef('tasks.page', 1)
const pageSize = usePersistedRef('tasks.pageSize', 20)
const totalTasks = ref(0)
// 分组折叠状态(localStorage 记忆)