优化: 审批弹窗全局化与体验打磨

This commit is contained in:
lxy
2026-08-10 08:04:30 +08:00
parent dedb9a8862
commit 6141a67b0e
10 changed files with 159 additions and 47 deletions
+27
View File
@@ -38,6 +38,15 @@
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂) 仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责 --> 应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责 -->
<ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" /> <ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" />
<!-- 人工审批对话框(全局,任意路由响应 store.pendingApproval)
ProjectDetail.vue 提至 App.vue,TaskDetail 等无 ApprovalDialog 的页面自动受益 -->
<ApprovalDialog
:visible="showApprovalDialog"
:store="projectStore"
@cancel="handleCancelApproval"
@later="showApprovalDialog = false"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -50,6 +59,7 @@ import { useAppSettingsStore } from './stores/appSettings'
import i18n from './i18n' import i18n from './i18n'
import AiChat from './components/AiChat.vue' import AiChat from './components/AiChat.vue'
import ApprovalOverlay from './components/ai/ApprovalOverlay.vue' import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
import ApprovalDialog from './components/project/ApprovalDialog.vue'
import ErrorBoundary from './components/ErrorBoundary.vue' import ErrorBoundary from './components/ErrorBoundary.vue'
import AppLayout from './components/layout/AppLayout.vue' import AppLayout from './components/layout/AppLayout.vue'
import { startApprovalPopup } from '@/composables/ai/useApprovalPopup' import { startApprovalPopup } from '@/composables/ai/useApprovalPopup'
@@ -91,6 +101,23 @@ watch(() => projectStore.error, (err) => {
errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500) errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500)
}) })
// ── 人工审批对话框(全局,P0-C:工作流推进到 HumanNode 审批时,任意页面都应能响应)──
// 原 ProjectDetail 局部渲染 → TaskDetail 无 ApprovalDialog 致 store.pendingApproval 被设但
// 用户在 TaskDetail 页无法操作 → HumanNode 超时失败。提到 App.vue 全局化:任何路由响应审批。
// (系统级浮窗 useApprovalPopup 是窗口失焦时的提示;此处是聚焦时弹的对话框,二者职责互补)
const showApprovalDialog = ref(false)
watch(() => projectStore.pendingApproval, (newApproval) => {
if (newApproval) {
showApprovalDialog.value = true
}
}, { immediate: true })
// 取消审批(调 cancel_workflow_node IPC 置节点 Cancelled),与原 ProjectDetail.handleCancelApproval 对齐
async function handleCancelApproval() {
await projectStore.cancelHumanApproval()
showApprovalDialog.value = false
}
// 点击菜单自动退出最大化 → 切为分栏模式 // 点击菜单自动退出最大化 → 切为分栏模式
watch(() => route.path, (path) => { watch(() => route.path, (path) => {
if (!['/', '/ai-home', '/ai-detached'].includes(path) && aiStore.state.maximized) { if (!['/', '/ai-home', '/ai-detached'].includes(path) && aiStore.state.maximized) {
+43 -12
View File
@@ -322,9 +322,11 @@ const emit = defineEmits<{
(e: 'promote'): void (e: 'promote'): void
(e: 'delete'): void (e: 'delete'): void
(e: 'status-change', value: IdeaStatus): void (e: 'status-change', value: IdeaStatus): void
(e: 'update-desc', value: string): void // WK-16:返 Promise<boolean> 让子组件 await 知成功/失败。
(e: 'update-related', ids: string[]): void // true=落库成功(关编辑态);false=失败(保持编辑态,toast 由父组件处理)。
(e: 'update-tags', tags: string): void (e: 'update-desc', value: string): Promise<boolean>
(e: 'update-related', ids: string[]): Promise<void>
(e: 'update-tags', tags: string): Promise<boolean>
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@@ -341,16 +343,15 @@ function startEdit() {
editing.value = true editing.value = true
} }
// P1-⑥ saveEdit 改异步:emit 父组件 store.updateIdea 是 Promise,但 emit 不回传结果; // P1-⑥ saveEdit 改异步:emit 返回 Promise<boolean>(父组件 store.updateIdea 落库结果),
// 这里用本地 savingDesc 包裹一个最小延迟窗口(等待父组件 patch 同步 props.idea.description // 仅成功时关编辑态;失败保持编辑态 + toast 由父组件处理(WK-16)。
// 完成),保证 UI 反馈一致。父组件 updateIdea 失败会 throw 但不冒泡回子组件(沿用既有契约)。
async function saveEdit() { async function saveEdit() {
if (savingDesc.value) return if (savingDesc.value) return
savingDesc.value = true savingDesc.value = true
try { try {
emit('update-desc', editDesc.value) const ok = await emit('update-desc', editDesc.value)
editing.value = false if (ok) editing.value = false
// 等下一帧让父组件 patch 生效再释放(微观反馈,失败也释放不卡死) // 成功后等下一帧让父组件 patch 生效再释放(微观反馈,失败也释放不卡死)
await nextTick() await nextTick()
} finally { } finally {
savingDesc.value = false savingDesc.value = false
@@ -501,8 +502,9 @@ async function saveTagsEdit() {
savingTags.value = true savingTags.value = true
try { try {
const tags = tagsEditInput.value.split(',').map(t => t.trim()).filter(Boolean) const tags = tagsEditInput.value.split(',').map(t => t.trim()).filter(Boolean)
emit('update-tags', JSON.stringify(tags)) // WK-16:emit 返回落库结果,失败保持编辑态(成功才关),toast 由父组件处理
tagsEditing.value = false const ok = await emit('update-tags', JSON.stringify(tags))
if (ok) tagsEditing.value = false
await nextTick() await nextTick()
} finally { } finally {
savingTags.value = false savingTags.value = false
@@ -545,13 +547,42 @@ interface AdversarialEval {
} }
// 对抗式评估数据持久化在 ai_analysis JSON 字段中,前端解析渲染 // 对抗式评估数据持久化在 ai_analysis JSON 字段中,前端解析渲染
//
// WK-15:旧实现直接 JSON.parse 后强转 AdversarialEval,字段缺失/非数值时
// 模板对 undefined*100 调 .toFixed 显 NaN,assessmentClass 对 undefined 调
// .toLowerCase() 崩。这里在解析层补齐数值默认值(防御式归一化),模板直读不二次判空:
// - 数值字段缺失/非数值 → 0
// - recommendation/thesis 缺失 → ''(assessmentClass/assessmentLabel 已在源头对空串防御)
// - 数组字段缺失 → []
const adversarialEval = computed<AdversarialEval | null>(() => { const adversarialEval = computed<AdversarialEval | null>(() => {
if (!props.idea.ai_analysis) return null if (!props.idea.ai_analysis) return null
let raw: any
try { try {
return JSON.parse(props.idea.ai_analysis) as AdversarialEval raw = JSON.parse(props.idea.ai_analysis)
} catch { } catch {
return null return null
} }
if (typeof raw !== 'object' || raw === null) return null
const num = (v: unknown): number => typeof v === 'number' && !isNaN(v) ? v : 0
const str = (v: unknown): string => typeof v === 'string' ? v : ''
const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
const thesis = (v: unknown): { thesis: string; evidence: string[] } => {
const o = (typeof v === 'object' && v !== null) ? v as Record<string, unknown> : {}
return { thesis: str(o.thesis), evidence: strArr(o.evidence) }
}
return {
positive_strength: num(raw.positive_strength),
negative_strength: num(raw.negative_strength),
net_sentiment: num(raw.net_sentiment),
recommendation: str(raw.recommendation),
final_score: num(raw.final_score),
summary: str(raw.summary),
action_items: strArr(raw.action_items),
positive: thesis(raw.positive),
negative: thesis(raw.negative),
analyst: { summary: str((typeof raw.analyst === 'object' && raw.analyst !== null ? (raw.analyst as Record<string, unknown>).summary : undefined)) },
evaluated_by: typeof raw.evaluated_by === 'string' ? raw.evaluated_by : undefined,
}
}) })
// 评估深度标签动态化(P0):根据 evaluated_by 字段映射 i18n key // 评估深度标签动态化(P0):根据 evaluated_by 字段映射 i18n key
+12
View File
@@ -49,6 +49,13 @@ export default {
decided: { decided: {
human: 'Human', human: 'Human',
auto: 'Auto', auto: 'Auto',
// SA-1: cover all backend decided_by sources (different decision paths, more precise than generic 'auto')
auto_trust: 'Auto (trusted)',
auto_dedup: 'Auto (deduped)',
cache_hit: 'Auto (cached)',
auto_blacklist: 'Auto (blacklist)',
auto_takeover_medium: 'Auto (med-takeover)',
auto_takeover_all: 'Auto (full-takeover)',
}, },
// Tab navigation // Tab navigation
tabAudit: 'Approval History', tabAudit: 'Approval History',
@@ -59,5 +66,10 @@ export default {
convTruncated: 'Content truncated (expand to view full conversation)', convTruncated: 'Content truncated (expand to view full conversation)',
convSelectHint: '← Select a conversation to view full interaction log', convSelectHint: '← Select a conversation to view full interaction log',
convReasoning: 'Reasoning (thinking)', convReasoning: 'Reasoning (thinking)',
// SA-2: conversation role labels (previously hardcoded Chinese, now i18n)
convRoleUser: '🧑 User',
convRoleAssistant: '🤖 Assistant',
convRoleTool: '🔧 Tool',
convRoleSystem: '⚙️ System',
}, },
} }
+7
View File
@@ -30,6 +30,13 @@ export default {
relateFailed: 'Failed to update relations', relateFailed: 'Failed to update relations',
}, },
// Action feedback toasts (WK-16/18: success/failure light toast, aligns Knowledge.createdToast)
createdToast: '✅ Idea created',
descSaved: '✅ Description saved',
descSaveFailed: 'Failed to save description',
tagsSaved: '✅ Tags saved',
tagsSaveFailed: 'Failed to save tags',
// Adversarial evaluation // Adversarial evaluation
adversarialTitle: '⚖️ Adversarial Evaluation', adversarialTitle: '⚖️ Adversarial Evaluation',
evalModeHeuristic: 'Heuristic', evalModeHeuristic: 'Heuristic',
+12
View File
@@ -49,6 +49,13 @@ export default {
decided: { decided: {
human: '人工', human: '人工',
auto: '自动', auto: '自动',
// SA-1:补齐后端 decided_by 全部来源(审计记录不同决策路径,精确区分而非笼统 'auto')
auto_trust: '自动(信任)',
auto_dedup: '自动(去重)',
cache_hit: '自动(缓存)',
auto_blacklist: '自动(黑名单)',
auto_takeover_medium: '自动(中风险接管)',
auto_takeover_all: '自动(全接管)',
}, },
// Tab 导航 // Tab 导航
tabAudit: '审批历史', tabAudit: '审批历史',
@@ -59,5 +66,10 @@ export default {
convTruncated: '内容过长已截断(展开查看完整对话)', convTruncated: '内容过长已截断(展开查看完整对话)',
convSelectHint: '← 选择左侧一次对话查看完整交互日志', convSelectHint: '← 选择左侧一次对话查看完整交互日志',
convReasoning: '推理过程(thinking)', convReasoning: '推理过程(thinking)',
// SA-2:对话角色标签(原硬编码 '🧑 用户' 等,现 i18n 化)
convRoleUser: '🧑 用户',
convRoleAssistant: '🤖 助手',
convRoleTool: '🔧 工具',
convRoleSystem: '⚙️ 系统',
}, },
} }
+7
View File
@@ -30,6 +30,13 @@ export default {
relateFailed: '更新关联关系失败', relateFailed: '更新关联关系失败',
}, },
// 操作反馈 toast(WK-16/18:成功/失败轻提示,对齐 Knowledge.createdToast)
createdToast: '✅ 灵感已创建',
descSaved: '✅ 描述已保存',
descSaveFailed: '描述保存失败',
tagsSaved: '✅ 标签已保存',
tagsSaveFailed: '标签保存失败',
// 对抗式评估 // 对抗式评估
adversarialTitle: '⚖️ 对抗式评估', adversarialTitle: '⚖️ 对抗式评估',
evalModeHeuristic: '启发式', evalModeHeuristic: '启发式',
+11 -3
View File
@@ -62,21 +62,29 @@ const ASSESSMENT_CLASS_MAP: Record<string, string> = {
/** /**
* recommendation 字符串 → CSS badge 类。 * recommendation 字符串 → CSS badge 类。
* 未知值回退到 'conditional'(中性,对齐原两处实现)。 * 未知值(或非字符串/空)回退到 'conditional'(中性,对齐原两处实现)。
*
* WK-15:旧实现对 undefined/null 调 `.toLowerCase()` 崩。对抗评估 ai_analysis
* 可能未生成 recommendation 字段(老数据/降级),调用方传值时也可能漏判空。
* 这里在源头做防御:非字符串/空串统一回退 conditional。
*/ */
export function assessmentClass(recommendation: string): string { export function assessmentClass(recommendation: string | null | undefined): string {
if (typeof recommendation !== 'string' || recommendation.length === 0) return 'conditional'
return ASSESSMENT_CLASS_MAP[recommendation.toLowerCase()] ?? 'conditional' return ASSESSMENT_CLASS_MAP[recommendation.toLowerCase()] ?? 'conditional'
} }
/** /**
* recommendation 字符串 → i18n 本地化标签。 * recommendation 字符串 → i18n 本地化标签。
* 命中 ideas.assessment.{recommendation} 则返回译文,否则回退到原始 recommendation。 * 命中 ideas.assessment.{recommendation} 则返回译文,否则回退到原始 recommendation。
*
* WK-15:非字符串/空 → 回退占位符 '—'(避免模板渲染 undefined),与详情页其他空值一致。
* @param t vue-i18n 的 t 函数(useI18n().t),由调用方注入避免本模块耦合 i18n 实例 * @param t vue-i18n 的 t 函数(useI18n().t),由调用方注入避免本模块耦合 i18n 实例
*/ */
export function assessmentLabel( export function assessmentLabel(
t: ComposerTranslation, t: ComposerTranslation,
recommendation: string, recommendation: string | null | undefined,
): string { ): string {
if (typeof recommendation !== 'string' || recommendation.length === 0) return '—'
const key = `ideas.assessment.${recommendation}` const key = `ideas.assessment.${recommendation}`
const translated = t(key) const translated = t(key)
return translated === key ? recommendation : translated return translated === key ? recommendation : translated
+10 -1
View File
@@ -133,7 +133,7 @@
<div v-for="(msg, mi) in convMessages" :key="mi" class="conv-msg" :class="'conv-msg--' + msg.role"> <div v-for="(msg, mi) in convMessages" :key="mi" class="conv-msg" :class="'conv-msg--' + msg.role">
<!-- 头部:角色 + model + 时间 + token --> <!-- 头部:角色 + model + 时间 + token -->
<div class="conv-msg-head"> <div class="conv-msg-head">
<span class="conv-msg-role">{{ msg.role === 'user' ? '🧑 用户' : msg.role === 'assistant' ? '🤖 助手' : msg.role === 'tool' ? '🔧 工具' : '⚙️ 系统' }}</span> <span class="conv-msg-role">{{ convRoleLabel(msg.role) }}</span>
<span v-if="msg.model" class="conv-msg-model">{{ msg.model }}</span> <span v-if="msg.model" class="conv-msg-model">{{ msg.model }}</span>
<span v-if="msg.tool_call_id" class="conv-msg-toolname">tool_call: {{ msg.tool_call_id }}</span> <span v-if="msg.tool_call_id" class="conv-msg-toolname">tool_call: {{ msg.tool_call_id }}</span>
<span v-if="msg.timestamp" class="conv-msg-time">{{ formatDate(msg.timestamp) }}</span> <span v-if="msg.timestamp" class="conv-msg-time">{{ formatDate(msg.timestamp) }}</span>
@@ -419,6 +419,15 @@ function decidedClass(d: string): string {
return d === 'human' ? 'decided-human' : 'decided-auto' return d === 'human' ? 'decided-human' : 'decided-auto'
} }
// SA-2:对话角色 i18n 化(原硬编码 '🧑 用户'/'🤖 助手'/'🔧 工具'/'⚙️ 系统' 不随 locale)
// user/assistant/tool 映射固定 key,其他未知 role 回退到 '⚙️ 系统'(含 emoji,与其他四态视觉一致)
function convRoleLabel(role: string): string {
if (role === 'user') return t('auditLog.convRoleUser')
if (role === 'assistant') return t('auditLog.convRoleAssistant')
if (role === 'tool') return t('auditLog.convRoleTool')
return t('auditLog.convRoleSystem')
}
onMounted(() => { onMounted(() => {
void load() void load()
if (activeTab.value === 'conversations') void loadConversations() if (activeTab.value === 'conversations') void loadConversations()
+25 -5
View File
@@ -362,14 +362,18 @@ async function confirmCapture() {
try { try {
// tags JSON ( KnowledgeDetail.vue saveEdit split+trim+filter ) // tags JSON ( KnowledgeDetail.vue saveEdit split+trim+filter )
const tags = newIdeaTags.value.split(',').map(t => t.trim()).filter(Boolean) const tags = newIdeaTags.value.split(',').map(t => t.trim()).filter(Boolean)
await store.createIdea({ const created = await store.createIdea({
title: newIdeaTitle.value.trim(), title: newIdeaTitle.value.trim(),
description: newIdeaDesc.value.trim() || undefined, description: newIdeaDesc.value.trim() || undefined,
priority: newIdeaPriority.value, priority: newIdeaPriority.value,
tags: JSON.stringify(tags), tags: JSON.stringify(tags),
source: newIdeaSource.value.trim() || undefined, source: newIdeaSource.value.trim() || undefined,
}) })
// WK-18:createIdea null=(state.error , error-banner ); + toast
if (created) {
showCaptureModal.value = false showCaptureModal.value = false
Message.success(t('ideas.createdToast'))
}
} finally { } finally {
creating.value = false creating.value = false
} }
@@ -449,9 +453,17 @@ async function onStatusChange(newStatus: IdeaStatus) {
} }
// IdeaDetail emit 'update-desc'() // IdeaDetail emit 'update-desc'()
async function onUpdateDesc(desc: string) { // WK-16: boolean( true/ false), toast; false
if (!currentIdea.value) return async function onUpdateDesc(desc: string): Promise<boolean> {
if (!currentIdea.value) return false
try {
await store.updateIdea(currentIdea.value.id, 'description', desc) await store.updateIdea(currentIdea.value.id, 'description', desc)
Message.success(t('ideas.descSaved'))
return true
} catch (e: any) {
Message.error(e?.toString() ?? t('ideas.descSaveFailed'))
return false
}
} }
// IdeaDetail emit 'update-related'(,) // IdeaDetail emit 'update-related'(,)
@@ -463,9 +475,17 @@ async function onUpdateRelated(ids: string[]) {
} }
// IdeaDetail emit 'update-tags'() // IdeaDetail emit 'update-tags'()
async function onUpdateTags(tags: string) { // WK-16: boolean( true/ false), toast; false
if (!currentIdea.value) return async function onUpdateTags(tags: string): Promise<boolean> {
if (!currentIdea.value) return false
try {
await store.updateIdea(currentIdea.value.id, 'tags', tags) await store.updateIdea(currentIdea.value.id, 'tags', tags)
Message.success(t('ideas.tagsSaved'))
return true
} catch (e: any) {
Message.error(e?.toString() ?? t('ideas.tagsSaveFailed'))
return false
}
} }
onMounted(async () => { onMounted(async () => {
+2 -23
View File
@@ -359,14 +359,6 @@
</section> </section>
</div> </div>
<!-- 人工审批对话框(抽离至 components/project/ApprovalDialog.vue)-->
<ApprovalDialog
:visible="showApprovalDialog"
:store="store"
@cancel="handleCancelApproval"
@later="showApprovalDialog = false"
/>
<!-- 确认弹层删除/重定位确认替代原生 window.confirm/alert --> <!-- 确认弹层删除/重定位确认替代原生 window.confirm/alert -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="confirmState.dangerLabel" @result="answerConfirm" /> <ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="confirmState.dangerLabel" @result="answerConfirm" />
@@ -429,7 +421,6 @@ import { parseStack } from '@/utils/project'
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n, scoreTier } from '@/utils/ideaEval' import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n, scoreTier } from '@/utils/ideaEval'
import { projectStatusLabel, taskStatusLabel, taskStatusClass } from '../constants/project' import { projectStatusLabel, taskStatusLabel, taskStatusClass } from '../constants/project'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ApprovalDialog from '@/components/project/ApprovalDialog.vue'
import FileExplorer from '@/components/project/FileExplorer.vue' import FileExplorer from '@/components/project/FileExplorer.vue'
import DependencyGraph from '@/components/project/DependencyGraph.vue' import DependencyGraph from '@/components/project/DependencyGraph.vue'
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue' import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
@@ -442,7 +433,6 @@ const router = useRouter()
const store = useProjectStore() const store = useProjectStore()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const logListRef = ref<HTMLElement | null>(null) const logListRef = ref<HTMLElement | null>(null)
const showApprovalDialog = ref(false)
// A1[PD-P1-4]: AI df-data-changed (entity=task) loadProjectTasks // A1[PD-P1-4]: AI df-data-changed (entity=task) loadProjectTasks
// TaskDetail B-260616-18 :onMounted onBeforeUnmount ;, // TaskDetail B-260616-18 :onMounted onBeforeUnmount ;,
@@ -876,12 +866,8 @@ async function handleImportDir() {
// UI + / components/project/ApprovalDialog.vue // UI + / components/project/ApprovalDialog.vue
// handleApproval/handleApprovalMulti/isMultipleSelect/multiDecisions ; // handleApproval/handleApprovalMulti/isMultipleSelect/multiDecisions ;
// submitting(UX-260618-17), submitting submitNewTask // submitting(UX-260618-17), submitting submitNewTask
// P0-C:ApprovalDialog + watch pendingApproval + handleCancelApproval App.vue(),
// cancel_workflow_node IPC Cancelled // TaskDetail ApprovalDialog ( HumanNode )
async function handleCancelApproval() {
await store.cancelHumanApproval()
showApprovalDialog.value = false
}
// ( ,) // ( ,)
async function handleDelete() { async function handleDelete() {
@@ -897,13 +883,6 @@ watch(formattedEvents, () => {
if (logListRef.value) logListRef.value.scrollTop = logListRef.value.scrollHeight if (logListRef.value) logListRef.value.scrollTop = logListRef.value.scrollHeight
}) })
// (pendingApproval ; ApprovalDialog watch)
watch(() => store.pendingApproval, (newApproval) => {
if (newApproval) {
showApprovalDialog.value = true
}
}, { immediate: true })
// //
// formatDate ../utils/time Invalid Date // formatDate ../utils/time Invalid Date