优化: 审批弹窗全局化与体验打磨
This commit is contained in:
+27
@@ -38,6 +38,15 @@
|
||||
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)。
|
||||
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责。 -->
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -50,6 +59,7 @@ import { useAppSettingsStore } from './stores/appSettings'
|
||||
import i18n from './i18n'
|
||||
import AiChat from './components/AiChat.vue'
|
||||
import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
|
||||
import ApprovalDialog from './components/project/ApprovalDialog.vue'
|
||||
import ErrorBoundary from './components/ErrorBoundary.vue'
|
||||
import AppLayout from './components/layout/AppLayout.vue'
|
||||
import { startApprovalPopup } from '@/composables/ai/useApprovalPopup'
|
||||
@@ -91,6 +101,23 @@ watch(() => projectStore.error, (err) => {
|
||||
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) => {
|
||||
if (!['/', '/ai-home', '/ai-detached'].includes(path) && aiStore.state.maximized) {
|
||||
|
||||
@@ -322,9 +322,11 @@ const emit = defineEmits<{
|
||||
(e: 'promote'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'status-change', value: IdeaStatus): void
|
||||
(e: 'update-desc', value: string): void
|
||||
(e: 'update-related', ids: string[]): void
|
||||
(e: 'update-tags', tags: string): void
|
||||
// WK-16:返 Promise<boolean> 让子组件 await 知成功/失败。
|
||||
// true=落库成功(关编辑态);false=失败(保持编辑态,toast 由父组件处理)。
|
||||
(e: 'update-desc', value: string): Promise<boolean>
|
||||
(e: 'update-related', ids: string[]): Promise<void>
|
||||
(e: 'update-tags', tags: string): Promise<boolean>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -341,16 +343,15 @@ function startEdit() {
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
// P1-⑥ saveEdit 改异步:emit 后父组件 store.updateIdea 是 Promise,但 emit 不回传结果;
|
||||
// 这里用本地 savingDesc 包裹一个最小延迟窗口(等待父组件 patch 同步 props.idea.description
|
||||
// 完成),保证 UI 反馈一致。父组件 updateIdea 失败会 throw 但不冒泡回子组件(沿用既有契约)。
|
||||
// P1-⑥ saveEdit 改异步:emit 返回 Promise<boolean>(父组件 store.updateIdea 落库结果),
|
||||
// 仅成功时关编辑态;失败保持编辑态 + toast 由父组件处理(WK-16)。
|
||||
async function saveEdit() {
|
||||
if (savingDesc.value) return
|
||||
savingDesc.value = true
|
||||
try {
|
||||
emit('update-desc', editDesc.value)
|
||||
editing.value = false
|
||||
// 等下一帧让父组件 patch 生效再释放(微观反馈,失败也释放不卡死)
|
||||
const ok = await emit('update-desc', editDesc.value)
|
||||
if (ok) editing.value = false
|
||||
// 成功后等下一帧让父组件 patch 生效再释放(微观反馈,失败也释放不卡死)
|
||||
await nextTick()
|
||||
} finally {
|
||||
savingDesc.value = false
|
||||
@@ -501,8 +502,9 @@ async function saveTagsEdit() {
|
||||
savingTags.value = true
|
||||
try {
|
||||
const tags = tagsEditInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
emit('update-tags', JSON.stringify(tags))
|
||||
tagsEditing.value = false
|
||||
// WK-16:emit 返回落库结果,失败保持编辑态(成功才关),toast 由父组件处理
|
||||
const ok = await emit('update-tags', JSON.stringify(tags))
|
||||
if (ok) tagsEditing.value = false
|
||||
await nextTick()
|
||||
} finally {
|
||||
savingTags.value = false
|
||||
@@ -545,13 +547,42 @@ interface AdversarialEval {
|
||||
}
|
||||
|
||||
// 对抗式评估数据持久化在 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>(() => {
|
||||
if (!props.idea.ai_analysis) return null
|
||||
let raw: any
|
||||
try {
|
||||
return JSON.parse(props.idea.ai_analysis) as AdversarialEval
|
||||
raw = JSON.parse(props.idea.ai_analysis)
|
||||
} catch {
|
||||
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
|
||||
|
||||
@@ -49,6 +49,13 @@ export default {
|
||||
decided: {
|
||||
human: 'Human',
|
||||
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
|
||||
tabAudit: 'Approval History',
|
||||
@@ -59,5 +66,10 @@ export default {
|
||||
convTruncated: 'Content truncated (expand to view full conversation)',
|
||||
convSelectHint: '← Select a conversation to view full interaction log',
|
||||
convReasoning: 'Reasoning (thinking)',
|
||||
// SA-2: conversation role labels (previously hardcoded Chinese, now i18n)
|
||||
convRoleUser: '🧑 User',
|
||||
convRoleAssistant: '🤖 Assistant',
|
||||
convRoleTool: '🔧 Tool',
|
||||
convRoleSystem: '⚙️ System',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ export default {
|
||||
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
|
||||
adversarialTitle: '⚖️ Adversarial Evaluation',
|
||||
evalModeHeuristic: 'Heuristic',
|
||||
|
||||
@@ -49,6 +49,13 @@ export default {
|
||||
decided: {
|
||||
human: '人工',
|
||||
auto: '自动',
|
||||
// SA-1:补齐后端 decided_by 全部来源(审计记录不同决策路径,精确区分而非笼统 'auto')
|
||||
auto_trust: '自动(信任)',
|
||||
auto_dedup: '自动(去重)',
|
||||
cache_hit: '自动(缓存)',
|
||||
auto_blacklist: '自动(黑名单)',
|
||||
auto_takeover_medium: '自动(中风险接管)',
|
||||
auto_takeover_all: '自动(全接管)',
|
||||
},
|
||||
// Tab 导航
|
||||
tabAudit: '审批历史',
|
||||
@@ -59,5 +66,10 @@ export default {
|
||||
convTruncated: '内容过长已截断(展开查看完整对话)',
|
||||
convSelectHint: '← 选择左侧一次对话查看完整交互日志',
|
||||
convReasoning: '推理过程(thinking)',
|
||||
// SA-2:对话角色标签(原硬编码 '🧑 用户' 等,现 i18n 化)
|
||||
convRoleUser: '🧑 用户',
|
||||
convRoleAssistant: '🤖 助手',
|
||||
convRoleTool: '🔧 工具',
|
||||
convRoleSystem: '⚙️ 系统',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ export default {
|
||||
relateFailed: '更新关联关系失败',
|
||||
},
|
||||
|
||||
// 操作反馈 toast(WK-16/18:成功/失败轻提示,对齐 Knowledge.createdToast)
|
||||
createdToast: '✅ 灵感已创建',
|
||||
descSaved: '✅ 描述已保存',
|
||||
descSaveFailed: '描述保存失败',
|
||||
tagsSaved: '✅ 标签已保存',
|
||||
tagsSaveFailed: '标签保存失败',
|
||||
|
||||
// 对抗式评估
|
||||
adversarialTitle: '⚖️ 对抗式评估',
|
||||
evalModeHeuristic: '启发式',
|
||||
|
||||
+11
-3
@@ -62,21 +62,29 @@ const ASSESSMENT_CLASS_MAP: Record<string, string> = {
|
||||
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
|
||||
/**
|
||||
* recommendation 字符串 → i18n 本地化标签。
|
||||
* 命中 ideas.assessment.{recommendation} 则返回译文,否则回退到原始 recommendation。
|
||||
*
|
||||
* WK-15:非字符串/空 → 回退占位符 '—'(避免模板渲染 undefined),与详情页其他空值一致。
|
||||
* @param t vue-i18n 的 t 函数(useI18n().t),由调用方注入避免本模块耦合 i18n 实例
|
||||
*/
|
||||
export function assessmentLabel(
|
||||
t: ComposerTranslation,
|
||||
recommendation: string,
|
||||
recommendation: string | null | undefined,
|
||||
): string {
|
||||
if (typeof recommendation !== 'string' || recommendation.length === 0) return '—'
|
||||
const key = `ideas.assessment.${recommendation}`
|
||||
const translated = t(key)
|
||||
return translated === key ? recommendation : translated
|
||||
|
||||
+10
-1
@@ -133,7 +133,7 @@
|
||||
<div v-for="(msg, mi) in convMessages" :key="mi" class="conv-msg" :class="'conv-msg--' + msg.role">
|
||||
<!-- 头部:角色 + model + 时间 + token -->
|
||||
<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.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>
|
||||
@@ -419,6 +419,15 @@ function decidedClass(d: string): string {
|
||||
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(() => {
|
||||
void load()
|
||||
if (activeTab.value === 'conversations') void loadConversations()
|
||||
|
||||
+28
-8
@@ -362,14 +362,18 @@ async function confirmCapture() {
|
||||
try {
|
||||
// tags 逗号分隔 → JSON 字符串(对标 KnowledgeDetail.vue saveEdit split+trim+filter 模式)
|
||||
const tags = newIdeaTags.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
await store.createIdea({
|
||||
const created = await store.createIdea({
|
||||
title: newIdeaTitle.value.trim(),
|
||||
description: newIdeaDesc.value.trim() || undefined,
|
||||
priority: newIdeaPriority.value,
|
||||
tags: JSON.stringify(tags),
|
||||
source: newIdeaSource.value.trim() || undefined,
|
||||
})
|
||||
showCaptureModal.value = false
|
||||
// WK-18:createIdea 返 null=失败(state.error 已置,顶部 error-banner 展示);成功关弹窗 + toast
|
||||
if (created) {
|
||||
showCaptureModal.value = false
|
||||
Message.success(t('ideas.createdToast'))
|
||||
}
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
@@ -449,9 +453,17 @@ async function onStatusChange(newStatus: IdeaStatus) {
|
||||
}
|
||||
|
||||
// 接收 IdeaDetail 子组件 emit 的 'update-desc'(描述编辑保存)
|
||||
async function onUpdateDesc(desc: string) {
|
||||
if (!currentIdea.value) return
|
||||
await store.updateIdea(currentIdea.value.id, 'description', desc)
|
||||
// WK-16:返 boolean(成功 true/失败 false),失败 toast;子组件据 false 保持编辑态。
|
||||
async function onUpdateDesc(desc: string): Promise<boolean> {
|
||||
if (!currentIdea.value) return false
|
||||
try {
|
||||
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'(关联灵感编辑保存,双向同步)
|
||||
@@ -463,9 +475,17 @@ async function onUpdateRelated(ids: string[]) {
|
||||
}
|
||||
|
||||
// 接收 IdeaDetail 子组件 emit 的 'update-tags'(标签编辑保存)
|
||||
async function onUpdateTags(tags: string) {
|
||||
if (!currentIdea.value) return
|
||||
await store.updateIdea(currentIdea.value.id, 'tags', tags)
|
||||
// WK-16:返 boolean(成功 true/失败 false),失败 toast;子组件据 false 保持编辑态。
|
||||
async function onUpdateTags(tags: string): Promise<boolean> {
|
||||
if (!currentIdea.value) return false
|
||||
try {
|
||||
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 () => {
|
||||
|
||||
@@ -359,14 +359,6 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 人工审批对话框(抽离至 components/project/ApprovalDialog.vue)-->
|
||||
<ApprovalDialog
|
||||
:visible="showApprovalDialog"
|
||||
:store="store"
|
||||
@cancel="handleCancelApproval"
|
||||
@later="showApprovalDialog = false"
|
||||
/>
|
||||
|
||||
<!-- 确认弹层(删除/重定位确认,替代原生 window.confirm/alert) -->
|
||||
<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 { projectStatusLabel, taskStatusLabel, taskStatusClass } from '../constants/project'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ApprovalDialog from '@/components/project/ApprovalDialog.vue'
|
||||
import FileExplorer from '@/components/project/FileExplorer.vue'
|
||||
import DependencyGraph from '@/components/project/DependencyGraph.vue'
|
||||
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
|
||||
@@ -442,7 +433,6 @@ const router = useRouter()
|
||||
const store = useProjectStore()
|
||||
const { t, locale } = useI18n()
|
||||
const logListRef = ref<HTMLElement | null>(null)
|
||||
const showApprovalDialog = ref(false)
|
||||
|
||||
// A1[PD-P1-4]:任务列表随 AI 改动刷新 —— 本地 df-data-changed 监听(entity=task)重拉 loadProjectTasks。
|
||||
// 对齐 TaskDetail B-260616-18 模式:onMounted 注册、onBeforeUnmount 释放;连发事件防抖合并,
|
||||
@@ -876,12 +866,8 @@ async function handleImportDir() {
|
||||
// 审批对话框 UI + 单/多选决策逻辑抽离至 components/project/ApprovalDialog.vue
|
||||
// handleApproval/handleApprovalMulti/isMultipleSelect/multiDecisions 已迁移;
|
||||
// submitting(UX-260618-17)在子组件自治,父级 submitting 仅用于 submitNewTask
|
||||
|
||||
// ── 取消审批(调 cancel_workflow_node IPC 置节点 Cancelled)──
|
||||
async function handleCancelApproval() {
|
||||
await store.cancelHumanApproval()
|
||||
showApprovalDialog.value = false
|
||||
}
|
||||
// P0-C:ApprovalDialog + watch pendingApproval + handleCancelApproval 已提至 App.vue(全局),
|
||||
// TaskDetail 等无 ApprovalDialog 的页面自动受益(原局部渲染致 HumanNode 超时失败)
|
||||
|
||||
// ── 删除项目(软删 → 回收站,可恢复)──
|
||||
async function handleDelete() {
|
||||
@@ -897,13 +883,6 @@ watch(formattedEvents, () => {
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user