- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底 - 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、 conversation-sidebar/ConversationList、settings/provider-panel - 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补 fill-draft父侧转发(重构半成品断链修复) - 验收:vue-tsc EXIT 0
825 lines
31 KiB
Vue
825 lines
31 KiB
Vue
<template>
|
|
<div class="audit-log">
|
|
<header class="page-header">
|
|
<h1>{{ t('auditLog.title') }}</h1>
|
|
<div class="header-actions">
|
|
<button class="btn btn-ghost" @click="reloadAudit" :disabled="loading" v-if="activeTab === 'audit'">
|
|
<svg aria-hidden="true" v-if="loading" class="audit-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>
|
|
{{ t('auditLog.refresh') }}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Tab 导航 -->
|
|
<nav class="detail-tabs audit-tabs">
|
|
<button class="df-tab" :class="{ 'df-tab--active': activeTab === 'audit' }" type="button" @click="switchTab('audit')">
|
|
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
|
{{ t('auditLog.tabAudit') }}
|
|
</button>
|
|
<button class="df-tab" :class="{ 'df-tab--active': activeTab === 'conversations' }" type="button" @click="switchTab('conversations')">
|
|
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
|
{{ t('auditLog.tabConversations') }}
|
|
</button>
|
|
</nav>
|
|
|
|
<!-- ═══ Tab: 审批历史 ═══ -->
|
|
<template v-if="activeTab === 'audit'">
|
|
<p class="page-desc">{{ t('auditLog.desc') }}</p>
|
|
|
|
<!-- 筛选栏 -->
|
|
<div class="filter-bar audit-filters">
|
|
<select v-model="statusFilter" class="audit-filter-select">
|
|
<option value="">{{ t('auditLog.filter.allStatus') }}</option>
|
|
<option v-for="s in statusKeys" :key="s" :value="s">{{ t(`auditLog.status.${s}`) }}</option>
|
|
</select>
|
|
<select v-model="riskFilter" class="audit-filter-select">
|
|
<option value="">{{ t('auditLog.filter.allRisk') }}</option>
|
|
<option v-for="r in riskKeys" :key="r" :value="r">{{ t(`auditLog.risk.${r}`) }}</option>
|
|
</select>
|
|
<div class="search-box audit-search">
|
|
<input v-model="toolSearch" type="text" :placeholder="t('auditLog.filter.toolPlaceholder')" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 状态条 -->
|
|
<div v-if="loading" class="empty-state audit-loading">
|
|
<svg aria-hidden="true" class="audit-spinner audit-spinner--lg" width="24" height="24" 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>
|
|
</div>
|
|
<div v-else-if="errorMsg" class="empty-state audit-error">⚠ {{ errorMsg }}</div>
|
|
<div v-else-if="filteredRecords.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">{{ 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>
|
|
<template v-for="r in filteredRecords" :key="r.id">
|
|
<tr class="audit-row" :class="{ expanded: expandedId === r.id }" tabindex="0" @click="toggleExpand(r.id)" @keydown.enter.prevent="toggleExpand(r.id)" @keydown.space.prevent="toggleExpand(r.id)">
|
|
<td class="col-time">
|
|
<div class="time-rel">{{ formatRelative(r.requested_at) }}</div>
|
|
<div class="time-abs">{{ formatDate(r.requested_at) }}</div>
|
|
<div v-if="r.executed_at" class="time-dur">⏱ {{ durationLabel(r.requested_at, r.executed_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">
|
|
<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">
|
|
<div class="audit-detail">
|
|
<div class="detail-section">
|
|
<span class="df-sec-h">{{ t('auditLog.col.args') }}</span>
|
|
<pre class="detail-pre">{{ r.arguments_brief || '—' }}</pre>
|
|
</div>
|
|
<div class="detail-section">
|
|
<span class="df-sec-h">{{ t('auditLog.col.result') }}</span>
|
|
<!-- 外置句柄(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>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- 分页 -->
|
|
<div v-if="!loading && !errorMsg" class="pager">
|
|
<button class="btn btn-ghost btn-sm" :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 class="pager-total">{{ t('auditLog.pager.total', { n: total }) }}</span></span>
|
|
<button class="btn btn-ghost btn-sm" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
|
|
</div>
|
|
</template>
|
|
<!-- ═══ Tab: 对话历史 ═══ -->
|
|
<template v-else>
|
|
<div class="conv-split">
|
|
<!-- 左侧:对话标题列表 -->
|
|
<div class="conv-list-panel">
|
|
<div class="conv-list" v-if="conversations.length > 0">
|
|
<div v-for="conv in conversations" :key="conv.id" class="conv-list-item" :class="{ active: expandedConv === conv.id }" role="button" tabindex="0" :aria-expanded="expandedConv === conv.id" @click="toggleConv(conv.id)" @keydown.enter.prevent="toggleConv(conv.id)" @keydown.space.prevent="toggleConv(conv.id)">
|
|
<span class="conv-list-item-title">{{ conv.title || '—' }}</span>
|
|
<span class="conv-list-item-date">{{ formatDate(conv.updated_at) }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="!convLoading" class="empty-state">{{ t('auditLog.convEmpty') }}</div>
|
|
<div v-if="convLoading && conversations.length === 0" class="empty-state"><span class="spinner"></span>{{ t('auditLog.convLoading') }}</div>
|
|
</div>
|
|
<!-- 右侧:完整交互日志 -->
|
|
<div class="conv-detail-panel">
|
|
<div v-if="!expandedConv" class="conv-detail-empty">{{ t('auditLog.convSelectHint') }}</div>
|
|
<div v-else-if="convLoading" class="conv-detail-loading"><span class="spinner"></span>{{ t('auditLog.loading') }}</div>
|
|
<div v-else-if="convError" class="conv-detail-error">{{ convError }}</div>
|
|
<div v-else class="conv-messages">
|
|
<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">{{ 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>
|
|
<span v-if="msg.prompt_tokens || msg.completion_tokens" class="conv-msg-token">
|
|
in={{ msg.prompt_tokens ?? 0 }} · out={{ msg.completion_tokens ?? 0 }}<template v-if="msg.reasoning_tokens"> · think={{ msg.reasoning_tokens }}</template>
|
|
</span>
|
|
</div>
|
|
<!-- 推理内容(DeepSeek thinking) -->
|
|
<div v-if="msg.reasoning_content" class="conv-msg-reasoning">
|
|
<div class="conv-msg-reasoning-label">🧠 {{ t('auditLog.convReasoning') }}</div>
|
|
<pre class="conv-msg-text conv-msg-text--reasoning">{{ msg.reasoning_content }}</pre>
|
|
</div>
|
|
<!-- 正文:完整内容(发往/来自 LLM 的完整载荷) -->
|
|
<div class="conv-msg-body">
|
|
<pre class="conv-msg-text">{{ msg.content || (msg.role === 'tool' ? '—' : '') }}</pre>
|
|
</div>
|
|
<!-- 工具调用(assistant 发起,完整 name + arguments JSON) -->
|
|
<div v-if="msg.tool_calls && msg.tool_calls.length > 0" class="conv-msg-tools">
|
|
<div v-for="(tc, tci) in msg.tool_calls" :key="tci" class="conv-msg-toolcall">
|
|
<div class="conv-msg-toolcall-head">
|
|
<span class="conv-msg-toolcall-name">🔧 {{ tc.function?.name || '工具调用' }}</span>
|
|
<span v-if="tc.id" class="conv-msg-toolcall-id">{{ tc.id }}</span>
|
|
</div>
|
|
<pre v-if="tc.function?.arguments" class="conv-msg-toolcall-args">{{ tc.function.arguments }}</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
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()
|
|
|
|
// 本地 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
|
|
}
|
|
|
|
// 后端分页结果 {items,total,has_more}(对标项目通用结构)
|
|
interface ToolExecutionPage {
|
|
items: ToolExecutionRecord[]
|
|
total: number
|
|
has_more: boolean
|
|
}
|
|
|
|
const PAGE_SIZE = 50
|
|
const records = ref<ToolExecutionRecord[]>([])
|
|
const total = ref(0)
|
|
// hasMore 来自后端(基于 offset+items.len()<total 推断),非"本页满 PAGE_SIZE"启发式
|
|
const hasMore = ref(false)
|
|
const offset = ref(0)
|
|
const loading = ref(false)
|
|
const errorMsg = ref('')
|
|
|
|
// 筛选状态(后端 WHERE 收口,非客户端 filter 当前页)。持久化到 localStorage。
|
|
// 空串 = 不过滤(对应后端 Option<String>=None)。
|
|
const statusFilter = usePersistedRef('auditLog.statusFilter', '')
|
|
const riskFilter = usePersistedRef('auditLog.riskFilter', '')
|
|
const toolSearch = usePersistedRef('auditLog.toolSearch', '')
|
|
|
|
// 展开行(查看完整 args/result)
|
|
const expandedId = ref<string | null>(null)
|
|
|
|
// interrupted 来自 cleanup_stale_pending(超时 pending 标记),与 statusKeys 一并展示
|
|
const statusKeys = ['pending', 'approved', 'rejected', 'executing', 'completed', 'failed', 'interrupted']
|
|
const riskKeys = ['low', 'medium', 'high']
|
|
|
|
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
|
|
|
|
// 筛选条件变化 → 重置分页 + 关闭展开行 + 重拉(后端 WHERE)
|
|
// 下拉框(status/risk)即变即查;工具名自由文本输入走 300ms 防抖(对齐 Tasks.vue searchKeyword)。
|
|
watch([statusFilter, riskFilter], () => {
|
|
offset.value = 0
|
|
expandedId.value = null
|
|
void load()
|
|
})
|
|
|
|
let _searchTimer: ReturnType<typeof setTimeout> | null = null
|
|
watch(toolSearch, () => {
|
|
if (_searchTimer) clearTimeout(_searchTimer)
|
|
_searchTimer = setTimeout(() => {
|
|
offset.value = 0
|
|
expandedId.value = null
|
|
void load()
|
|
}, 300)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (_searchTimer) clearTimeout(_searchTimer)
|
|
})
|
|
|
|
// 列表展示用 records(后端已按筛选条件过滤,前端不再二次 filter)
|
|
const filteredRecords = computed(() => records.value)
|
|
|
|
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()
|
|
if (isNaN(ms) || ms < 0) return '—'
|
|
if (ms < 1000) return '<1s'
|
|
const s = Math.floor(ms / 1000)
|
|
if (s < 60) return `${s}s`
|
|
const m = Math.floor(s / 60)
|
|
return `${m}m${s % 60}s`
|
|
}
|
|
|
|
// 构造后端查询参数(空串/纯空白 → undefined = 不过滤)
|
|
function buildQuery() {
|
|
const q: {
|
|
status?: string
|
|
risk_level?: string
|
|
tool_keyword?: string
|
|
limit: number
|
|
offset: number
|
|
} = { limit: PAGE_SIZE, offset: offset.value }
|
|
if (statusFilter.value) q.status = statusFilter.value
|
|
if (riskFilter.value) q.risk_level = riskFilter.value
|
|
const kw = toolSearch.value.trim()
|
|
if (kw) q.tool_keyword = kw
|
|
return q
|
|
}
|
|
|
|
// 请求序号守卫(对齐 project/tasks store 的 _tasksSeq 模式):快速切筛选/翻页时
|
|
// 旧响应晚到不覆盖新结果,旧请求的失败也不污染当前视图
|
|
let _loadSeq = 0
|
|
|
|
async function load() {
|
|
const seq = ++_loadSeq
|
|
loading.value = true
|
|
errorMsg.value = ''
|
|
try {
|
|
const result = await invoke<ToolExecutionPage>('list_tool_executions', {
|
|
query: buildQuery(),
|
|
})
|
|
if (seq !== _loadSeq) return // 旧响应丢弃,只取最新
|
|
records.value = result.items
|
|
total.value = result.total
|
|
hasMore.value = result.has_more
|
|
// 后端 total 已正确,空结果即真无数据(不再像旧"满页推断"需回退)
|
|
} catch (e) {
|
|
if (seq !== _loadSeq) return // 旧请求的失败不污染当前视图
|
|
errorMsg.value = String(e)
|
|
} finally {
|
|
if (seq === _loadSeq) loading.value = false
|
|
}
|
|
}
|
|
|
|
// ── Tab �──
|
|
const activeTab = usePersistedRef<'audit' | 'conversations'>('auditLog.activeTab', 'audit')
|
|
function switchTab(tab: 'audit' | 'conversations') {
|
|
activeTab.value = tab
|
|
if (tab === 'conversations') void loadConversations()
|
|
}
|
|
|
|
// ── 对话历史 ──
|
|
const conversations = ref<AiConversationSummary[]>([])
|
|
const expandedConv = ref<string | null>(null)
|
|
const convLoading = ref(false)
|
|
const convError = ref('')
|
|
interface ConvToolCall {
|
|
id?: string
|
|
type?: string
|
|
function?: { name: string; arguments: string }
|
|
}
|
|
interface ConvMessage {
|
|
role: string
|
|
content: string
|
|
model?: string | null
|
|
/** Unix 毫秒(ChatMessage.timestamp) */
|
|
timestamp?: number | null
|
|
reasoning_content?: string | null
|
|
/** 工具调用(Vec<ToolCall>,role=assistant 时可能有) */
|
|
tool_calls?: ConvToolCall[] | null
|
|
/** 工具调用 ID(role=tool 时) */
|
|
tool_call_id?: string | null
|
|
parts?: { type?: string; text?: string; base64?: string; url?: string }[] | null
|
|
prompt_tokens?: number | null
|
|
completion_tokens?: number | null
|
|
reasoning_tokens?: number | null
|
|
status?: string | null
|
|
}
|
|
const convMessages = ref<ConvMessage[]>([])
|
|
|
|
async function loadConversations() {
|
|
convLoading.value = true
|
|
try {
|
|
const list = await aiApi.listConversations(200, false)
|
|
// 按 updated_at 降序排列
|
|
conversations.value = (list ?? []).sort((a, b) => (b.updated_at > a.updated_at ? 1 : b.updated_at < a.updated_at ? -1 : 0))
|
|
} catch (e) {
|
|
conversations.value = []
|
|
console.error('[AuditLog] 加载对话列表失败:', e)
|
|
} finally {
|
|
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 {
|
|
if (seq === _convSeq) convLoading.value = false
|
|
}
|
|
}
|
|
|
|
|
|
function reloadAudit() {
|
|
if (loading.value) return
|
|
offset.value = 0
|
|
expandedId.value = null
|
|
void load()
|
|
}
|
|
function prevPage() {
|
|
if (loading.value) return // 加载中禁止翻页,防并发请求互踩
|
|
if (offset.value === 0) return
|
|
offset.value = Math.max(0, offset.value - PAGE_SIZE)
|
|
expandedId.value = null
|
|
void load()
|
|
}
|
|
function nextPage() {
|
|
if (loading.value) return // 加载中禁止翻页,防并发请求互踩
|
|
if (!hasMore.value) return
|
|
offset.value += PAGE_SIZE
|
|
expandedId.value = null
|
|
void load()
|
|
}
|
|
|
|
// ── 标签 / 样式映射 ──
|
|
// 文本走 i18n(auditLog.risk/status/decided);class 映射不国际化
|
|
function riskLabel(r: string): string {
|
|
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 t(`auditLog.status.${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',
|
|
interrupted: 'status-interrupted',
|
|
} as Record<string, string>)[s] ?? 'status-pending'
|
|
}
|
|
function decidedLabel(d: string): string {
|
|
return t(`auditLog.decided.${d}`)
|
|
}
|
|
function decidedClass(d: string): string {
|
|
return d === 'human' ? 'decided-human' : 'decided-auto'
|
|
}
|
|
|
|
// 对话角色 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()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.audit-log {
|
|
padding: var(--df-pad-page, 24px);
|
|
max-width: 1200px;
|
|
}
|
|
|
|
/* page-header / btn 等已提取到 global.css 全局 */
|
|
|
|
.page-desc {
|
|
font-size: 12px;
|
|
color: var(--df-text-dim);
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
/* ===== 筛选栏 ===== */
|
|
.audit-filters { gap: 8px; margin-bottom: 16px; }
|
|
.audit-filter-select {
|
|
padding: 6px 10px;
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius-sm);
|
|
background: var(--df-bg);
|
|
color: var(--df-text);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
.audit-filter-select:focus { outline: none; border-color: var(--df-accent); }
|
|
.audit-search { max-width: 200px; }
|
|
|
|
/* ===== 加载/错误状态 ===== */
|
|
.audit-loading { display: flex; align-items: center; justify-content: center; padding: 40px; }
|
|
.audit-error { color: var(--df-danger); }
|
|
/* 消费 global.css df-spin(原本地 audit-spin 重复定义已删,2026-10-18 整洁波) */
|
|
.audit-spinner { animation: df-spin 0.9s linear infinite; }
|
|
.audit-spinner--lg { color: var(--df-text-dim); }
|
|
|
|
/* ===== 表格 ===== */
|
|
.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-hover); }
|
|
/* 展开/hover 行高亮由 .audit-row:hover/.expanded 管理,避免选择器冲突 */
|
|
.audit-table tbody tr.audit-row:hover td { background: var(--df-hover); }
|
|
|
|
.col-time { min-width: 120px; }
|
|
.col-tool { min-width: 130px; }
|
|
.col-risk { width: 56px; }
|
|
.col-status { width: 80px; }
|
|
/* min-width 而非定宽:容纳最长标签「自动(信任)」不逐字竖排,超出由 .audit-table-wrap overflow:auto 兜底 */
|
|
.col-decided { min-width: 88px; }
|
|
.col-args { min-width: 200px; max-width: 320px; }
|
|
.col-result { min-width: 220px; max-width: 360px; }
|
|
/* 表格内 args/result 单元格限高 + 截断(长内容不撑爆行),完整内容点行展开查 detail-pre */
|
|
.col-args .brief, .col-result .brief {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
max-height: 48px; /* 约 3 行(行高 ~16px),超长内容折叠,展开行看完整 */
|
|
}
|
|
|
|
/* 可点击行 */
|
|
.audit-row { cursor: pointer; transition: background 0.12s; }
|
|
.audit-row:hover td { background: var(--df-hover); }
|
|
.audit-row.expanded td { background: var(--df-hover); }
|
|
|
|
/* 展开详情行 */
|
|
.audit-detail-row td { padding: 0 !important; border-bottom: 0.5px solid var(--df-border); }
|
|
.audit-detail { padding: 12px 16px; display: flex; flex-direction: column; gap: 10px; }
|
|
.detail-section { display: flex; flex-direction: column; gap: 4px; }
|
|
/* 小节头走公共 .df-sec-h */
|
|
.detail-pre {
|
|
margin: 0; padding: 8px 10px;
|
|
background: var(--df-bg);
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius-sm);
|
|
font-family: var(--df-font-mono);
|
|
font-size: 11px;
|
|
color: var(--df-text-secondary);
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.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); }
|
|
.time-dur { 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);
|
|
padding: var(--df-pad-chip);
|
|
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;
|
|
}
|
|
|
|
/* D2a 外置句柄:结果列显标记替代截断 JSON(点行展开看详情并可读回全文) */
|
|
.offload-chip {
|
|
display: inline-block;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: var(--df-pad-chip);
|
|
border-radius: var(--df-radius-xs);
|
|
background: var(--df-warning-bg);
|
|
color: var(--df-warning);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* 风险 badge */
|
|
.risk-badge {
|
|
display: inline-block;
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
padding: 2px 8px;
|
|
border-radius: var(--df-radius-xs);
|
|
}
|
|
.risk-low { background: var(--df-neutral-bg); color: var(--df-text-dim); }
|
|
.risk-medium { background: var(--df-warning-bg); color: var(--df-warning); }
|
|
.risk-high { background: var(--df-danger-bg); color: var(--df-danger); }
|
|
|
|
/* 状态 tag */
|
|
.status-tag {
|
|
display: inline-block;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
padding: var(--df-pad-badge);
|
|
border-radius: var(--df-radius-sm);
|
|
white-space: nowrap;
|
|
}
|
|
.status-pending { background: var(--df-warning-bg); color: var(--df-warning); }
|
|
.status-approved { background: var(--df-success-bg); color: var(--df-success); }
|
|
.status-rejected { background: var(--df-danger-bg); color: var(--df-danger); }
|
|
.status-executing { background: var(--df-info-bg); color: var(--df-info); }
|
|
.status-completed { background: var(--df-success-bg); color: var(--df-success); }
|
|
.status-failed { background: var(--df-danger-bg); color: var(--df-danger); }
|
|
.status-interrupted { background: var(--df-neutral-bg); color: var(--df-text-dim); }
|
|
|
|
/* 决策者 tag */
|
|
.decided-tag {
|
|
display: inline-block;
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
padding: 2px 8px;
|
|
border-radius: var(--df-radius-xs);
|
|
white-space: nowrap; /* 窄列防 CJK 逐字竖排(对齐 .status-tag) */
|
|
}
|
|
.decided-human { background: var(--df-accent-bg); color: var(--df-accent); }
|
|
.decided-auto { background: var(--df-neutral-bg); 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);
|
|
}
|
|
.pager-total {
|
|
margin-left: 8px;
|
|
color: var(--df-text-dim);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
/* empty-state 已提取到 global.css 全局 */
|
|
|
|
/* ===== Tab 导航(公共 .df-tab 下划线范式,此处仅补底线叠齐/hover) ===== */
|
|
.audit-tabs {
|
|
display: flex;
|
|
gap: 4px;
|
|
border-bottom: 0.5px solid var(--df-border);
|
|
margin-bottom: 16px;
|
|
}
|
|
.audit-tabs .df-tab { margin-bottom: -0.5px; }
|
|
.audit-tabs .df-tab:hover { color: var(--df-text); }
|
|
|
|
/* ===== 对话历史 ===== */
|
|
.conv-split {
|
|
display: grid;
|
|
grid-template-columns: 260px 1fr;
|
|
gap: var(--df-gap-grid);
|
|
min-height: 400px;
|
|
}
|
|
.conv-list-panel {
|
|
background: var(--df-bg-card);
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius-lg);
|
|
overflow-y: auto;
|
|
max-height: calc(100vh - 220px);
|
|
}
|
|
.conv-list-item {
|
|
padding: 12px 14px;
|
|
border-bottom: 0.5px solid var(--df-border);
|
|
cursor: pointer;
|
|
transition: background 0.12s;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.conv-list-item:last-child { border-bottom: none; }
|
|
.conv-list-item:hover { background: var(--df-bg-card-hover); }
|
|
.conv-list-item.active { background: var(--df-accent-soft); border-left: 3px solid var(--df-accent); }
|
|
.conv-list-item-title {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--df-text);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.conv-list-item-date { font-size: 11px; color: var(--df-text-dim); }
|
|
.conv-detail-panel {
|
|
background: var(--df-bg-card);
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius-lg);
|
|
padding: var(--df-pad-panel);
|
|
overflow-y: auto;
|
|
max-height: calc(100vh - 220px);
|
|
}
|
|
.conv-detail-empty, .conv-detail-loading, .conv-detail-error {
|
|
display: flex; align-items: center; justify-content: center;
|
|
height: 100%; min-height: 200px;
|
|
font-size: 13px; color: var(--df-text-dim);
|
|
}
|
|
.conv-detail-error { color: var(--df-danger); }
|
|
.conv-messages { display: flex; flex-direction: column; gap: 10px; }
|
|
.conv-msg {
|
|
padding: 8px 10px;
|
|
border-radius: var(--df-radius-sm);
|
|
font-size: 12px;
|
|
}
|
|
.conv-msg--user { background: var(--df-accent-bg); }
|
|
.conv-msg--assistant { background: var(--df-bg-raised); }
|
|
.conv-msg--tool { background: rgba(90,99,128,0.1); }
|
|
.conv-msg-head {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
margin-bottom: 4px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.conv-msg-role { font-size: 12px; font-weight: 500; color: var(--df-text); }
|
|
.conv-msg-model, .conv-msg-toolname, .conv-msg-token {
|
|
font-size: 10px; padding: var(--df-pad-chip);
|
|
border-radius: var(--df-radius-xs);
|
|
background: var(--df-neutral-bg); color: var(--df-text-dim);
|
|
}
|
|
.conv-msg-token { font-family: var(--df-font-mono); color: var(--df-info); background: var(--df-info-bg); }
|
|
.conv-msg-time { font-size: 10px; color: var(--df-text-dim); margin-left: auto; }
|
|
.conv-msg-body { min-width: 0; }
|
|
.conv-msg-text {
|
|
margin: 0;
|
|
font-size: 12px;
|
|
color: var(--df-text-secondary);
|
|
line-height: 1.6;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
font-family: var(--df-font-sans);
|
|
}
|
|
/* 推理过程(thinking)块 */
|
|
.conv-msg-reasoning {
|
|
margin: 4px 0;
|
|
padding: 6px 10px;
|
|
border-left: 2px solid var(--df-info);
|
|
background: var(--df-info-bg);
|
|
border-radius: var(--df-radius-sm);
|
|
}
|
|
.conv-msg-reasoning-label {
|
|
font-size: 10px; color: var(--df-info); font-weight: 500; margin-bottom: 4px;
|
|
}
|
|
.conv-msg-text--reasoning {
|
|
font-size: 11px; color: var(--df-text-dim); font-style: italic;
|
|
}
|
|
/* 工具调用块(完整 name + arguments JSON) */
|
|
.conv-msg-tools { margin-top: 4px; display: flex; flex-direction: column; gap: 4px; }
|
|
.conv-msg-toolcall {
|
|
padding: 6px 10px;
|
|
border-radius: var(--df-radius-sm);
|
|
background: var(--df-warning-bg);
|
|
border: 0.5px solid var(--df-warning-bd);
|
|
}
|
|
.conv-msg-toolcall-head {
|
|
display: flex; align-items: center; gap: 8px; margin-bottom: 4px;
|
|
}
|
|
.conv-msg-toolcall-name { font-size: 11px; font-weight: 500; color: var(--df-warning); }
|
|
.conv-msg-toolcall-id { font-size: 10px; color: var(--df-text-dim); font-family: var(--df-font-mono); }
|
|
.conv-msg-toolcall-args {
|
|
margin: 0;
|
|
font-size: 11px;
|
|
color: var(--df-text-secondary);
|
|
background: var(--df-bg);
|
|
border-radius: var(--df-radius-xs);
|
|
padding: 6px 8px;
|
|
font-family: var(--df-font-mono);
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
max-height: 240px;
|
|
overflow-y: auto;
|
|
}
|
|
</style>
|