优化: AI Chat全栈多批审查修复与架构清理(risk_level清理/路由解耦/工具渲染/测试补测/死代码)
This commit is contained in:
@@ -1,31 +1,31 @@
|
||||
<template>
|
||||
<div class="audit-log">
|
||||
<header class="page-header">
|
||||
<h1>审批历史</h1>
|
||||
<h1>{{ t('auditLog.title') }}</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="reload">刷新</button>
|
||||
<button class="btn btn-ghost" @click="reload">{{ t('auditLog.refresh') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="page-desc">AI 工具调用审计记录(按请求时间倒序)。参数与结果仅展示摘要,完整数据留存本地数据库。</p>
|
||||
<p class="page-desc">{{ t('auditLog.desc') }}</p>
|
||||
|
||||
<!-- 状态条 -->
|
||||
<div v-if="loading" class="empty-state">加载中…</div>
|
||||
<div v-if="loading" class="empty-state">{{ t('auditLog.loading') }}</div>
|
||||
<div v-else-if="errorMsg" class="empty-state">{{ errorMsg }}</div>
|
||||
<div v-else-if="records.length === 0" class="empty-state">暂无审计记录</div>
|
||||
<div v-else-if="records.length === 0" class="empty-state">{{ t('auditLog.empty') }}</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div v-else class="audit-table-wrap">
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-time">请求时间</th>
|
||||
<th class="col-tool">工具</th>
|
||||
<th class="col-risk">风险</th>
|
||||
<th class="col-status">状态</th>
|
||||
<th class="col-decided">决策者</th>
|
||||
<th class="col-args">参数摘要</th>
|
||||
<th class="col-result">结果摘要</th>
|
||||
<th class="col-time">{{ t('auditLog.col.time') }}</th>
|
||||
<th class="col-tool">{{ t('auditLog.col.tool') }}</th>
|
||||
<th class="col-risk">{{ t('auditLog.col.risk') }}</th>
|
||||
<th class="col-status">{{ t('auditLog.col.status') }}</th>
|
||||
<th class="col-decided">{{ t('auditLog.col.decided') }}</th>
|
||||
<th class="col-args">{{ t('auditLog.col.args') }}</th>
|
||||
<th class="col-result">{{ t('auditLog.col.result') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -54,18 +54,21 @@
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="!loading && !errorMsg" class="pager">
|
||||
<button class="btn btn-ghost" :disabled="offset === 0" @click="prevPage">上一页</button>
|
||||
<span class="pager-info">第 {{ page }} 页{{ hasMore ? '' : '(末页)' }}</span>
|
||||
<button class="btn btn-ghost" :disabled="!hasMore" @click="nextPage">下一页</button>
|
||||
<button class="btn btn-ghost" :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>
|
||||
<button class="btn btn-ghost" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { formatRelativeZh, formatDate } from '@/utils/time'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 本地 interface(不进 api/types.ts,避撞 B-42 agent)
|
||||
// 字段与后端 ToolExecutionDto(audit.rs) 一一对应
|
||||
interface ToolExecutionRecord {
|
||||
@@ -128,21 +131,15 @@ function nextPage() {
|
||||
}
|
||||
|
||||
// ── 标签 / 样式映射 ──
|
||||
// 文本走 i18n(auditLog.risk/status/decided);class 映射不国际化
|
||||
function riskLabel(r: string): string {
|
||||
return ({ low: '低', medium: '中', high: '高' } as Record<string, string>)[r] ?? r
|
||||
return t(`auditLog.risk.${r}`)
|
||||
}
|
||||
function riskClass(r: string): string {
|
||||
return ({ low: 'risk-low', medium: 'risk-medium', high: 'risk-high' } as Record<string, string>)[r] ?? 'risk-low'
|
||||
}
|
||||
function statusLabel(s: string): string {
|
||||
return ({
|
||||
pending: '待审批',
|
||||
approved: '已批准',
|
||||
rejected: '已拒绝',
|
||||
executing: '执行中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
} as Record<string, string>)[s] ?? s
|
||||
return t(`auditLog.status.${s}`)
|
||||
}
|
||||
function statusClass(s: string): string {
|
||||
return ({
|
||||
@@ -155,7 +152,7 @@ function statusClass(s: string): string {
|
||||
} as Record<string, string>)[s] ?? 'status-pending'
|
||||
}
|
||||
function decidedLabel(d: string): string {
|
||||
return d === 'human' ? '人工' : d === 'auto' ? '自动' : d
|
||||
return t(`auditLog.decided.${d}`)
|
||||
}
|
||||
function decidedClass(d: string): string {
|
||||
return d === 'human' ? 'decided-human' : 'decided-auto'
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<span class="idea-title">{{ idea.title }}</span>
|
||||
<span class="idea-score" :class="scoreClass(idea.score)">{{ idea.score ?? '-' }}</span>
|
||||
</div>
|
||||
<p class="idea-desc-preview">{{ idea.description?.slice(0, 60) ?? '' }}{{ idea.description && idea.description.length > 60 ? '...' : '' }}</p>
|
||||
<p class="idea-desc-preview">{{ stripMd(idea.description).slice(0, 60) }}{{ stripMd(idea.description).length > 60 ? '...' : '' }}</p>
|
||||
<div class="idea-card-footer">
|
||||
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
|
||||
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
|
||||
@@ -145,8 +145,8 @@
|
||||
<!-- 标签 -->
|
||||
<div class="detail-section">
|
||||
<h3>{{ $t('ideas.tagsTitle') }}</h3>
|
||||
<div class="tag-list" v-if="parseTags(currentIdea).length > 0">
|
||||
<span class="tag" v-for="tag in parseTags(currentIdea)" :key="tag">{{ tag }}</span>
|
||||
<div class="tag-list" v-if="parseTags(currentIdea.tags).length > 0">
|
||||
<span class="tag" v-for="tag in parseTags(currentIdea.tags)" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noTags') }}</div>
|
||||
</div>
|
||||
@@ -211,7 +211,9 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { parseTags } from '../stores/knowledge'
|
||||
import { formatDate } from '../utils/time'
|
||||
import { stripMd } from '../utils/markdown'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import { useRendered } from '../composables/useMarkdown'
|
||||
@@ -274,7 +276,7 @@ const filteredIdeas = computed(() => {
|
||||
ideas = ideas.filter(i =>
|
||||
i.title.toLowerCase().includes(query) ||
|
||||
(i.description && i.description.toLowerCase().includes(query)) ||
|
||||
(i.tags && JSON.parse(i.tags || '[]').some((tag: string) => tag.toLowerCase().includes(query)))
|
||||
parseTags(i.tags).some((tag: string) => tag.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -296,16 +298,6 @@ const currentStatus = computed(() => {
|
||||
return currentIdea.value?.status || 'draft'
|
||||
})
|
||||
|
||||
function parseTags(idea: IdeaRecord): string[] {
|
||||
if (!idea.tags) return []
|
||||
try {
|
||||
const parsed = JSON.parse(idea.tags)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
interface ScoreDimension { name: string; score: number }
|
||||
|
||||
function parseScores(idea: IdeaRecord): ScoreDimension[] {
|
||||
@@ -734,6 +726,7 @@ watch(() => route.params.id, (id) => {
|
||||
|
||||
.net-sentiment.positive { background: rgba(61, 219, 160, 0.15); color: var(--df-success); }
|
||||
.net-sentiment.negative { background: rgba(240, 101, 101, 0.15); color: var(--df-danger); }
|
||||
.net-sentiment.neutral { background: var(--df-bg-raised); color: var(--df-text-secondary); }
|
||||
|
||||
.summary {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<span class="kn-card-title">{{ item.title }}</span>
|
||||
<span class="kn-card-status" :class="'st-' + item.status">{{ statusLabel(item.status) }}</span>
|
||||
</div>
|
||||
<div class="kn-card-desc">{{ item.content }}</div>
|
||||
<div class="kn-card-desc">{{ stripMd(item.content) }}</div>
|
||||
<div class="kn-card-meta">
|
||||
<span class="meta-kind">{{ kindLabel(item.kind) }}</span>
|
||||
<span v-if="item.confidence" :class="'conf-' + item.confidence">{{ confidenceLabel(item.confidence) }}</span>
|
||||
@@ -238,10 +238,11 @@
|
||||
</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 { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '@/stores/knowledge'
|
||||
import { useRendered } from '@/composables/useMarkdown'
|
||||
import { stripMd } from '@/utils/markdown'
|
||||
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -277,6 +278,15 @@ function onSearchInput() {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 组件卸载清搜索 debounce timer:防卸载后 300ms 内 timer 触发,向单例 store 写入
|
||||
// 已离开视图的搜索结果/列表(污染下次进入时的列表态)。对齐 Settings.vue toast timer 清理模式。
|
||||
onUnmounted(() => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
function switchTopTab(tab: 'library' | 'inbox') {
|
||||
topTab.value = tab
|
||||
selectedId.value = null
|
||||
@@ -422,7 +432,9 @@ function parseContext(e: KnowledgeEventRecord): Record<string, any> {
|
||||
}
|
||||
|
||||
function refConvTitle(e: KnowledgeEventRecord): string {
|
||||
return parseContext(e).conv_title || parseContext(e).conv_id || ''
|
||||
// 解析一次复用:原两次 parseContext 调用会重复 JSON.parse,引用列表项越多浪费越大
|
||||
const ctx = parseContext(e)
|
||||
return ctx.conv_title || ctx.conv_id || ''
|
||||
}
|
||||
function refQuery(e: KnowledgeEventRecord): string {
|
||||
return parseContext(e).query || ''
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="card-desc">{{ project.description }}</p>
|
||||
<p class="card-desc">{{ stripMd(project.description) }}</p>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="card-footer">
|
||||
@@ -180,7 +180,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
@@ -188,6 +188,7 @@ import { useProjectStore } from '@/stores/project'
|
||||
import { projectApi } from '@/api'
|
||||
import { formatDate } from '@/utils/time'
|
||||
import { parseStack } from '@/utils/project'
|
||||
import { stripMd } from '@/utils/markdown'
|
||||
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '../constants/project'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
@@ -302,6 +303,15 @@ onMounted(() => {
|
||||
store.loadProjects()
|
||||
})
|
||||
|
||||
// 卸载清 toast timer:防组件已离开视图后 4s 内 timer 触发向已销毁组件写状态。
|
||||
// 对齐 Settings.vue 的 _toastTimer 清理模式(Knowledge.vue searchTimer 同模式)。
|
||||
onUnmounted(() => {
|
||||
if (_toastTimer) {
|
||||
clearTimeout(_toastTimer)
|
||||
_toastTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// ── 导入历史项目(F-260614-06 scan 第二步) ──
|
||||
const showImportModal = ref(false)
|
||||
const importRootPath = ref('')
|
||||
|
||||
@@ -194,6 +194,8 @@ const wfTotalNodes = ref(0)
|
||||
const wfDoneCount = ref(0)
|
||||
const wfRunningNode = ref<string>('')
|
||||
const wfResult = ref<'completed' | 'failed' | null>(null)
|
||||
// SW-260618-21: 终态提示 timer 引用,卸载时清理防写已销毁 ref(对齐 Projects.vue _toastTimer 模式)
|
||||
let _wfResultTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const wfProgressHint = computed(() => wfResult.value !== null)
|
||||
const wfCompletedHint = computed(() => wfResult.value === 'completed')
|
||||
@@ -408,8 +410,10 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) {
|
||||
wfRunningNode.value = ''
|
||||
wfResult.value = 'completed'
|
||||
// 终态提示保留 3s 后清空(任务本体由 df-data-changed 刷新,提示不影响数据)
|
||||
setTimeout(() => {
|
||||
if (_wfResultTimer) clearTimeout(_wfResultTimer)
|
||||
_wfResultTimer = setTimeout(() => {
|
||||
if (wfResult.value === 'completed') wfResult.value = null
|
||||
_wfResultTimer = null
|
||||
}, 3000)
|
||||
break
|
||||
}
|
||||
@@ -419,8 +423,10 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) {
|
||||
wfAdvancing.value = false
|
||||
wfRunningNode.value = ''
|
||||
wfResult.value = 'failed'
|
||||
setTimeout(() => {
|
||||
if (_wfResultTimer) clearTimeout(_wfResultTimer)
|
||||
_wfResultTimer = setTimeout(() => {
|
||||
if (wfResult.value === 'failed') wfResult.value = null
|
||||
_wfResultTimer = null
|
||||
}, 3000)
|
||||
}
|
||||
// node_failed: 单节点失败,工作流后续会发 workflow_failed,此处不提前改终态
|
||||
@@ -492,6 +498,8 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
|
||||
if (_unlistenWorkflowEvent) { _unlistenWorkflowEvent(); _unlistenWorkflowEvent = null }
|
||||
// SW-260618-21: 清终态提示 timer 防卸载后写已销毁 ref
|
||||
if (_wfResultTimer) { clearTimeout(_wfResultTimer); _wfResultTimer = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
:class="{ active: activeStatus === s.key }"
|
||||
@click="activeStatus = s.key"
|
||||
>
|
||||
{{ s.icon }} {{ s.label }}
|
||||
<span class="filter-btn-icon">{{ s.icon }}</span>
|
||||
<span class="filter-btn-label">{{ s.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +42,7 @@
|
||||
<!-- Y-2: loading/error/empty 兜底(参考 Projects.vue 空态写法) -->
|
||||
<div v-if="loading" class="empty-state">{{ $t('common.loading') }}</div>
|
||||
<div v-else-if="store.error" class="empty-state">{{ store.error }}</div>
|
||||
<div v-else-if="filteredGroups.length === 0" class="empty-state">暂无任务</div>
|
||||
<div v-else-if="filteredGroups.length === 0" class="empty-state">{{ $t('tasks.group.empty') }}</div>
|
||||
<template v-else>
|
||||
<section
|
||||
v-for="group in filteredGroups"
|
||||
@@ -287,6 +288,7 @@ onMounted(async () => {
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap; /* 窄屏按钮可换行,避免 nowrap 溢出 */
|
||||
gap: 6px;
|
||||
}
|
||||
.filter-label {
|
||||
@@ -295,6 +297,9 @@ onMounted(async () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
@@ -304,12 +309,22 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.filter-btn-icon { line-height: 1; } /* 图标基线对齐,避免行高跳动 */
|
||||
.filter-btn-label { white-space: nowrap; } /* 文字不内断行,换行只发生在按钮间 */
|
||||
.filter-btn:hover { background: rgba(108, 99, 255, 0.06); color: var(--df-text); }
|
||||
.filter-btn.active {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
/* 窄屏(手机宽):按钮内图标与文字改纵向排布,避免横向挤压致图标/文字在空格处错位断行 */
|
||||
@media (max-width: 480px) {
|
||||
.filter-btn {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 任务分组 ===== */
|
||||
.task-groups {
|
||||
|
||||
Reference in New Issue
Block a user