新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地
This commit is contained in:
311
src/views/AuditLog.vue
Normal file
311
src/views/AuditLog.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="audit-log">
|
||||
<header class="page-header">
|
||||
<h1>审批历史</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="reload">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="page-desc">AI 工具调用审计记录(按请求时间倒序)。参数与结果仅展示摘要,完整数据留存本地数据库。</p>
|
||||
|
||||
<!-- 状态条 -->
|
||||
<div v-if="loading" class="empty-state">加载中…</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 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in records" :key="r.id">
|
||||
<td class="col-time">
|
||||
<div class="time-rel">{{ formatRelativeZh(r.requested_at) }}</div>
|
||||
<div class="time-abs">{{ formatDate(r.requested_at) }}</div>
|
||||
</td>
|
||||
<td class="col-tool"><code class="tool-name">{{ r.tool_name }}</code></td>
|
||||
<td class="col-risk">
|
||||
<span class="risk-badge" :class="riskClass(r.risk_level)">{{ riskLabel(r.risk_level) }}</span>
|
||||
</td>
|
||||
<td class="col-status">
|
||||
<span class="status-tag" :class="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||
</td>
|
||||
<td class="col-decided">
|
||||
<span v-if="r.decided_by" class="decided-tag" :class="decidedClass(r.decided_by)">{{ decidedLabel(r.decided_by) }}</span>
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { formatRelativeZh, formatDate } from '@/utils/time'
|
||||
|
||||
// 本地 interface(不进 api/types.ts,避撞 B-42 agent)
|
||||
// 字段与后端 ToolExecutionDto(audit.rs) 一一对应
|
||||
interface ToolExecutionRecord {
|
||||
id: string
|
||||
conversation_id: string | null
|
||||
tool_call_id: string
|
||||
tool_name: string
|
||||
arguments_brief: string
|
||||
result_brief: string | null
|
||||
status: string
|
||||
risk_level: string
|
||||
requested_at: string
|
||||
executed_at: string | null
|
||||
decided_by: string | null
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
const records = ref<ToolExecutionRecord[]>([])
|
||||
const offset = ref(0)
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
|
||||
// 下一页存在性:本页满 PAGE_SIZE 视为可能还有更多(hasMore);末页不足时点下一页会拉空,自动回退
|
||||
const hasMore = computed(() => records.value.length === PAGE_SIZE)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const list = await invoke<ToolExecutionRecord[]>('list_tool_executions', {
|
||||
limit: PAGE_SIZE,
|
||||
offset: offset.value,
|
||||
})
|
||||
records.value = list
|
||||
// 下一页拉到空数组 → 应回退到上一页(避免停在空页)
|
||||
if (list.length === 0 && offset.value > 0) {
|
||||
offset.value = Math.max(0, offset.value - PAGE_SIZE)
|
||||
await load()
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.value = String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
offset.value = 0
|
||||
void load()
|
||||
}
|
||||
function prevPage() {
|
||||
if (offset.value === 0) return
|
||||
offset.value = Math.max(0, offset.value - PAGE_SIZE)
|
||||
void load()
|
||||
}
|
||||
function nextPage() {
|
||||
offset.value += PAGE_SIZE
|
||||
void load()
|
||||
}
|
||||
|
||||
// ── 标签 / 样式映射 ──
|
||||
function riskLabel(r: string): string {
|
||||
return ({ low: '低', medium: '中', high: '高' } as Record<string, string>)[r] ?? 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
|
||||
}
|
||||
function statusClass(s: string): string {
|
||||
return ({
|
||||
pending: 'status-pending',
|
||||
approved: 'status-approved',
|
||||
rejected: 'status-rejected',
|
||||
executing: 'status-executing',
|
||||
completed: 'status-completed',
|
||||
failed: 'status-failed',
|
||||
} as Record<string, string>)[s] ?? 'status-pending'
|
||||
}
|
||||
function decidedLabel(d: string): string {
|
||||
return d === 'human' ? '人工' : d === 'auto' ? '自动' : d
|
||||
}
|
||||
function decidedClass(d: string): string {
|
||||
return d === 'human' ? 'decided-human' : 'decided-auto'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audit-log {
|
||||
padding: var(--df-pad-page, 24px);
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
.page-desc {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ===== 表格 ===== */
|
||||
.audit-table-wrap {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
overflow: auto;
|
||||
}
|
||||
.audit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.audit-table thead th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
white-space: nowrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--df-bg-card);
|
||||
}
|
||||
.audit-table tbody td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
color: var(--df-text-secondary);
|
||||
vertical-align: top;
|
||||
}
|
||||
.audit-table tbody tr:last-child td { border-bottom: none; }
|
||||
.audit-table tbody tr:hover td { background: var(--df-sidebar-hover, rgba(255,255,255,0.03)); }
|
||||
|
||||
.col-time { min-width: 120px; }
|
||||
.col-tool { min-width: 130px; }
|
||||
.col-risk { width: 56px; }
|
||||
.col-status { width: 80px; }
|
||||
.col-decided { width: 64px; }
|
||||
.col-args { min-width: 200px; }
|
||||
.col-result { min-width: 220px; }
|
||||
|
||||
.time-rel { font-size: 12px; color: var(--df-text-secondary); }
|
||||
.time-abs { font-size: 10px; color: var(--df-text-dim); margin-top: 2px; font-family: var(--df-font-mono); }
|
||||
|
||||
.tool-name {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-accent);
|
||||
background: var(--df-accent-bg, rgba(123,111,240,0.08));
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
.brief {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 风险 badge */
|
||||
.risk-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.risk-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.risk-medium { background: rgba(255,152,0,0.2); color: #ff9800; }
|
||||
.risk-high { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
|
||||
/* 状态 tag */
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-pending { background: rgba(255,217,61,0.2); color: var(--df-warning); }
|
||||
.status-approved { background: rgba(76,175,80,0.2); color: var(--df-success, #4caf50); }
|
||||
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
.status-executing { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.status-completed { background: rgba(76,175,80,0.15); color: var(--df-success, #4caf50); }
|
||||
.status-failed { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
|
||||
/* 决策者 tag */
|
||||
.decided-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.decided-human { background: rgba(123,111,240,0.2); color: var(--df-accent); }
|
||||
.decided-auto { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.decided-none { color: var(--df-text-dim); }
|
||||
|
||||
/* ===== 分页 ===== */
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.pager-info {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
font-size: 14px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user