优化: AI Chat 渲染与无障碍 + 文件查看 + 同步 IO 收口
- 渲染: 结果/过程分离(气泡只放最终结果,过程轮叙述+工具卡收进轨迹卡默认收起可展开——治『流式后正文消失』且不再 N 气泡打扰) - 渲染 P0: v-memo 定向 dep(只有真正渲染流式块的 item 失效,其余取占位) + renderItems 桶 memo(签名覆盖全消费输入,item 引用稳定) + 指纹 watch 降为 O(1) toolEpoch - TopBar 三处 O(n) messages computed 合并单次遍历;md 缓存改有界 LRU(除满即全清抖动);useTurnTrace 补 never 穷尽 + default;MessageItem 模板去重 - 无障碍(四批累计): 可点容器键盘化(含 role/tabindex/keydown/aria-expanded) + icon-only aria-label 约 60 处 + 装饰图标 aria-hidden + 52 个滚动容器 overscroll-behavior:contain + 设置开关聚焦可见环 + outline 零补偿站点补 :focus-visible - UX 走查项: 灵感 IDEA-10/11、知识 KNOW-6/7/9、项目详情 O8、审计 SA-4、设置 SA-6/26/27/28(脏表单/冗余 save/clamp/删除二次确认) - 文件查看专项: 切工程状态残留 F5/F6、FileTree 去重与展开反馈 F7/F15/F17、切文件头部残留 F18、R 状态着色 F19;FilePreview diff 文件头(---/+++)误当增删渲染修复 + 大纲行高亮死样式修复 + 图片失败文案 i18n 化 - 同步 IO 收口(异步上下文内阻塞): unattended 看板读写改 tokio::fs、fetch_url 探活改 spawn_blocking、http/generate_image 清理改 tokio::fs - 子代理 P3 批量: 任务书钉扎上限/截断文案区分父子语境/环境画像日期/temperature 可覆盖/model 回退上报/lang 透传 - crate 清理: df-execute·df-types 注释与格式、df-ideas 过时注释、df-nodes subflow 夸大描述、df-project 死变量
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
<span v-if="approvals.length > 1" class="popup-header-badge">{{ approvals.length }}</span>
|
||||
</div>
|
||||
<div class="popup-header-title">{{ $t('aiChat.popupTitle') }}</div>
|
||||
<button class="popup-header-close" :title="$t('common.close')" @click.stop="onClose">
|
||||
<button class="popup-header-close" :title="$t('common.close')" :aria-label="$t('common.close')" @click.stop="onClose">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
@@ -51,7 +51,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<!-- 风险说明(后端 build_approval_reason 已含工具/参数/risk 描述) -->
|
||||
<div v-if="tc.reason" class="popup-item-reason">⚠ {{ tc.reason }}</div>
|
||||
<div v-if="tc.reason" class="popup-item-reason"><span aria-hidden="true">⚠</span> {{ tc.reason }}</div>
|
||||
<!-- H1 修复:审批挂起时长提示(对齐 ToolCard: >2min 橙色 / >5min 红色脉冲)。
|
||||
后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝,浮窗原本无计时,用户误以为还有时间。
|
||||
基于审批到达时间(popup 首次见到该 tc.id 的时刻)计时,与 ToolCard 同款语义同款样式。 -->
|
||||
@@ -60,7 +60,7 @@
|
||||
</div>
|
||||
<!-- path 类:目录提示 -->
|
||||
<div v-if="tc.kind === 'path' && tc.dir" class="popup-item-dir">
|
||||
📁 {{ tc.dir }}
|
||||
<span aria-hidden="true">📁</span> {{ tc.dir }}
|
||||
</div>
|
||||
<!-- 操作按钮组:path 类显 once/session/always/deny 四选项(与 ToolCard/DirAuthDialog 同款);
|
||||
risk 类显 approve/reject 两选项。IPC 分派统一走 dispatchApprovalIPC。 -->
|
||||
@@ -529,7 +529,6 @@ function onDragEnd() { isDragging.value = false }
|
||||
border-radius: var(--df-radius-md);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--df-accent-soft);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
.popup-shell[data-dragging="true"] {
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 0 2px var(--df-accent);
|
||||
@@ -544,6 +543,7 @@ function onDragEnd() { isDragging.value = false }
|
||||
background: linear-gradient(135deg, var(--df-accent-bg), transparent);
|
||||
border-bottom: 0.5px solid var(--df-border, rgba(123, 111, 240, 0.2));
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
.popup-header-icon {
|
||||
position: relative;
|
||||
|
||||
+53
-3
@@ -82,7 +82,10 @@
|
||||
<span v-else class="decided-none">—</span>
|
||||
</td>
|
||||
<td class="col-args"><code class="brief">{{ r.arguments_brief || '—' }}</code></td>
|
||||
<td class="col-result"><code class="brief">{{ r.result_brief || '—' }}</code></td>
|
||||
<td class="col-result">
|
||||
<span v-if="hasOffloadMark(r.result_brief)" class="offload-chip">{{ t('aiTool.resultOffloaded') }}</span>
|
||||
<code v-else class="brief">{{ r.result_brief || '—' }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedId === r.id" class="audit-detail-row">
|
||||
<td colspan="7">
|
||||
@@ -93,7 +96,18 @@
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<span class="detail-label">{{ t('auditLog.col.result') }}</span>
|
||||
<pre class="detail-pre">{{ r.result_brief || '—' }}</pre>
|
||||
<!-- 外置句柄(D2a):显「结果已外置」+ 展开经 IPC 读回原文全文 -->
|
||||
<template v-if="offloadHandle">
|
||||
<OffloadedResultBar
|
||||
:handle="offloadHandle"
|
||||
:expanded="offloadExpanded"
|
||||
:loading="offloadLoading"
|
||||
:error="offloadError"
|
||||
@toggle="toggleOffload"
|
||||
/>
|
||||
<pre v-if="offloadResolved != null" class="detail-pre">{{ offloadResolved }}</pre>
|
||||
</template>
|
||||
<pre v-else class="detail-pre">{{ r.result_brief || '—' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -175,6 +189,8 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { formatRelative, formatDate } from '@/utils/time'
|
||||
import { usePersistedRef } from '@/composables/usePersistedRef'
|
||||
import { aiApi } from '@/api/ai'
|
||||
import { hasOffloadMark, useOffloadedResult } from '@/composables/ai/useOffloadedResult'
|
||||
import OffloadedResultBar from '@/components/OffloadedResultBar.vue'
|
||||
import type { AiConversationSummary } from '@/api/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -255,6 +271,20 @@ function toggleExpand(id: string) {
|
||||
expandedId.value = expandedId.value === id ? null : id
|
||||
}
|
||||
|
||||
// 展开行的外置句柄详情(D2a):同一视图只展开一行,读回状态复用单实例;
|
||||
// 切行时 reset 防串上一行的读回结果/错误。句柄来源=后端截断的 result_brief。
|
||||
const expandedRecord = computed(() => records.value.find(r => r.id === expandedId.value) ?? null)
|
||||
const {
|
||||
handle: offloadHandle,
|
||||
expanded: offloadExpanded,
|
||||
loading: offloadLoading,
|
||||
error: offloadError,
|
||||
resolved: offloadResolved,
|
||||
toggle: toggleOffload,
|
||||
reset: resetOffload,
|
||||
} = useOffloadedResult(() => expandedRecord.value?.result_brief ?? null)
|
||||
watch(expandedId, () => resetOffload())
|
||||
|
||||
// 审批→执行 耗时(秒级,毫秒差太精细)
|
||||
function durationLabel(requested: string, executed: string): string {
|
||||
const ms = new Date(executed).getTime() - new Date(requested).getTime()
|
||||
@@ -356,24 +386,32 @@ async function loadConversations() {
|
||||
convLoading.value = false
|
||||
}
|
||||
}
|
||||
// 对话详情请求序号守卫(对齐上方 _loadSeq 模式):快速切换对话时,
|
||||
// 旧对话 export 响应晚到不覆盖新对话消息;折叠时自增使在途请求作废
|
||||
let _convSeq = 0
|
||||
async function toggleConv(id: string) {
|
||||
if (expandedConv.value === id) {
|
||||
_convSeq++ // 折叠:作废在途请求,避免其回填残留
|
||||
expandedConv.value = null
|
||||
convMessages.value = []
|
||||
convLoading.value = false
|
||||
return
|
||||
}
|
||||
const seq = ++_convSeq
|
||||
expandedConv.value = id
|
||||
convLoading.value = true
|
||||
convError.value = ''
|
||||
try {
|
||||
const json = await aiApi.exportConversation(id, 'json')
|
||||
if (seq !== _convSeq) return // 旧响应丢弃,只取最新
|
||||
const parsed = JSON.parse(json) as ConvMessage[] ?? []
|
||||
convMessages.value = parsed || []
|
||||
} catch (e) {
|
||||
if (seq !== _convSeq) return // 旧请求的失败不污染当前视图
|
||||
convError.value = String(e)
|
||||
convMessages.value = []
|
||||
} finally {
|
||||
convLoading.value = false
|
||||
if (seq === _convSeq) convLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,6 +616,18 @@ onMounted(() => {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* D2a 外置句柄:结果列显标记替代截断 JSON(点行展开看详情并可读回全文) */
|
||||
.offload-chip {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: var(--df-warning-bg);
|
||||
color: var(--df-warning);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 风险 badge */
|
||||
.risk-badge {
|
||||
display: inline-block;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<span class="dash-subtitle">{{ $t('dashboard.subtitle') }}</span>
|
||||
</div>
|
||||
<div class="dash-header-right">
|
||||
<button class="btn btn-ghost btn-sm dash-refresh-btn" @click="refresh" :disabled="loading" :title="$t('dashboard.refresh')">
|
||||
<button class="btn btn-ghost btn-sm dash-refresh-btn" @click="refresh" :disabled="loading" :title="$t('dashboard.refresh')" :aria-label="$t('dashboard.refresh')">
|
||||
<svg aria-hidden="true" v-if="loading" class="dash-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
||||
<svg aria-hidden="true" v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
@@ -18,7 +18,7 @@
|
||||
+ 本地加载异常 errorMsg(样式全局 .error-banner) ═══ -->
|
||||
<div v-if="store.error || errorMsg" class="error-banner" style="margin-bottom: 14px">
|
||||
<span class="error-text">{{ store.error || errorMsg }}</span>
|
||||
<button class="error-dismiss" @click="store.clearError(); errorMsg = ''">✕</button>
|
||||
<button class="error-dismiss" @click="store.clearError(); errorMsg = ''" :aria-label="$t('common.close')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Main Grid ═══ -->
|
||||
@@ -35,15 +35,15 @@
|
||||
|
||||
<!-- ═══ 统计概要(底部一行小字,原独立统计卡已下沉) ═══ -->
|
||||
<div class="dash-stats-inline">
|
||||
<span class="dash-stats-item clickable" @click="go('/ideas')" :title="$t('dashboard.viewAll')">
|
||||
<span class="dash-stats-item clickable" role="button" tabindex="0" @click="go('/ideas')" @keydown.enter.prevent="go('/ideas')" @keydown.space.prevent="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-item clickable" role="button" tabindex="0" @click="go('/projects')" @keydown.enter.prevent="go('/projects')" @keydown.space.prevent="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-item clickable" role="button" tabindex="0" @click="go('/tasks')" @keydown.enter.prevent="go('/tasks')" @keydown.space.prevent="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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- projectId 来自 URL query(同步可得),缺失时直接给错误态而非无限 spinner -->
|
||||
<FileExplorer v-if="projectId" :project-id="projectId" />
|
||||
<div v-else class="fe-detached-error">
|
||||
<span class="fe-detached-error-icon">⚠️</span>
|
||||
<span class="fe-detached-error-icon" aria-hidden="true">⚠️</span>
|
||||
<p>{{ $t('fileExplorer.missingProjectId') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-1
@@ -11,7 +11,7 @@
|
||||
<!-- 错误条:消费 store.error(对齐 Knowledge/Projects error-banner,不整表替换列表) -->
|
||||
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
|
||||
<span class="error-text">{{ store.error }}</span>
|
||||
<button class="error-dismiss" @click="store.clearError()">✕</button>
|
||||
<button class="error-dismiss" @click="store.clearError()" :aria-label="$t('common.close')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选栏 -->
|
||||
@@ -455,6 +455,8 @@ async function evaluateCurrentIdea() {
|
||||
evalError.value = ''
|
||||
try {
|
||||
await store.evaluateIdea(currentIdea.value.id)
|
||||
// 评分排序模式下评估会改写分数,store 原地替换不重排 → 按当前视图重载以重排序(时间排序无需重排)
|
||||
if (sortMode.value === 'score') await loadCurrentView()
|
||||
} catch (e: any) {
|
||||
evalError.value = e?.toString() ?? t('ideas.evalFailed')
|
||||
console.error(t('ideas.evalFailed'), e)
|
||||
|
||||
+15
-5
@@ -16,7 +16,7 @@
|
||||
<!-- 错误条:消费 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>
|
||||
<button class="error-dismiss" @click="clearError">✕</button>
|
||||
<button class="error-dismiss" @click="clearError" :aria-label="$t('common.close')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- 顶层 Tab: 知识库 / 审核收件箱 -->
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="listItems.length === 0" class="list-empty">
|
||||
<div v-if="listItems.length === 0 && !store.loading" class="list-empty">
|
||||
<div>{{ topTab === 'inbox' ? $t('knowledge.emptyInbox') : topTab === 'rejected' ? $t('knowledge.emptyRejected') : topTab === 'archived' ? $t('knowledge.emptyArchived') : $t('knowledge.emptyLibrary') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,6 +113,9 @@
|
||||
@resubmit="resubmitCurrent"
|
||||
/>
|
||||
|
||||
<!-- 详情加载中(切换知识时清空旧详情,避免残留上一条内容;失败走 toast,不再静默) -->
|
||||
<div v-else-if="detailLoading" class="detail-empty">{{ $t('common.loading') }}</div>
|
||||
|
||||
<div v-else class="detail-empty">
|
||||
<div class="detail-empty-icon">👈</div>
|
||||
<div>{{ $t('knowledge.detailEmptyHint') }}</div>
|
||||
@@ -260,16 +263,23 @@ function getCategoryCount(key: string): number {
|
||||
// ===== 详情选中 + 加载 =====
|
||||
const selectedId = ref<string | null>(null)
|
||||
const detail = ref<KnowledgeDetailPayload | null>(null)
|
||||
// 详情加载态:切换知识期间显示 loading,避免旧详情残留;加载失败 toast 反馈
|
||||
const detailLoading = ref(false)
|
||||
// Markdown 渲染器预热保留在父级(模块单例,与 AiChat/TaskDetail/KnowledgeDetail 共享),
|
||||
// 详情内容渲染由 KnowledgeDetail 子组件通过 useRendered 消费,此处仅 ensureLoaded 预热不阻塞首屏
|
||||
const { ensureLoaded } = useRendered(() => '')
|
||||
|
||||
async function selectKnowledge(id: string) {
|
||||
selectedId.value = id
|
||||
detailLoading.value = true
|
||||
detail.value = null // 清旧详情驱动 loading 态(否则残留上一条内容)
|
||||
try {
|
||||
detail.value = await store.getDetail(id)
|
||||
} catch (e: any) {
|
||||
console.error('加载详情失败', e)
|
||||
// 加载失败:详情保持空态 + toast(此前静默 console.error,用户看不到失败)
|
||||
showToast(e?.toString() ?? t('knowledge.err.loadFailed'), 'error')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,8 +479,8 @@ onMounted(() => {
|
||||
} else {
|
||||
store.loadList()
|
||||
}
|
||||
// candidates badge 始终预加载(顶部 Tab 计数用)
|
||||
store.loadCandidates()
|
||||
// candidates badge 始终预加载(顶部 Tab 计数用);inbox 视图上面分支已加载,跳过避免重复请求
|
||||
if (topTab.value !== 'inbox') store.loadCandidates()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -317,6 +317,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="task-list">
|
||||
<div v-if="tasksLoading" class="empty-hint">{{ $t('common.loading') }}</div>
|
||||
<div
|
||||
class="task-card"
|
||||
v-for="task in filteredProjectTasks"
|
||||
@@ -346,7 +347,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
|
||||
<div v-if="!tasksLoading && projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
|
||||
<!-- 批量操作栏(选中任务后浮现) -->
|
||||
<TaskBatchBar
|
||||
v-if="selection.count > 0"
|
||||
@@ -611,12 +612,15 @@ const assessmentLabel = (recommendation: string): string =>
|
||||
// 改为按 project_id 走后端 list_tasks {project_id} 独立拉取,存本地 ref。
|
||||
// TaskQuery.project_id 命中 idx_tasks_project_id(见 api/types.ts:191)。
|
||||
const projectTasks = ref<TaskRecord[]>([])
|
||||
// 概览任务列表加载态(O8):加载中显占位,避免与「空」提示混淆(此前长度恒 0 → 首屏先闪空态)
|
||||
const tasksLoading = ref(false)
|
||||
// A4[PD-P2-2]:请求序号守卫 —— 快速切项目时旧请求晚到不覆盖新项目数据(闭包捕获 projectId,
|
||||
// resolve 后比对当前 projectId 相同才赋值)。
|
||||
let taskReqSeq = 0
|
||||
async function loadProjectTasks() {
|
||||
const seq = ++taskReqSeq
|
||||
const pid = projectId.value
|
||||
tasksLoading.value = true
|
||||
try {
|
||||
const list = await taskApi.list({ project_id: pid })
|
||||
if (seq !== taskReqSeq || pid !== projectId.value) return
|
||||
@@ -625,6 +629,8 @@ async function loadProjectTasks() {
|
||||
if (seq !== taskReqSeq || pid !== projectId.value) return
|
||||
console.error('加载项目任务失败:', e)
|
||||
projectTasks.value = []
|
||||
} finally {
|
||||
if (seq === taskReqSeq) tasksLoading.value = false
|
||||
}
|
||||
}
|
||||
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
class="view-toggle-btn"
|
||||
:class="{ active: viewMode === 'list' }"
|
||||
:title="$t('projects.viewList')"
|
||||
:aria-label="$t('projects.viewList')"
|
||||
@click="viewMode = 'list'"
|
||||
>☰</button>
|
||||
<button
|
||||
class="view-toggle-btn"
|
||||
:class="{ active: viewMode === 'card' }"
|
||||
:title="$t('projects.viewCard')"
|
||||
:aria-label="$t('projects.viewCard')"
|
||||
@click="viewMode = 'card'"
|
||||
>▦</button>
|
||||
</div>
|
||||
@@ -28,7 +30,7 @@
|
||||
<!-- 错误条:消费 store.error(加载/创建/删除失败对用户可见,对齐 Knowledge error-banner) -->
|
||||
<div v-if="store.error" class="error-banner">
|
||||
<span class="error-text">{{ store.error }}</span>
|
||||
<button class="error-dismiss" @click="store.clearError()">✕</button>
|
||||
<button class="error-dismiss" @click="store.clearError()" :aria-label="$t('common.close')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- 新建项目模态框 -->
|
||||
@@ -50,14 +52,14 @@
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="pickDir">{{ $t('projects.selectDir') }}</button>
|
||||
<button v-if="newPath" class="btn btn-ghost btn-sm" type="button" @click="clearDir">{{ $t('projects.clearDir') }}</button>
|
||||
</div>
|
||||
<div v-if="pathWarning" class="path-warning">⚠ {{ pathWarning }}</div>
|
||||
<div v-if="pathWarning" class="path-warning"><span aria-hidden="true">⚠</span> {{ pathWarning }}</div>
|
||||
<div v-if="newPath" class="scan-row">
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="aiScan" :disabled="scanning">
|
||||
{{ scanning ? $t('projects.aiScanning') : $t('projects.aiScanFill') }}
|
||||
</button>
|
||||
<span v-if="scanProjectType" class="tech-tag">{{ scanProjectType }}</span>
|
||||
</div>
|
||||
<div v-if="scanError" class="path-warning">⚠ {{ scanError }}</div>
|
||||
<div v-if="scanError" class="path-warning"><span aria-hidden="true">⚠</span> {{ scanError }}</div>
|
||||
<div v-if="detectedStack.length" class="card-tags" style="margin-top: 8px">
|
||||
<span class="tech-tag" v-for="t in detectedStack" :key="t">{{ t }}</span>
|
||||
</div>
|
||||
@@ -129,7 +131,7 @@
|
||||
|
||||
<!-- 扫描结果表格(只读预览 + 勾选) -->
|
||||
<div v-if="importScanning" class="import-status">{{ $t('projects.importScanning') }}</div>
|
||||
<div v-else-if="importError" class="path-warning">⚠ {{ importError }}</div>
|
||||
<div v-else-if="importError" class="path-warning"><span aria-hidden="true">⚠</span> {{ importError }}</div>
|
||||
<div v-else-if="scannedItems.length === 0 && importRootPath" class="import-status">{{ $t('projects.importEmpty') }}</div>
|
||||
|
||||
<div v-if="scannedItems.length" class="import-table-wrap">
|
||||
|
||||
@@ -167,7 +167,10 @@
|
||||
v-for="child in children"
|
||||
:key="child.id"
|
||||
class="subtask-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="router.push(`/tasks/${child.id}`)"
|
||||
@keydown="onSubtaskKeydown($event, child)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -179,7 +182,7 @@
|
||||
<span class="subtask-title">{{ child.title }}</span>
|
||||
<span class="status-tag" :class="taskStatusClass(child.status)">{{ $t(taskStatusLabel(child.status)) }}</span>
|
||||
<span class="priority-badge" :class="priorityClass(child.priority)">{{ priorityLabel(child.priority) }}</span>
|
||||
<button class="subtask-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleChildMenu(child.id)">⚙️</button>
|
||||
<button class="subtask-quick-btn" :title="$t('tasks.quickActions')" :aria-label="$t('tasks.quickActions')" :aria-expanded="childMenuId === child.id" @click.stop="toggleChildMenu(child.id)">⚙️</button>
|
||||
<!-- 子任务行快捷菜单(复用列表页 quickStatuses/quickPriorities 模式,advance 后刷新子列表) -->
|
||||
<div v-if="childMenuId === child.id" class="quick-menu" @click.stop>
|
||||
<div class="quick-menu-section">
|
||||
@@ -338,6 +341,18 @@ function toggleChildMenu(id: string) {
|
||||
}
|
||||
function closeChildMenu() { childMenuId.value = null }
|
||||
|
||||
/** 子任务行键盘可达(role=button tabindex=0):Enter/Space 等价点击进入任务详情。
|
||||
* 仅焦点落在行 div 自身时处理——焦点在内部勾选框/快捷按钮时 target≠currentTarget,
|
||||
* 避免与其原生语义(空格勾选、按钮激活)双触发。 */
|
||||
function onSubtaskKeydown(e: KeyboardEvent, child: TaskRecord) {
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
router.push(`/tasks/${child.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 复用列表页 quickStatuses/quickPriorities 模式(子任务行快捷推进)。
|
||||
// 基础列表 = TASK_STATUS_ORDER 全 8 态(含 deferred/cancelled,快捷菜单经 ADVANCE_MAP 过滤后只显合法项)。
|
||||
const STATUS_FILTER_META: Record<string, { icon: string; labelKey: string }> = {
|
||||
|
||||
+23
-6
@@ -17,7 +17,7 @@
|
||||
<!-- 错误条:消费 store.error(对齐 Knowledge/Projects error-banner,不整表替换列表) -->
|
||||
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
|
||||
<span class="error-text">{{ store.error }}</span>
|
||||
<button class="error-dismiss" @click="store.clearError()">✕</button>
|
||||
<button class="error-dismiss" @click="store.clearError()" :aria-label="$t('common.close')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏(紧凑下拉) -->
|
||||
@@ -91,7 +91,15 @@
|
||||
class="task-group"
|
||||
:class="{ collapsed: collapsedGroups.has(group.projectName) }"
|
||||
>
|
||||
<div class="group-header" @click="toggleGroup(group.projectName)">
|
||||
<div
|
||||
class="group-header"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="!collapsedGroups.has(group.projectName)"
|
||||
@click="toggleGroup(group.projectName)"
|
||||
@keydown.enter.prevent="toggleGroup(group.projectName)"
|
||||
@keydown.space.prevent="toggleGroup(group.projectName)"
|
||||
>
|
||||
<span class="group-chevron">{{ collapsedGroups.has(group.projectName) ? '▸' : '▾' }}</span>
|
||||
<span class="group-icon">{{ group.icon }}</span>
|
||||
<h2 class="group-name">{{ group.projectName }}</h2>
|
||||
@@ -140,7 +148,7 @@
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<span class="status-tag" :class="taskStatusClass(row.task.status)">{{ $t(statusLabel(row.task.status)) }}</span>
|
||||
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)">⚙️</button>
|
||||
<button class="task-quick-btn" :title="$t('tasks.quickActions')" :aria-label="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)">⚙️</button>
|
||||
<div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||
@@ -197,7 +205,7 @@
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<span class="status-tag" :class="taskStatusClass(child.status)">{{ $t(statusLabel(child.status)) }}</span>
|
||||
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(child.id)">⚙️</button>
|
||||
<button class="task-quick-btn" :title="$t('tasks.quickActions')" :aria-label="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(child.id)">⚙️</button>
|
||||
<div v-if="quickMenuId === child.id" class="quick-menu" @click.stop>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||
@@ -245,7 +253,7 @@
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<span class="status-tag" :class="taskStatusClass(row.task.status)">{{ $t(statusLabel(row.task.status)) }}</span>
|
||||
<button class="task-quick-btn" :title="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)">⚙️</button>
|
||||
<button class="task-quick-btn" :title="$t('tasks.quickActions')" :aria-label="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)">⚙️</button>
|
||||
<div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||
@@ -298,7 +306,14 @@
|
||||
:title="parentIdSet.has(task.id) ? $t('tasks.kanban.parentNoDrag') : undefined"
|
||||
>
|
||||
<div class="task-card-top">
|
||||
<span class="task-card-title" @click.stop="openDrawer(task.id)">{{ task.title }}</span>
|
||||
<span
|
||||
class="task-card-title"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click.stop="openDrawer(task.id)"
|
||||
@keydown.enter.prevent="openDrawer(task.id)"
|
||||
@keydown.space.prevent="openDrawer(task.id)"
|
||||
>{{ task.title }}</span>
|
||||
</div>
|
||||
<div class="task-card-meta">
|
||||
<span v-if="task.parent_id" class="parent-tag" :title="parentTitle(task.parent_id)">⤷ {{ parentTitle(task.parent_id) }}</span>
|
||||
@@ -1241,6 +1256,8 @@ onUnmounted(() => {
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.filter-select:hover { border-color: var(--df-accent); }
|
||||
/* 聚焦可见环:仅 hover 有变化、聚焦态无边框变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环 */
|
||||
.filter-select:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||
.filter-chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user